@clawchatsai/connector 0.0.86 → 0.0.88
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 +80 -89
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -8
- package/prebuilds/darwin-arm64/node_datachannel.node +0 -0
- package/prebuilds/darwin-x64/node_datachannel.node +0 -0
- package/prebuilds/linux-arm/node_datachannel.node +0 -0
- package/prebuilds/linux-arm64/node_datachannel.node +0 -0
- package/prebuilds/linux-x64/node_datachannel.node +0 -0
- package/prebuilds/linuxmusl-arm64/node_datachannel.node +0 -0
- package/prebuilds/linuxmusl-x64/node_datachannel.node +0 -0
- package/prebuilds/win32-arm64/node_datachannel.node +0 -0
- package/prebuilds/win32-x64/node_datachannel.node +0 -0
- package/server/bootstrap/identity.js +47 -0
- package/server/bootstrap/native.js +9 -0
- package/server/config.js +63 -0
- package/server/controllers/agents.js +20 -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/settings.js +28 -0
- package/server/controllers/static.js +56 -0
- package/server/controllers/threads.js +113 -0
- package/server/controllers/transcribe.js +44 -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 +397 -0
- package/server/providers/memory-config.js +52 -0
- package/server/providers/memory.js +108 -0
- package/server/store/workspace-store.js +31 -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/dist/migrate.d.ts +0 -16
- package/dist/migrate.js +0 -114
- package/dist/updater.d.ts +0 -21
- package/dist/updater.js +0 -64
- package/server.js +0 -2459
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { send, sendError } from '../util/http.js';
|
|
5
|
+
import { parseMultipart } from '../util/multipart.js';
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
const ALLOWED_FILE_DIRS = [HOME, '/tmp'];
|
|
9
|
+
|
|
10
|
+
export function handleServeFile(req, res, query, memoryConfig) {
|
|
11
|
+
const filePath = query.path;
|
|
12
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
13
|
+
const resolved = (filePath.startsWith('./') || filePath.startsWith('../'))
|
|
14
|
+
? path.resolve(memoryConfig.workspaceDir, filePath)
|
|
15
|
+
: path.resolve(filePath);
|
|
16
|
+
if (!ALLOWED_FILE_DIRS.some(dir => resolved.startsWith(dir + '/') || resolved === dir)) return sendError(res, 403, 'Access denied: path not in allowed directories');
|
|
17
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return sendError(res, 404, 'File not found');
|
|
18
|
+
|
|
19
|
+
const MIME = {
|
|
20
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
21
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
22
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
|
|
23
|
+
'.md': 'text/markdown', '.csv': 'text/csv', '.xml': 'text/xml',
|
|
24
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
|
|
25
|
+
'.py': 'text/x-python', '.sh': 'text/x-shellscript',
|
|
26
|
+
'.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/toml',
|
|
27
|
+
'.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar',
|
|
28
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
29
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
30
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
31
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
32
|
+
};
|
|
33
|
+
const stat = fs.statSync(resolved);
|
|
34
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(resolved).toLowerCase()] || 'application/octet-stream', 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
|
|
35
|
+
fs.createReadStream(resolved).pipe(res);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function handleWorkspaceList(req, res, query) {
|
|
39
|
+
const reqPath = query.path || '~/.openclaw/workspace';
|
|
40
|
+
const depth = parseInt(query.depth || '2', 10);
|
|
41
|
+
const showHidden = query.hidden === '1' || query.hidden === 'true';
|
|
42
|
+
const resolved = path.resolve(reqPath.replace(/^~/, HOME));
|
|
43
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
44
|
+
if (!fs.existsSync(resolved)) return sendError(res, 404, 'Path not found');
|
|
45
|
+
|
|
46
|
+
const files = [{ path: resolved + '/', type: 'dir', name: path.basename(resolved), size: 0 }];
|
|
47
|
+
const walk = (dir, d) => {
|
|
48
|
+
if (d > depth) return;
|
|
49
|
+
try {
|
|
50
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
51
|
+
if (entry.name.startsWith('.') && entry.name !== '.openclaw' && !showHidden) continue;
|
|
52
|
+
if (entry.name === 'node_modules') continue;
|
|
53
|
+
const fullPath = path.join(dir, entry.name);
|
|
54
|
+
const isDir = entry.isDirectory();
|
|
55
|
+
files.push({ path: fullPath + (isDir ? '/' : ''), type: isDir ? 'dir' : 'file', name: entry.name, size: isDir ? 0 : (() => { try { return fs.statSync(fullPath).size; } catch { return 0; } })() });
|
|
56
|
+
if (isDir) walk(fullPath, d + 1);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* permission denied */ }
|
|
59
|
+
};
|
|
60
|
+
walk(resolved, 1);
|
|
61
|
+
send(res, 200, { files, cwd: resolved });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function handleWorkspaceFileRead(req, res, query) {
|
|
65
|
+
const filePath = query.path;
|
|
66
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
67
|
+
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
68
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
69
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return sendError(res, 404, 'File not found');
|
|
70
|
+
|
|
71
|
+
const stat = fs.statSync(resolved);
|
|
72
|
+
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
73
|
+
const binaryMime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', pdf: 'application/pdf', mp3: 'audio/mpeg', mp4: 'video/mp4', wav: 'audio/wav', ogg: 'audio/ogg', webm: 'video/webm' };
|
|
74
|
+
const mime = binaryMime[ext];
|
|
75
|
+
|
|
76
|
+
if (mime) {
|
|
77
|
+
if (stat.size > 20 * 1024 * 1024) return sendError(res, 413, 'File too large (max 20MB)');
|
|
78
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'private, max-age=60' });
|
|
79
|
+
res.end(fs.readFileSync(resolved));
|
|
80
|
+
} else {
|
|
81
|
+
if (stat.size > 1024 * 1024) return sendError(res, 413, 'File too large (max 1MB)');
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
83
|
+
res.end(fs.readFileSync(resolved, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function handleWorkspaceFileWrite(req, res, query) {
|
|
88
|
+
const filePath = query.path;
|
|
89
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
90
|
+
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
91
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Can only write to workspace directory');
|
|
92
|
+
const chunks = [];
|
|
93
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
94
|
+
const dir = path.dirname(resolved);
|
|
95
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
fs.writeFileSync(resolved, Buffer.concat(chunks).toString('utf8'), 'utf8');
|
|
97
|
+
send(res, 200, { ok: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function handleWorkspaceFileDelete(req, res, query) {
|
|
101
|
+
const filePath = query.path;
|
|
102
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
103
|
+
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
104
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
105
|
+
if (!fs.existsSync(resolved)) return sendError(res, 404, 'Path not found');
|
|
106
|
+
try {
|
|
107
|
+
const stat = fs.statSync(resolved);
|
|
108
|
+
if (stat.isDirectory()) { fs.rmSync(resolved, { recursive: true, force: true }); send(res, 200, { ok: true, type: 'dir' }); }
|
|
109
|
+
else { fs.unlinkSync(resolved); send(res, 200, { ok: true, type: 'file' }); }
|
|
110
|
+
} catch (err) { sendError(res, 500, 'Delete failed: ' + err.message); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function handleWorkspaceUpload(req, res, query) {
|
|
114
|
+
const targetDir = query.path;
|
|
115
|
+
if (!targetDir) return sendError(res, 400, 'Missing path parameter');
|
|
116
|
+
const resolved = path.resolve(targetDir.replace(/^~/, HOME));
|
|
117
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
118
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) return sendError(res, 404, 'Target directory not found');
|
|
119
|
+
if (!(req.headers['content-type'] || '').includes('multipart/form-data')) return sendError(res, 400, 'Expected multipart/form-data');
|
|
120
|
+
|
|
121
|
+
let files;
|
|
122
|
+
try { files = await parseMultipart(req); }
|
|
123
|
+
catch (err) { return sendError(res, 400, 'Invalid multipart data: ' + err.message); }
|
|
124
|
+
|
|
125
|
+
const uploaded = [];
|
|
126
|
+
for (const { filename, data } of files) {
|
|
127
|
+
if (!filename || !data.length) continue;
|
|
128
|
+
const safeName = path.basename(filename);
|
|
129
|
+
let finalPath = path.join(resolved, safeName);
|
|
130
|
+
let counter = 1;
|
|
131
|
+
while (fs.existsSync(finalPath)) {
|
|
132
|
+
const ext = path.extname(safeName);
|
|
133
|
+
finalPath = path.join(resolved, `${path.basename(safeName, ext)} (${counter++})${ext}`);
|
|
134
|
+
}
|
|
135
|
+
fs.writeFileSync(finalPath, data);
|
|
136
|
+
uploaded.push({ name: path.basename(finalPath), size: data.length });
|
|
137
|
+
}
|
|
138
|
+
send(res, 200, { ok: true, uploaded });
|
|
139
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError } from '../util/http.js';
|
|
4
|
+
|
|
5
|
+
export class MemoryController {
|
|
6
|
+
constructor({ memoryProvider, memoryFilesDir, memoryConfig }) {
|
|
7
|
+
this.provider = memoryProvider;
|
|
8
|
+
this.filesDir = memoryFilesDir;
|
|
9
|
+
this.config = memoryConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async list(req, res, query) {
|
|
13
|
+
const limit = Math.min(parseInt(query.limit) || 20, 100);
|
|
14
|
+
try { send(res, 200, await this.provider.list(limit, query.offset || null)); }
|
|
15
|
+
catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async search(req, res, query) {
|
|
19
|
+
const q = (query.query || '').toLowerCase().trim();
|
|
20
|
+
if (!q) return send(res, 400, { error: 'Missing query parameter' });
|
|
21
|
+
try { send(res, 200, await this.provider.search(q)); }
|
|
22
|
+
catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
files(req, res, query) {
|
|
26
|
+
const q = (query.query || '').toLowerCase().trim();
|
|
27
|
+
const memories = this._parseFiles();
|
|
28
|
+
const filtered = q ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
|
|
29
|
+
filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
|
|
30
|
+
send(res, 200, { memories: filtered });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_parseFiles() {
|
|
34
|
+
const memories = [];
|
|
35
|
+
const scanDir = (dir, prefix = '') => {
|
|
36
|
+
let entries;
|
|
37
|
+
try { entries = fs.readdirSync(dir); } catch { return; }
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const fullPath = path.join(dir, entry);
|
|
40
|
+
const stat = (() => { try { return fs.statSync(fullPath); } catch { return null; } })();
|
|
41
|
+
if (!stat) continue;
|
|
42
|
+
if (stat.isDirectory() && !prefix) { scanDir(fullPath, entry + '/'); continue; }
|
|
43
|
+
if (!entry.endsWith('.md') || !stat.isFile()) continue;
|
|
44
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
45
|
+
const basename = entry.replace(/\.md$/, '');
|
|
46
|
+
const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
47
|
+
if (prefix) {
|
|
48
|
+
memories.push({ id: `file:${prefix + basename}`, source: 'file', file: prefix + basename, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
|
|
49
|
+
} else {
|
|
50
|
+
for (const section of content.split(/^(?=## )/m)) {
|
|
51
|
+
const trimmed = section.trim();
|
|
52
|
+
if (!trimmed) continue;
|
|
53
|
+
const headingMatch = trimmed.match(/^##\s+(.+)/);
|
|
54
|
+
const heading = headingMatch ? headingMatch[1].trim() : null;
|
|
55
|
+
const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
|
|
56
|
+
if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
|
|
57
|
+
const title = heading || basename;
|
|
58
|
+
memories.push({ id: `file:${basename}:${title}`, source: 'file', file: basename, title, data: heading ? `**${title}**\n${body}` : body, createdAt: dateMatch ? `${dateMatch[1]}T00:00:00Z` : stat.mtime.toISOString() });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
scanDir(this.filesDir);
|
|
64
|
+
return memories;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async update(req, res, params) {
|
|
68
|
+
try {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
71
|
+
const { data } = JSON.parse(Buffer.concat(chunks).toString());
|
|
72
|
+
if (!(data || '').trim()) return send(res, 400, { error: 'Missing data field' });
|
|
73
|
+
send(res, 200, { ok: true, result: await this.provider.update(params.id, data.trim()) });
|
|
74
|
+
} catch (err) { send(res, 502, { error: 'Failed to update memory', detail: err.message }); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async delete(req, res, params) {
|
|
78
|
+
try { send(res, 200, { ok: true, result: await this.provider.delete(params.id) }); }
|
|
79
|
+
catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async status(req, res) {
|
|
83
|
+
const status = await this.provider.status();
|
|
84
|
+
send(res, 200, { provider: this.provider.name, host: this.config.host, port: this.config.port, collection: this.config.collection, backend: status, memoryFilesDir: this.filesDir, memoryFilesDirExists: fs.existsSync(this.filesDir) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -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,28 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send } from '../util/http.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Factory that returns settings GET/PUT handlers bound to a specific settings file path.
|
|
7
|
+
* Keeps file I/O out of the HTTP router (server/index.js).
|
|
8
|
+
*/
|
|
9
|
+
export function createSettingsHandlers(settingsFile) {
|
|
10
|
+
function handleGetSettings(req, res) {
|
|
11
|
+
try {
|
|
12
|
+
send(res, 200, fs.existsSync(settingsFile)
|
|
13
|
+
? JSON.parse(fs.readFileSync(settingsFile, 'utf8'))
|
|
14
|
+
: {});
|
|
15
|
+
} catch {
|
|
16
|
+
send(res, 200, {});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function handleSaveSettings(req, res, parseBody) {
|
|
21
|
+
const body = await parseBody(req);
|
|
22
|
+
fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
|
|
23
|
+
fs.writeFileSync(settingsFile, JSON.stringify(body, null, 2));
|
|
24
|
+
send(res, 200, { ok: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { handleGetSettings, handleSaveSettings };
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MIME = {
|
|
5
|
+
'.html': 'text/html',
|
|
6
|
+
'.js': 'text/javascript',
|
|
7
|
+
'.css': 'text/css',
|
|
8
|
+
'.json': 'application/json',
|
|
9
|
+
'.ico': 'image/x-icon',
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
'.svg': 'image/svg+xml',
|
|
12
|
+
'.gif': 'image/gif',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const STATIC_MAP = {
|
|
17
|
+
'/': 'index.html',
|
|
18
|
+
'/index.html': 'index.html',
|
|
19
|
+
'/app.js': 'app.js',
|
|
20
|
+
'/style.css': 'style.css',
|
|
21
|
+
'/error-handler.js': 'error-handler.js',
|
|
22
|
+
'/manifest.json': 'manifest.json',
|
|
23
|
+
'/favicon.ico': 'favicon.ico',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Serve static files from pluginDir.
|
|
28
|
+
* Returns true if the request was handled, false if it should fall through.
|
|
29
|
+
* Keeps fs.createReadStream out of the HTTP router (server/index.js).
|
|
30
|
+
*/
|
|
31
|
+
export function handleStatic(req, res, pluginDir) {
|
|
32
|
+
const urlPath = (req.url || '/').split('?')[0];
|
|
33
|
+
const fileName = STATIC_MAP[urlPath];
|
|
34
|
+
const isAllowed =
|
|
35
|
+
fileName ||
|
|
36
|
+
urlPath.startsWith('/icons/') ||
|
|
37
|
+
urlPath.startsWith('/lib/') ||
|
|
38
|
+
urlPath.startsWith('/frontend/') ||
|
|
39
|
+
urlPath.startsWith('/emoji/') ||
|
|
40
|
+
urlPath === '/config.js';
|
|
41
|
+
|
|
42
|
+
if (!isAllowed) return false;
|
|
43
|
+
|
|
44
|
+
const staticPath = path.join(pluginDir, fileName || urlPath.slice(1));
|
|
45
|
+
if (!fs.existsSync(staticPath) || !fs.statSync(staticPath).isFile()) return false;
|
|
46
|
+
|
|
47
|
+
const ext = path.extname(staticPath).toLowerCase();
|
|
48
|
+
const stat = fs.statSync(staticPath);
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
51
|
+
'Content-Length': stat.size,
|
|
52
|
+
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
|
|
53
|
+
});
|
|
54
|
+
fs.createReadStream(staticPath).pipe(res);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
@@ -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,44 @@
|
|
|
1
|
+
import { send } from '../util/http.js';
|
|
2
|
+
|
|
3
|
+
export async function handleTranscribe(req, res, opts = {}) {
|
|
4
|
+
try {
|
|
5
|
+
const chunks = [];
|
|
6
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
7
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
8
|
+
|
|
9
|
+
if (audioBuffer.length === 0) return send(res, 400, { error: 'No audio data' });
|
|
10
|
+
if (audioBuffer.length > 25 * 1024 * 1024) return send(res, 400, { error: 'Audio too large (max 25MB)' });
|
|
11
|
+
|
|
12
|
+
// API key is resolved by the plugin host (src/index.ts) and passed via opts.openaiApiKey.
|
|
13
|
+
const apiKey = opts.openaiApiKey;
|
|
14
|
+
if (!apiKey) return send(res, 500, { error: 'No OpenAI API key configured' });
|
|
15
|
+
|
|
16
|
+
const contentType = req.headers['content-type'] || 'audio/webm';
|
|
17
|
+
const ext = contentType.includes('wav') ? 'wav' : contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a' : contentType.includes('ogg') ? 'ogg' : 'webm';
|
|
18
|
+
|
|
19
|
+
const boundary = '----WhisperBoundary' + Date.now();
|
|
20
|
+
const body = Buffer.concat([
|
|
21
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.${ext}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
|
22
|
+
audioBuffer,
|
|
23
|
+
`\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1\r\n`,
|
|
24
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n`,
|
|
25
|
+
`--${boundary}--\r\n`,
|
|
26
|
+
].map(p => typeof p === 'string' ? Buffer.from(p) : p));
|
|
27
|
+
|
|
28
|
+
const resp = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
31
|
+
body,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!resp.ok) {
|
|
35
|
+
console.error('Whisper API error:', resp.status, await resp.text());
|
|
36
|
+
return send(res, 502, { error: `Whisper API error: ${resp.status}` });
|
|
37
|
+
}
|
|
38
|
+
const result = await resp.json();
|
|
39
|
+
return send(res, 200, { text: result.text || '' });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('Transcribe error:', err);
|
|
42
|
+
return send(res, 500, { error: err.message });
|
|
43
|
+
}
|
|
44
|
+
}
|