fluxy-bot 0.1.46 → 0.2.0
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/bin/cli.js +1 -3
- package/client/src/App.tsx +0 -3
- package/client/src/components/Layout/DashboardLayout.tsx +1 -1
- package/client/src/hooks/useChat.ts +51 -62
- package/client/src/lib/ws-client.ts +1 -1
- package/dist/assets/{index-BAUWfBMW.js → index-D8wa5QyC.js} +21 -21
- package/dist/assets/index-Dpj8titN.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +10 -8
- package/scripts/postinstall.js +10 -26
- package/shared/paths.ts +1 -11
- package/supervisor/fluxy.html +94 -0
- package/supervisor/index.ts +66 -194
- package/supervisor/worker.ts +3 -16
- package/tsconfig.json +2 -3
- package/vite.config.ts +4 -1
- package/{supervisor → worker}/claude-agent.ts +43 -50
- package/{shared → worker}/db.ts +1 -9
- package/{shared → worker}/file-storage.ts +1 -1
- package/worker/index.ts +133 -31
- package/worker/prompts/fluxy-system-prompt.txt +8 -0
- package/client/src/components/BuildOverlay.tsx +0 -75
- package/dist/assets/index-CiN0-4-O.css +0 -1
- package/shared/workspace.ts +0 -196
- package/supervisor/prompts/fluxy-system-prompt.txt +0 -35
- package/supervisor/vite-dev.ts +0 -75
package/supervisor/index.ts
CHANGED
|
@@ -1,234 +1,108 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import net from 'net';
|
|
3
|
+
import fs from 'fs';
|
|
3
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
5
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
5
6
|
import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
|
|
6
|
-
import { paths
|
|
7
|
+
import { paths } from '../shared/paths.js';
|
|
7
8
|
import { log } from '../shared/logger.js';
|
|
8
|
-
import { initDb, closeDb, createConversation, addMessage, getMessages, getSetting } from '../shared/db.js';
|
|
9
|
-
import { initWorkspace } from '../shared/workspace.js';
|
|
10
|
-
import { ensureFileDirs, saveFile } from '../shared/file-storage.js';
|
|
11
|
-
import { startAgentQuery, stopAgentQuery } from './claude-agent.js';
|
|
12
9
|
import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel } from './tunnel.js';
|
|
13
10
|
import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
|
|
14
|
-
import { spawnVite, stopVite, getVitePort, isViteAlive } from './vite-dev.js';
|
|
15
11
|
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
16
12
|
|
|
17
13
|
const RECOVERING_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Recovering</title>
|
|
18
14
|
<style>body{background:#0a0a0f;color:#94a3b8;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
19
15
|
div{text-align:center}h1{font-size:18px;margin-bottom:8px;color:#e2e8f0}p{font-size:14px}a{color:#60a5fa}</style></head>
|
|
20
|
-
<body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically
|
|
16
|
+
<body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically. <a href="/fluxy">Talk to Fluxy</a></p></div>
|
|
21
17
|
<script>setTimeout(()=>location.reload(),3000)</script></body></html>`;
|
|
22
18
|
|
|
23
|
-
/** Broadcast a message to all connected chat clients */
|
|
24
|
-
function broadcastWs(chatWss: WebSocketServer, type: string, data?: any): void {
|
|
25
|
-
const payload = JSON.stringify({ type, data });
|
|
26
|
-
for (const client of chatWss.clients) {
|
|
27
|
-
if (client.readyState === WebSocket.OPEN) client.send(payload);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Notify clients after every agent turn — HMR handles the actual update */
|
|
32
|
-
function runPostIteration(chatWss: WebSocketServer): void {
|
|
33
|
-
log.info('[post-iteration] Changes applied — HMR active');
|
|
34
|
-
broadcastWs(chatWss, 'changes:applied', { timestamp: Date.now() });
|
|
35
|
-
}
|
|
36
|
-
|
|
37
19
|
export async function startSupervisor() {
|
|
38
20
|
const config = loadConfig();
|
|
39
21
|
const workerPort = getWorkerPort(config.port);
|
|
40
22
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// Active non-Anthropic streams (keyed by conversationId)
|
|
47
|
-
const activeStreams = new Map<string, AbortController>();
|
|
23
|
+
// Fluxy's AI brain
|
|
24
|
+
let ai: AiProvider | null = null;
|
|
25
|
+
if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
|
|
26
|
+
ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
|
|
27
|
+
}
|
|
48
28
|
|
|
49
|
-
|
|
29
|
+
// Fluxy chat conversations (in-memory for now)
|
|
30
|
+
const conversations = new Map<WebSocket, ChatMessage[]>();
|
|
50
31
|
|
|
51
|
-
// HTTP server
|
|
32
|
+
// HTTP server
|
|
52
33
|
const server = http.createServer((req, res) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
res.end(RECOVERING_HTML);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const proxy = http.request(
|
|
64
|
-
{ host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
|
|
65
|
-
(proxyRes) => {
|
|
66
|
-
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
67
|
-
proxyRes.pipe(res);
|
|
68
|
-
},
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
proxy.on('error', () => {
|
|
72
|
-
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
73
|
-
res.end(RECOVERING_HTML);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
req.pipe(proxy);
|
|
77
|
-
} else {
|
|
78
|
-
// Frontend requests → Vite dev server (fallback to worker if Vite is down)
|
|
79
|
-
const targetPort = isViteAlive() ? vitePort : workerPort;
|
|
80
|
-
|
|
81
|
-
// Override Host header to localhost so Vite accepts the request
|
|
82
|
-
const headers = { ...req.headers, host: `127.0.0.1:${targetPort}` };
|
|
34
|
+
// Fluxy routes — served directly, never proxied
|
|
35
|
+
if (req.url === '/fluxy' || req.url === '/fluxy/') {
|
|
36
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
37
|
+
res.end(fs.readFileSync(paths.fluxyHtml));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
83
40
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
);
|
|
41
|
+
// Everything else → proxy to worker
|
|
42
|
+
if (!isWorkerAlive()) {
|
|
43
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
44
|
+
res.end(RECOVERING_HTML);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
91
47
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
48
|
+
const proxy = http.request(
|
|
49
|
+
{ host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
|
|
50
|
+
(proxyRes) => {
|
|
51
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
52
|
+
proxyRes.pipe(res);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
proxy.on('error', () => {
|
|
57
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
58
|
+
res.end(RECOVERING_HTML);
|
|
59
|
+
});
|
|
96
60
|
|
|
97
|
-
|
|
98
|
-
}
|
|
61
|
+
req.pipe(proxy);
|
|
99
62
|
});
|
|
100
63
|
|
|
101
|
-
// WebSocket: chat
|
|
102
|
-
const
|
|
64
|
+
// WebSocket: Fluxy chat + proxy worker WS
|
|
65
|
+
const fluxyWss = new WebSocketServer({ noServer: true });
|
|
103
66
|
|
|
104
|
-
|
|
105
|
-
log.info('
|
|
67
|
+
fluxyWss.on('connection', (ws) => {
|
|
68
|
+
log.info('Fluxy chat connected');
|
|
69
|
+
conversations.set(ws, []);
|
|
106
70
|
|
|
107
71
|
ws.on('message', (raw) => {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
// Heartbeat ping/pong
|
|
111
|
-
if (text === 'ping') { ws.send('pong'); return; }
|
|
112
|
-
|
|
113
|
-
const msg = JSON.parse(text);
|
|
114
|
-
|
|
115
|
-
if (msg.type === 'user:message') {
|
|
116
|
-
// Re-read config on each message so onboarding changes are picked up
|
|
117
|
-
const freshConfig = loadConfig();
|
|
118
|
-
|
|
119
|
-
const { conversationId, content, attachments, audioData } = msg.data;
|
|
120
|
-
let convId = conversationId;
|
|
121
|
-
if (!convId) convId = createConversation(content.slice(0, 50), freshConfig.ai.model).id;
|
|
122
|
-
|
|
123
|
-
// Save audio file to disk
|
|
124
|
-
let audioPath: string | undefined;
|
|
125
|
-
if (audioData) {
|
|
126
|
-
try {
|
|
127
|
-
audioPath = saveFile(audioData, 'audio', 'webm');
|
|
128
|
-
} catch (err: any) {
|
|
129
|
-
log.warn(`Failed to save audio file: ${err.message}`);
|
|
130
|
-
audioPath = audioData; // fallback to inline
|
|
131
|
-
}
|
|
132
|
-
}
|
|
72
|
+
const msg = JSON.parse(raw.toString());
|
|
73
|
+
if (msg.type !== 'message' || !msg.content) return;
|
|
133
74
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let storedAttachments: StoredAttachment[] | undefined;
|
|
137
|
-
if (attachments?.length) {
|
|
138
|
-
storedAttachments = [];
|
|
139
|
-
for (const att of attachments) {
|
|
140
|
-
try {
|
|
141
|
-
const isImage = att.mediaType?.startsWith('image/');
|
|
142
|
-
const ext = att.name?.split('.').pop() || (isImage ? 'jpg' : 'bin');
|
|
143
|
-
const category = isImage ? 'images' as const : 'documents' as const;
|
|
144
|
-
const filePath = saveFile(att.data, category, ext);
|
|
145
|
-
storedAttachments.push({ type: att.type, name: att.name, mediaType: att.mediaType, filePath });
|
|
146
|
-
} catch (err: any) {
|
|
147
|
-
log.warn(`Failed to save attachment ${att.name}: ${err.message}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
75
|
+
const history = conversations.get(ws) || [];
|
|
76
|
+
history.push({ role: 'user', content: msg.content });
|
|
151
77
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
addMessage(convId, 'user', content, Object.keys(msgMeta).length > 0 ? msgMeta : undefined);
|
|
156
|
-
|
|
157
|
-
// Route Anthropic provider through Agent SDK
|
|
158
|
-
if (freshConfig.ai.provider === 'anthropic') {
|
|
159
|
-
let usedTools = false;
|
|
160
|
-
startAgentQuery(convId, content, freshConfig.ai.model, (type, data) => {
|
|
161
|
-
if (type === 'bot:tool') usedTools = true;
|
|
162
|
-
if (type === 'bot:response') {
|
|
163
|
-
addMessage(convId, 'assistant', data.content, { model: freshConfig.ai.model });
|
|
164
|
-
}
|
|
165
|
-
ws.send(JSON.stringify({ type, data: { ...data, conversationId: convId } }));
|
|
166
|
-
}, attachments).then(() => {
|
|
167
|
-
if (usedTools) runPostIteration(chatWss);
|
|
168
|
-
});
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Non-Anthropic providers use ai.chat()
|
|
173
|
-
let ai: AiProvider | null = null;
|
|
174
|
-
if (freshConfig.ai.provider && (freshConfig.ai.apiKey || freshConfig.ai.provider === 'ollama')) {
|
|
175
|
-
ai = createProvider(freshConfig.ai.provider, freshConfig.ai.apiKey, freshConfig.ai.baseUrl);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!ai) {
|
|
179
|
-
ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const history = getMessages(convId) as { role: string; content: string }[];
|
|
184
|
-
const systemPrompt = getSetting('system_prompt');
|
|
185
|
-
const messages: ChatMessage[] = [
|
|
186
|
-
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
|
|
187
|
-
...history.map((m) => ({ role: m.role as ChatMessage['role'], content: m.content })),
|
|
188
|
-
];
|
|
189
|
-
|
|
190
|
-
ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
|
|
191
|
-
const ctrl = new AbortController();
|
|
192
|
-
activeStreams.set(convId, ctrl);
|
|
193
|
-
|
|
194
|
-
ai.chat(
|
|
195
|
-
messages,
|
|
196
|
-
freshConfig.ai.model,
|
|
197
|
-
(token) => ws.send(JSON.stringify({ type: 'bot:token', data: { conversationId: convId, token } })),
|
|
198
|
-
(full, usage) => {
|
|
199
|
-
const m = addMessage(convId, 'assistant', full, { tokens_in: usage?.tokensIn, tokens_out: usage?.tokensOut, model: freshConfig.ai.model });
|
|
200
|
-
ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, messageId: (m as any).id, content: full } }));
|
|
201
|
-
activeStreams.delete(convId);
|
|
202
|
-
},
|
|
203
|
-
(err) => {
|
|
204
|
-
ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: err.message } }));
|
|
205
|
-
activeStreams.delete(convId);
|
|
206
|
-
},
|
|
207
|
-
ctrl.signal,
|
|
208
|
-
);
|
|
78
|
+
if (!ai) {
|
|
79
|
+
ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
|
|
80
|
+
return;
|
|
209
81
|
}
|
|
210
82
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
83
|
+
ai.chat(
|
|
84
|
+
[{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
|
|
85
|
+
config.ai.model,
|
|
86
|
+
(token) => ws.send(JSON.stringify({ type: 'token', token })),
|
|
87
|
+
(full) => {
|
|
88
|
+
history.push({ role: 'assistant', content: full });
|
|
89
|
+
ws.send(JSON.stringify({ type: 'done' }));
|
|
90
|
+
},
|
|
91
|
+
(err) => ws.send(JSON.stringify({ type: 'error', error: err.message })),
|
|
92
|
+
);
|
|
218
93
|
});
|
|
94
|
+
|
|
95
|
+
ws.on('close', () => conversations.delete(ws));
|
|
219
96
|
});
|
|
220
97
|
|
|
221
98
|
server.on('upgrade', (req, socket: net.Socket, head) => {
|
|
222
|
-
if (req.url === '/ws') {
|
|
223
|
-
|
|
99
|
+
if (req.url === '/fluxy/ws') {
|
|
100
|
+
fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
|
|
224
101
|
return;
|
|
225
102
|
}
|
|
226
103
|
|
|
227
|
-
// Proxy
|
|
228
|
-
const
|
|
229
|
-
if (!targetPort) { socket.destroy(); return; }
|
|
230
|
-
|
|
231
|
-
const proxy = net.connect(targetPort, () => {
|
|
104
|
+
// Proxy WS upgrade to worker
|
|
105
|
+
const proxy = net.connect(workerPort, () => {
|
|
232
106
|
const headers = Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n');
|
|
233
107
|
proxy.write(`GET ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
|
|
234
108
|
if (head.length > 0) proxy.write(head);
|
|
@@ -251,11 +125,11 @@ export async function startSupervisor() {
|
|
|
251
125
|
|
|
252
126
|
server.listen(config.port, () => {
|
|
253
127
|
log.ok(`Supervisor on http://localhost:${config.port}`);
|
|
128
|
+
log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
|
|
254
129
|
});
|
|
255
130
|
|
|
256
|
-
// Spawn worker
|
|
131
|
+
// Spawn worker
|
|
257
132
|
spawnWorker(workerPort);
|
|
258
|
-
await spawnVite(vitePort);
|
|
259
133
|
|
|
260
134
|
// Tunnel
|
|
261
135
|
let tunnelUrl: string | null = null;
|
|
@@ -335,9 +209,7 @@ export async function startSupervisor() {
|
|
|
335
209
|
delete latestConfig.tunnelUrl;
|
|
336
210
|
saveConfig(latestConfig);
|
|
337
211
|
stopWorker();
|
|
338
|
-
stopVite();
|
|
339
212
|
stopTunnel();
|
|
340
|
-
closeDb();
|
|
341
213
|
server.close();
|
|
342
214
|
process.exit(0);
|
|
343
215
|
};
|
package/supervisor/worker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { PKG_DIR
|
|
3
|
+
import { PKG_DIR } from '../shared/paths.js';
|
|
4
4
|
import { log } from '../shared/logger.js';
|
|
5
5
|
|
|
6
6
|
let child: ChildProcess | null = null;
|
|
@@ -14,20 +14,7 @@ export function getWorkerPort(basePort: number): number {
|
|
|
14
14
|
export function spawnWorker(port: number): ChildProcess {
|
|
15
15
|
const workerPath = path.join(PKG_DIR, 'worker', 'index.ts');
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
// Keeps the parent process alive and internally restarts
|
|
19
|
-
// the worker when imported files change.
|
|
20
|
-
// --watch-path: scope watching to worker/ and shared/ so frontend
|
|
21
|
-
// edits don't cause unnecessary restarts.
|
|
22
|
-
child = spawn(process.execPath, [
|
|
23
|
-
'--watch',
|
|
24
|
-
'--watch-preserve-output',
|
|
25
|
-
`--watch-path=${path.join(PKG_DIR, 'worker')}`,
|
|
26
|
-
`--watch-path=${path.join(PKG_DIR, 'shared')}`,
|
|
27
|
-
`--watch-path=${paths.env}`,
|
|
28
|
-
'--import', 'tsx/esm',
|
|
29
|
-
workerPath,
|
|
30
|
-
], {
|
|
17
|
+
child = spawn(process.execPath, ['--import', 'tsx/esm', workerPath], {
|
|
31
18
|
cwd: PKG_DIR,
|
|
32
19
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
20
|
env: { ...process.env, WORKER_PORT: String(port) },
|
|
@@ -54,7 +41,7 @@ export function spawnWorker(port: number): ChildProcess {
|
|
|
54
41
|
}
|
|
55
42
|
});
|
|
56
43
|
|
|
57
|
-
log.ok(`Worker spawned on port ${port}
|
|
44
|
+
log.ok(`Worker spawned on port ${port}`);
|
|
58
45
|
return child;
|
|
59
46
|
}
|
|
60
47
|
|
package/tsconfig.json
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
"outDir": "dist",
|
|
10
10
|
"rootDir": ".",
|
|
11
11
|
"jsx": "react-jsx",
|
|
12
|
-
"types": [
|
|
12
|
+
"types": [],
|
|
13
13
|
"paths": {
|
|
14
14
|
"@server/*": ["./server/*"],
|
|
15
|
-
"@client/*": ["./client/src/*"]
|
|
16
|
-
"@/*": ["./client/src/*"]
|
|
15
|
+
"@client/*": ["./client/src/*"]
|
|
17
16
|
}
|
|
18
17
|
},
|
|
19
18
|
"include": ["server/**/*", "client/src/**/*", "vite.config.ts"],
|
package/vite.config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Claude Agent SDK service — runs Claude Code as a subprocess with tool use
|
|
3
|
-
* and the claude_code system prompt preset.
|
|
2
|
+
* Claude Agent SDK service — runs Claude Code as a subprocess with tool use,
|
|
3
|
+
* session persistence, and the claude_code system prompt preset.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Replaces the raw Anthropic Messages API for Anthropic-provider chats.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
@@ -10,10 +10,10 @@ import fs from 'fs';
|
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import os from 'os';
|
|
12
12
|
import { log } from '../shared/logger.js';
|
|
13
|
-
import {
|
|
14
|
-
import { buildSystemPromptAddendum, initWorkspace } from '../shared/workspace.js';
|
|
13
|
+
import { getSessionId, saveSessionId } from './db.js';
|
|
15
14
|
|
|
16
15
|
const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
16
|
+
const PROMPT_FILE = path.join(import.meta.dirname, 'prompts', 'fluxy-system-prompt.txt');
|
|
17
17
|
|
|
18
18
|
export interface AgentAttachment {
|
|
19
19
|
type: 'image' | 'file';
|
|
@@ -24,19 +24,11 @@ export interface AgentAttachment {
|
|
|
24
24
|
|
|
25
25
|
interface ActiveSession {
|
|
26
26
|
abortController: AbortController;
|
|
27
|
+
sessionId?: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const activeSessions = new Map<string, ActiveSession>();
|
|
30
31
|
|
|
31
|
-
/** Read the pointer prompt from the bundled file */
|
|
32
|
-
function readPointerPrompt(): string {
|
|
33
|
-
try {
|
|
34
|
-
return fs.readFileSync(path.join(import.meta.dirname, 'prompts', 'fluxy-system-prompt.txt'), 'utf-8').trim();
|
|
35
|
-
} catch {
|
|
36
|
-
return '';
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
32
|
/** Read the OAuth token stored by claude-auth.ts */
|
|
41
33
|
function readOAuthToken(): string | null {
|
|
42
34
|
try {
|
|
@@ -51,24 +43,13 @@ function readOAuthToken(): string | null {
|
|
|
51
43
|
return null;
|
|
52
44
|
}
|
|
53
45
|
|
|
54
|
-
/**
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
lines.push('[Recent conversation history]');
|
|
61
|
-
for (const row of rows) {
|
|
62
|
-
const tag = row.role === 'user' ? 'USER' : 'ASSISTANT';
|
|
63
|
-
lines.push(`${tag}: ${row.content}`);
|
|
64
|
-
}
|
|
65
|
-
lines.push('');
|
|
46
|
+
/** Read the custom system prompt addendum */
|
|
47
|
+
function readSystemPromptAddendum(): string {
|
|
48
|
+
try {
|
|
49
|
+
return fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return '';
|
|
66
52
|
}
|
|
67
|
-
|
|
68
|
-
lines.push('[Current message]');
|
|
69
|
-
lines.push(`USER: ${currentMessage}`);
|
|
70
|
-
|
|
71
|
-
return lines.join('\n');
|
|
72
53
|
}
|
|
73
54
|
|
|
74
55
|
/** Build a multi-part prompt with attachments for the SDK */
|
|
@@ -83,6 +64,7 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[]): Asy
|
|
|
83
64
|
source: { type: 'base64', media_type: att.mediaType, data: att.data },
|
|
84
65
|
});
|
|
85
66
|
} else {
|
|
67
|
+
// PDF / document
|
|
86
68
|
content.push({
|
|
87
69
|
type: 'document',
|
|
88
70
|
source: { type: 'base64', media_type: att.mediaType, data: att.data },
|
|
@@ -103,8 +85,12 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[]): Asy
|
|
|
103
85
|
/**
|
|
104
86
|
* Start an Agent SDK query for a conversation.
|
|
105
87
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
88
|
+
* Streams events back via the onMessage callback:
|
|
89
|
+
* bot:typing — agent started thinking
|
|
90
|
+
* bot:token — text chunk from assistant
|
|
91
|
+
* bot:tool — tool invocation (name + input)
|
|
92
|
+
* bot:response — final complete response
|
|
93
|
+
* bot:error — error
|
|
108
94
|
*/
|
|
109
95
|
export async function startAgentQuery(
|
|
110
96
|
conversationId: string,
|
|
@@ -120,37 +106,33 @@ export async function startAgentQuery(
|
|
|
120
106
|
}
|
|
121
107
|
|
|
122
108
|
const abortController = new AbortController();
|
|
123
|
-
|
|
109
|
+
const existingSessionId = getSessionId(conversationId);
|
|
110
|
+
const addendum = readSystemPromptAddendum();
|
|
124
111
|
|
|
125
|
-
|
|
126
|
-
initWorkspace();
|
|
127
|
-
|
|
128
|
-
const pointerPrompt = readPointerPrompt();
|
|
129
|
-
const addendum = buildSystemPromptAddendum(pointerPrompt);
|
|
130
|
-
const transcript = buildTranscript(conversationId, prompt);
|
|
112
|
+
activeSessions.set(conversationId, { abortController, sessionId: existingSessionId ?? undefined });
|
|
131
113
|
|
|
132
114
|
let fullText = '';
|
|
133
115
|
|
|
134
116
|
// Use multi-part prompt when attachments are present
|
|
135
117
|
const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
|
|
136
118
|
attachments?.length
|
|
137
|
-
? buildMultiPartPrompt(
|
|
138
|
-
:
|
|
119
|
+
? buildMultiPartPrompt(prompt, attachments)
|
|
120
|
+
: prompt;
|
|
139
121
|
|
|
140
122
|
try {
|
|
141
123
|
const claudeQuery = query({
|
|
142
124
|
prompt: sdkPrompt,
|
|
143
125
|
options: {
|
|
144
126
|
model,
|
|
145
|
-
cwd:
|
|
127
|
+
cwd: process.cwd(),
|
|
146
128
|
permissionMode: 'bypassPermissions',
|
|
147
129
|
allowDangerouslySkipPermissions: true,
|
|
148
130
|
maxTurns: 50,
|
|
149
131
|
abortController,
|
|
150
|
-
persistSession: false,
|
|
151
132
|
systemPrompt: addendum
|
|
152
133
|
? { type: 'preset', preset: 'claude_code', append: addendum }
|
|
153
134
|
: { type: 'preset', preset: 'claude_code' },
|
|
135
|
+
...(existingSessionId ? { resume: existingSessionId } : {}),
|
|
154
136
|
env: {
|
|
155
137
|
...process.env as Record<string, string>,
|
|
156
138
|
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
@@ -166,6 +148,7 @@ export async function startAgentQuery(
|
|
|
166
148
|
handleSDKMessage(msg, conversationId, fullText, onMessage, (text) => { fullText = text; });
|
|
167
149
|
}
|
|
168
150
|
|
|
151
|
+
// If we accumulated text but didn't hit a result message, send what we have
|
|
169
152
|
if (fullText && !abortController.signal.aborted) {
|
|
170
153
|
onMessage('bot:response', { conversationId, content: fullText });
|
|
171
154
|
}
|
|
@@ -189,17 +172,20 @@ function handleSDKMessage(
|
|
|
189
172
|
): void {
|
|
190
173
|
switch (msg.type) {
|
|
191
174
|
case 'system':
|
|
175
|
+
// system init — typing already sent
|
|
192
176
|
break;
|
|
193
177
|
|
|
194
178
|
case 'assistant': {
|
|
195
179
|
const assistantMsg = msg.message;
|
|
196
180
|
if (!assistantMsg?.content) break;
|
|
197
181
|
|
|
198
|
-
//
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
182
|
+
// Save session_id from first assistant message
|
|
183
|
+
if (msg.session_id) {
|
|
184
|
+
const session = activeSessions.get(conversationId);
|
|
185
|
+
if (session && !session.sessionId) {
|
|
186
|
+
session.sessionId = msg.session_id;
|
|
187
|
+
saveSessionId(conversationId, msg.session_id);
|
|
188
|
+
}
|
|
203
189
|
}
|
|
204
190
|
|
|
205
191
|
for (const block of assistantMsg.content) {
|
|
@@ -218,9 +204,16 @@ function handleSDKMessage(
|
|
|
218
204
|
}
|
|
219
205
|
|
|
220
206
|
case 'result': {
|
|
207
|
+
// Final result — send the accumulated text as the complete response
|
|
208
|
+
if (msg.subtype === 'success') {
|
|
209
|
+
if (msg.session_id) {
|
|
210
|
+
saveSessionId(conversationId, msg.session_id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
221
214
|
if (currentText) {
|
|
222
215
|
onMessage('bot:response', { conversationId, content: currentText });
|
|
223
|
-
setText('');
|
|
216
|
+
setText(''); // prevent duplicate in the finally block
|
|
224
217
|
} else if (msg.subtype?.startsWith('error')) {
|
|
225
218
|
const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
|
|
226
219
|
onMessage('bot:error', { conversationId, error: errorText });
|
package/{shared → worker}/db.ts
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
-
import { paths, DATA_DIR } from '
|
|
3
|
+
import { paths, DATA_DIR } from '../shared/paths.js';
|
|
4
4
|
|
|
5
5
|
const SCHEMA = `
|
|
6
6
|
CREATE TABLE IF NOT EXISTS conversations (
|
|
@@ -35,7 +35,6 @@ export function initDb(): void {
|
|
|
35
35
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
36
36
|
db = new Database(paths.db);
|
|
37
37
|
db.pragma('journal_mode = WAL');
|
|
38
|
-
db.pragma('busy_timeout = 5000');
|
|
39
38
|
db.pragma('foreign_keys = ON');
|
|
40
39
|
db.exec(SCHEMA);
|
|
41
40
|
|
|
@@ -81,13 +80,6 @@ export function addMessage(convId: string, role: string, content: string, meta?:
|
|
|
81
80
|
export function getMessages(convId: string) {
|
|
82
81
|
return db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId);
|
|
83
82
|
}
|
|
84
|
-
export function getLastMessages(convId: string, limit = 20) {
|
|
85
|
-
return db.prepare(`
|
|
86
|
-
SELECT * FROM (
|
|
87
|
-
SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?
|
|
88
|
-
) sub ORDER BY created_at ASC
|
|
89
|
-
`).all(convId, limit);
|
|
90
|
-
}
|
|
91
83
|
|
|
92
84
|
// Settings
|
|
93
85
|
export function getSetting(key: string): string | undefined {
|