@clawchatsai/connector 0.0.29 → 0.0.31
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/dist/index.js +20 -2
- package/dist/shim.d.ts +1 -0
- package/dist/shim.js +1 -0
- package/package.json +1 -1
- package/server.js +546 -343
package/dist/index.js
CHANGED
|
@@ -488,15 +488,33 @@ async function handleRpcMessage(dc, msg, ctx) {
|
|
|
488
488
|
};
|
|
489
489
|
try {
|
|
490
490
|
const response = await dispatchRpc(rpcReq, app.handleRequest);
|
|
491
|
+
// For binary content types (images, audio, etc.), wrap as _binary envelope
|
|
492
|
+
// so the browser transport can reconstruct a proper Blob with correct MIME type
|
|
493
|
+
const contentType = response.headers['content-type'] || '';
|
|
494
|
+
const isBinaryResponse = /^(image|audio|video|application\/octet-stream|application\/pdf)/.test(contentType);
|
|
495
|
+
let responseBody;
|
|
496
|
+
if (isBinaryResponse && response.rawBody) {
|
|
497
|
+
// Encode raw bytes as base64 in a _binary envelope.
|
|
498
|
+
// The transport layer on the browser side already handles this format,
|
|
499
|
+
// reconstructing a proper Blob with the correct MIME type.
|
|
500
|
+
responseBody = {
|
|
501
|
+
_binary: true,
|
|
502
|
+
contentType,
|
|
503
|
+
data: response.rawBody.toString('base64'),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
responseBody = response.body;
|
|
508
|
+
}
|
|
491
509
|
const responseMsg = {
|
|
492
510
|
type: 'rpc-res',
|
|
493
511
|
id: response.id,
|
|
494
512
|
status: response.status,
|
|
495
|
-
body:
|
|
513
|
+
body: responseBody,
|
|
496
514
|
};
|
|
497
515
|
const responseStr = JSON.stringify(responseMsg);
|
|
498
516
|
if (responseStr.length > MAX_DC_MESSAGE_SIZE) {
|
|
499
|
-
sendChunked(dc, response.id, response.status,
|
|
517
|
+
sendChunked(dc, response.id, response.status, JSON.stringify(responseBody));
|
|
500
518
|
}
|
|
501
519
|
else {
|
|
502
520
|
dc.send(responseStr);
|
package/dist/shim.d.ts
CHANGED
package/dist/shim.js
CHANGED
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -368,13 +368,6 @@ function migrate(db) {
|
|
|
368
368
|
`);
|
|
369
369
|
db.exec('CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id)');
|
|
370
370
|
|
|
371
|
-
// Migration: add type column for segment model (text/tool/thinking)
|
|
372
|
-
try {
|
|
373
|
-
db.exec("ALTER TABLE messages ADD COLUMN type TEXT DEFAULT 'text'");
|
|
374
|
-
} catch (e) {
|
|
375
|
-
// Column already exists — ignore
|
|
376
|
-
}
|
|
377
|
-
|
|
378
371
|
// FTS5 table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS in all versions,
|
|
379
372
|
// so check if it exists first
|
|
380
373
|
const hasFts = db.prepare(
|
|
@@ -2044,16 +2037,17 @@ async function handleRequest(req, res) {
|
|
|
2044
2037
|
const isIcon = urlPath.startsWith('/icons/');
|
|
2045
2038
|
const isLib = urlPath.startsWith('/lib/');
|
|
2046
2039
|
const isFrontend = urlPath.startsWith('/frontend/');
|
|
2040
|
+
const isEmoji = urlPath.startsWith('/emoji/');
|
|
2047
2041
|
const isConfig = urlPath === '/config.js';
|
|
2048
2042
|
const staticPath = fileName ? path.join(__dirname, fileName)
|
|
2049
|
-
: (isIcon || isLib || isFrontend || isConfig) ? path.join(__dirname, urlPath.slice(1))
|
|
2043
|
+
: (isIcon || isLib || isFrontend || isEmoji || isConfig) ? path.join(__dirname, urlPath.slice(1))
|
|
2050
2044
|
: null;
|
|
2051
2045
|
if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
|
|
2052
2046
|
const ext = path.extname(staticPath).toLowerCase();
|
|
2053
2047
|
const mimeMap = {
|
|
2054
2048
|
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
|
2055
2049
|
'.json': 'application/json', '.ico': 'image/x-icon',
|
|
2056
|
-
'.png': 'image/png', '.svg': 'image/svg+xml',
|
|
2050
|
+
'.png': 'image/png', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
2057
2051
|
};
|
|
2058
2052
|
const ct = mimeMap[ext] || 'application/octet-stream';
|
|
2059
2053
|
const stat = fs.statSync(staticPath);
|
|
@@ -2072,6 +2066,133 @@ async function handleRequest(req, res) {
|
|
|
2072
2066
|
return handleServeUpload(req, res, p);
|
|
2073
2067
|
}
|
|
2074
2068
|
|
|
2069
|
+
// Custom emoji listing (no auth — public like static assets)
|
|
2070
|
+
if (method === 'GET' && urlPath === '/api/emoji') {
|
|
2071
|
+
const emojiDir = path.join(__dirname, 'emoji');
|
|
2072
|
+
if (!fs.existsSync(emojiDir)) {
|
|
2073
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2074
|
+
return res.end('[]');
|
|
2075
|
+
}
|
|
2076
|
+
try {
|
|
2077
|
+
const packs = fs.readdirSync(emojiDir).filter(d =>
|
|
2078
|
+
fs.statSync(path.join(emojiDir, d)).isDirectory()
|
|
2079
|
+
);
|
|
2080
|
+
const result = [];
|
|
2081
|
+
for (const pack of packs) {
|
|
2082
|
+
const packDir = path.join(emojiDir, pack);
|
|
2083
|
+
const files = fs.readdirSync(packDir).filter(f =>
|
|
2084
|
+
/\.(png|gif|webp|jpg|jpeg)$/i.test(f)
|
|
2085
|
+
);
|
|
2086
|
+
for (const file of files) {
|
|
2087
|
+
const name = file.replace(/\.[^.]+$/, '');
|
|
2088
|
+
result.push({ name, pack, path: `/emoji/${pack}/${file}` });
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
|
|
2092
|
+
return res.end(JSON.stringify(result));
|
|
2093
|
+
} catch (e) {
|
|
2094
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2095
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Search slackmojis.com (scrapes HTML search since JSON API has no search)
|
|
2100
|
+
if (method === 'GET' && urlPath === '/api/emoji/search') {
|
|
2101
|
+
const q = query.q || '';
|
|
2102
|
+
if (!q) {
|
|
2103
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2104
|
+
return res.end(JSON.stringify({ error: 'Missing ?q= parameter' }));
|
|
2105
|
+
}
|
|
2106
|
+
try {
|
|
2107
|
+
const https = await import('https');
|
|
2108
|
+
const fetchUrl = `https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`;
|
|
2109
|
+
const html = await new Promise((resolve, reject) => {
|
|
2110
|
+
https.default.get(fetchUrl, (resp) => {
|
|
2111
|
+
let body = '';
|
|
2112
|
+
resp.on('data', chunk => body += chunk);
|
|
2113
|
+
resp.on('end', () => resolve(body));
|
|
2114
|
+
}).on('error', reject);
|
|
2115
|
+
});
|
|
2116
|
+
// Parse emoji entries from HTML:
|
|
2117
|
+
// <li class='emoji name' title='name'>
|
|
2118
|
+
// <a ... data-emoji-id="123" data-emoji-id-name="123-name" href="/emojis/123-name/download">
|
|
2119
|
+
// <img ... src="https://emojis.slackmojis.com/emojis/images/.../name.png?..." />
|
|
2120
|
+
const results = [];
|
|
2121
|
+
const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
|
|
2122
|
+
let match;
|
|
2123
|
+
while ((match = regex.exec(html)) !== null && results.length < 50) {
|
|
2124
|
+
const idName = match[1]; // e.g. "57350-sextant"
|
|
2125
|
+
const downloadPath = match[2];
|
|
2126
|
+
const imageUrl = match[3];
|
|
2127
|
+
const name = idName.replace(/^\d+-/, '');
|
|
2128
|
+
results.push({
|
|
2129
|
+
name,
|
|
2130
|
+
image_url: imageUrl,
|
|
2131
|
+
download_url: `https://slackmojis.com${downloadPath}`,
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2135
|
+
return res.end(JSON.stringify(results));
|
|
2136
|
+
} catch (e) {
|
|
2137
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2138
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Download emoji from URL and save to emoji/ directory
|
|
2143
|
+
if (method === 'POST' && urlPath === '/api/emoji/add') {
|
|
2144
|
+
if (!checkAuth(req, res)) return;
|
|
2145
|
+
try {
|
|
2146
|
+
const { url, name, pack } = await parseBody(req);
|
|
2147
|
+
if (!url || !name) {
|
|
2148
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2149
|
+
return res.end(JSON.stringify({ error: 'Missing url or name' }));
|
|
2150
|
+
}
|
|
2151
|
+
const targetPack = pack || 'custom';
|
|
2152
|
+
const emojiDir = path.join(__dirname, 'emoji', targetPack);
|
|
2153
|
+
if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
|
|
2154
|
+
|
|
2155
|
+
// Follow redirects and download
|
|
2156
|
+
const https = await import('https');
|
|
2157
|
+
const http = await import('http');
|
|
2158
|
+
const download = (downloadUrl, redirects = 0) => new Promise((resolve, reject) => {
|
|
2159
|
+
if (redirects > 5) return reject(new Error('Too many redirects'));
|
|
2160
|
+
const mod = downloadUrl.startsWith('https') ? https.default : http.default;
|
|
2161
|
+
mod.get(downloadUrl, (resp) => {
|
|
2162
|
+
if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
|
|
2163
|
+
return resolve(download(resp.headers.location, redirects + 1));
|
|
2164
|
+
}
|
|
2165
|
+
if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
|
|
2166
|
+
const chunks = [];
|
|
2167
|
+
resp.on('data', chunk => chunks.push(chunk));
|
|
2168
|
+
resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
|
|
2169
|
+
}).on('error', reject);
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
const { buffer, contentType } = await download(url);
|
|
2173
|
+
// Determine extension from content-type or URL
|
|
2174
|
+
let ext = 'png';
|
|
2175
|
+
if (contentType.includes('gif')) ext = 'gif';
|
|
2176
|
+
else if (contentType.includes('webp')) ext = 'webp';
|
|
2177
|
+
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
|
|
2178
|
+
else {
|
|
2179
|
+
const urlExt = url.split('?')[0].split('.').pop().toLowerCase();
|
|
2180
|
+
if (['png', 'gif', 'webp', 'jpg', 'jpeg'].includes(urlExt)) ext = urlExt;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
2184
|
+
const filePath = path.join(emojiDir, `${safeName}.${ext}`);
|
|
2185
|
+
fs.writeFileSync(filePath, buffer);
|
|
2186
|
+
|
|
2187
|
+
const result = { name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` };
|
|
2188
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2189
|
+
return res.end(JSON.stringify(result));
|
|
2190
|
+
} catch (e) {
|
|
2191
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2192
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2075
2196
|
// Auth check for all other routes
|
|
2076
2197
|
if (!checkAuth(req, res)) return;
|
|
2077
2198
|
|
|
@@ -2233,14 +2354,24 @@ class GatewayClient {
|
|
|
2233
2354
|
this.maxReconnectDelay = 30000;
|
|
2234
2355
|
this.browserClients = new Map(); // Map<WebSocket, { activeWorkspace, activeThreadId }>
|
|
2235
2356
|
this.streamState = new Map(); // Map<sessionKey, { state, buffer, threadId }>
|
|
2236
|
-
this.
|
|
2357
|
+
this.activityLogs = new Map(); // Map<runId, { sessionKey, steps, startTime }>
|
|
2237
2358
|
|
|
2238
|
-
// Clean up stale
|
|
2359
|
+
// Clean up stale activity logs every 5 minutes (runs that never completed)
|
|
2239
2360
|
setInterval(() => {
|
|
2240
|
-
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
2241
|
-
for (const [runId,
|
|
2242
|
-
if (
|
|
2243
|
-
|
|
2361
|
+
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
2362
|
+
for (const [runId, log] of this.activityLogs) {
|
|
2363
|
+
if (log.startTime < cutoff) {
|
|
2364
|
+
if (log._messageId) {
|
|
2365
|
+
const db = getDb(log._parsed?.workspace);
|
|
2366
|
+
if (db) {
|
|
2367
|
+
db.prepare(`
|
|
2368
|
+
UPDATE messages SET content = '[Response interrupted]',
|
|
2369
|
+
metadata = json_remove(metadata, '$.pending')
|
|
2370
|
+
WHERE id = ? AND content = ''
|
|
2371
|
+
`).run(log._messageId);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
this.activityLogs.delete(runId);
|
|
2244
2375
|
}
|
|
2245
2376
|
}
|
|
2246
2377
|
}, 5 * 60 * 1000);
|
|
@@ -2354,10 +2485,9 @@ class GatewayClient {
|
|
|
2354
2485
|
this.streamState.delete(sessionKey);
|
|
2355
2486
|
}
|
|
2356
2487
|
|
|
2357
|
-
// Save assistant messages on final
|
|
2488
|
+
// Save assistant messages on final
|
|
2358
2489
|
if (state === 'final') {
|
|
2359
|
-
|
|
2360
|
-
this.saveAssistantMessage(sessionKey, message, seq, run?.finalSeq);
|
|
2490
|
+
this.saveAssistantMessage(sessionKey, message, seq);
|
|
2361
2491
|
}
|
|
2362
2492
|
|
|
2363
2493
|
// Save error markers
|
|
@@ -2366,11 +2496,10 @@ class GatewayClient {
|
|
|
2366
2496
|
}
|
|
2367
2497
|
}
|
|
2368
2498
|
|
|
2369
|
-
saveAssistantMessage(sessionKey, message, seq
|
|
2499
|
+
saveAssistantMessage(sessionKey, message, seq) {
|
|
2370
2500
|
const parsed = parseSessionKey(sessionKey);
|
|
2371
|
-
if (!parsed) return;
|
|
2501
|
+
if (!parsed) return;
|
|
2372
2502
|
|
|
2373
|
-
// Guard: verify workspace still exists
|
|
2374
2503
|
const ws = getWorkspaces();
|
|
2375
2504
|
if (!ws.workspaces[parsed.workspace]) {
|
|
2376
2505
|
console.log(`Ignoring response for deleted workspace: ${parsed.workspace}`);
|
|
@@ -2379,52 +2508,66 @@ class GatewayClient {
|
|
|
2379
2508
|
|
|
2380
2509
|
const db = getDb(parsed.workspace);
|
|
2381
2510
|
|
|
2382
|
-
// Guard: verify thread still exists
|
|
2383
2511
|
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2384
2512
|
if (!thread) {
|
|
2385
2513
|
console.log(`Ignoring response for deleted thread: ${parsed.threadId}`);
|
|
2386
2514
|
return;
|
|
2387
2515
|
}
|
|
2388
2516
|
|
|
2389
|
-
// Extract content
|
|
2390
2517
|
const content = extractContent(message);
|
|
2391
|
-
|
|
2392
|
-
// Guard: skip empty content
|
|
2393
2518
|
if (!content || !content.trim()) {
|
|
2394
2519
|
console.log(`Skipping empty assistant response for thread ${parsed.threadId}`);
|
|
2395
2520
|
return;
|
|
2396
2521
|
}
|
|
2397
2522
|
|
|
2398
|
-
// Deterministic message ID from seq (deduplicates tool-call loops)
|
|
2399
2523
|
const now = Date.now();
|
|
2400
|
-
const messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
|
|
2401
2524
|
|
|
2402
|
-
//
|
|
2403
|
-
const
|
|
2525
|
+
// Check for pending activity message
|
|
2526
|
+
const pendingMsg = db.prepare(`
|
|
2527
|
+
SELECT id, metadata FROM messages
|
|
2528
|
+
WHERE thread_id = ? AND role = 'assistant'
|
|
2529
|
+
AND json_extract(metadata, '$.pending') = 1
|
|
2530
|
+
ORDER BY timestamp DESC LIMIT 1
|
|
2531
|
+
`).get(parsed.threadId);
|
|
2532
|
+
|
|
2533
|
+
let messageId;
|
|
2534
|
+
|
|
2535
|
+
if (pendingMsg) {
|
|
2536
|
+
// Merge final content into existing activity row
|
|
2537
|
+
const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
|
|
2538
|
+
delete metadata.pending;
|
|
2539
|
+
|
|
2540
|
+
// Clean up: remove last assistant narration (it's the final reply text)
|
|
2541
|
+
if (metadata.activityLog) {
|
|
2542
|
+
const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
|
|
2543
|
+
if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
|
|
2544
|
+
metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
|
|
2545
|
+
}
|
|
2404
2546
|
|
|
2405
|
-
|
|
2406
|
-
|
|
2547
|
+
db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
|
|
2548
|
+
.run(content, JSON.stringify(metadata), now, pendingMsg.id);
|
|
2549
|
+
|
|
2550
|
+
messageId = pendingMsg.id;
|
|
2551
|
+
} else {
|
|
2552
|
+
// No pending activity — normal INSERT (simple responses, no tools)
|
|
2553
|
+
messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
|
|
2407
2554
|
db.prepare(`
|
|
2408
|
-
INSERT INTO messages (id, thread_id, role,
|
|
2409
|
-
VALUES (?, ?, 'assistant',
|
|
2410
|
-
ON CONFLICT(id) DO UPDATE SET content = excluded.content,
|
|
2411
|
-
`).run(messageId, parsed.threadId, content,
|
|
2555
|
+
INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at)
|
|
2556
|
+
VALUES (?, ?, 'assistant', ?, 'sent', ?, ?)
|
|
2557
|
+
ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp
|
|
2558
|
+
`).run(messageId, parsed.threadId, content, now, now);
|
|
2559
|
+
}
|
|
2412
2560
|
|
|
2413
|
-
|
|
2561
|
+
// Thread timestamp + unreads + broadcast (same for both paths)
|
|
2562
|
+
try {
|
|
2414
2563
|
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
|
|
2415
|
-
|
|
2416
|
-
// Always mark as unread server-side. The browser client is responsible
|
|
2417
|
-
// for sending read receipts (active-thread) to clear unreads — same
|
|
2418
|
-
// pattern as Slack/Discord. Server doesn't track viewport state.
|
|
2419
2564
|
db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
|
|
2420
2565
|
syncThreadUnreadCount(db, parsed.threadId);
|
|
2421
2566
|
|
|
2422
|
-
// Get thread title and unread info for notification
|
|
2423
2567
|
const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2424
2568
|
const unreadCount = db.prepare('SELECT COUNT(*) as c FROM unread_messages WHERE thread_id = ?').get(parsed.threadId).c;
|
|
2425
2569
|
const preview = content.length > 120 ? content.substring(0, 120) + '...' : content;
|
|
2426
2570
|
|
|
2427
|
-
// Broadcast message-saved for active thread reload
|
|
2428
2571
|
this.broadcastToBrowsers(JSON.stringify({
|
|
2429
2572
|
type: 'clawchats',
|
|
2430
2573
|
event: 'message-saved',
|
|
@@ -2437,7 +2580,6 @@ class GatewayClient {
|
|
|
2437
2580
|
unreadCount
|
|
2438
2581
|
}));
|
|
2439
2582
|
|
|
2440
|
-
// Always broadcast unread-update — browser sends read receipts to clear
|
|
2441
2583
|
const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
2442
2584
|
this.broadcastToBrowsers(JSON.stringify({
|
|
2443
2585
|
type: 'clawchats',
|
|
@@ -2453,7 +2595,7 @@ class GatewayClient {
|
|
|
2453
2595
|
timestamp: now
|
|
2454
2596
|
}));
|
|
2455
2597
|
|
|
2456
|
-
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq:
|
|
2598
|
+
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
|
|
2457
2599
|
} catch (e) {
|
|
2458
2600
|
console.error(`Failed to save assistant message:`, e.message);
|
|
2459
2601
|
}
|
|
@@ -2490,242 +2632,183 @@ class GatewayClient {
|
|
|
2490
2632
|
const { runId, stream, data, sessionKey } = payload;
|
|
2491
2633
|
if (!runId) return;
|
|
2492
2634
|
|
|
2493
|
-
// Initialize
|
|
2494
|
-
if (!this.
|
|
2495
|
-
this.
|
|
2635
|
+
// Initialize log if needed
|
|
2636
|
+
if (!this.activityLogs.has(runId)) {
|
|
2637
|
+
this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
|
|
2496
2638
|
}
|
|
2497
|
-
const
|
|
2498
|
-
|
|
2499
|
-
const parsed = parseSessionKey(sessionKey);
|
|
2500
|
-
if (!parsed) return;
|
|
2501
|
-
|
|
2502
|
-
// Verify workspace/thread still exist
|
|
2503
|
-
const ws = getWorkspaces();
|
|
2504
|
-
if (!ws.workspaces[parsed.workspace]) return;
|
|
2505
|
-
const db = getDb(parsed.workspace);
|
|
2506
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2507
|
-
if (!thread) return;
|
|
2639
|
+
const log = this.activityLogs.get(runId);
|
|
2508
2640
|
|
|
2509
2641
|
if (stream === 'assistant') {
|
|
2642
|
+
// Capture intermediate text turns (narration between tool calls)
|
|
2510
2643
|
const text = data?.text || '';
|
|
2511
|
-
if (
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
// Broadcast segment update for live display (throttled ~150ms)
|
|
2526
|
-
const now = Date.now();
|
|
2527
|
-
if (!run.lastTextBroadcast || now - run.lastTextBroadcast >= 150) {
|
|
2528
|
-
run.lastTextBroadcast = now;
|
|
2529
|
-
this.broadcastSegmentUpdate(parsed, runId, {
|
|
2530
|
-
id: run.currentTextSegmentId,
|
|
2531
|
-
type: 'text',
|
|
2532
|
-
seq: run.currentTextSeq,
|
|
2533
|
-
content: run.currentTextContent
|
|
2534
|
-
});
|
|
2644
|
+
if (text) {
|
|
2645
|
+
let currentSegment = log._currentAssistantSegment;
|
|
2646
|
+
if (!currentSegment || currentSegment._sealed) {
|
|
2647
|
+
currentSegment = {
|
|
2648
|
+
type: 'assistant',
|
|
2649
|
+
timestamp: Date.now(),
|
|
2650
|
+
text: text,
|
|
2651
|
+
_sealed: false
|
|
2652
|
+
};
|
|
2653
|
+
log._currentAssistantSegment = currentSegment;
|
|
2654
|
+
log.steps.push(currentSegment);
|
|
2655
|
+
} else {
|
|
2656
|
+
currentSegment.text = text;
|
|
2657
|
+
}
|
|
2535
2658
|
}
|
|
2659
|
+
// Don't broadcast on every assistant delta — too noisy
|
|
2536
2660
|
return;
|
|
2537
2661
|
}
|
|
2538
2662
|
|
|
2539
2663
|
if (stream === 'thinking') {
|
|
2540
2664
|
const thinkingText = data?.text || '';
|
|
2541
|
-
|
|
2542
|
-
if (
|
|
2543
|
-
|
|
2544
|
-
const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
|
|
2545
|
-
run.thinkingSegmentId = segId;
|
|
2546
|
-
run.thinkingSeq = run.seqCounter;
|
|
2547
|
-
const now = Date.now();
|
|
2548
|
-
try {
|
|
2549
|
-
db.prepare(`
|
|
2550
|
-
INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at)
|
|
2551
|
-
VALUES (?, ?, 'assistant', 'thinking', ?, 'sent', ?, ?, ?)
|
|
2552
|
-
`).run(segId, parsed.threadId, thinkingText, run.seqCounter, now, now);
|
|
2553
|
-
} catch (e) {
|
|
2554
|
-
console.error('Failed to save thinking segment:', e.message);
|
|
2555
|
-
}
|
|
2665
|
+
let thinkingStep = log.steps.find(s => s.type === 'thinking');
|
|
2666
|
+
if (thinkingStep) {
|
|
2667
|
+
thinkingStep.text = thinkingText;
|
|
2556
2668
|
} else {
|
|
2557
|
-
|
|
2558
|
-
db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(thinkingText, run.thinkingSegmentId);
|
|
2559
|
-
} catch (e) {
|
|
2560
|
-
console.error('Failed to update thinking segment:', e.message);
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
// Broadcast (throttled ~300ms)
|
|
2565
|
-
const now = Date.now();
|
|
2566
|
-
if (!run.lastThinkingBroadcast || now - run.lastThinkingBroadcast >= 300) {
|
|
2567
|
-
run.lastThinkingBroadcast = now;
|
|
2568
|
-
this.broadcastSegmentUpdate(parsed, runId, {
|
|
2569
|
-
id: run.thinkingSegmentId,
|
|
2669
|
+
log.steps.push({
|
|
2570
2670
|
type: 'thinking',
|
|
2571
|
-
|
|
2572
|
-
|
|
2671
|
+
timestamp: Date.now(),
|
|
2672
|
+
text: thinkingText
|
|
2573
2673
|
});
|
|
2574
2674
|
}
|
|
2575
|
-
|
|
2675
|
+
// Always write to DB; throttle broadcasts to every 300ms
|
|
2676
|
+
this._writeActivityToDb(runId, log);
|
|
2677
|
+
const now = Date.now();
|
|
2678
|
+
if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
|
|
2679
|
+
log._lastThinkingBroadcast = now;
|
|
2680
|
+
this._broadcastActivityUpdate(runId, log);
|
|
2681
|
+
}
|
|
2576
2682
|
}
|
|
2577
2683
|
|
|
2578
2684
|
if (stream === 'tool') {
|
|
2579
|
-
// Seal
|
|
2580
|
-
if (
|
|
2581
|
-
|
|
2582
|
-
const content = run.currentTextContent;
|
|
2583
|
-
const seq = run.currentTextSeq;
|
|
2584
|
-
const ts = run.currentTextTimestamp || Date.now();
|
|
2585
|
-
try {
|
|
2586
|
-
db.prepare(`
|
|
2587
|
-
INSERT OR REPLACE INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at)
|
|
2588
|
-
VALUES (?, ?, 'assistant', 'text', ?, 'sent', ?, ?, ?)
|
|
2589
|
-
`).run(segId, parsed.threadId, content, seq, ts, ts);
|
|
2590
|
-
} catch (e) {
|
|
2591
|
-
console.error('Failed to save sealed text segment:', e.message);
|
|
2592
|
-
}
|
|
2685
|
+
// Seal any current assistant text segment (narration before this tool call)
|
|
2686
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
|
|
2687
|
+
log._currentAssistantSegment._sealed = true;
|
|
2593
2688
|
}
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
try {
|
|
2617
|
-
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId);
|
|
2618
|
-
} catch (e) {
|
|
2619
|
-
console.error('Failed to update tool segment result:', e.message);
|
|
2620
|
-
}
|
|
2621
|
-
this.broadcastSegmentUpdate(parsed, runId, {
|
|
2622
|
-
id: existingId,
|
|
2623
|
-
type: 'tool',
|
|
2624
|
-
seq: run.toolSeqMap.get(toolCallId),
|
|
2625
|
-
metadata
|
|
2626
|
-
});
|
|
2689
|
+
|
|
2690
|
+
const step = {
|
|
2691
|
+
type: 'tool',
|
|
2692
|
+
timestamp: Date.now(),
|
|
2693
|
+
name: data?.name || 'unknown',
|
|
2694
|
+
phase: data?.phase || 'start',
|
|
2695
|
+
toolCallId: data?.toolCallId,
|
|
2696
|
+
meta: data?.meta,
|
|
2697
|
+
isError: data?.isError || false
|
|
2698
|
+
};
|
|
2699
|
+
|
|
2700
|
+
// On result phase, update the existing start step or add new
|
|
2701
|
+
if (data?.phase === 'result') {
|
|
2702
|
+
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
|
|
2703
|
+
if (existing) {
|
|
2704
|
+
existing.phase = 'done';
|
|
2705
|
+
existing.resultMeta = data?.meta;
|
|
2706
|
+
existing.isError = data?.isError || false;
|
|
2707
|
+
existing.durationMs = Date.now() - existing.timestamp;
|
|
2708
|
+
} else {
|
|
2709
|
+
step.phase = 'done';
|
|
2710
|
+
log.steps.push(step);
|
|
2627
2711
|
}
|
|
2628
|
-
} else if (phase === 'update') {
|
|
2629
|
-
const
|
|
2630
|
-
if (
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
meta: data?.meta || ''
|
|
2635
|
-
};
|
|
2636
|
-
try {
|
|
2637
|
-
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId);
|
|
2638
|
-
} catch (e) {
|
|
2639
|
-
console.error('Failed to update tool segment:', e.message);
|
|
2640
|
-
}
|
|
2641
|
-
this.broadcastSegmentUpdate(parsed, runId, {
|
|
2642
|
-
id: existingId,
|
|
2643
|
-
type: 'tool',
|
|
2644
|
-
seq: run.toolSeqMap.get(toolCallId),
|
|
2645
|
-
metadata
|
|
2646
|
-
});
|
|
2712
|
+
} else if (data?.phase === 'update') {
|
|
2713
|
+
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
|
|
2714
|
+
if (existing) {
|
|
2715
|
+
if (data?.meta) existing.resultMeta = data.meta;
|
|
2716
|
+
if (data?.isError) existing.isError = true;
|
|
2717
|
+
existing.phase = 'running';
|
|
2647
2718
|
}
|
|
2648
2719
|
} else {
|
|
2649
|
-
|
|
2650
|
-
run.seqCounter++;
|
|
2651
|
-
const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
|
|
2652
|
-
const now = Date.now();
|
|
2653
|
-
const metadata = {
|
|
2654
|
-
name: data?.name || 'unknown',
|
|
2655
|
-
status: 'running',
|
|
2656
|
-
meta: data?.meta || ''
|
|
2657
|
-
};
|
|
2658
|
-
try {
|
|
2659
|
-
db.prepare(`
|
|
2660
|
-
INSERT INTO messages (id, thread_id, role, type, content, status, metadata, seq, timestamp, created_at)
|
|
2661
|
-
VALUES (?, ?, 'assistant', 'tool', '', 'sent', ?, ?, ?, ?)
|
|
2662
|
-
`).run(segId, parsed.threadId, JSON.stringify(metadata), run.seqCounter, now, now);
|
|
2663
|
-
} catch (e) {
|
|
2664
|
-
console.error('Failed to save tool segment:', e.message);
|
|
2665
|
-
}
|
|
2666
|
-
run.toolSegmentMap.set(toolCallId, segId);
|
|
2667
|
-
run.toolSeqMap.set(toolCallId, run.seqCounter);
|
|
2668
|
-
run.toolStartTimes.set(toolCallId, now);
|
|
2669
|
-
this.broadcastSegmentUpdate(parsed, runId, {
|
|
2670
|
-
id: segId,
|
|
2671
|
-
type: 'tool',
|
|
2672
|
-
seq: run.seqCounter,
|
|
2673
|
-
metadata
|
|
2674
|
-
});
|
|
2720
|
+
log.steps.push(step);
|
|
2675
2721
|
}
|
|
2676
|
-
|
|
2722
|
+
|
|
2723
|
+
this._writeActivityToDb(runId, log);
|
|
2724
|
+
this._broadcastActivityUpdate(runId, log);
|
|
2677
2725
|
}
|
|
2678
2726
|
|
|
2679
2727
|
if (stream === 'lifecycle') {
|
|
2680
2728
|
if (data?.phase === 'end' || data?.phase === 'error') {
|
|
2681
|
-
//
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
});
|
|
2690
|
-
// Store seq for handleChatEvent.final to position final message correctly
|
|
2691
|
-
run.finalSeq = run.currentTextSeq;
|
|
2692
|
-
run.currentTextSegmentId = null;
|
|
2693
|
-
run.currentTextContent = null;
|
|
2694
|
-
} else {
|
|
2695
|
-
// No active text segment — final seq is next position
|
|
2696
|
-
run.finalSeq = run.seqCounter + 1;
|
|
2729
|
+
// Seal any remaining assistant segment
|
|
2730
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) {
|
|
2731
|
+
log._currentAssistantSegment._sealed = true;
|
|
2732
|
+
}
|
|
2733
|
+
// Remove the final assistant segment — that's the actual response, not narration
|
|
2734
|
+
const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
|
|
2735
|
+
if (lastAssistantIdx >= 0) {
|
|
2736
|
+
log.steps.splice(lastAssistantIdx, 1);
|
|
2697
2737
|
}
|
|
2698
2738
|
|
|
2699
|
-
//
|
|
2700
|
-
this.
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
workspace: parsed.workspace,
|
|
2704
|
-
threadId: parsed.threadId,
|
|
2705
|
-
runId
|
|
2706
|
-
}));
|
|
2707
|
-
|
|
2708
|
-
// Keep run state briefly for handleChatEvent.final to read finalSeq
|
|
2709
|
-
setTimeout(() => this.runState.delete(runId), 30000);
|
|
2739
|
+
// Write final state to DB, then clean up
|
|
2740
|
+
this._writeActivityToDb(runId, log);
|
|
2741
|
+
this.activityLogs.delete(runId);
|
|
2742
|
+
return;
|
|
2710
2743
|
}
|
|
2711
2744
|
}
|
|
2712
2745
|
}
|
|
2713
2746
|
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2747
|
+
_writeActivityToDb(runId, log) {
|
|
2748
|
+
if (!log._parsed) {
|
|
2749
|
+
log._parsed = parseSessionKey(log.sessionKey);
|
|
2750
|
+
}
|
|
2751
|
+
const parsed = log._parsed;
|
|
2752
|
+
if (!parsed) return;
|
|
2753
|
+
|
|
2754
|
+
const db = getDb(parsed.workspace);
|
|
2755
|
+
if (!db) return;
|
|
2756
|
+
|
|
2757
|
+
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
2758
|
+
const summary = this.generateActivitySummary(log.steps);
|
|
2759
|
+
const now = Date.now();
|
|
2760
|
+
|
|
2761
|
+
if (!log._messageId) {
|
|
2762
|
+
// First write — INSERT the assistant message row
|
|
2763
|
+
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
2764
|
+
if (!thread) return;
|
|
2765
|
+
|
|
2766
|
+
const messageId = `gw-activity-${runId}`;
|
|
2767
|
+
const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
|
|
2768
|
+
|
|
2769
|
+
db.prepare(`
|
|
2770
|
+
INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
|
|
2771
|
+
VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
|
|
2772
|
+
`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
|
|
2773
|
+
|
|
2774
|
+
log._messageId = messageId;
|
|
2775
|
+
|
|
2776
|
+
// First event — broadcast message-saved so browser creates the message element
|
|
2777
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
2778
|
+
type: 'clawchats',
|
|
2779
|
+
event: 'message-saved',
|
|
2780
|
+
threadId: parsed.threadId,
|
|
2781
|
+
workspace: parsed.workspace,
|
|
2782
|
+
messageId,
|
|
2783
|
+
timestamp: now
|
|
2784
|
+
}));
|
|
2785
|
+
} else {
|
|
2786
|
+
// Subsequent writes — UPDATE metadata on existing row
|
|
2787
|
+
const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
|
|
2788
|
+
const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
|
|
2789
|
+
metadata.activityLog = cleanSteps;
|
|
2790
|
+
metadata.activitySummary = summary;
|
|
2791
|
+
metadata.pending = true;
|
|
2792
|
+
|
|
2793
|
+
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
|
|
2794
|
+
.run(JSON.stringify(metadata), log._messageId);
|
|
2717
2795
|
}
|
|
2718
|
-
return null;
|
|
2719
2796
|
}
|
|
2720
2797
|
|
|
2721
|
-
|
|
2798
|
+
_broadcastActivityUpdate(runId, log) {
|
|
2799
|
+
const parsed = log._parsed;
|
|
2800
|
+
if (!parsed || !log._messageId) return;
|
|
2801
|
+
|
|
2802
|
+
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
2803
|
+
|
|
2722
2804
|
this.broadcastToBrowsers(JSON.stringify({
|
|
2723
2805
|
type: 'clawchats',
|
|
2724
|
-
event: '
|
|
2806
|
+
event: 'activity-updated',
|
|
2725
2807
|
workspace: parsed.workspace,
|
|
2726
2808
|
threadId: parsed.threadId,
|
|
2727
|
-
|
|
2728
|
-
|
|
2809
|
+
messageId: log._messageId,
|
|
2810
|
+
activityLog: cleanSteps,
|
|
2811
|
+
activitySummary: this.generateActivitySummary(log.steps)
|
|
2729
2812
|
}));
|
|
2730
2813
|
}
|
|
2731
2814
|
|
|
@@ -3292,10 +3375,14 @@ export function createApp(config = {}) {
|
|
|
3292
3375
|
return sendError(res, 400, 'Required: id, role, content, timestamp');
|
|
3293
3376
|
}
|
|
3294
3377
|
const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
|
|
3295
|
-
const existing = db.prepare('SELECT id, status FROM messages WHERE id = ?').get(body.id);
|
|
3378
|
+
const existing = db.prepare('SELECT id, status, metadata FROM messages WHERE id = ?').get(body.id);
|
|
3296
3379
|
if (existing) {
|
|
3297
|
-
|
|
3298
|
-
|
|
3380
|
+
const newStatus = body.status || existing.status;
|
|
3381
|
+
const statusChanged = body.status && body.status !== existing.status;
|
|
3382
|
+
if (statusChanged || metadata) {
|
|
3383
|
+
// Only overwrite metadata if new metadata is provided; otherwise preserve existing
|
|
3384
|
+
const finalMetadata = metadata || existing.metadata || null;
|
|
3385
|
+
db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(newStatus, body.content, finalMetadata, body.id);
|
|
3299
3386
|
}
|
|
3300
3387
|
} else {
|
|
3301
3388
|
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());
|
|
@@ -3548,11 +3635,23 @@ export function createApp(config = {}) {
|
|
|
3548
3635
|
this.browserClients = new Map();
|
|
3549
3636
|
this._externalBroadcastTargets = [];
|
|
3550
3637
|
this.streamState = new Map();
|
|
3551
|
-
this.
|
|
3638
|
+
this.activityLogs = new Map();
|
|
3552
3639
|
setInterval(() => {
|
|
3553
3640
|
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
3554
|
-
for (const [runId,
|
|
3555
|
-
if (
|
|
3641
|
+
for (const [runId, log] of this.activityLogs) {
|
|
3642
|
+
if (log.startTime < cutoff) {
|
|
3643
|
+
if (log._messageId) {
|
|
3644
|
+
const db = _getDb(log._parsed?.workspace);
|
|
3645
|
+
if (db) {
|
|
3646
|
+
db.prepare(`
|
|
3647
|
+
UPDATE messages SET content = '[Response interrupted]',
|
|
3648
|
+
metadata = json_remove(metadata, '$.pending')
|
|
3649
|
+
WHERE id = ? AND content = ''
|
|
3650
|
+
`).run(log._messageId);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
this.activityLogs.delete(runId);
|
|
3654
|
+
}
|
|
3556
3655
|
}
|
|
3557
3656
|
}, 5 * 60 * 1000);
|
|
3558
3657
|
}
|
|
@@ -3601,11 +3700,11 @@ export function createApp(config = {}) {
|
|
|
3601
3700
|
return;
|
|
3602
3701
|
}
|
|
3603
3702
|
if (state === 'final' || state === 'aborted' || state === 'error') this.streamState.delete(sessionKey);
|
|
3604
|
-
if (state === 'final')
|
|
3703
|
+
if (state === 'final') this.saveAssistantMessage(sessionKey, message, seq);
|
|
3605
3704
|
if (state === 'error') this.saveErrorMarker(sessionKey, message);
|
|
3606
3705
|
}
|
|
3607
3706
|
|
|
3608
|
-
saveAssistantMessage(sessionKey, message, seq
|
|
3707
|
+
saveAssistantMessage(sessionKey, message, seq) {
|
|
3609
3708
|
const parsed = parseSessionKey(sessionKey);
|
|
3610
3709
|
if (!parsed) return;
|
|
3611
3710
|
const ws = _getWorkspaces();
|
|
@@ -3616,12 +3715,37 @@ export function createApp(config = {}) {
|
|
|
3616
3715
|
const content = extractContent(message);
|
|
3617
3716
|
if (!content || !content.trim()) { console.log(`Skipping empty assistant response for thread ${parsed.threadId}`); return; }
|
|
3618
3717
|
const now = Date.now();
|
|
3619
|
-
|
|
3620
|
-
|
|
3718
|
+
|
|
3719
|
+
// Check for pending activity message
|
|
3720
|
+
const pendingMsg = db.prepare(`
|
|
3721
|
+
SELECT id, metadata FROM messages
|
|
3722
|
+
WHERE thread_id = ? AND role = 'assistant'
|
|
3723
|
+
AND json_extract(metadata, '$.pending') = 1
|
|
3724
|
+
ORDER BY timestamp DESC LIMIT 1
|
|
3725
|
+
`).get(parsed.threadId);
|
|
3726
|
+
|
|
3727
|
+
let messageId;
|
|
3728
|
+
|
|
3729
|
+
if (pendingMsg) {
|
|
3730
|
+
// Merge final content into existing activity row
|
|
3731
|
+
const metadata = pendingMsg.metadata ? JSON.parse(pendingMsg.metadata) : {};
|
|
3732
|
+
delete metadata.pending;
|
|
3733
|
+
if (metadata.activityLog) {
|
|
3734
|
+
const lastAssistantIdx = metadata.activityLog.findLastIndex(s => s.type === 'assistant');
|
|
3735
|
+
if (lastAssistantIdx >= 0) metadata.activityLog.splice(lastAssistantIdx, 1);
|
|
3736
|
+
metadata.activitySummary = this.generateActivitySummary(metadata.activityLog);
|
|
3737
|
+
}
|
|
3738
|
+
db.prepare('UPDATE messages SET content = ?, metadata = ?, timestamp = ? WHERE id = ?')
|
|
3739
|
+
.run(content, JSON.stringify(metadata), now, pendingMsg.id);
|
|
3740
|
+
messageId = pendingMsg.id;
|
|
3741
|
+
} else {
|
|
3742
|
+
// No pending activity — normal INSERT (simple responses, no tools)
|
|
3743
|
+
messageId = seq != null ? `gw-${parsed.threadId}-${seq}` : `gw-${parsed.threadId}-${now}`;
|
|
3744
|
+
db.prepare(`INSERT INTO messages (id, thread_id, role, content, status, timestamp, created_at) VALUES (?, ?, 'assistant', ?, 'sent', ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, timestamp = excluded.timestamp`).run(messageId, parsed.threadId, content, now, now);
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3621
3747
|
try {
|
|
3622
|
-
db.prepare('INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'text\', ?, \'sent\', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET content = excluded.content, seq = excluded.seq, timestamp = excluded.timestamp').run(messageId, parsed.threadId, content, msgSeq, now, now);
|
|
3623
3748
|
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(now, parsed.threadId);
|
|
3624
|
-
// Always mark as unread — browser sends read receipts to clear
|
|
3625
3749
|
db.prepare('INSERT OR IGNORE INTO unread_messages (thread_id, message_id, created_at) VALUES (?, ?, ?)').run(parsed.threadId, messageId, now);
|
|
3626
3750
|
syncThreadUnreadCount(db, parsed.threadId);
|
|
3627
3751
|
const threadInfo = db.prepare('SELECT title FROM threads WHERE id = ?').get(parsed.threadId);
|
|
@@ -3630,7 +3754,7 @@ export function createApp(config = {}) {
|
|
|
3630
3754
|
this.broadcastToBrowsers(JSON.stringify({ type: 'clawchats', event: 'message-saved', threadId: parsed.threadId, workspace: parsed.workspace, messageId, timestamp: now, title: threadInfo?.title || 'Chat', preview, unreadCount }));
|
|
3631
3755
|
const workspaceUnreadTotal = db.prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
3632
3756
|
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 }));
|
|
3633
|
-
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (seq:
|
|
3757
|
+
console.log(`Saved assistant message to ${parsed.workspace}/${parsed.threadId} (${pendingMsg ? 'merged into pending' : 'seq: ' + seq})`);
|
|
3634
3758
|
} catch (e) { console.error(`Failed to save assistant message:`, e.message); }
|
|
3635
3759
|
}
|
|
3636
3760
|
|
|
@@ -3655,114 +3779,121 @@ export function createApp(config = {}) {
|
|
|
3655
3779
|
handleAgentEvent(payload) {
|
|
3656
3780
|
const { runId, stream, data, sessionKey } = payload;
|
|
3657
3781
|
if (!runId) return;
|
|
3658
|
-
if (!this.
|
|
3659
|
-
const
|
|
3660
|
-
const parsed = parseSessionKey(sessionKey);
|
|
3661
|
-
if (!parsed) return;
|
|
3662
|
-
const wsData = _getWorkspaces();
|
|
3663
|
-
if (!wsData.workspaces[parsed.workspace]) return;
|
|
3664
|
-
const db = _getDb(parsed.workspace);
|
|
3665
|
-
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
3666
|
-
if (!thread) return;
|
|
3667
|
-
|
|
3782
|
+
if (!this.activityLogs.has(runId)) this.activityLogs.set(runId, { sessionKey, steps: [], startTime: Date.now() });
|
|
3783
|
+
const log = this.activityLogs.get(runId);
|
|
3668
3784
|
if (stream === 'assistant') {
|
|
3669
3785
|
const text = data?.text || '';
|
|
3670
|
-
if (
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
} else { run.currentTextContent = text; }
|
|
3678
|
-
const now = Date.now();
|
|
3679
|
-
if (!run.lastTextBroadcast || now - run.lastTextBroadcast >= 150) {
|
|
3680
|
-
run.lastTextBroadcast = now;
|
|
3681
|
-
this.broadcastSegmentUpdate(parsed, runId, { id: run.currentTextSegmentId, type: 'text', seq: run.currentTextSeq, content: run.currentTextContent });
|
|
3786
|
+
if (text) {
|
|
3787
|
+
let currentSegment = log._currentAssistantSegment;
|
|
3788
|
+
if (!currentSegment || currentSegment._sealed) {
|
|
3789
|
+
currentSegment = { type: 'assistant', timestamp: Date.now(), text, _sealed: false };
|
|
3790
|
+
log._currentAssistantSegment = currentSegment;
|
|
3791
|
+
log.steps.push(currentSegment);
|
|
3792
|
+
} else { currentSegment.text = text; }
|
|
3682
3793
|
}
|
|
3683
3794
|
return;
|
|
3684
3795
|
}
|
|
3685
|
-
|
|
3686
3796
|
if (stream === 'thinking') {
|
|
3687
3797
|
const thinkingText = data?.text || '';
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
run.thinkingSeq = run.seqCounter;
|
|
3693
|
-
const now = Date.now();
|
|
3694
|
-
try { db.prepare('INSERT INTO messages (id, thread_id, role, type, content, status, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'thinking\', ?, \'sent\', ?, ?, ?)').run(segId, parsed.threadId, thinkingText, run.seqCounter, now, now); } catch (e) { console.error('Failed to save thinking segment:', e.message); }
|
|
3695
|
-
} else {
|
|
3696
|
-
try { db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(thinkingText, run.thinkingSegmentId); } catch (e) { console.error('Failed to update thinking segment:', e.message); }
|
|
3697
|
-
}
|
|
3798
|
+
let thinkingStep = log.steps.find(s => s.type === 'thinking');
|
|
3799
|
+
if (thinkingStep) { thinkingStep.text = thinkingText; }
|
|
3800
|
+
else { log.steps.push({ type: 'thinking', timestamp: Date.now(), text: thinkingText }); }
|
|
3801
|
+
this._writeActivityToDb(runId, log);
|
|
3698
3802
|
const now = Date.now();
|
|
3699
|
-
if (!
|
|
3700
|
-
|
|
3701
|
-
this.
|
|
3803
|
+
if (!log._lastThinkingBroadcast || now - log._lastThinkingBroadcast >= 300) {
|
|
3804
|
+
log._lastThinkingBroadcast = now;
|
|
3805
|
+
this._broadcastActivityUpdate(runId, log);
|
|
3702
3806
|
}
|
|
3703
|
-
return;
|
|
3704
3807
|
}
|
|
3705
|
-
|
|
3706
3808
|
if (stream === 'tool') {
|
|
3707
|
-
if (
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
if (
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
if (existingId) {
|
|
3720
|
-
const durationMs = run.toolStartTimes.get(toolCallId) ? Date.now() - run.toolStartTimes.get(toolCallId) : null;
|
|
3721
|
-
const metadata = { name: data?.name || 'unknown', status: 'done', meta: data?.meta || '', isError: data?.isError || false, durationMs };
|
|
3722
|
-
try { db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId); } catch (e) { console.error('Failed to update tool segment result:', e.message); }
|
|
3723
|
-
this.broadcastSegmentUpdate(parsed, runId, { id: existingId, type: 'tool', seq: run.toolSeqMap.get(toolCallId), metadata });
|
|
3724
|
-
}
|
|
3725
|
-
} else if (phase === 'update') {
|
|
3726
|
-
const existingId = run.toolSegmentMap.get(toolCallId);
|
|
3727
|
-
if (existingId) {
|
|
3728
|
-
const metadata = { name: data?.name || 'unknown', status: 'running', meta: data?.meta || '' };
|
|
3729
|
-
try { db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), existingId); } catch (e) { console.error('Failed to update tool segment:', e.message); }
|
|
3730
|
-
this.broadcastSegmentUpdate(parsed, runId, { id: existingId, type: 'tool', seq: run.toolSeqMap.get(toolCallId), metadata });
|
|
3731
|
-
}
|
|
3732
|
-
} else {
|
|
3733
|
-
run.seqCounter++;
|
|
3734
|
-
const segId = `seg-${parsed.threadId}-${runId}-${run.seqCounter}`;
|
|
3735
|
-
const now = Date.now();
|
|
3736
|
-
const metadata = { name: data?.name || 'unknown', status: 'running', meta: data?.meta || '' };
|
|
3737
|
-
try { db.prepare('INSERT INTO messages (id, thread_id, role, type, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, \'assistant\', \'tool\', \'\', \'sent\', ?, ?, ?, ?)').run(segId, parsed.threadId, JSON.stringify(metadata), run.seqCounter, now, now); } catch (e) { console.error('Failed to save tool segment:', e.message); }
|
|
3738
|
-
run.toolSegmentMap.set(toolCallId, segId); run.toolSeqMap.set(toolCallId, run.seqCounter); run.toolStartTimes.set(toolCallId, now);
|
|
3739
|
-
this.broadcastSegmentUpdate(parsed, runId, { id: segId, type: 'tool', seq: run.seqCounter, metadata });
|
|
3740
|
-
}
|
|
3741
|
-
return;
|
|
3809
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) { log._currentAssistantSegment._sealed = true; }
|
|
3810
|
+
const step = { type: 'tool', timestamp: Date.now(), name: data?.name || 'unknown', phase: data?.phase || 'start', toolCallId: data?.toolCallId, meta: data?.meta, isError: data?.isError || false };
|
|
3811
|
+
if (data?.phase === 'result') {
|
|
3812
|
+
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId && (s.phase === 'start' || s.phase === 'running'));
|
|
3813
|
+
if (existing) { existing.phase = 'done'; existing.resultMeta = data?.meta; existing.isError = data?.isError || false; existing.durationMs = Date.now() - existing.timestamp; }
|
|
3814
|
+
else { step.phase = 'done'; log.steps.push(step); }
|
|
3815
|
+
} else if (data?.phase === 'update') {
|
|
3816
|
+
const existing = log.steps.findLast(s => s.toolCallId === data.toolCallId);
|
|
3817
|
+
if (existing) { if (data?.meta) existing.resultMeta = data.meta; if (data?.isError) existing.isError = true; existing.phase = 'running'; }
|
|
3818
|
+
} else { log.steps.push(step); }
|
|
3819
|
+
this._writeActivityToDb(runId, log);
|
|
3820
|
+
this._broadcastActivityUpdate(runId, log);
|
|
3742
3821
|
}
|
|
3743
|
-
|
|
3744
3822
|
if (stream === 'lifecycle') {
|
|
3745
3823
|
if (data?.phase === 'end' || data?.phase === 'error') {
|
|
3746
|
-
if (
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
setTimeout(() => this.runState.delete(runId), 30000);
|
|
3824
|
+
if (log._currentAssistantSegment && !log._currentAssistantSegment._sealed) log._currentAssistantSegment._sealed = true;
|
|
3825
|
+
const lastAssistantIdx = log.steps.findLastIndex(s => s.type === 'assistant');
|
|
3826
|
+
if (lastAssistantIdx >= 0) log.steps.splice(lastAssistantIdx, 1);
|
|
3827
|
+
this._writeActivityToDb(runId, log);
|
|
3828
|
+
this.activityLogs.delete(runId);
|
|
3829
|
+
return;
|
|
3753
3830
|
}
|
|
3754
3831
|
}
|
|
3755
3832
|
}
|
|
3756
3833
|
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3834
|
+
_writeActivityToDb(runId, log) {
|
|
3835
|
+
if (!log._parsed) {
|
|
3836
|
+
log._parsed = parseSessionKey(log.sessionKey);
|
|
3837
|
+
}
|
|
3838
|
+
const parsed = log._parsed;
|
|
3839
|
+
if (!parsed) return;
|
|
3840
|
+
|
|
3841
|
+
const db = _getDb(parsed.workspace);
|
|
3842
|
+
if (!db) return;
|
|
3843
|
+
|
|
3844
|
+
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
3845
|
+
const summary = this.generateActivitySummary(log.steps);
|
|
3846
|
+
const now = Date.now();
|
|
3847
|
+
|
|
3848
|
+
if (!log._messageId) {
|
|
3849
|
+
const thread = db.prepare('SELECT id FROM threads WHERE id = ?').get(parsed.threadId);
|
|
3850
|
+
if (!thread) return;
|
|
3851
|
+
|
|
3852
|
+
const messageId = `gw-activity-${runId}`;
|
|
3853
|
+
const metadata = { activityLog: cleanSteps, activitySummary: summary, pending: true };
|
|
3854
|
+
|
|
3855
|
+
db.prepare(`
|
|
3856
|
+
INSERT INTO messages (id, thread_id, role, content, status, metadata, timestamp, created_at)
|
|
3857
|
+
VALUES (?, ?, 'assistant', '', 'sent', ?, ?, ?)
|
|
3858
|
+
`).run(messageId, parsed.threadId, JSON.stringify(metadata), now, now);
|
|
3859
|
+
|
|
3860
|
+
log._messageId = messageId;
|
|
3861
|
+
|
|
3862
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
3863
|
+
type: 'clawchats',
|
|
3864
|
+
event: 'message-saved',
|
|
3865
|
+
threadId: parsed.threadId,
|
|
3866
|
+
workspace: parsed.workspace,
|
|
3867
|
+
messageId,
|
|
3868
|
+
timestamp: now
|
|
3869
|
+
}));
|
|
3870
|
+
} else {
|
|
3871
|
+
const existing = db.prepare('SELECT metadata FROM messages WHERE id = ?').get(log._messageId);
|
|
3872
|
+
const metadata = existing?.metadata ? JSON.parse(existing.metadata) : {};
|
|
3873
|
+
metadata.activityLog = cleanSteps;
|
|
3874
|
+
metadata.activitySummary = summary;
|
|
3875
|
+
metadata.pending = true;
|
|
3876
|
+
|
|
3877
|
+
db.prepare('UPDATE messages SET metadata = ? WHERE id = ?')
|
|
3878
|
+
.run(JSON.stringify(metadata), log._messageId);
|
|
3760
3879
|
}
|
|
3761
|
-
return null;
|
|
3762
3880
|
}
|
|
3763
3881
|
|
|
3764
|
-
|
|
3765
|
-
|
|
3882
|
+
_broadcastActivityUpdate(runId, log) {
|
|
3883
|
+
const parsed = log._parsed;
|
|
3884
|
+
if (!parsed || !log._messageId) return;
|
|
3885
|
+
|
|
3886
|
+
const cleanSteps = log.steps.map(s => { const c = {...s}; delete c._sealed; return c; });
|
|
3887
|
+
|
|
3888
|
+
this.broadcastToBrowsers(JSON.stringify({
|
|
3889
|
+
type: 'clawchats',
|
|
3890
|
+
event: 'activity-updated',
|
|
3891
|
+
workspace: parsed.workspace,
|
|
3892
|
+
threadId: parsed.threadId,
|
|
3893
|
+
messageId: log._messageId,
|
|
3894
|
+
activityLog: cleanSteps,
|
|
3895
|
+
activitySummary: this.generateActivitySummary(log.steps)
|
|
3896
|
+
}));
|
|
3766
3897
|
}
|
|
3767
3898
|
|
|
3768
3899
|
generateActivitySummary(steps) {
|
|
@@ -3870,11 +4001,12 @@ export function createApp(config = {}) {
|
|
|
3870
4001
|
const isIcon = urlPath.startsWith('/icons/');
|
|
3871
4002
|
const isLib = urlPath.startsWith('/lib/');
|
|
3872
4003
|
const isFrontend = urlPath.startsWith('/frontend/');
|
|
4004
|
+
const isEmoji = urlPath.startsWith('/emoji/');
|
|
3873
4005
|
const isConfig = urlPath === '/config.js';
|
|
3874
|
-
const staticPath = fileName ? path.join(__dirname, fileName) : (isIcon || isLib || isFrontend || isConfig) ? path.join(__dirname, urlPath.slice(1)) : null;
|
|
4006
|
+
const staticPath = fileName ? path.join(__dirname, fileName) : (isIcon || isLib || isFrontend || isEmoji || isConfig) ? path.join(__dirname, urlPath.slice(1)) : null;
|
|
3875
4007
|
if (staticPath && fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
|
|
3876
4008
|
const ext = path.extname(staticPath).toLowerCase();
|
|
3877
|
-
const mimeMap = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml' };
|
|
4009
|
+
const mimeMap = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
3878
4010
|
const ct = mimeMap[ext] || 'application/octet-stream';
|
|
3879
4011
|
const stat = fs.statSync(staticPath);
|
|
3880
4012
|
res.writeHead(200, { 'Content-Type': ct, 'Content-Length': stat.size, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' });
|
|
@@ -3885,8 +4017,79 @@ export function createApp(config = {}) {
|
|
|
3885
4017
|
let p;
|
|
3886
4018
|
if ((p = matchRoute(method, urlPath, 'GET /api/uploads/:threadId/:fileId'))) return _handleServeUpload(req, res, p);
|
|
3887
4019
|
|
|
4020
|
+
// Custom emoji listing (no auth)
|
|
4021
|
+
if (method === 'GET' && urlPath === '/api/emoji') {
|
|
4022
|
+
const emojiDir = path.join(__dirname, 'emoji');
|
|
4023
|
+
if (!fs.existsSync(emojiDir)) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('[]'); }
|
|
4024
|
+
try {
|
|
4025
|
+
const packs = fs.readdirSync(emojiDir).filter(d => fs.statSync(path.join(emojiDir, d)).isDirectory());
|
|
4026
|
+
const result = [];
|
|
4027
|
+
for (const pack of packs) {
|
|
4028
|
+
const files = fs.readdirSync(path.join(emojiDir, pack)).filter(f => /\.(png|gif|webp|jpg|jpeg)$/i.test(f));
|
|
4029
|
+
for (const file of files) result.push({ name: file.replace(/\.[^.]+$/, ''), pack, path: `/emoji/${pack}/${file}` });
|
|
4030
|
+
}
|
|
4031
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' });
|
|
4032
|
+
return res.end(JSON.stringify(result));
|
|
4033
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
// Search slackmojis.com (no auth, proxied)
|
|
4037
|
+
if (method === 'GET' && urlPath === '/api/emoji/search') {
|
|
4038
|
+
const q = query.q || '';
|
|
4039
|
+
if (!q) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing ?q=' })); }
|
|
4040
|
+
try {
|
|
4041
|
+
const https = await import('https');
|
|
4042
|
+
const html = await new Promise((resolve, reject) => {
|
|
4043
|
+
https.default.get(`https://slackmojis.com/emojis/search?query=${encodeURIComponent(q)}`, (resp) => {
|
|
4044
|
+
let body = ''; resp.on('data', c => body += c); resp.on('end', () => resolve(body));
|
|
4045
|
+
}).on('error', reject);
|
|
4046
|
+
});
|
|
4047
|
+
const results = [];
|
|
4048
|
+
const regex = /data-emoji-id-name="([^"]+)"[^>]*href="([^"]+)"[\s\S]*?<img[^>]*src="([^"]+)"/g;
|
|
4049
|
+
let match;
|
|
4050
|
+
while ((match = regex.exec(html)) !== null && results.length < 50) {
|
|
4051
|
+
const name = match[1].replace(/^\d+-/, '');
|
|
4052
|
+
results.push({ name, image_url: match[3], download_url: `https://slackmojis.com${match[2]}` });
|
|
4053
|
+
}
|
|
4054
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4055
|
+
return res.end(JSON.stringify(results));
|
|
4056
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
4057
|
+
}
|
|
4058
|
+
|
|
3888
4059
|
if (!_checkAuth(req, res)) return;
|
|
3889
4060
|
|
|
4061
|
+
// Download emoji from URL (auth required)
|
|
4062
|
+
if (method === 'POST' && urlPath === '/api/emoji/add') {
|
|
4063
|
+
try {
|
|
4064
|
+
const { url, name, pack } = await parseBody(req);
|
|
4065
|
+
if (!url || !name) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'Missing url or name' })); }
|
|
4066
|
+
const targetPack = pack || 'custom';
|
|
4067
|
+
const emojiDir = path.join(__dirname, 'emoji', targetPack);
|
|
4068
|
+
if (!fs.existsSync(emojiDir)) fs.mkdirSync(emojiDir, { recursive: true });
|
|
4069
|
+
const https = await import('https');
|
|
4070
|
+
const http = await import('http');
|
|
4071
|
+
const download = (dlUrl, redirects = 0) => new Promise((resolve, reject) => {
|
|
4072
|
+
if (redirects > 5) return reject(new Error('Too many redirects'));
|
|
4073
|
+
const mod = dlUrl.startsWith('https') ? https.default : http.default;
|
|
4074
|
+
mod.get(dlUrl, (resp) => {
|
|
4075
|
+
if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) return resolve(download(resp.headers.location, redirects + 1));
|
|
4076
|
+
if (resp.statusCode !== 200) return reject(new Error(`HTTP ${resp.statusCode}`));
|
|
4077
|
+
const chunks = []; resp.on('data', c => chunks.push(c)); resp.on('end', () => resolve({ buffer: Buffer.concat(chunks), contentType: resp.headers['content-type'] || '' }));
|
|
4078
|
+
}).on('error', reject);
|
|
4079
|
+
});
|
|
4080
|
+
const { buffer, contentType } = await download(url);
|
|
4081
|
+
let ext = 'png';
|
|
4082
|
+
if (contentType.includes('gif')) ext = 'gif';
|
|
4083
|
+
else if (contentType.includes('webp')) ext = 'webp';
|
|
4084
|
+
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = 'jpg';
|
|
4085
|
+
else { const u = url.split('?')[0].split('.').pop().toLowerCase(); if (['png','gif','webp','jpg','jpeg'].includes(u)) ext = u; }
|
|
4086
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
4087
|
+
fs.writeFileSync(path.join(emojiDir, `${safeName}.${ext}`), buffer);
|
|
4088
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
4089
|
+
return res.end(JSON.stringify({ name: safeName, pack: targetPack, path: `/emoji/${targetPack}/${safeName}.${ext}` }));
|
|
4090
|
+
} catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: e.message })); }
|
|
4091
|
+
}
|
|
4092
|
+
|
|
3890
4093
|
try {
|
|
3891
4094
|
if (method === 'GET' && urlPath === '/api/file') return handleServeFile(req, res, query);
|
|
3892
4095
|
if (method === 'GET' && urlPath === '/api/workspace') return handleWorkspaceList(req, res, query);
|