@clawchatsai/connector 0.0.86 → 0.0.87
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/LICENSE +661 -0
- package/README.md +67 -13
- package/dist/index.js +0 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +9 -5
- package/server/bootstrap/identity.js +47 -0
- package/server/bootstrap/native.js +9 -0
- package/server/config.js +62 -0
- package/server/controllers/files.js +64 -0
- package/server/controllers/filesystem.js +139 -0
- package/server/controllers/memory.js +86 -0
- package/server/controllers/messages.js +128 -0
- package/server/controllers/threads.js +113 -0
- package/server/controllers/transcribe.js +51 -0
- package/server/controllers/workspaces.js +102 -0
- package/server/debug.js +56 -0
- package/server/gateway-cleanup.js +47 -0
- package/server/gateway.js +331 -0
- package/server/index.js +422 -0
- package/server/providers/memory.js +144 -0
- package/server/util/context.js +49 -0
- package/server/util/helpers.js +111 -0
- package/server/util/http.js +57 -0
- package/server/util/multipart.js +46 -0
- package/server.js +155 -222
- package/dist/migrate.d.ts +0 -16
- package/dist/migrate.js +0 -114
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { send, sendError, parseBody } from '../util/http.js';
|
|
2
|
+
import { buildContextPreamble } from '../util/context.js';
|
|
3
|
+
|
|
4
|
+
export class MessageController {
|
|
5
|
+
constructor({ getActiveDb, getWorkspaces, broadcast }) {
|
|
6
|
+
this.getActiveDb = getActiveDb;
|
|
7
|
+
this.getWorkspaces = getWorkspaces;
|
|
8
|
+
this.broadcast = broadcast;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
getAll(req, res, params, query) {
|
|
12
|
+
const db = this.getActiveDb();
|
|
13
|
+
if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
|
|
14
|
+
const limit = Math.min(parseInt(query.limit || '100', 10), 500);
|
|
15
|
+
const before = query.before ? parseInt(query.before, 10) : null;
|
|
16
|
+
const after = query.after ? parseInt(query.after, 10) : null;
|
|
17
|
+
let sql = 'SELECT * FROM messages WHERE thread_id = ?';
|
|
18
|
+
const sqlParams = [params.id];
|
|
19
|
+
if (before) { sql += ' AND timestamp < ?'; sqlParams.push(before); }
|
|
20
|
+
if (after) { sql += ' AND timestamp > ?'; sqlParams.push(after); }
|
|
21
|
+
const total = db.prepare(sql.replace('SELECT *', 'SELECT COUNT(*) as c')).get(...sqlParams).c;
|
|
22
|
+
const rows = db.prepare(sql + ' ORDER BY timestamp DESC LIMIT ?').all(...sqlParams, limit + 1);
|
|
23
|
+
const messages = rows.slice(0, limit).reverse();
|
|
24
|
+
for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
|
|
25
|
+
send(res, 200, { messages, hasMore: rows.length > limit });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async create(req, res, params) {
|
|
29
|
+
const body = await parseBody(req);
|
|
30
|
+
const db = this.getActiveDb();
|
|
31
|
+
if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
|
|
32
|
+
if (!body.id || !body.role || body.content === undefined || !body.timestamp) return sendError(res, 400, 'Required: id, role, content, timestamp');
|
|
33
|
+
const metadata = body.metadata ? JSON.stringify(body.metadata) : null;
|
|
34
|
+
const existing = db.prepare('SELECT id, status, metadata FROM messages WHERE id = ?').get(body.id);
|
|
35
|
+
if (existing) {
|
|
36
|
+
if (body.status && body.status !== existing.status) {
|
|
37
|
+
db.prepare('UPDATE messages SET status = ?, content = ?, metadata = ? WHERE id = ?').run(body.status, body.content, metadata || existing.metadata, body.id);
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
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());
|
|
41
|
+
db.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(Date.now(), params.id);
|
|
42
|
+
if (body.role === 'user' && body.content) {
|
|
43
|
+
const thread = db.prepare('SELECT title FROM threads WHERE id = ?').get(params.id);
|
|
44
|
+
if (thread?.title === 'New chat') {
|
|
45
|
+
const title = body.content.replace(/\n.*/s, '').slice(0, 40).trim() + (body.content.length > 40 ? '...' : '');
|
|
46
|
+
if (title) {
|
|
47
|
+
db.prepare('UPDATE threads SET title = ? WHERE id = ?').run(title, params.id);
|
|
48
|
+
this.broadcast(JSON.stringify({ type: 'clawchats', event: 'thread-title-updated', threadId: params.id, workspace: this.getWorkspaces().active, title }));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(body.id);
|
|
54
|
+
if (message?.metadata) { try { message.metadata = JSON.parse(message.metadata); } catch { /* ok */ } }
|
|
55
|
+
send(res, existing ? 200 : 201, { message });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
delete(req, res, params) {
|
|
59
|
+
const db = this.getActiveDb();
|
|
60
|
+
if (!db.prepare('SELECT id FROM messages WHERE id = ? AND thread_id = ?').get(params.messageId, params.id)) return sendError(res, 404, 'Message not found');
|
|
61
|
+
db.prepare('DELETE FROM messages WHERE id = ?').run(params.messageId);
|
|
62
|
+
send(res, 200, { ok: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
contextFill(req, res, params) {
|
|
66
|
+
const db = this.getActiveDb();
|
|
67
|
+
const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
|
|
68
|
+
if (!thread) return sendError(res, 404, 'Thread not found');
|
|
69
|
+
send(res, 200, buildContextPreamble(db, params.id, thread.last_session_id, thread.session_key));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
search(req, res, params, query) {
|
|
73
|
+
const q = query.q || '';
|
|
74
|
+
if (!q) return send(res, 200, { results: [], total: 0 });
|
|
75
|
+
const db = this.getActiveDb();
|
|
76
|
+
const page = parseInt(query.page || '1', 10);
|
|
77
|
+
const limit = Math.min(parseInt(query.limit || '20', 10), 100);
|
|
78
|
+
const offset = (page - 1) * limit;
|
|
79
|
+
try {
|
|
80
|
+
const results = db.prepare(`SELECT m.id as messageId, m.thread_id as threadId, t.title as threadTitle, m.role, snippet(messages_fts, 0, '<mark>', '</mark>', '...', 40) as content, m.timestamp FROM messages_fts JOIN messages m ON messages_fts.rowid = m.rowid JOIN threads t ON m.thread_id = t.id WHERE messages_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?`).all(q, limit, offset);
|
|
81
|
+
const total = db.prepare(`SELECT COUNT(*) as c FROM messages_fts WHERE messages_fts MATCH ?`).get(q).c;
|
|
82
|
+
send(res, 200, { results, total });
|
|
83
|
+
} catch { send(res, 200, { results: [], total: 0 }); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export(req, res) {
|
|
87
|
+
const db = this.getActiveDb();
|
|
88
|
+
const ws = this.getWorkspaces();
|
|
89
|
+
const threads = db.prepare('SELECT * FROM threads ORDER BY updated_at DESC').all();
|
|
90
|
+
send(res, 200, {
|
|
91
|
+
workspace: ws.active,
|
|
92
|
+
exportedAt: Date.now(),
|
|
93
|
+
threads: threads.map(t => {
|
|
94
|
+
const messages = db.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY timestamp ASC').all(t.id);
|
|
95
|
+
for (const m of messages) { if (m.metadata) { try { m.metadata = JSON.parse(m.metadata); } catch { /* ok */ } } }
|
|
96
|
+
return { ...t, messages };
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async import(req, res) {
|
|
102
|
+
const body = await parseBody(req);
|
|
103
|
+
const db = this.getActiveDb();
|
|
104
|
+
const ws = this.getWorkspaces();
|
|
105
|
+
if (!body.threads || !Array.isArray(body.threads)) return sendError(res, 400, 'Expected { threads: [...] }');
|
|
106
|
+
let threadsImported = 0, messagesImported = 0;
|
|
107
|
+
const insertThread = db.prepare('INSERT OR IGNORE INTO threads (id, session_key, title, pinned, pin_order, model, last_session_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
108
|
+
const insertMsg = db.prepare('INSERT OR IGNORE INTO messages (id, thread_id, role, content, status, metadata, seq, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
109
|
+
db.exec('BEGIN');
|
|
110
|
+
try {
|
|
111
|
+
for (const t of body.threads) {
|
|
112
|
+
if (!t.id) continue;
|
|
113
|
+
const sessionKey = t.session_key || `agent:main:${ws.active}:chat:${t.id}`;
|
|
114
|
+
if (insertThread.run(t.id, sessionKey, t.title || 'Imported chat', t.pinned || 0, t.pin_order || 0, t.model || null, t.last_session_id || null, t.created_at || Date.now(), t.updated_at || Date.now()).changes > 0) threadsImported++;
|
|
115
|
+
for (const m of (t.messages || [])) {
|
|
116
|
+
if (!m.id || !m.role) continue;
|
|
117
|
+
const meta = m.metadata ? (typeof m.metadata === 'string' ? m.metadata : JSON.stringify(m.metadata)) : null;
|
|
118
|
+
if (insertMsg.run(m.id, t.id, m.role, m.content || '', m.status || 'sent', meta, m.seq || null, m.timestamp || Date.now(), m.created_at || Date.now()).changes > 0) messagesImported++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
db.exec('COMMIT');
|
|
122
|
+
} catch (e) {
|
|
123
|
+
db.exec('ROLLBACK');
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
send(res, 200, { ok: true, threadsImported, messagesImported });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError, parseBody, uuid } from '../util/http.js';
|
|
4
|
+
import { syncThreadUnreadCount } from '../util/helpers.js';
|
|
5
|
+
import { getSessionsDirForAgent } from '../config.js';
|
|
6
|
+
import { cleanGatewaySession } from '../gateway-cleanup.js';
|
|
7
|
+
|
|
8
|
+
export class ThreadController {
|
|
9
|
+
constructor({ getActiveDb, getWorkspaces, uploadsDir, broadcast }) {
|
|
10
|
+
this.getActiveDb = getActiveDb;
|
|
11
|
+
this.getWorkspaces = getWorkspaces;
|
|
12
|
+
this.uploadsDir = uploadsDir;
|
|
13
|
+
this.broadcast = broadcast;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getAll(req, res, params, query) {
|
|
17
|
+
const db = this.getActiveDb();
|
|
18
|
+
const page = parseInt(query.page || '1', 10);
|
|
19
|
+
const limit = Math.min(parseInt(query.limit || '50', 10), 200);
|
|
20
|
+
const offset = (page - 1) * limit;
|
|
21
|
+
const search = query.search || '';
|
|
22
|
+
let threads, total;
|
|
23
|
+
if (search) {
|
|
24
|
+
try {
|
|
25
|
+
const matchingIds = db.prepare(`SELECT DISTINCT m.thread_id FROM messages m JOIN messages_fts ON messages_fts.rowid = m.rowid WHERE messages_fts MATCH ?`).all(search).map(r => r.thread_id);
|
|
26
|
+
if (!matchingIds.length) return send(res, 200, { threads: [], total: 0, page });
|
|
27
|
+
const ph = matchingIds.map(() => '?').join(',');
|
|
28
|
+
total = db.prepare(`SELECT COUNT(*) as c FROM threads WHERE id IN (${ph})`).get(...matchingIds).c;
|
|
29
|
+
threads = db.prepare(`SELECT * FROM threads WHERE id IN (${ph}) ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?`).all(...matchingIds, limit, offset);
|
|
30
|
+
} catch { return send(res, 200, { threads: [], total: 0, page }); }
|
|
31
|
+
} else {
|
|
32
|
+
total = db.prepare('SELECT COUNT(*) as c FROM threads').get().c;
|
|
33
|
+
threads = db.prepare('SELECT * FROM threads ORDER BY pinned DESC, sort_order DESC, updated_at DESC LIMIT ? OFFSET ?').all(limit, offset);
|
|
34
|
+
}
|
|
35
|
+
send(res, 200, { threads, total, page });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getUnread(req, res) {
|
|
39
|
+
const db = this.getActiveDb();
|
|
40
|
+
const threads = db.prepare(`SELECT t.id, t.title, t.unread_count, m.content as lastMessage FROM threads t LEFT JOIN messages m ON m.thread_id = t.id WHERE t.unread_count > 0 AND m.timestamp = (SELECT MAX(timestamp) FROM messages WHERE thread_id = t.id) ORDER BY t.updated_at DESC`).all();
|
|
41
|
+
for (const t of threads) t.unreadMessageIds = db.prepare('SELECT message_id FROM unread_messages WHERE thread_id = ?').all(t.id).map(r => r.message_id);
|
|
42
|
+
send(res, 200, { threads });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async markRead(req, res, params) {
|
|
46
|
+
const { messageIds } = await parseBody(req);
|
|
47
|
+
if (!Array.isArray(messageIds) || !messageIds.length) return send(res, 400, { error: 'messageIds array required' });
|
|
48
|
+
const db = this.getActiveDb();
|
|
49
|
+
const ph = messageIds.map(() => '?').join(',');
|
|
50
|
+
db.prepare(`DELETE FROM unread_messages WHERE thread_id = ? AND message_id IN (${ph})`).run(params.id, ...messageIds);
|
|
51
|
+
const remaining = syncThreadUnreadCount(db, params.id);
|
|
52
|
+
this.broadcast(JSON.stringify({ type: 'clawchats', event: 'unread-update', workspace: this.getWorkspaces().active, threadId: params.id, action: 'read', messageIds, unreadCount: remaining, timestamp: Date.now() }));
|
|
53
|
+
send(res, 200, { unread_count: remaining });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async create(req, res) {
|
|
57
|
+
const body = await parseBody(req);
|
|
58
|
+
const db = this.getActiveDb();
|
|
59
|
+
const ws = this.getWorkspaces();
|
|
60
|
+
const id = body.id || uuid();
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const agent = ws.workspaces[ws.active]?.agent || 'main';
|
|
63
|
+
try {
|
|
64
|
+
db.prepare('INSERT INTO threads (id, session_key, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, `agent:${agent}:${ws.active}:chat:${id}`, 'New chat', now, now);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e.message.includes('UNIQUE constraint')) return sendError(res, 409, 'Thread already exists');
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
send(res, 201, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(id) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get(req, res, params) {
|
|
73
|
+
const thread = this.getActiveDb().prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
|
|
74
|
+
if (!thread) return sendError(res, 404, 'Thread not found');
|
|
75
|
+
send(res, 200, { thread });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async update(req, res, params) {
|
|
79
|
+
const body = await parseBody(req);
|
|
80
|
+
const db = this.getActiveDb();
|
|
81
|
+
if (!db.prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
|
|
82
|
+
const fields = [], values = [];
|
|
83
|
+
for (const [col, val] of [['title', body.title], ['model', body.model], ['last_session_id', body.last_session_id], ['unread_count', body.unread_count]]) {
|
|
84
|
+
if (val !== undefined) { fields.push(`${col} = ?`); values.push(val); }
|
|
85
|
+
}
|
|
86
|
+
if (body.pinned !== undefined) { fields.push('pinned = ?'); values.push(body.pinned ? 1 : 0); }
|
|
87
|
+
if (body.pin_order !== undefined) { fields.push('pin_order = ?'); values.push(body.pin_order); }
|
|
88
|
+
if (body.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(body.sort_order); }
|
|
89
|
+
if (fields.length) {
|
|
90
|
+
fields.push('updated_at = ?');
|
|
91
|
+
values.push(Date.now(), params.id);
|
|
92
|
+
db.prepare(`UPDATE threads SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
|
93
|
+
}
|
|
94
|
+
send(res, 200, { thread: db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
delete(req, res, params) {
|
|
98
|
+
const db = this.getActiveDb();
|
|
99
|
+
const thread = db.prepare('SELECT * FROM threads WHERE id = ?').get(params.id);
|
|
100
|
+
if (!thread) return sendError(res, 404, 'Thread not found');
|
|
101
|
+
db.prepare('DELETE FROM threads WHERE id = ?').run(params.id);
|
|
102
|
+
const agentMatch = (thread.session_key || '').match(/^agent:([^:]+):/);
|
|
103
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
104
|
+
let sessionIdToDelete = thread.last_session_id;
|
|
105
|
+
if (!sessionIdToDelete) {
|
|
106
|
+
try { sessionIdToDelete = JSON.parse(fs.readFileSync(path.join(sessionsDir, 'sessions.json'), 'utf8'))[thread.session_key]?.sessionId; } catch { /* ok */ }
|
|
107
|
+
}
|
|
108
|
+
cleanGatewaySession(thread.session_key);
|
|
109
|
+
if (sessionIdToDelete) { try { fs.unlinkSync(path.join(sessionsDir, `${sessionIdToDelete}.jsonl`)); } catch { /* ok */ } }
|
|
110
|
+
try { fs.rmSync(path.join(this.uploadsDir, params.id), { recursive: true }); } catch { /* ok */ }
|
|
111
|
+
send(res, 200, { ok: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { send } from '../util/http.js';
|
|
5
|
+
|
|
6
|
+
export async function handleTranscribe(req, res) {
|
|
7
|
+
try {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
10
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
11
|
+
|
|
12
|
+
if (audioBuffer.length === 0) return send(res, 400, { error: 'No audio data' });
|
|
13
|
+
if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: 'Audio too large (max 25MB)' });
|
|
14
|
+
|
|
15
|
+
let apiKey;
|
|
16
|
+
try {
|
|
17
|
+
const ocConfig = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf8'));
|
|
18
|
+
apiKey = ocConfig?.skills?.entries?.['openai-whisper-api']?.apiKey;
|
|
19
|
+
} catch { /* ok */ }
|
|
20
|
+
if (!apiKey) apiKey = process.env.OPENAI_API_KEY;
|
|
21
|
+
if (!apiKey) return send(res, 500, { error: 'No OpenAI API key configured' });
|
|
22
|
+
|
|
23
|
+
const contentType = req.headers['content-type'] || 'audio/webm';
|
|
24
|
+
const ext = contentType.includes('wav') ? 'wav' : contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a' : contentType.includes('ogg') ? 'ogg' : 'webm';
|
|
25
|
+
|
|
26
|
+
const boundary = '----WhisperBoundary' + Date.now();
|
|
27
|
+
const body = Buffer.concat([
|
|
28
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.${ext}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
29
|
+
audioBuffer,
|
|
30
|
+
`\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n`,
|
|
31
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n`,
|
|
32
|
+
`--${boundary}--\r\n`,
|
|
33
|
+
].map(p => typeof p === 'string' ? Buffer.from(p) : p));
|
|
34
|
+
|
|
35
|
+
const resp = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
38
|
+
body,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!resp.ok) {
|
|
42
|
+
console.error('Whisper API error:', resp.status, await resp.text());
|
|
43
|
+
return send(res, 502, { error: `Whisper API error: ${resp.status}` });
|
|
44
|
+
}
|
|
45
|
+
const result = await resp.json();
|
|
46
|
+
return send(res, 200, { text: result.text || '' });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Transcribe error:', err);
|
|
49
|
+
return send(res, 500, { error: err.message });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError, parseBody } from '../util/http.js';
|
|
4
|
+
import { validateAgent } from '../config.js';
|
|
5
|
+
import { cleanGatewaySession, cleanGatewaySessionsByPrefix } from '../gateway-cleanup.js';
|
|
6
|
+
|
|
7
|
+
export class WorkspaceController {
|
|
8
|
+
constructor({ getDb, closeDb, getWorkspaces, setWorkspaces, dataDir, broadcast }) {
|
|
9
|
+
this.getDb = getDb;
|
|
10
|
+
this.closeDb = closeDb;
|
|
11
|
+
this.getWorkspaces = getWorkspaces;
|
|
12
|
+
this.setWorkspaces = setWorkspaces;
|
|
13
|
+
this.dataDir = dataDir;
|
|
14
|
+
this.broadcast = broadcast;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getAll(req, res) {
|
|
18
|
+
const ws = this.getWorkspaces();
|
|
19
|
+
const sorted = Object.values(ws.workspaces).sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
|
|
20
|
+
for (const workspace of sorted) {
|
|
21
|
+
try {
|
|
22
|
+
workspace.unread_count = this.getDb(workspace.name).prepare('SELECT COALESCE(SUM(unread_count), 0) as total FROM threads').get().total;
|
|
23
|
+
} catch { workspace.unread_count = 0; }
|
|
24
|
+
}
|
|
25
|
+
send(res, 200, { active: ws.active, workspaces: sorted });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async create(req, res) {
|
|
29
|
+
const body = await parseBody(req);
|
|
30
|
+
const { name, label } = body;
|
|
31
|
+
if (!name || !/^[a-z0-9-]{1,32}$/.test(name)) return sendError(res, 400, 'Name must be [a-z0-9-], 1-32 chars');
|
|
32
|
+
const ws = this.getWorkspaces();
|
|
33
|
+
if (ws.workspaces[name]) return sendError(res, 409, 'Workspace already exists');
|
|
34
|
+
let agent = 'main';
|
|
35
|
+
try { agent = validateAgent(body.agent || 'main'); } catch { agent = 'main'; }
|
|
36
|
+
ws.workspaces[name] = { name, label: label || name, color: body.color || null, icon: body.icon || null, agent, createdAt: Date.now() };
|
|
37
|
+
this.setWorkspaces(ws);
|
|
38
|
+
this.getDb(name);
|
|
39
|
+
send(res, 201, { workspace: ws.workspaces[name] });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async update(req, res, params) {
|
|
43
|
+
const body = await parseBody(req);
|
|
44
|
+
const ws = this.getWorkspaces();
|
|
45
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
|
|
46
|
+
if (body.label !== undefined) ws.workspaces[params.name].label = body.label;
|
|
47
|
+
if (body.color !== undefined) ws.workspaces[params.name].color = body.color;
|
|
48
|
+
if (body.icon !== undefined) ws.workspaces[params.name].icon = body.icon;
|
|
49
|
+
if (body.lastThread !== undefined) ws.workspaces[params.name].lastThread = body.lastThread;
|
|
50
|
+
let migratedThreads = 0;
|
|
51
|
+
if (body.agent !== undefined) {
|
|
52
|
+
let newAgent;
|
|
53
|
+
try { newAgent = validateAgent(body.agent); } catch (e) { return sendError(res, 400, e.message); }
|
|
54
|
+
const oldAgent = ws.workspaces[params.name].agent || 'main';
|
|
55
|
+
if (newAgent !== oldAgent) {
|
|
56
|
+
const db = this.getDb(params.name);
|
|
57
|
+
const threads = db.prepare(`SELECT id, session_key FROM threads WHERE session_key LIKE ?`).all(`agent:${oldAgent}:${params.name}:chat:%`);
|
|
58
|
+
db.prepare(`UPDATE threads SET session_key = replace(session_key, 'agent:' || ? || ':' || ? || ':chat:', 'agent:' || ? || ':' || ? || ':chat:') WHERE session_key LIKE 'agent:' || ? || ':' || ? || ':chat:%'`).run(oldAgent, params.name, newAgent, params.name, oldAgent, params.name);
|
|
59
|
+
for (const t of threads) cleanGatewaySession(t.session_key);
|
|
60
|
+
ws.workspaces[params.name].agent = newAgent;
|
|
61
|
+
migratedThreads = threads.length;
|
|
62
|
+
this.broadcast(JSON.stringify({ type: 'clawchats', event: 'workspace-agent-changed', workspace: params.name, agent: newAgent }));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.setWorkspaces(ws);
|
|
66
|
+
send(res, 200, { workspace: ws.workspaces[params.name], migratedThreads });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(req, res, params) {
|
|
70
|
+
const ws = this.getWorkspaces();
|
|
71
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
|
|
72
|
+
if (Object.keys(ws.workspaces).length <= 1) return sendError(res, 400, 'Cannot delete the only workspace');
|
|
73
|
+
this.closeDb(params.name);
|
|
74
|
+
const dbPath = path.join(this.dataDir, `${params.name}.db`);
|
|
75
|
+
for (const suffix of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + suffix); } catch { /* ok */ } }
|
|
76
|
+
const wsAgent = ws.workspaces[params.name]?.agent || 'main';
|
|
77
|
+
const cleaned = cleanGatewaySessionsByPrefix(`agent:${wsAgent}:${params.name}:chat:`);
|
|
78
|
+
if (cleaned > 0) console.log(`Cleaned ${cleaned} gateway sessions for workspace: ${params.name}`);
|
|
79
|
+
delete ws.workspaces[params.name];
|
|
80
|
+
if (ws.active === params.name) ws.active = Object.keys(ws.workspaces)[0] || null;
|
|
81
|
+
this.setWorkspaces(ws);
|
|
82
|
+
send(res, 200, { ok: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async reorder(req, res) {
|
|
86
|
+
const body = await parseBody(req);
|
|
87
|
+
if (!Array.isArray(body.order)) return sendError(res, 400, 'order must be an array of workspace names');
|
|
88
|
+
const ws = this.getWorkspaces();
|
|
89
|
+
body.order.forEach((name, i) => { if (ws.workspaces[name]) ws.workspaces[name].order = i; });
|
|
90
|
+
this.setWorkspaces(ws);
|
|
91
|
+
send(res, 200, { ok: true, workspaces: Object.values(ws.workspaces) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
activate(req, res, params) {
|
|
95
|
+
const ws = this.getWorkspaces();
|
|
96
|
+
if (!ws.workspaces[params.name]) return sendError(res, 404, 'Workspace not found');
|
|
97
|
+
ws.active = params.name;
|
|
98
|
+
this.setWorkspaces(ws);
|
|
99
|
+
this.getDb(params.name);
|
|
100
|
+
send(res, 200, { ok: true, workspace: ws.workspaces[params.name] });
|
|
101
|
+
}
|
|
102
|
+
}
|
package/server/debug.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class DebugLogger {
|
|
5
|
+
constructor(baseDir) {
|
|
6
|
+
this.baseDir = path.join(baseDir, '..', 'debug');
|
|
7
|
+
this.active = false;
|
|
8
|
+
this.sessionId = null;
|
|
9
|
+
this.wsStream = null;
|
|
10
|
+
this.originatingClient = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
start(ts, originatingClient) {
|
|
14
|
+
if (this.active) return { error: 'already-active', sessionId: this.sessionId };
|
|
15
|
+
this.sessionId = ts.replace(/[:.]/g, '-');
|
|
16
|
+
this.originatingClient = originatingClient;
|
|
17
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
18
|
+
this.wsStream = fs.createWriteStream(path.join(this.baseDir, `session-${this.sessionId}-ws.log`), { flags: 'a' });
|
|
19
|
+
this.active = true;
|
|
20
|
+
console.log(`Debug recording started: ${this.sessionId}`);
|
|
21
|
+
return { sessionId: this.sessionId };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
logFrame(direction, data) {
|
|
25
|
+
if (this.active && this.wsStream) this.wsStream.write(`${new Date().toISOString()} ${direction} ${data}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
saveDump(payload) {
|
|
29
|
+
if (!this.sessionId) return { sessionId: null, files: [] };
|
|
30
|
+
const files = [];
|
|
31
|
+
const id = this.sessionId;
|
|
32
|
+
if (this.wsStream) { this.wsStream.end(); this.wsStream = null; files.push(`session-${id}-ws.log`); }
|
|
33
|
+
|
|
34
|
+
let logContent = '';
|
|
35
|
+
for (const entry of (payload.console || [])) {
|
|
36
|
+
logContent += `${entry.ts} [${entry.level.toUpperCase()}] ${entry.args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
|
|
37
|
+
}
|
|
38
|
+
for (const err of (payload.errors || [])) logContent += `${err.ts} [UNHANDLED] ${err.message}\n${err.stack || ''}\n`;
|
|
39
|
+
if (logContent) { fs.writeFileSync(path.join(this.baseDir, `session-${id}-client.log`), logContent); files.push(`session-${id}-client.log`); }
|
|
40
|
+
if (payload.state) { fs.writeFileSync(path.join(this.baseDir, `session-${id}-state.json`), JSON.stringify(payload.state, null, 2)); files.push(`session-${id}-state.json`); }
|
|
41
|
+
if (payload.screenshot) { fs.writeFileSync(path.join(this.baseDir, `session-${id}-screenshot.jpg`), Buffer.from(payload.screenshot, 'base64')); files.push(`session-${id}-screenshot.jpg`); }
|
|
42
|
+
|
|
43
|
+
const savedId = id;
|
|
44
|
+
this.active = false; this.sessionId = null; this.originatingClient = null;
|
|
45
|
+
console.log(`Debug session saved: ${files.join(', ')}`);
|
|
46
|
+
return { sessionId: savedId, files };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleClientDisconnect(ws) {
|
|
50
|
+
if (this.active && this.originatingClient === ws) {
|
|
51
|
+
console.log(`Debug session ${this.sessionId} auto-closed: client disconnected`);
|
|
52
|
+
if (this.wsStream) { this.wsStream.write(`${new Date().toISOString()} SYSTEM Client disconnected — session auto-closed\n`); this.wsStream.end(); this.wsStream = null; }
|
|
53
|
+
this.active = false; this.sessionId = null; this.originatingClient = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getSessionsDirForAgent } from './config.js';
|
|
4
|
+
|
|
5
|
+
export function cleanGatewaySession(sessionKey) {
|
|
6
|
+
try {
|
|
7
|
+
const agentMatch = (sessionKey || '').match(/^agent:([^:]+):/);
|
|
8
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
9
|
+
const sessionsPath = path.join(sessionsDir, 'sessions.json');
|
|
10
|
+
const store = JSON.parse(fs.readFileSync(sessionsPath, 'utf8'));
|
|
11
|
+
const entry = store[sessionKey];
|
|
12
|
+
if (!entry) return null;
|
|
13
|
+
if (entry.sessionId) {
|
|
14
|
+
try { fs.unlinkSync(path.join(sessionsDir, `${entry.sessionId}.jsonl`)); } catch { /* ok */ }
|
|
15
|
+
}
|
|
16
|
+
const sessionId = entry.sessionId || null;
|
|
17
|
+
delete store[sessionKey];
|
|
18
|
+
fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
19
|
+
return sessionId;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.warn(`cleanGatewaySession(${sessionKey}):`, err.message);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cleanGatewaySessionsByPrefix(prefix) {
|
|
27
|
+
try {
|
|
28
|
+
const agentMatch = (prefix || '').match(/^agent:([^:]+):/);
|
|
29
|
+
const sessionsDir = getSessionsDirForAgent(agentMatch?.[1]);
|
|
30
|
+
const sessionsPath = path.join(sessionsDir, 'sessions.json');
|
|
31
|
+
const store = JSON.parse(fs.readFileSync(sessionsPath, 'utf8'));
|
|
32
|
+
let cleaned = 0;
|
|
33
|
+
for (const key of Object.keys(store)) {
|
|
34
|
+
if (!key.startsWith(prefix)) continue;
|
|
35
|
+
if (store[key]?.sessionId) {
|
|
36
|
+
try { fs.unlinkSync(path.join(sessionsDir, `${store[key].sessionId}.jsonl`)); } catch { /* ok */ }
|
|
37
|
+
}
|
|
38
|
+
delete store[key];
|
|
39
|
+
cleaned++;
|
|
40
|
+
}
|
|
41
|
+
if (cleaned > 0) fs.writeFileSync(sessionsPath, JSON.stringify(store, null, 2));
|
|
42
|
+
return cleaned;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn(`cleanGatewaySessionsByPrefix(${prefix}):`, err.message);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
}
|