fluxy-bot 0.1.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.
Files changed (79) hide show
  1. package/bin/cli.js +469 -0
  2. package/client/index.html +13 -0
  3. package/client/public/fluxy.png +0 -0
  4. package/client/public/icons/claude.png +0 -0
  5. package/client/public/icons/codex.png +0 -0
  6. package/client/public/icons/openai.svg +15 -0
  7. package/client/src/App.tsx +81 -0
  8. package/client/src/components/Chat/ChatView.tsx +19 -0
  9. package/client/src/components/Chat/InputBar.tsx +242 -0
  10. package/client/src/components/Chat/MessageBubble.tsx +20 -0
  11. package/client/src/components/Chat/MessageList.tsx +39 -0
  12. package/client/src/components/Chat/TypingIndicator.tsx +10 -0
  13. package/client/src/components/Dashboard/ConversationAnalytics.tsx +84 -0
  14. package/client/src/components/Dashboard/DashboardPage.tsx +52 -0
  15. package/client/src/components/Dashboard/PromoCard.tsx +44 -0
  16. package/client/src/components/Dashboard/ReportCard.tsx +35 -0
  17. package/client/src/components/Dashboard/TodayStats.tsx +28 -0
  18. package/client/src/components/ErrorBoundary.tsx +23 -0
  19. package/client/src/components/FluxyFab.tsx +25 -0
  20. package/client/src/components/Layout/ConnectionStatus.tsx +8 -0
  21. package/client/src/components/Layout/DashboardHeader.tsx +90 -0
  22. package/client/src/components/Layout/DashboardLayout.tsx +24 -0
  23. package/client/src/components/Layout/Header.tsx +10 -0
  24. package/client/src/components/Layout/MobileNav.tsx +30 -0
  25. package/client/src/components/Layout/Sidebar.tsx +55 -0
  26. package/client/src/components/Onboard/OnboardWizard.tsx +763 -0
  27. package/client/src/components/ui/avatar.tsx +109 -0
  28. package/client/src/components/ui/badge.tsx +48 -0
  29. package/client/src/components/ui/button.tsx +64 -0
  30. package/client/src/components/ui/card.tsx +92 -0
  31. package/client/src/components/ui/dialog.tsx +156 -0
  32. package/client/src/components/ui/dropdown-menu.tsx +257 -0
  33. package/client/src/components/ui/input.tsx +21 -0
  34. package/client/src/components/ui/scroll-area.tsx +58 -0
  35. package/client/src/components/ui/select.tsx +190 -0
  36. package/client/src/components/ui/separator.tsx +28 -0
  37. package/client/src/components/ui/sheet.tsx +141 -0
  38. package/client/src/components/ui/skeleton.tsx +13 -0
  39. package/client/src/components/ui/switch.tsx +33 -0
  40. package/client/src/components/ui/tabs.tsx +89 -0
  41. package/client/src/components/ui/textarea.tsx +18 -0
  42. package/client/src/components/ui/tooltip.tsx +55 -0
  43. package/client/src/hooks/useChat.ts +69 -0
  44. package/client/src/hooks/useMobile.ts +16 -0
  45. package/client/src/hooks/useWebSocket.ts +24 -0
  46. package/client/src/lib/mock-data.ts +104 -0
  47. package/client/src/lib/utils.ts +6 -0
  48. package/client/src/lib/ws-client.ts +52 -0
  49. package/client/src/main.tsx +10 -0
  50. package/client/src/styles/globals.css +55 -0
  51. package/components.json +20 -0
  52. package/dist/assets/index-BkNWpS06.css +1 -0
  53. package/dist/assets/index-CX3QeqQ8.js +64 -0
  54. package/dist/fluxy.png +0 -0
  55. package/dist/icons/claude.png +0 -0
  56. package/dist/icons/codex.png +0 -0
  57. package/dist/icons/openai.svg +15 -0
  58. package/dist/index.html +14 -0
  59. package/dist/manifest.webmanifest +1 -0
  60. package/dist/registerSW.js +1 -0
  61. package/dist/sw.js +1 -0
  62. package/dist/workbox-8c29f6e4.js +1 -0
  63. package/package.json +82 -0
  64. package/postcss.config.js +5 -0
  65. package/shared/ai.ts +141 -0
  66. package/shared/config.ts +37 -0
  67. package/shared/logger.ts +13 -0
  68. package/shared/paths.ts +14 -0
  69. package/shared/relay.ts +101 -0
  70. package/supervisor/fluxy.html +94 -0
  71. package/supervisor/index.ts +173 -0
  72. package/supervisor/tunnel.ts +62 -0
  73. package/supervisor/worker.ts +55 -0
  74. package/tsconfig.json +20 -0
  75. package/vite.config.ts +38 -0
  76. package/worker/claude-auth.ts +224 -0
  77. package/worker/codex-auth.ts +199 -0
  78. package/worker/db.ts +75 -0
  79. package/worker/index.ts +169 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Codex OAuth PKCE flow for ChatGPT Plus/Pro subscription authentication.
3
+ * Adapted from CodeDeck's CodexOAuthService for server-side Node.js.
4
+ *
5
+ * Spins up a temporary HTTP server on port 1455 to capture the OAuth callback.
6
+ * Credentials are stored in ~/.codex/codedeck-auth.json.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import http from 'http';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+ import { log } from '../shared/logger.js';
15
+
16
+ const OAUTH_CONFIG = {
17
+ AUTHORIZE_URL: 'https://auth.openai.com/oauth/authorize',
18
+ TOKEN_URL: 'https://auth.openai.com/oauth/token',
19
+ REDIRECT_URI: 'http://localhost:1455/auth/callback',
20
+ CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann',
21
+ SCOPES: 'openid profile email offline_access',
22
+ PORT: 1455,
23
+ };
24
+
25
+ const AUTH_DIR = path.join(os.homedir(), '.codex');
26
+ const AUTH_FILE = path.join(AUTH_DIR, 'codedeck-auth.json');
27
+
28
+ let codeVerifier: string | null = null;
29
+ let oauthState: string | null = null;
30
+ let callbackServer: http.Server | null = null;
31
+
32
+ /* ── Helpers ── */
33
+
34
+ function stopCallbackServer(): void {
35
+ if (callbackServer) {
36
+ try { callbackServer.close(); } catch {}
37
+ callbackServer = null;
38
+ }
39
+ }
40
+
41
+ function storeCredentials(tokens: any): void {
42
+ if (!fs.existsSync(AUTH_DIR)) {
43
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
44
+ }
45
+
46
+ const credentials: Record<string, any> = {
47
+ access_token: tokens.access_token,
48
+ token_type: tokens.token_type || 'Bearer',
49
+ };
50
+
51
+ if (tokens.refresh_token) credentials.refresh_token = tokens.refresh_token;
52
+ if (tokens.id_token) credentials.id_token = tokens.id_token;
53
+ if (tokens.expires_in) {
54
+ credentials.expires_at = Date.now() + (tokens.expires_in - 300) * 1000;
55
+ }
56
+
57
+ // Decode JWT claims for account info
58
+ try {
59
+ const payload = JSON.parse(Buffer.from(tokens.access_token.split('.')[1], 'base64url').toString());
60
+ const authClaims = payload['https://api.openai.com/auth'] || {};
61
+ if (authClaims.chatgpt_account_id) credentials.chatgpt_account_id = authClaims.chatgpt_account_id;
62
+ if (authClaims.chatgpt_plan_type) credentials.chatgpt_plan_type = authClaims.chatgpt_plan_type;
63
+ } catch {
64
+ log.warn('Codex: failed to decode JWT claims');
65
+ }
66
+
67
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(credentials, null, 2), 'utf-8');
68
+ try { fs.chmodSync(AUTH_FILE, 0o600); } catch {}
69
+ log.ok('Codex credentials stored');
70
+ }
71
+
72
+ async function exchangeCode(code: string): Promise<{ success: boolean; error?: string }> {
73
+ if (!codeVerifier) {
74
+ return { success: false, error: 'No code verifier — OAuth flow was not started.' };
75
+ }
76
+
77
+ try {
78
+ const payload = new URLSearchParams({
79
+ grant_type: 'authorization_code',
80
+ client_id: OAUTH_CONFIG.CLIENT_ID,
81
+ code,
82
+ redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
83
+ code_verifier: codeVerifier,
84
+ });
85
+
86
+ const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
89
+ body: payload.toString(),
90
+ });
91
+
92
+ if (!response.ok) {
93
+ return { success: false, error: `Authentication failed (${response.status}). Please try again.` };
94
+ }
95
+
96
+ const tokens = await response.json();
97
+ storeCredentials(tokens);
98
+ codeVerifier = null;
99
+ oauthState = null;
100
+ return { success: true };
101
+ } catch (err: any) {
102
+ return { success: false, error: err.message };
103
+ }
104
+ }
105
+
106
+ /* ── Public API ── */
107
+
108
+ export function startCodexOAuth(): Promise<{ success: boolean; authUrl?: string; error?: string }> {
109
+ return new Promise((resolve) => {
110
+ stopCallbackServer();
111
+
112
+ // Generate PKCE
113
+ codeVerifier = crypto.randomBytes(32).toString('base64url');
114
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
115
+ oauthState = crypto.randomUUID();
116
+
117
+ // Start local callback server
118
+ callbackServer = http.createServer((req, res) => {
119
+ if (!req.url?.startsWith('/auth/callback')) {
120
+ res.writeHead(404);
121
+ res.end();
122
+ return;
123
+ }
124
+
125
+ const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.PORT}`);
126
+ const code = url.searchParams.get('code');
127
+ const returnedState = url.searchParams.get('state');
128
+ const error = url.searchParams.get('error');
129
+
130
+ res.writeHead(200, { 'Content-Type': 'text/html' });
131
+ res.end(`<html><body style="font-family:sans-serif;text-align:center;padding:60px;background:#0a0a0a;color:#fff">
132
+ <h2>${error ? 'Authentication Failed' : 'Authenticated!'}</h2>
133
+ <p style="color:#888">${error || 'You can close this tab and return to Fluxy.'}</p>
134
+ </body></html>`);
135
+
136
+ stopCallbackServer();
137
+
138
+ if (error || !code || returnedState !== oauthState) return;
139
+ exchangeCode(code);
140
+ });
141
+
142
+ callbackServer.listen(OAUTH_CONFIG.PORT, '127.0.0.1', () => {
143
+ log.ok(`Codex OAuth callback server on port ${OAUTH_CONFIG.PORT}`);
144
+
145
+ const params = new URLSearchParams({
146
+ response_type: 'code',
147
+ client_id: OAUTH_CONFIG.CLIENT_ID,
148
+ redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
149
+ scope: OAUTH_CONFIG.SCOPES,
150
+ code_challenge: codeChallenge,
151
+ code_challenge_method: 'S256',
152
+ state: oauthState!,
153
+ id_token_add_organizations: 'true',
154
+ codex_cli_simplified_flow: 'true',
155
+ });
156
+
157
+ resolve({ success: true, authUrl: `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` });
158
+ });
159
+
160
+ callbackServer.on('error', (err: any) => {
161
+ const error = err.code === 'EADDRINUSE'
162
+ ? `Port ${OAUTH_CONFIG.PORT} is busy. Close other Codex instances.`
163
+ : err.message;
164
+ resolve({ success: false, error });
165
+ });
166
+ });
167
+ }
168
+
169
+ export function cancelCodexOAuth(): void {
170
+ stopCallbackServer();
171
+ codeVerifier = null;
172
+ oauthState = null;
173
+ }
174
+
175
+ export function getCodexAuthStatus(): { authenticated: boolean; plan?: string; error?: string } {
176
+ try {
177
+ if (!fs.existsSync(AUTH_FILE)) return { authenticated: false };
178
+ const creds = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
179
+ if (!creds.access_token) return { authenticated: false };
180
+ if (creds.expires_at && Date.now() >= creds.expires_at) {
181
+ return { authenticated: false, error: 'Token expired' };
182
+ }
183
+ return { authenticated: true, plan: creds.chatgpt_plan_type || 'plus' };
184
+ } catch (err: any) {
185
+ return { authenticated: false, error: err.message };
186
+ }
187
+ }
188
+
189
+ export function readCodexAccessToken(): string | null {
190
+ try {
191
+ if (!fs.existsSync(AUTH_FILE)) return null;
192
+ const creds = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
193
+ if (!creds.access_token) return null;
194
+ if (creds.expires_at && Date.now() >= creds.expires_at) return null;
195
+ return creds.access_token;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
package/worker/db.ts ADDED
@@ -0,0 +1,75 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs';
3
+ import { paths, DATA_DIR } from '../shared/paths.js';
4
+
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS conversations (
7
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
8
+ title TEXT,
9
+ model TEXT,
10
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
11
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
12
+ );
13
+ CREATE TABLE IF NOT EXISTS messages (
14
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
15
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
16
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
17
+ content TEXT NOT NULL,
18
+ tokens_in INTEGER,
19
+ tokens_out INTEGER,
20
+ model TEXT,
21
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversation_id, created_at);
24
+ CREATE TABLE IF NOT EXISTS settings (
25
+ key TEXT PRIMARY KEY,
26
+ value TEXT NOT NULL,
27
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
28
+ );
29
+ `;
30
+
31
+ let db: Database.Database;
32
+
33
+ export function initDb(): void {
34
+ fs.mkdirSync(DATA_DIR, { recursive: true });
35
+ db = new Database(paths.db);
36
+ db.pragma('journal_mode = WAL');
37
+ db.pragma('foreign_keys = ON');
38
+ db.exec(SCHEMA);
39
+ }
40
+
41
+ export function closeDb(): void { db?.close(); }
42
+
43
+ // Conversations
44
+ export function createConversation(title?: string, model?: string) {
45
+ return db.prepare('INSERT INTO conversations (title, model) VALUES (?, ?) RETURNING *').get(title ?? null, model ?? null) as any;
46
+ }
47
+ export function listConversations(limit = 50) {
48
+ return db.prepare('SELECT * FROM conversations ORDER BY updated_at DESC LIMIT ?').all(limit);
49
+ }
50
+ export function deleteConversation(id: string) {
51
+ db.prepare('DELETE FROM conversations WHERE id = ?').run(id);
52
+ }
53
+
54
+ // Messages
55
+ export function addMessage(convId: string, role: string, content: string, meta?: { tokens_in?: number; tokens_out?: number; model?: string }) {
56
+ const msg = db.prepare('INSERT INTO messages (conversation_id, role, content, tokens_in, tokens_out, model) VALUES (?, ?, ?, ?, ?, ?) RETURNING *')
57
+ .get(convId, role, content, meta?.tokens_in ?? null, meta?.tokens_out ?? null, meta?.model ?? null);
58
+ db.prepare('UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(convId);
59
+ return msg as any;
60
+ }
61
+ export function getMessages(convId: string) {
62
+ return db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId);
63
+ }
64
+
65
+ // Settings
66
+ export function getSetting(key: string): string | undefined {
67
+ return (db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as any)?.value;
68
+ }
69
+ export function setSetting(key: string, value: string) {
70
+ db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(key, value);
71
+ }
72
+ export function getAllSettings() {
73
+ const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
74
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]));
75
+ }
@@ -0,0 +1,169 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { loadConfig, saveConfig } from '../shared/config.js';
5
+ import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
6
+ import { paths } from '../shared/paths.js';
7
+ import { log } from '../shared/logger.js';
8
+ import { initDb, closeDb, createConversation, listConversations, deleteConversation, addMessage, getMessages, getSetting, getAllSettings, setSetting } from './db.js';
9
+ import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
10
+ import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
11
+
12
+ const port = parseInt(process.env.WORKER_PORT || '3001', 10);
13
+ const config = loadConfig();
14
+
15
+ // Database
16
+ initDb();
17
+
18
+ // AI provider (for chatbot feature)
19
+ let ai: AiProvider | null = null;
20
+ if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
21
+ ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
22
+ log.ok(`Worker AI: ${config.ai.provider} (${config.ai.model})`);
23
+ }
24
+
25
+ // Express
26
+ const app = express();
27
+ app.use(express.json());
28
+
29
+ app.get('/api/health', (_, res) => res.json({ status: 'ok' }));
30
+ app.get('/api/conversations', (_, res) => res.json(listConversations()));
31
+ app.get('/api/conversations/:id', (req, res) => {
32
+ const msgs = getMessages(req.params.id);
33
+ res.json({ id: req.params.id, messages: msgs });
34
+ });
35
+ app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); });
36
+ app.get('/api/settings', (_, res) => res.json(getAllSettings()));
37
+ app.put('/api/settings/:key', (req, res) => {
38
+ setSetting(req.params.key, req.body.value);
39
+ res.json({ ok: true });
40
+ });
41
+
42
+ // ── Codex OAuth routes ──
43
+
44
+ app.post('/api/auth/codex/start', async (_req, res) => {
45
+ const result = await startCodexOAuth();
46
+ res.json(result);
47
+ });
48
+
49
+ app.post('/api/auth/codex/cancel', (_req, res) => {
50
+ cancelCodexOAuth();
51
+ res.json({ ok: true });
52
+ });
53
+
54
+ app.get('/api/auth/codex/status', (_req, res) => {
55
+ res.json(getCodexAuthStatus());
56
+ });
57
+
58
+ // ── Claude OAuth routes ──
59
+
60
+ app.post('/api/auth/claude/start', (_req, res) => {
61
+ res.json(startClaudeOAuth());
62
+ });
63
+
64
+ app.post('/api/auth/claude/exchange', async (req, res) => {
65
+ const { code } = req.body;
66
+ if (!code) { res.json({ success: false, error: 'No code provided' }); return; }
67
+ const result = await exchangeClaudeCode(code);
68
+ res.json(result);
69
+ });
70
+
71
+ app.get('/api/auth/claude/status', (_req, res) => {
72
+ res.json(getClaudeAuthStatus());
73
+ });
74
+
75
+ // ── Onboarding ──
76
+
77
+ app.post('/api/onboard', (req, res) => {
78
+ const { userName, agentName, provider, model, apiKey, baseUrl } = req.body;
79
+ setSetting('user_name', userName || '');
80
+ setSetting('agent_name', agentName || 'Fluxy');
81
+ setSetting('onboard_complete', 'true');
82
+
83
+ config.ai.provider = provider || '';
84
+ config.ai.model = model || '';
85
+ config.ai.baseUrl = baseUrl || undefined;
86
+
87
+ // Read OAuth token if no API key provided
88
+ if (!apiKey && provider === 'openai') {
89
+ config.ai.apiKey = readCodexAccessToken() || '';
90
+ } else if (!apiKey && provider === 'anthropic') {
91
+ config.ai.apiKey = readClaudeAccessToken() || '';
92
+ } else {
93
+ config.ai.apiKey = apiKey || '';
94
+ }
95
+
96
+ saveConfig(config);
97
+
98
+ if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
99
+ ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
100
+ log.ok(`AI reconfigured: ${config.ai.provider} (${config.ai.model})`);
101
+ }
102
+
103
+ res.json({ ok: true });
104
+ });
105
+
106
+ // Serve PWA
107
+ app.use(express.static(paths.dist));
108
+ app.get('/{*splat}', (_, res) => res.sendFile('index.html', { root: paths.dist }));
109
+
110
+ // HTTP + WebSocket
111
+ const server = createServer(app);
112
+ const wss = new WebSocketServer({ server, path: '/ws' });
113
+
114
+ const activeStreams = new Map<string, AbortController>();
115
+
116
+ wss.on('connection', (ws: WebSocket) => {
117
+ ws.on('message', (raw) => {
118
+ const msg = JSON.parse(raw.toString());
119
+
120
+ if (msg.type === 'user:message') {
121
+ const { conversationId, content } = msg.data;
122
+ let convId = conversationId;
123
+ if (!convId) convId = createConversation(content.slice(0, 50), config.ai.model).id;
124
+
125
+ addMessage(convId, 'user', content);
126
+
127
+ if (!ai) {
128
+ ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
129
+ return;
130
+ }
131
+
132
+ const history = getMessages(convId) as { role: string; content: string }[];
133
+ const systemPrompt = getSetting('system_prompt');
134
+ const messages: ChatMessage[] = [
135
+ ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
136
+ ...history.map((m) => ({ role: m.role as ChatMessage['role'], content: m.content })),
137
+ ];
138
+
139
+ ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
140
+ const ctrl = new AbortController();
141
+ activeStreams.set(convId, ctrl);
142
+
143
+ ai.chat(
144
+ messages,
145
+ config.ai.model,
146
+ (token) => ws.send(JSON.stringify({ type: 'bot:token', data: { conversationId: convId, token } })),
147
+ (full, usage) => {
148
+ const m = addMessage(convId, 'assistant', full, { tokens_in: usage?.tokensIn, tokens_out: usage?.tokensOut, model: config.ai.model });
149
+ ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, messageId: (m as any).id, content: full } }));
150
+ activeStreams.delete(convId);
151
+ },
152
+ (err) => {
153
+ ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: err.message } }));
154
+ activeStreams.delete(convId);
155
+ },
156
+ ctrl.signal,
157
+ );
158
+ }
159
+
160
+ if (msg.type === 'user:stop') {
161
+ const ctrl = activeStreams.get(msg.data.conversationId);
162
+ if (ctrl) { ctrl.abort(); activeStreams.delete(msg.data.conversationId); }
163
+ }
164
+ });
165
+ });
166
+
167
+ server.listen(port, () => log.ok(`Worker on port ${port}`));
168
+
169
+ process.on('SIGTERM', () => { closeDb(); server.close(); process.exit(0); });