fluxy-bot 0.1.46 → 0.2.1

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 (45) hide show
  1. package/bin/cli.js +1 -3
  2. package/client/fluxy-main.tsx +75 -0
  3. package/client/fluxy.html +12 -0
  4. package/client/index.html +1 -0
  5. package/client/src/App.tsx +2 -83
  6. package/client/src/components/Layout/DashboardLayout.tsx +1 -1
  7. package/client/src/hooks/useChat.ts +51 -62
  8. package/client/src/hooks/useFluxyChat.ts +119 -0
  9. package/client/src/lib/ws-client.ts +1 -1
  10. package/dist/assets/index-BxQ8et35.js +64 -0
  11. package/dist/assets/index-D2PQx64r.css +1 -0
  12. package/dist/index.html +3 -2
  13. package/dist/sw.js +1 -1
  14. package/dist-fluxy/assets/fluxy-B49yi-07.js +53 -0
  15. package/dist-fluxy/assets/fluxy-D2PQx64r.css +1 -0
  16. package/dist-fluxy/fluxy.html +13 -0
  17. package/dist-fluxy/fluxy.png +0 -0
  18. package/dist-fluxy/fluxy_frame1.png +0 -0
  19. package/dist-fluxy/fluxy_say_hi.webm +0 -0
  20. package/dist-fluxy/fluxy_tilts.webm +0 -0
  21. package/dist-fluxy/icons/claude.png +0 -0
  22. package/dist-fluxy/icons/codex.png +0 -0
  23. package/dist-fluxy/icons/openai.svg +15 -0
  24. package/package.json +14 -9
  25. package/scripts/postinstall.js +10 -26
  26. package/shared/paths.ts +2 -11
  27. package/supervisor/index.ts +130 -176
  28. package/supervisor/widget.js +75 -0
  29. package/supervisor/worker.ts +3 -16
  30. package/tsconfig.json +2 -3
  31. package/vite.config.ts +4 -1
  32. package/vite.fluxy.config.ts +19 -0
  33. package/{supervisor → worker}/claude-agent.ts +43 -50
  34. package/{shared → worker}/db.ts +1 -9
  35. package/{shared → worker}/file-storage.ts +1 -1
  36. package/worker/index.ts +133 -31
  37. package/worker/prompts/fluxy-system-prompt.txt +8 -0
  38. package/client/src/components/BuildOverlay.tsx +0 -75
  39. package/client/src/components/FluxyFab.tsx +0 -29
  40. package/client/src/hooks/useWebSocket.ts +0 -22
  41. package/dist/assets/index-BAUWfBMW.js +0 -100
  42. package/dist/assets/index-CiN0-4-O.css +0 -1
  43. package/shared/workspace.ts +0 -196
  44. package/supervisor/prompts/fluxy-system-prompt.txt +0 -35
  45. package/supervisor/vite-dev.ts +0 -75
@@ -1,234 +1,190 @@
1
1
  import http from 'http';
2
2
  import net from 'net';
3
+ import fs from 'fs';
4
+ import path from 'path';
3
5
  import { WebSocketServer, WebSocket } from 'ws';
4
6
  import { loadConfig, saveConfig } from '../shared/config.js';
5
7
  import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
6
- import { paths, PKG_DIR } from '../shared/paths.js';
8
+ import { paths } from '../shared/paths.js';
7
9
  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
10
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel } from './tunnel.js';
13
11
  import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
14
- import { spawnVite, stopVite, getVitePort, isViteAlive } from './vite-dev.js';
15
12
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
16
13
 
17
14
  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
15
  <style>body{background:#0a0a0f;color:#94a3b8;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
19
16
  div{text-align:center}h1{font-size:18px;margin-bottom:8px;color:#e2e8f0}p{font-size:14px}a{color:#60a5fa}</style></head>
20
17
  <body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically.</p></div>
21
- <script>setTimeout(()=>location.reload(),3000)</script></body></html>`;
22
-
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
- }
18
+ <script>setTimeout(()=>location.reload(),3000)</script>
19
+ <script src="/fluxy/widget.js"></script></body></html>`;
36
20
 
37
21
  export async function startSupervisor() {
38
22
  const config = loadConfig();
39
23
  const workerPort = getWorkerPort(config.port);
40
24
 
41
- // Initialize shared infrastructure in the supervisor (the kernel)
42
- initDb();
43
- initWorkspace();
44
- ensureFileDirs();
45
-
46
- // Active non-Anthropic streams (keyed by conversationId)
47
- const activeStreams = new Map<string, AbortController>();
25
+ // Fluxy's AI brain
26
+ let ai: AiProvider | null = null;
27
+ if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
28
+ ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
29
+ }
48
30
 
49
- const vitePort = getVitePort(config.port);
31
+ // Fluxy chat conversations (in-memory for now)
32
+ const conversations = new Map<WebSocket, ChatMessage[]>();
50
33
 
51
- // HTTP server — split routing: /api → worker, everything else → Vite
34
+ // HTTP server
52
35
  const server = http.createServer((req, res) => {
53
- const isApiRoute = req.url?.startsWith('/api/') || req.url === '/api';
36
+ // Fluxy routes served directly by supervisor, never proxied
37
+ if (req.url === '/fluxy/widget.js') {
38
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
39
+ res.end(fs.readFileSync(paths.widgetJs));
40
+ return;
41
+ }
54
42
 
55
- if (isApiRoute) {
56
- // API requests worker
57
- if (!isWorkerAlive()) {
58
- res.writeHead(503, { 'Content-Type': 'text/html' });
59
- res.end(RECOVERING_HTML);
43
+ if (req.url?.startsWith('/fluxy/assets/')) {
44
+ const assetPath = path.join(paths.distFluxy, req.url.slice('/fluxy'.length));
45
+ if (fs.existsSync(assetPath)) {
46
+ const ext = path.extname(assetPath);
47
+ const mimeTypes: Record<string, string> = {
48
+ '.js': 'application/javascript',
49
+ '.css': 'text/css',
50
+ '.woff2': 'font/woff2',
51
+ '.woff': 'font/woff',
52
+ '.png': 'image/png',
53
+ '.svg': 'image/svg+xml',
54
+ };
55
+ res.writeHead(200, {
56
+ 'Content-Type': mimeTypes[ext] || 'application/octet-stream',
57
+ 'Cache-Control': 'public, max-age=31536000, immutable',
58
+ });
59
+ fs.createReadStream(assetPath).pipe(res);
60
60
  return;
61
61
  }
62
+ }
62
63
 
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
- });
64
+ if (req.url === '/fluxy' || req.url === '/fluxy/') {
65
+ const indexPath = path.join(paths.distFluxy, 'fluxy.html');
66
+ if (fs.existsSync(indexPath)) {
67
+ res.writeHead(200, { 'Content-Type': 'text/html' });
68
+ res.end(fs.readFileSync(indexPath));
69
+ } else {
70
+ // Fallback: dev mode or build not yet done
71
+ res.writeHead(200, { 'Content-Type': 'text/html' });
72
+ res.end('<html><body style="background:#212121;color:#f5f5f5;display:flex;align-items:center;justify-content:center;height:100vh;font-family:system-ui"><p>Fluxy chat not built yet. Run <code>npm run build</code></p></body></html>');
73
+ }
74
+ return;
75
+ }
75
76
 
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}` };
83
-
84
- const proxy = http.request(
85
- { host: '127.0.0.1', port: targetPort, path: req.url, method: req.method, headers },
86
- (proxyRes) => {
87
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
88
- proxyRes.pipe(res);
89
- },
90
- );
91
-
92
- proxy.on('error', () => {
93
- res.writeHead(503, { 'Content-Type': 'text/html' });
94
- res.end(RECOVERING_HTML);
95
- });
96
-
97
- req.pipe(proxy);
77
+ // Everything else → proxy to worker
78
+ if (!isWorkerAlive()) {
79
+ res.writeHead(503, { 'Content-Type': 'text/html' });
80
+ res.end(RECOVERING_HTML);
81
+ return;
98
82
  }
83
+
84
+ const proxy = http.request(
85
+ { host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
86
+ (proxyRes) => {
87
+ res.writeHead(proxyRes.statusCode!, proxyRes.headers);
88
+ proxyRes.pipe(res);
89
+ },
90
+ );
91
+
92
+ proxy.on('error', () => {
93
+ res.writeHead(503, { 'Content-Type': 'text/html' });
94
+ res.end(RECOVERING_HTML);
95
+ });
96
+
97
+ req.pipe(proxy);
99
98
  });
100
99
 
101
- // WebSocket: chat handled directly by supervisor
102
- const chatWss = new WebSocketServer({ noServer: true });
100
+ // WebSocket: Fluxy chat + proxy worker WS
101
+ const fluxyWss = new WebSocketServer({ noServer: true });
103
102
 
104
- chatWss.on('connection', (ws: WebSocket) => {
105
- log.info('Chat client connected to supervisor');
103
+ fluxyWss.on('connection', (ws) => {
104
+ log.info('Fluxy chat connected');
105
+ let convId = Math.random().toString(36).slice(2) + Date.now().toString(36);
106
+ conversations.set(ws, []);
106
107
 
107
108
  ws.on('message', (raw) => {
108
- const text = raw.toString();
109
+ const rawStr = raw.toString();
109
110
 
110
- // Heartbeat ping/pong
111
- if (text === 'ping') { ws.send('pong'); return; }
111
+ // Heartbeat
112
+ if (rawStr === 'ping') {
113
+ ws.send('pong');
114
+ return;
115
+ }
112
116
 
113
- const msg = JSON.parse(text);
117
+ const msg = JSON.parse(rawStr);
114
118
 
119
+ // New protocol: { type: 'user:message', data: { content, conversationId? } }
115
120
  if (msg.type === 'user:message') {
116
- // Re-read config on each message so onboarding changes are picked up
117
- const freshConfig = loadConfig();
121
+ const data = msg.data || {};
122
+ const content = data.content;
123
+ if (!content) return;
124
+ if (data.conversationId) convId = data.conversationId;
118
125
 
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;
126
+ const history = conversations.get(ws) || [];
127
+ history.push({ role: 'user', content });
122
128
 
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
- }
129
+ if (!ai) {
130
+ ws.send(JSON.stringify({ type: 'bot:error', data: { error: 'AI not configured. Set up your provider first.' } }));
131
+ return;
132
132
  }
133
133
 
134
- // Save attachments to disk
135
- type StoredAttachment = { type: string; name: string; mediaType: string; filePath: string };
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
- }
134
+ ws.send(JSON.stringify({ type: 'bot:typing' }));
151
135
 
152
- const msgMeta: any = {};
153
- if (audioPath) msgMeta.audio_data = audioPath;
154
- if (storedAttachments?.length) msgMeta.attachments = JSON.stringify(storedAttachments);
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
- }
136
+ ai.chat(
137
+ [{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
138
+ config.ai.model,
139
+ (token) => ws.send(JSON.stringify({ type: 'bot:token', data: { token, conversationId: convId } })),
140
+ (full) => {
141
+ history.push({ role: 'assistant', content: full });
142
+ ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, content: full } }));
143
+ },
144
+ (err) => ws.send(JSON.stringify({ type: 'bot:error', data: { error: err.message } })),
145
+ );
146
+ return;
147
+ }
171
148
 
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
- }
149
+ if (msg.type === 'user:stop') {
150
+ // Stop streaming (best-effort AI provider may not support cancellation)
151
+ return;
152
+ }
153
+
154
+ // Legacy protocol support (old fluxy.html format)
155
+ if (msg.type === 'message' && msg.content) {
156
+ const history = conversations.get(ws) || [];
157
+ history.push({ role: 'user', content: msg.content });
177
158
 
178
159
  if (!ai) {
179
- ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
160
+ ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
180
161
  return;
181
162
  }
182
163
 
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
164
  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);
165
+ [{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
166
+ config.ai.model,
167
+ (token) => ws.send(JSON.stringify({ type: 'token', token })),
168
+ (full) => {
169
+ history.push({ role: 'assistant', content: full });
170
+ ws.send(JSON.stringify({ type: 'done' }));
206
171
  },
207
- ctrl.signal,
172
+ (err) => ws.send(JSON.stringify({ type: 'error', error: err.message })),
208
173
  );
209
174
  }
210
-
211
- if (msg.type === 'user:stop') {
212
- // Stop Agent SDK queries
213
- stopAgentQuery(msg.data.conversationId);
214
- // Stop legacy streams
215
- const ctrl = activeStreams.get(msg.data.conversationId);
216
- if (ctrl) { ctrl.abort(); activeStreams.delete(msg.data.conversationId); }
217
- }
218
175
  });
176
+
177
+ ws.on('close', () => conversations.delete(ws));
219
178
  });
220
179
 
221
180
  server.on('upgrade', (req, socket: net.Socket, head) => {
222
- if (req.url === '/ws') {
223
- chatWss.handleUpgrade(req, socket, head, (ws) => chatWss.emit('connection', ws, req));
181
+ if (req.url === '/fluxy/ws') {
182
+ fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
224
183
  return;
225
184
  }
226
185
 
227
- // Proxy all other WS upgrades to Vite (HMR), fallback to worker
228
- const targetPort = isViteAlive() ? vitePort : (isWorkerAlive() ? workerPort : 0);
229
- if (!targetPort) { socket.destroy(); return; }
230
-
231
- const proxy = net.connect(targetPort, () => {
186
+ // Proxy WS upgrade to worker
187
+ const proxy = net.connect(workerPort, () => {
232
188
  const headers = Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n');
233
189
  proxy.write(`GET ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
234
190
  if (head.length > 0) proxy.write(head);
@@ -251,11 +207,11 @@ export async function startSupervisor() {
251
207
 
252
208
  server.listen(config.port, () => {
253
209
  log.ok(`Supervisor on http://localhost:${config.port}`);
210
+ log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
254
211
  });
255
212
 
256
- // Spawn worker and Vite dev server
213
+ // Spawn worker
257
214
  spawnWorker(workerPort);
258
- await spawnVite(vitePort);
259
215
 
260
216
  // Tunnel
261
217
  let tunnelUrl: string | null = null;
@@ -335,9 +291,7 @@ export async function startSupervisor() {
335
291
  delete latestConfig.tunnelUrl;
336
292
  saveConfig(latestConfig);
337
293
  stopWorker();
338
- stopVite();
339
294
  stopTunnel();
340
- closeDb();
341
295
  server.close();
342
296
  process.exit(0);
343
297
  };
@@ -0,0 +1,75 @@
1
+ (function () {
2
+ if (document.getElementById('fluxy-widget')) return;
3
+
4
+ var PANEL_WIDTH = '400px';
5
+
6
+ // ── Styles ──
7
+ var style = document.createElement('style');
8
+ style.textContent = [
9
+ '#fluxy-widget-bubble{position:fixed;bottom:24px;right:24px;z-index:99998;cursor:pointer;width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:transform .15s ease;-webkit-tap-highlight-color:transparent}',
10
+ '#fluxy-widget-bubble:hover{transform:scale(1.1)}',
11
+ '#fluxy-widget-bubble:active{transform:scale(0.95)}',
12
+ '#fluxy-widget-bubble video,#fluxy-widget-bubble img{height:44px;width:auto;pointer-events:none;-webkit-user-drag:none}',
13
+ '#fluxy-widget-backdrop{position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.4);opacity:0;transition:opacity .2s ease;pointer-events:none}',
14
+ '#fluxy-widget-backdrop.open{opacity:1;pointer-events:auto}',
15
+ '#fluxy-widget-panel{position:fixed;top:0;right:0;bottom:0;z-index:99999;width:' + PANEL_WIDTH + ';max-width:100vw;transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1);box-shadow:-4px 0 24px rgba(0,0,0,0.3);border-left:1px solid #3a3a3a;overflow:hidden}',
16
+ '#fluxy-widget-panel.open{transform:translateX(0)}',
17
+ '#fluxy-widget-panel iframe{width:100%;height:100%;border:none;background:#212121}',
18
+ '@media(max-width:480px){#fluxy-widget-panel{width:100vw}}',
19
+ ].join('\n');
20
+ document.head.appendChild(style);
21
+
22
+ // ── Backdrop ──
23
+ var backdrop = document.createElement('div');
24
+ backdrop.id = 'fluxy-widget-backdrop';
25
+ document.body.appendChild(backdrop);
26
+
27
+ // ── Panel ──
28
+ var panel = document.createElement('div');
29
+ panel.id = 'fluxy-widget-panel';
30
+ document.body.appendChild(panel);
31
+
32
+ var iframe = document.createElement('iframe');
33
+ iframe.src = '/fluxy/';
34
+ iframe.setAttribute('loading', 'lazy');
35
+ panel.appendChild(iframe);
36
+
37
+ // ── Bubble ──
38
+ var bubble = document.createElement('div');
39
+ bubble.id = 'fluxy-widget-bubble';
40
+ bubble.setAttribute('role', 'button');
41
+ bubble.setAttribute('aria-label', 'Open Fluxy chat');
42
+
43
+ var video = document.createElement('video');
44
+ video.src = '/fluxy_tilts.webm';
45
+ video.poster = '/fluxy_frame1.png';
46
+ video.autoplay = true;
47
+ video.loop = true;
48
+ video.muted = true;
49
+ video.playsInline = true;
50
+ video.setAttribute('playsinline', '');
51
+ video.draggable = false;
52
+ bubble.appendChild(video);
53
+
54
+ // Mark widget present
55
+ bubble.dataset.fluxyWidget = '1';
56
+ document.body.appendChild(bubble);
57
+
58
+ // ── Toggle ──
59
+ var isOpen = false;
60
+
61
+ function toggle() {
62
+ isOpen = !isOpen;
63
+ panel.classList.toggle('open', isOpen);
64
+ backdrop.classList.toggle('open', isOpen);
65
+ bubble.style.display = isOpen ? 'none' : 'flex';
66
+ }
67
+
68
+ bubble.addEventListener('click', toggle);
69
+ backdrop.addEventListener('click', toggle);
70
+
71
+ // Close on Escape
72
+ document.addEventListener('keydown', function (e) {
73
+ if (e.key === 'Escape' && isOpen) toggle();
74
+ });
75
+ })();
@@ -1,6 +1,6 @@
1
1
  import { spawn, type ChildProcess } from 'child_process';
2
2
  import path from 'path';
3
- import { PKG_DIR, paths } from '../shared/paths.js';
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
- // --watch: Node built-in file watcher (stable since Node 22).
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} (watch mode)`);
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": ["vite/client"],
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
@@ -13,7 +13,10 @@ export default defineConfig({
13
13
  emptyOutDir: true,
14
14
  },
15
15
  server: {
16
- allowedHosts: true,
16
+ port: 5173,
17
+ proxy: {
18
+ '/api': 'http://localhost:3000',
19
+ },
17
20
  },
18
21
  plugins: [
19
22
  react(),
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import path from 'path';
4
+
5
+ export default defineConfig({
6
+ root: 'client',
7
+ base: '/fluxy/',
8
+ resolve: {
9
+ alias: { '@': path.resolve(__dirname, 'client/src') },
10
+ },
11
+ build: {
12
+ outDir: '../dist-fluxy',
13
+ emptyOutDir: true,
14
+ rollupOptions: {
15
+ input: path.resolve(__dirname, 'client/fluxy.html'),
16
+ },
17
+ },
18
+ plugins: [react()],
19
+ });