@clawchatsai/connector 0.0.36 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server.js +355 -88
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -296,6 +296,26 @@ function getActiveDb() {
|
|
|
296
296
|
return getDb(getWorkspaces().active);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
let _globalDb = null;
|
|
300
|
+
function getGlobalDb() {
|
|
301
|
+
if (_globalDb) return _globalDb;
|
|
302
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
303
|
+
const dbPath = path.join(DATA_DIR, 'global.db');
|
|
304
|
+
_globalDb = new Database(dbPath);
|
|
305
|
+
_globalDb.pragma('journal_mode = WAL');
|
|
306
|
+
_globalDb.exec(`
|
|
307
|
+
CREATE TABLE IF NOT EXISTS custom_emojis (
|
|
308
|
+
name TEXT NOT NULL,
|
|
309
|
+
pack TEXT NOT NULL DEFAULT 'slackmojis',
|
|
310
|
+
url TEXT NOT NULL,
|
|
311
|
+
mime_type TEXT,
|
|
312
|
+
created_at INTEGER DEFAULT (strftime('%s','now')),
|
|
313
|
+
PRIMARY KEY (name, pack)
|
|
314
|
+
)
|
|
315
|
+
`);
|
|
316
|
+
return _globalDb;
|
|
317
|
+
}
|
|
318
|
+
|
|
299
319
|
function closeDb(workspaceName) {
|
|
300
320
|
const db = dbCache.get(workspaceName);
|
|
301
321
|
if (db) {
|
|
@@ -309,6 +329,7 @@ function closeAllDbs() {
|
|
|
309
329
|
db.close();
|
|
310
330
|
}
|
|
311
331
|
dbCache.clear();
|
|
332
|
+
if (_globalDb) { _globalDb.close(); _globalDb = null; }
|
|
312
333
|
}
|
|
313
334
|
|
|
314
335
|
function migrate(db) {
|
|
@@ -1064,6 +1085,26 @@ async function handleCreateMessage(req, res, params) {
|
|
|
1064
1085
|
|
|
1065
1086
|
// Bump thread updated_at
|
|
1066
1087
|
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
|
|
1088
|
+
|
|
1089
|
+
// Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
|
|
1090
|
+
if (body.role === 'user' && body.content) {
|
|
1091
|
+
const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
|
|
1092
|
+
if (threadInfo?.title === 'New chat') {
|
|
1093
|
+
const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
|
|
1094
|
+
+ (body.content.length > 40 ? '...' : '');
|
|
1095
|
+
if (heuristic) {
|
|
1096
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
|
|
1097
|
+
const activeWs = getWorkspaces().active;
|
|
1098
|
+
gatewayClient.broadcastToBrowsers(JSON.stringify({
|
|
1099
|
+
type: 'clawchats',
|
|
1100
|
+
event: 'thread-title-updated',
|
|
1101
|
+
threadId: params.id,
|
|
1102
|
+
workspace: activeWs,
|
|
1103
|
+
title: heuristic
|
|
1104
|
+
}));
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1067
1108
|
}
|
|
1068
1109
|
|
|
1069
1110
|
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
|
|
@@ -2070,28 +2111,11 @@ async function handleRequest(req, res) {
|
|
|
2070
2111
|
|
|
2071
2112
|
// Custom emoji listing (no auth — public like static assets)
|
|
2072
2113
|
if (method === 'GET' && urlPath === '/api/emoji') {
|
|
2073
|
-
const emojiDir = path.join(__dirname, 'emoji');
|
|
2074
|
-
if (!fs.existsSync(emojiDir)) {
|
|
2075
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2076
|
-
return res.end('[]');
|
|
2077
|
-
}
|
|
2078
2114
|
try {
|
|
2079
|
-
const
|
|
2080
|
-
|
|
2081
|
-
);
|
|
2082
|
-
const result = [];
|
|
2083
|
-
for (const pack of packs) {
|
|
2084
|
-
const packDir = path.join(emojiDir, pack);
|
|
2085
|
-
const files = fs.readdirSync(packDir).filter(f =>
|
|
2086
|
-
/\.(png|gif|webp|jpg|jpeg)$/i.test(f)
|
|
2087
|
-
);
|
|
2088
|
-
for (const file of files) {
|
|
2089
|
-
const name = file.replace(/\.[^.]+$/, '');
|
|
2090
|
-
result.push({ name, pack, path: `/emoji/${pack}/${file}` });
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2115
|
+
const db = getGlobalDb();
|
|
2116
|
+
const rows = db.prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all();
|
|
2093
2117
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
|
|
2094
|
-
return res.end(JSON.stringify(
|
|
2118
|
+
return res.end(JSON.stringify(rows));
|
|
2095
2119
|
} catch (e) {
|
|
2096
2120
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2097
2121
|
return res.end(JSON.stringify({ error: e.message }));
|
|
@@ -2141,7 +2165,7 @@ async function handleRequest(req, res) {
|
|
|
2141
2165
|
}
|
|
2142
2166
|
}
|
|
2143
2167
|
|
|
2144
|
-
//
|
|
2168
|
+
// Add custom emoji (store URL in global.db)
|
|
2145
2169
|
if (method === 'POST' && urlPath === '/api/emoji/add') {
|
|
2146
2170
|
if (!checkAuth(req, res)) return;
|
|
2147
2171
|
try {
|
|
@@ -2150,45 +2174,40 @@ async function handleRequest(req, res) {
|
|
|
2150
2174
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2151
2175
|
return res.end(JSON.stringify({ error: 'Missing url or name' }));
|
|
2152
2176
|
}
|
|
2153
|
-
const
|
|
2154
|
-
const
|
|
2155
|
-
|
|
2177
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
2178
|
+
const targetPack = pack || 'slackmojis';
|
|
2179
|
+
// Determine mime type from URL extension
|
|
2180
|
+
const urlLower = url.split('?')[0].toLowerCase();
|
|
2181
|
+
let mimeType = 'image/png';
|
|
2182
|
+
if (urlLower.endsWith('.gif')) mimeType = 'image/gif';
|
|
2183
|
+
else if (urlLower.endsWith('.webp')) mimeType = 'image/webp';
|
|
2184
|
+
else if (urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg')) mimeType = 'image/jpeg';
|
|
2185
|
+
|
|
2186
|
+
const db = getGlobalDb();
|
|
2187
|
+
db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)')
|
|
2188
|
+
.run(safeName, targetPack, url, mimeType);
|
|
2156
2189
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
|
|
2165
|
-
return resolve(download(resp.headers.location, redirects + 1));
|
|
2166
|
-
}
|
|
2167
|
-
if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
|
|
2168
|
-
const chunks = [];
|
|
2169
|
-
resp.on('data', chunk => chunks.push(chunk));
|
|
2170
|
-
resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
|
|
2171
|
-
}).on('error', reject);
|
|
2172
|
-
});
|
|
2190
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2191
|
+
return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
|
|
2192
|
+
} catch (e) {
|
|
2193
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2194
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2173
2197
|
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
if (['png', 'gif', 'webp', 'jpg', 'jpeg'].includes(urlExt)) ext = urlExt;
|
|
2198
|
+
// Delete custom emoji
|
|
2199
|
+
if (method === 'DELETE' && urlPath === '/api/emoji') {
|
|
2200
|
+
if (!checkAuth(req, res)) return;
|
|
2201
|
+
try {
|
|
2202
|
+
const { name, pack } = await parseBody(req);
|
|
2203
|
+
if (!name || !pack) {
|
|
2204
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2205
|
+
return res.end(JSON.stringify({ error: 'Missing name or pack' }));
|
|
2183
2206
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
const filePath = path.join(emojiDir, `${safeName}.${ext}`);
|
|
2187
|
-
fs.writeFileSync(filePath, buffer);
|
|
2188
|
-
|
|
2189
|
-
const result = { name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` };
|
|
2207
|
+
const db = getGlobalDb();
|
|
2208
|
+
db.prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
|
|
2190
2209
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2191
|
-
return res.end(JSON.stringify(
|
|
2210
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
2192
2211
|
} catch (e) {
|
|
2193
2212
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2194
2213
|
return res.end(JSON.stringify({ error: e.message }));
|
|
@@ -2297,6 +2316,15 @@ async function handleRequest(req, res) {
|
|
|
2297
2316
|
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) {
|
|
2298
2317
|
return handleContextFill(req, res, p);
|
|
2299
2318
|
}
|
|
2319
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
|
|
2320
|
+
const db = getActiveDb();
|
|
2321
|
+
const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
|
|
2322
|
+
if (!thread) return sendError(res, 404, 'Thread not found');
|
|
2323
|
+
// Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
|
|
2324
|
+
const activeWs = getWorkspaces().active;
|
|
2325
|
+
gatewayClient.generateThreadTitle(db, p.id, activeWs);
|
|
2326
|
+
return send(res, 200, { ok: true });
|
|
2327
|
+
}
|
|
2300
2328
|
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) {
|
|
2301
2329
|
return await handleUpload(req, res, p);
|
|
2302
2330
|
}
|
|
@@ -2487,6 +2515,22 @@ class GatewayClient {
|
|
|
2487
2515
|
this.streamState.delete(sessionKey);
|
|
2488
2516
|
}
|
|
2489
2517
|
|
|
2518
|
+
// Intercept title generation responses (final, error, or aborted)
|
|
2519
|
+
if (sessionKey && sessionKey.includes('__clawchats_title_')) {
|
|
2520
|
+
if (state === 'final') {
|
|
2521
|
+
const content = extractContent(message);
|
|
2522
|
+
if (content && this.handleTitleResponse(sessionKey, content)) return;
|
|
2523
|
+
} else if (state === 'error' || state === 'aborted') {
|
|
2524
|
+
// Clean up pending entry by substring match — heuristic title stays
|
|
2525
|
+
if (this._pendingTitleGens) {
|
|
2526
|
+
for (const key of this._pendingTitleGens.keys()) {
|
|
2527
|
+
if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2490
2534
|
// Save assistant messages on final
|
|
2491
2535
|
if (state === 'final') {
|
|
2492
2536
|
this.saveAssistantMessage(sessionKey, message, seq);
|
|
@@ -2598,6 +2642,16 @@ class GatewayClient {
|
|
|
2598
2642
|
}));
|
|
2599
2643
|
|
|
2600
2644
|
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
|
|
2645
|
+
|
|
2646
|
+
// Auto-generate AI title upgrade after first assistant response
|
|
2647
|
+
// Heuristic was already set on user message save; this fires the AI upgrade
|
|
2648
|
+
const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
|
|
2649
|
+
if (currentTitle && currentTitle !== 'New chat') {
|
|
2650
|
+
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
2651
|
+
if (msgCount >= 2) {
|
|
2652
|
+
this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2601
2655
|
} catch (e) {
|
|
2602
2656
|
console.error(`Failed to save assistant message:`, e.message);
|
|
2603
2657
|
}
|
|
@@ -2630,6 +2684,125 @@ class GatewayClient {
|
|
|
2630
2684
|
}
|
|
2631
2685
|
}
|
|
2632
2686
|
|
|
2687
|
+
/**
|
|
2688
|
+
* Generate a title for a thread. Optionally sets a heuristic title immediately,
|
|
2689
|
+
* then fires an async AI title upgrade via the gateway.
|
|
2690
|
+
* @param {boolean} skipHeuristic - If true, skip heuristic step (already set on user message save)
|
|
2691
|
+
*/
|
|
2692
|
+
generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
|
|
2693
|
+
const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
|
|
2694
|
+
if (!thread) return;
|
|
2695
|
+
|
|
2696
|
+
// Skip if AI title gen already in progress for this thread
|
|
2697
|
+
const titleKey = `__clawchats_title_${threadId}`;
|
|
2698
|
+
if (this._pendingTitleGens?.has(titleKey)) return;
|
|
2699
|
+
|
|
2700
|
+
// Get first user message for heuristic title
|
|
2701
|
+
const firstUserMsg = db.prepare(
|
|
2702
|
+
"SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1"
|
|
2703
|
+
).get(threadId);
|
|
2704
|
+
if (!firstUserMsg?.content) return;
|
|
2705
|
+
|
|
2706
|
+
// Step 1: Heuristic title (immediate) — skip if already set on user message save
|
|
2707
|
+
if (!skipHeuristic) {
|
|
2708
|
+
const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim()
|
|
2709
|
+
+ (firstUserMsg.content.length > 40 ? '...' : '');
|
|
2710
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
|
|
2711
|
+
|
|
2712
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
2713
|
+
type: 'clawchats',
|
|
2714
|
+
event: 'thread-title-updated',
|
|
2715
|
+
threadId,
|
|
2716
|
+
workspace,
|
|
2717
|
+
title: heuristic
|
|
2718
|
+
}));
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// Step 2: AI title upgrade (async, best-effort)
|
|
2722
|
+
const messages = db.prepare(
|
|
2723
|
+
'SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6'
|
|
2724
|
+
).all(threadId);
|
|
2725
|
+
|
|
2726
|
+
// Need at least 2 messages (user + assistant) for meaningful AI title
|
|
2727
|
+
if (messages.length < 2) return;
|
|
2728
|
+
|
|
2729
|
+
const conversation = messages.map(m => {
|
|
2730
|
+
const role = m.role === 'user' ? 'User' : 'Assistant';
|
|
2731
|
+
const content = m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content;
|
|
2732
|
+
return `${role}: ${content}`;
|
|
2733
|
+
}).join('\n\n');
|
|
2734
|
+
|
|
2735
|
+
const prompt = `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:\n\n${conversation}\n\nTitle:`;
|
|
2736
|
+
|
|
2737
|
+
const reqId = `title-${threadId}-${Date.now()}`;
|
|
2738
|
+
if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
|
|
2739
|
+
this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
|
|
2740
|
+
|
|
2741
|
+
// Timeout cleanup — prevent unbounded map growth if gateway never responds
|
|
2742
|
+
setTimeout(() => {
|
|
2743
|
+
if (this._pendingTitleGens?.has(titleKey)) {
|
|
2744
|
+
this._pendingTitleGens.delete(titleKey);
|
|
2745
|
+
console.log(`Title gen timeout for ${threadId} — keeping heuristic title`);
|
|
2746
|
+
}
|
|
2747
|
+
}, 30000);
|
|
2748
|
+
|
|
2749
|
+
this.sendToGateway(JSON.stringify({
|
|
2750
|
+
type: 'req',
|
|
2751
|
+
id: reqId,
|
|
2752
|
+
method: 'chat.send',
|
|
2753
|
+
params: {
|
|
2754
|
+
sessionKey: titleKey,
|
|
2755
|
+
message: prompt,
|
|
2756
|
+
deliver: false,
|
|
2757
|
+
idempotencyKey: reqId
|
|
2758
|
+
}
|
|
2759
|
+
}));
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
/**
|
|
2763
|
+
* Handle AI title response from gateway.
|
|
2764
|
+
* Returns true if the event was consumed (was a title gen response).
|
|
2765
|
+
*/
|
|
2766
|
+
handleTitleResponse(sessionKey, content) {
|
|
2767
|
+
if (!this._pendingTitleGens) return false;
|
|
2768
|
+
// Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
|
|
2769
|
+
// Find the matching pending entry by substring
|
|
2770
|
+
let matchKey = null, pending = null;
|
|
2771
|
+
for (const [key, val] of this._pendingTitleGens) {
|
|
2772
|
+
if (sessionKey === key || sessionKey.includes(key)) {
|
|
2773
|
+
matchKey = key;
|
|
2774
|
+
pending = val;
|
|
2775
|
+
break;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
if (!pending) return false;
|
|
2779
|
+
|
|
2780
|
+
this._pendingTitleGens.delete(matchKey);
|
|
2781
|
+
|
|
2782
|
+
let title = content.trim()
|
|
2783
|
+
.replace(/^["']|["']$/g, '')
|
|
2784
|
+
.replace(/^Title:\s*/i, '')
|
|
2785
|
+
.replace(/\n.*/s, '')
|
|
2786
|
+
.trim();
|
|
2787
|
+
|
|
2788
|
+
if (title.length > 50) title = title.substring(0, 47) + '...';
|
|
2789
|
+
if (title.length === 0 || title.length >= 100) return true; // bad response, keep heuristic
|
|
2790
|
+
|
|
2791
|
+
const db = getDb(pending.workspace);
|
|
2792
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
|
|
2793
|
+
|
|
2794
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
2795
|
+
type: 'clawchats',
|
|
2796
|
+
event: 'thread-title-updated',
|
|
2797
|
+
threadId: pending.threadId,
|
|
2798
|
+
workspace: pending.workspace,
|
|
2799
|
+
title
|
|
2800
|
+
}));
|
|
2801
|
+
|
|
2802
|
+
console.log(`AI title generated for ${pending.threadId}: "${title}"`);
|
|
2803
|
+
return true;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2633
2806
|
handleAgentEvent(payload) {
|
|
2634
2807
|
const { runId, stream, data, sessionKey } = payload;
|
|
2635
2808
|
if (!runId) return;
|
|
@@ -3390,6 +3563,26 @@ export function createApp(config = {}) {
|
|
|
3390
3563
|
} else {
|
|
3391
3564
|
db.prepare('INSERT INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(body.id, params.id, body.role, body.content, body.status || 'sent', metadata, body.seq || null, body.timestamp, Date.now());
|
|
3392
3565
|
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
|
|
3566
|
+
|
|
3567
|
+
// Heuristic title on first user message (mimics Claude/ChatGPT — title appears immediately on send)
|
|
3568
|
+
if (body.role === 'user' && body.content) {
|
|
3569
|
+
const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
|
|
3570
|
+
if (threadInfo?.title === 'New chat') {
|
|
3571
|
+
const heuristic = body.content.replace(/\n.*/s, '').slice(0, 40).trim()
|
|
3572
|
+
+ (body.content.length > 40 ? '...' : '');
|
|
3573
|
+
if (heuristic) {
|
|
3574
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, params.id);
|
|
3575
|
+
const activeWs = _getWorkspaces().active;
|
|
3576
|
+
_gatewayClient.broadcastToBrowsers(JSON.stringify({
|
|
3577
|
+
type: 'clawchats',
|
|
3578
|
+
event: 'thread-title-updated',
|
|
3579
|
+
threadId: params.id,
|
|
3580
|
+
workspace: activeWs,
|
|
3581
|
+
title: heuristic
|
|
3582
|
+
}));
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3393
3586
|
}
|
|
3394
3587
|
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
|
|
3395
3588
|
if (message && message.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
|
|
@@ -3703,6 +3896,22 @@ export function createApp(config = {}) {
|
|
|
3703
3896
|
return;
|
|
3704
3897
|
}
|
|
3705
3898
|
if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
|
|
3899
|
+
|
|
3900
|
+
// Intercept title generation responses (final, error, or aborted)
|
|
3901
|
+
if (sessionKey && sessionKey.includes('__clawchats_title_')) {
|
|
3902
|
+
if (state === 'final') {
|
|
3903
|
+
const content = extractContent(message);
|
|
3904
|
+
if (content && this.handleTitleResponse(sessionKey, content)) return;
|
|
3905
|
+
} else if (state === 'error' || state === 'aborted') {
|
|
3906
|
+
if (this._pendingTitleGens) {
|
|
3907
|
+
for (const key of this._pendingTitleGens.keys()) {
|
|
3908
|
+
if (sessionKey === key || sessionKey.includes(key)) { this._pendingTitleGens.delete(key); break; }
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
return;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3706
3915
|
if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
|
|
3707
3916
|
if (state === 'error') this.saveErrorMarker(sessionKey, message);
|
|
3708
3917
|
}
|
|
@@ -3758,6 +3967,15 @@ export function createApp(config = {}) {
|
|
|
3758
3967
|
const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
3759
3968
|
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: parsed.workspace, threadId: parsed.threadId, messageId, action: 'new', unreadCount, workspaceUnreadTotal, title: threadInfo?.title || 'Chat', preview, timestamp: now }));
|
|
3760
3969
|
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
|
|
3970
|
+
|
|
3971
|
+
// Auto-generate AI title upgrade after first assistant response
|
|
3972
|
+
const currentTitle = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId)?.title;
|
|
3973
|
+
if (currentTitle && currentTitle !== 'New chat') {
|
|
3974
|
+
const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
3975
|
+
if (msgCount >= 2) {
|
|
3976
|
+
this.generateThreadTitle(db, parsed.threadId, parsed.workspace, true);
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3761
3979
|
} catch (e) { console.error(`Failed to save assistant message:`, e.message); }
|
|
3762
3980
|
}
|
|
3763
3981
|
|
|
@@ -3779,6 +3997,50 @@ export function createApp(config = {}) {
|
|
|
3779
3997
|
} catch (e) { console.error(`Failed to save error marker:`, e.message); }
|
|
3780
3998
|
}
|
|
3781
3999
|
|
|
4000
|
+
generateThreadTitle(db, threadId, workspace, skipHeuristic = false) {
|
|
4001
|
+
const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(threadId);
|
|
4002
|
+
if (!thread) return;
|
|
4003
|
+
const titleKey = `__clawchats_title_${threadId}`;
|
|
4004
|
+
if (this._pendingTitleGens?.has(titleKey)) return;
|
|
4005
|
+
const firstUserMsg = db.prepare("SELECT content FROM messages WHERE thread_id = ? AND role = 'user' ORDER BY created_at ASC LIMIT 1").get(threadId);
|
|
4006
|
+
if (!firstUserMsg?.content) return;
|
|
4007
|
+
|
|
4008
|
+
if (!skipHeuristic) {
|
|
4009
|
+
const heuristic = firstUserMsg.content.replace(/\n.*/s, '').slice(0, 40).trim() + (firstUserMsg.content.length > 40 ? '...' : '');
|
|
4010
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(heuristic, threadId);
|
|
4011
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId, workspace, title: heuristic }));
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
const messages = db.prepare('SELECT role, content FROM messages WHERE thread_id = ? ORDER BY created_at ASC LIMIT 6').all(threadId);
|
|
4015
|
+
if (messages.length < 2) return;
|
|
4016
|
+
const conversation = messages.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.length > 300 ? m.content.slice(0, 300) + '...' : m.content}`).join('\n\n');
|
|
4017
|
+
const prompt = `Based on this conversation, generate a concise 3-5 word title. Return ONLY the title text, no quotes, no explanation:\n\n${conversation}\n\nTitle:`;
|
|
4018
|
+
const reqId = `title-${threadId}-${Date.now()}`;
|
|
4019
|
+
if (!this._pendingTitleGens) this._pendingTitleGens = new Map();
|
|
4020
|
+
this._pendingTitleGens.set(titleKey, { threadId, workspace, reqId });
|
|
4021
|
+
setTimeout(() => { if (this._pendingTitleGens?.has(titleKey)) { this._pendingTitleGens.delete(titleKey); console.log(`Title gen timeout for ${threadId} — keeping heuristic title`); } }, 30000);
|
|
4022
|
+
this.sendToGateway(JSON.stringify({ type: 'req', id: reqId, method: 'chat.send', params: { sessionKey: titleKey, message: prompt, deliver: false, idempotencyKey: reqId } }));
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
handleTitleResponse(sessionKey, content) {
|
|
4026
|
+
if (!this._pendingTitleGens) return false;
|
|
4027
|
+
// Gateway may prefix sessionKey (e.g. agent:main:__clawchats_title_xxx)
|
|
4028
|
+
let matchKey = null, pending = null;
|
|
4029
|
+
for (const [key, val] of this._pendingTitleGens) {
|
|
4030
|
+
if (sessionKey === key || sessionKey.includes(key)) { matchKey = key; pending = val; break; }
|
|
4031
|
+
}
|
|
4032
|
+
if (!pending) return false;
|
|
4033
|
+
this._pendingTitleGens.delete(matchKey);
|
|
4034
|
+
let title = content.trim().replace(/^["']|["']$/g, '').replace(/^Title:\s*/i, '').replace(/\n.*/s, '').trim();
|
|
4035
|
+
if (title.length > 50) title = title.substring(0, 47) + '...';
|
|
4036
|
+
if (title.length === 0 || title.length >= 100) return true;
|
|
4037
|
+
const db = _getDb(pending.workspace);
|
|
4038
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, pending.threadId);
|
|
4039
|
+
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: pending.threadId, workspace: pending.workspace, title }));
|
|
4040
|
+
console.log(`AI title generated for ${pending.threadId}: "${title}"`);
|
|
4041
|
+
return true;
|
|
4042
|
+
}
|
|
4043
|
+
|
|
3782
4044
|
handleAgentEvent(payload) {
|
|
3783
4045
|
const { runId, stream, data, sessionKey } = payload;
|
|
3784
4046
|
if (!runId) return;
|
|
@@ -4022,17 +4284,11 @@ export function createApp(config = {}) {
|
|
|
4022
4284
|
|
|
4023
4285
|
// Custom emoji listing (no auth)
|
|
4024
4286
|
if (method === 'GET' && urlPath === '/api/emoji') {
|
|
4025
|
-
const emojiDir = path.join(__dirname, 'emoji');
|
|
4026
|
-
if (!fs.existsSync(emojiDir)) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('[]'); }
|
|
4027
4287
|
try {
|
|
4028
|
-
const
|
|
4029
|
-
const
|
|
4030
|
-
for (const pack of packs) {
|
|
4031
|
-
const files = fs.readdirSync(path.join(emojiDir, pack)).filter(f => /\.(png|gif|webp|jpg|jpeg)$/i.test(f));
|
|
4032
|
-
for (const file of files) result.push({ name: file.replace(/\.[^.]+$/, ''), pack, path: `/emoji/${pack}/${file}` });
|
|
4033
|
-
}
|
|
4288
|
+
const db = getGlobalDb();
|
|
4289
|
+
const rows = db.prepare('SELECT name, pack, url, mime_type FROM custom_emojis ORDER BY created_at DESC').all();
|
|
4034
4290
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
|
|
4035
|
-
return res.end(JSON.stringify(
|
|
4291
|
+
return res.end(JSON.stringify(rows));
|
|
4036
4292
|
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
4037
4293
|
}
|
|
4038
4294
|
|
|
@@ -4061,35 +4317,34 @@ export function createApp(config = {}) {
|
|
|
4061
4317
|
|
|
4062
4318
|
if (!_checkAuth(req, res)) return;
|
|
4063
4319
|
|
|
4064
|
-
//
|
|
4320
|
+
// Add custom emoji (auth required)
|
|
4065
4321
|
if (method === 'POST' && urlPath === '/api/emoji/add') {
|
|
4066
4322
|
try {
|
|
4067
4323
|
const { url, name, pack } = await parseBody(req);
|
|
4068
4324
|
if (!url || !name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing url or name' })); }
|
|
4069
|
-
const targetPack = pack || 'custom';
|
|
4070
|
-
const emojiDir = path.join(__dirname, 'emoji', targetPack);
|
|
4071
|
-
if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
|
|
4072
|
-
const https = await import('https');
|
|
4073
|
-
const http = await import('http');
|
|
4074
|
-
const download = (dlUrl, redirects = 0) => new Promise((resolve, reject) => {
|
|
4075
|
-
if (redirects > 5) return reject(new Error('Too many redirects'));
|
|
4076
|
-
const mod = dlUrl.startsWith('https') ? https.default : http.default;
|
|
4077
|
-
mod.get(dlUrl, (resp) => {
|
|
4078
|
-
if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) return resolve(download(resp.headers.location, redirects + 1));
|
|
4079
|
-
if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
|
|
4080
|
-
const chunks = []; resp.on('data', c => chunks.push(c)); resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
|
|
4081
|
-
}).on('error', reject);
|
|
4082
|
-
});
|
|
4083
|
-
const { buffer, contentType } = await download(url);
|
|
4084
|
-
let ext = 'png';
|
|
4085
|
-
if (contentType.includes('gif')) ext = 'gif';
|
|
4086
|
-
else if (contentType.includes('webp')) ext = 'webp';
|
|
4087
|
-
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
|
|
4088
|
-
else { const u = url.split('?')[0].split('.').pop().toLowerCase(); if (['png','gif','webp','jpg','jpeg'].includes(u)) ext = u; }
|
|
4089
4325
|
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
4090
|
-
|
|
4326
|
+
const targetPack = pack || 'slackmojis';
|
|
4327
|
+
const urlLower = url.split('?')[0].toLowerCase();
|
|
4328
|
+
let mimeType = 'image/png';
|
|
4329
|
+
if (urlLower.endsWith('.gif')) mimeType = 'image/gif';
|
|
4330
|
+
else if (urlLower.endsWith('.webp')) mimeType = 'image/webp';
|
|
4331
|
+
else if (urlLower.endsWith('.jpg') || urlLower.endsWith('.jpeg')) mimeType = 'image/jpeg';
|
|
4332
|
+
const db = getGlobalDb();
|
|
4333
|
+
db.prepare('INSERT OR REPLACE INTO custom_emojis (name, pack, url, mime_type) VALUES (?, ?, ?, ?)').run(safeName, targetPack, url, mimeType);
|
|
4091
4334
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4092
|
-
return res.end(JSON.stringify({ name: safeName, pack: targetPack,
|
|
4335
|
+
return res.end(JSON.stringify({ name: safeName, pack: targetPack, url, mime_type: mimeType }));
|
|
4336
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
// Delete custom emoji (auth required)
|
|
4340
|
+
if (method === 'DELETE' && urlPath === '/api/emoji') {
|
|
4341
|
+
try {
|
|
4342
|
+
const { name, pack } = await parseBody(req);
|
|
4343
|
+
if (!name || !pack) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing name or pack' })); }
|
|
4344
|
+
const db = getGlobalDb();
|
|
4345
|
+
db.prepare('DELETE FROM custom_emojis WHERE name = ? AND pack = ?').run(name, pack);
|
|
4346
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4347
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
4093
4348
|
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
4094
4349
|
}
|
|
4095
4350
|
|
|
@@ -4122,6 +4377,15 @@ export function createApp(config = {}) {
|
|
|
4122
4377
|
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/messages'))) return await _handleCreateMessage(req, res, p);
|
|
4123
4378
|
if ((p = matchRoute(method, urlPath, 'DELETE /api/threads/:id/messages/:messageId'))) return _handleDeleteMessage(req, res, p);
|
|
4124
4379
|
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/context-fill'))) return _handleContextFill(req, res, p);
|
|
4380
|
+
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/generate-title'))) {
|
|
4381
|
+
const db = _getActiveDb();
|
|
4382
|
+
const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(p.id);
|
|
4383
|
+
if (!thread) return sendError(res, 404, 'Thread not found');
|
|
4384
|
+
// Regenerate: sets heuristic immediately (safe fallback), then fires AI upgrade
|
|
4385
|
+
const activeWs = _getWorkspaces().active;
|
|
4386
|
+
_gatewayClient.generateThreadTitle(db, p.id, activeWs);
|
|
4387
|
+
return send(res, 200, { ok: true });
|
|
4388
|
+
}
|
|
4125
4389
|
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/upload'))) return await _handleUpload(req, res, p);
|
|
4126
4390
|
if ((p = matchRoute(method, urlPath, 'GET /api/threads/:id/intelligence'))) return _handleGetIntelligence(req, res, p);
|
|
4127
4391
|
if ((p = matchRoute(method, urlPath, 'POST /api/threads/:id/intelligence'))) return await _handleSaveIntelligence(req, res, p);
|
|
@@ -4231,6 +4495,9 @@ if (isDirectRun) {
|
|
|
4231
4495
|
|
|
4232
4496
|
// Connect to gateway
|
|
4233
4497
|
app.gatewayClient.connect();
|
|
4498
|
+
|
|
4499
|
+
// Initialize global DB (custom emojis, etc.)
|
|
4500
|
+
getGlobalDb();
|
|
4234
4501
|
});
|
|
4235
4502
|
|
|
4236
4503
|
// Graceful shutdown
|