fluxy-bot 0.1.45 → 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 -191
- 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 -73
package/supervisor/index.ts
CHANGED
|
@@ -1,231 +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
|
-
});
|
|
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
|
+
}
|
|
75
40
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
{ host: '127.0.0.1', port: targetPort, path: req.url, method: req.method, headers: req.headers },
|
|
83
|
-
(proxyRes) => {
|
|
84
|
-
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
85
|
-
proxyRes.pipe(res);
|
|
86
|
-
},
|
|
87
|
-
);
|
|
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
|
+
}
|
|
88
47
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
});
|
|
93
60
|
|
|
94
|
-
|
|
95
|
-
}
|
|
61
|
+
req.pipe(proxy);
|
|
96
62
|
});
|
|
97
63
|
|
|
98
|
-
// WebSocket: chat
|
|
99
|
-
const
|
|
64
|
+
// WebSocket: Fluxy chat + proxy worker WS
|
|
65
|
+
const fluxyWss = new WebSocketServer({ noServer: true });
|
|
100
66
|
|
|
101
|
-
|
|
102
|
-
log.info('
|
|
67
|
+
fluxyWss.on('connection', (ws) => {
|
|
68
|
+
log.info('Fluxy chat connected');
|
|
69
|
+
conversations.set(ws, []);
|
|
103
70
|
|
|
104
71
|
ws.on('message', (raw) => {
|
|
105
|
-
const
|
|
72
|
+
const msg = JSON.parse(raw.toString());
|
|
73
|
+
if (msg.type !== 'message' || !msg.content) return;
|
|
106
74
|
|
|
107
|
-
|
|
108
|
-
|
|
75
|
+
const history = conversations.get(ws) || [];
|
|
76
|
+
history.push({ role: 'user', content: msg.content });
|
|
109
77
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Re-read config on each message so onboarding changes are picked up
|
|
114
|
-
const freshConfig = loadConfig();
|
|
115
|
-
|
|
116
|
-
const { conversationId, content, attachments, audioData } = msg.data;
|
|
117
|
-
let convId = conversationId;
|
|
118
|
-
if (!convId) convId = createConversation(content.slice(0, 50), freshConfig.ai.model).id;
|
|
119
|
-
|
|
120
|
-
// Save audio file to disk
|
|
121
|
-
let audioPath: string | undefined;
|
|
122
|
-
if (audioData) {
|
|
123
|
-
try {
|
|
124
|
-
audioPath = saveFile(audioData, 'audio', 'webm');
|
|
125
|
-
} catch (err: any) {
|
|
126
|
-
log.warn(`Failed to save audio file: ${err.message}`);
|
|
127
|
-
audioPath = audioData; // fallback to inline
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Save attachments to disk
|
|
132
|
-
type StoredAttachment = { type: string; name: string; mediaType: string; filePath: string };
|
|
133
|
-
let storedAttachments: StoredAttachment[] | undefined;
|
|
134
|
-
if (attachments?.length) {
|
|
135
|
-
storedAttachments = [];
|
|
136
|
-
for (const att of attachments) {
|
|
137
|
-
try {
|
|
138
|
-
const isImage = att.mediaType?.startsWith('image/');
|
|
139
|
-
const ext = att.name?.split('.').pop() || (isImage ? 'jpg' : 'bin');
|
|
140
|
-
const category = isImage ? 'images' as const : 'documents' as const;
|
|
141
|
-
const filePath = saveFile(att.data, category, ext);
|
|
142
|
-
storedAttachments.push({ type: att.type, name: att.name, mediaType: att.mediaType, filePath });
|
|
143
|
-
} catch (err: any) {
|
|
144
|
-
log.warn(`Failed to save attachment ${att.name}: ${err.message}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const msgMeta: any = {};
|
|
150
|
-
if (audioPath) msgMeta.audio_data = audioPath;
|
|
151
|
-
if (storedAttachments?.length) msgMeta.attachments = JSON.stringify(storedAttachments);
|
|
152
|
-
addMessage(convId, 'user', content, Object.keys(msgMeta).length > 0 ? msgMeta : undefined);
|
|
153
|
-
|
|
154
|
-
// Route Anthropic provider through Agent SDK
|
|
155
|
-
if (freshConfig.ai.provider === 'anthropic') {
|
|
156
|
-
let usedTools = false;
|
|
157
|
-
startAgentQuery(convId, content, freshConfig.ai.model, (type, data) => {
|
|
158
|
-
if (type === 'bot:tool') usedTools = true;
|
|
159
|
-
if (type === 'bot:response') {
|
|
160
|
-
addMessage(convId, 'assistant', data.content, { model: freshConfig.ai.model });
|
|
161
|
-
}
|
|
162
|
-
ws.send(JSON.stringify({ type, data: { ...data, conversationId: convId } }));
|
|
163
|
-
}, attachments).then(() => {
|
|
164
|
-
if (usedTools) runPostIteration(chatWss);
|
|
165
|
-
});
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Non-Anthropic providers use ai.chat()
|
|
170
|
-
let ai: AiProvider | null = null;
|
|
171
|
-
if (freshConfig.ai.provider && (freshConfig.ai.apiKey || freshConfig.ai.provider === 'ollama')) {
|
|
172
|
-
ai = createProvider(freshConfig.ai.provider, freshConfig.ai.apiKey, freshConfig.ai.baseUrl);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (!ai) {
|
|
176
|
-
ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const history = getMessages(convId) as { role: string; content: string }[];
|
|
181
|
-
const systemPrompt = getSetting('system_prompt');
|
|
182
|
-
const messages: ChatMessage[] = [
|
|
183
|
-
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
|
|
184
|
-
...history.map((m) => ({ role: m.role as ChatMessage['role'], content: m.content })),
|
|
185
|
-
];
|
|
186
|
-
|
|
187
|
-
ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
|
|
188
|
-
const ctrl = new AbortController();
|
|
189
|
-
activeStreams.set(convId, ctrl);
|
|
190
|
-
|
|
191
|
-
ai.chat(
|
|
192
|
-
messages,
|
|
193
|
-
freshConfig.ai.model,
|
|
194
|
-
(token) => ws.send(JSON.stringify({ type: 'bot:token', data: { conversationId: convId, token } })),
|
|
195
|
-
(full, usage) => {
|
|
196
|
-
const m = addMessage(convId, 'assistant', full, { tokens_in: usage?.tokensIn, tokens_out: usage?.tokensOut, model: freshConfig.ai.model });
|
|
197
|
-
ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, messageId: (m as any).id, content: full } }));
|
|
198
|
-
activeStreams.delete(convId);
|
|
199
|
-
},
|
|
200
|
-
(err) => {
|
|
201
|
-
ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: err.message } }));
|
|
202
|
-
activeStreams.delete(convId);
|
|
203
|
-
},
|
|
204
|
-
ctrl.signal,
|
|
205
|
-
);
|
|
78
|
+
if (!ai) {
|
|
79
|
+
ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
|
|
80
|
+
return;
|
|
206
81
|
}
|
|
207
82
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
);
|
|
215
93
|
});
|
|
94
|
+
|
|
95
|
+
ws.on('close', () => conversations.delete(ws));
|
|
216
96
|
});
|
|
217
97
|
|
|
218
98
|
server.on('upgrade', (req, socket: net.Socket, head) => {
|
|
219
|
-
if (req.url === '/ws') {
|
|
220
|
-
|
|
99
|
+
if (req.url === '/fluxy/ws') {
|
|
100
|
+
fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
|
|
221
101
|
return;
|
|
222
102
|
}
|
|
223
103
|
|
|
224
|
-
// Proxy
|
|
225
|
-
const
|
|
226
|
-
if (!targetPort) { socket.destroy(); return; }
|
|
227
|
-
|
|
228
|
-
const proxy = net.connect(targetPort, () => {
|
|
104
|
+
// Proxy WS upgrade to worker
|
|
105
|
+
const proxy = net.connect(workerPort, () => {
|
|
229
106
|
const headers = Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n');
|
|
230
107
|
proxy.write(`GET ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
|
|
231
108
|
if (head.length > 0) proxy.write(head);
|
|
@@ -248,11 +125,11 @@ export async function startSupervisor() {
|
|
|
248
125
|
|
|
249
126
|
server.listen(config.port, () => {
|
|
250
127
|
log.ok(`Supervisor on http://localhost:${config.port}`);
|
|
128
|
+
log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
|
|
251
129
|
});
|
|
252
130
|
|
|
253
|
-
// Spawn worker
|
|
131
|
+
// Spawn worker
|
|
254
132
|
spawnWorker(workerPort);
|
|
255
|
-
await spawnVite(vitePort);
|
|
256
133
|
|
|
257
134
|
// Tunnel
|
|
258
135
|
let tunnelUrl: string | null = null;
|
|
@@ -332,9 +209,7 @@ export async function startSupervisor() {
|
|
|
332
209
|
delete latestConfig.tunnelUrl;
|
|
333
210
|
saveConfig(latestConfig);
|
|
334
211
|
stopWorker();
|
|
335
|
-
stopVite();
|
|
336
212
|
stopTunnel();
|
|
337
|
-
closeDb();
|
|
338
213
|
server.close();
|
|
339
214
|
process.exit(0);
|
|
340
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 {
|