fluxy-bot 0.8.9 → 0.9.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 (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -1,11 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { WsClient } from '../lib/ws-client';
3
- import type {
4
- ChatMessage,
5
- ToolActivity,
6
- Attachment,
7
- StoredAttachment
8
- } from './useChat';
3
+ import type { ChatMessage, ToolActivity, Attachment, StoredAttachment } from './useChat';
9
4
  import { authFetch } from '../lib/auth';
10
5
 
11
6
  /**
@@ -13,344 +8,259 @@ import { authFetch } from '../lib/auth';
13
8
  * Loads/persists messages via the DB (worker API).
14
9
  * Supports cross-device sync via chat:sync WS events.
15
10
  */
16
- export function useFluxyChat(
17
- ws: WsClient | null,
18
- triggerReload?: number,
19
- enabled = true
20
- ) {
21
- const [messages, setMessages] = useState<ChatMessage[]>([]);
22
- const [conversationId, setConversationId] = useState<string | null>(null);
23
- const [streaming, setStreaming] = useState(false);
24
- const [streamBuffer, setStreamBuffer] = useState('');
25
- const [tools, setTools] = useState<ToolActivity[]>([]);
26
- const [hasMore, setHasMore] = useState(false);
27
- const loaded = useRef(false);
28
- const conversationIdRef = useRef<string | null>(null);
29
- const streamingRef = useRef(false);
30
- const loadingOlder = useRef(false);
11
+ export function useFluxyChat(ws: WsClient | null, triggerReload?: number, enabled = true) {
12
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
13
+ const [conversationId, setConversationId] = useState<string | null>(null);
14
+ const [streaming, setStreaming] = useState(false);
15
+ const [streamBuffer, setStreamBuffer] = useState('');
16
+ const [tools, setTools] = useState<ToolActivity[]>([]);
17
+ const [hasMore, setHasMore] = useState(false);
18
+ const loaded = useRef(false);
19
+ const conversationIdRef = useRef<string | null>(null);
20
+ const streamingRef = useRef(false);
21
+ const loadingOlder = useRef(false);
31
22
 
32
- // Keep refs in sync with state
33
- useEffect(() => {
34
- conversationIdRef.current = conversationId;
35
- }, [conversationId]);
36
- useEffect(() => {
37
- streamingRef.current = streaming;
38
- }, [streaming]);
23
+ // Keep refs in sync with state
24
+ useEffect(() => { conversationIdRef.current = conversationId; }, [conversationId]);
25
+ useEffect(() => { streamingRef.current = streaming; }, [streaming]);
39
26
 
40
- // Parse a raw DB message into a ChatMessage
41
- const parseMessage = useCallback((m: any): ChatMessage => {
42
- let audioData: string | undefined;
43
- if (m.audio_data) {
44
- if (m.audio_data.startsWith('data:')) {
45
- audioData = m.audio_data;
46
- } else if (m.audio_data.includes('/')) {
47
- audioData = `/api/files/${m.audio_data}`;
48
- } else {
49
- audioData = `data:audio/webm;base64,${m.audio_data}`;
50
- }
51
- }
52
-
53
- let attachments: StoredAttachment[] | undefined;
54
- if (m.attachments) {
55
- try {
56
- attachments = JSON.parse(m.attachments);
57
- } catch {
58
- /* ignore */
59
- }
60
- }
61
-
62
- return {
63
- id: m.id,
64
- role: m.role,
65
- content: m.content,
66
- timestamp: m.created_at,
67
- audioData,
68
- hasAttachments: !!(attachments && attachments.length > 0),
69
- attachments
70
- };
71
- }, []);
27
+ // Parse a raw DB message into a ChatMessage
28
+ const parseMessage = useCallback((m: any): ChatMessage => {
29
+ let audioData: string | undefined;
30
+ if (m.audio_data) {
31
+ if (m.audio_data.startsWith('data:')) {
32
+ audioData = m.audio_data;
33
+ } else if (m.audio_data.includes('/')) {
34
+ audioData = `/api/files/${m.audio_data}`;
35
+ } else {
36
+ audioData = `data:audio/webm;base64,${m.audio_data}`;
37
+ }
38
+ }
72
39
 
73
- // Load current conversation from DB (last 20 messages)
74
- const loadFromDb = useCallback(async () => {
75
- try {
76
- const ctx = await authFetch('/api/context/current').then(r =>
77
- r.json()
78
- );
79
- if (!ctx.conversationId) return;
80
- setConversationId(ctx.conversationId);
81
-
82
- const limit = 20;
83
- const res = await authFetch(
84
- `/api/conversations/${ctx.conversationId}/messages?limit=${limit}`
85
- );
86
- if (!res.ok) return;
87
- const data = await res.json();
88
- if (!data?.length) return;
89
-
90
- const filtered = data.filter(
91
- (m: any) => m.role === 'user' || m.role === 'assistant'
92
- );
93
- setMessages(filtered.map(parseMessage));
94
- setHasMore(data.length >= limit);
95
- } catch {
96
- /* worker not ready yet */
97
- }
98
- }, [parseMessage]);
40
+ let attachments: StoredAttachment[] | undefined;
41
+ if (m.attachments) {
42
+ try { attachments = JSON.parse(m.attachments); } catch { /* ignore */ }
43
+ }
99
44
 
100
- // Load older messages (cursor-based pagination)
101
- const loadOlder = useCallback(async () => {
102
- if (loadingOlder.current || !conversationIdRef.current) return;
103
- loadingOlder.current = true;
104
- try {
105
- const oldestId = messages[0]?.id;
106
- if (!oldestId) return;
107
- const limit = 20;
108
- const res = await authFetch(
109
- `/api/conversations/${conversationIdRef.current}/messages?before=${oldestId}&limit=${limit}`
110
- );
111
- if (!res.ok) return;
112
- const data = await res.json();
113
- if (!data?.length) {
114
- setHasMore(false);
115
- return;
116
- }
117
-
118
- const filtered = data
119
- .filter((m: any) => m.role === 'user' || m.role === 'assistant')
120
- .map(parseMessage);
121
- setMessages(prev => [...filtered, ...prev]);
122
- if (data.length < limit) setHasMore(false);
123
- } catch {
124
- /* ignore */
125
- } finally {
126
- loadingOlder.current = false;
127
- }
128
- }, [messages, parseMessage]);
45
+ return {
46
+ id: m.id,
47
+ role: m.role,
48
+ content: m.content,
49
+ timestamp: m.created_at,
50
+ audioData,
51
+ hasAttachments: !!(attachments && attachments.length > 0),
52
+ attachments,
53
+ };
54
+ }, []);
129
55
 
130
- // Load on mount (only when enabled/authenticated)
131
- useEffect(() => {
132
- if (!enabled || loaded.current) return;
133
- loaded.current = true;
134
- loadFromDb();
135
- }, [enabled, loadFromDb]);
56
+ // Load current conversation from DB (last 20 messages)
57
+ const loadFromDb = useCallback(async () => {
58
+ try {
59
+ const ctx = await authFetch('/api/context/current').then((r) => r.json());
60
+ if (!ctx.conversationId) return;
61
+ setConversationId(ctx.conversationId);
136
62
 
137
- // Reload on reconnect (triggerReload changes)
138
- useEffect(() => {
139
- if (enabled && triggerReload && triggerReload > 0) {
140
- loadFromDb();
141
- }
142
- }, [enabled, triggerReload, loadFromDb]);
63
+ const limit = 20;
64
+ const res = await authFetch(`/api/conversations/${ctx.conversationId}/messages?limit=${limit}`);
65
+ if (!res.ok) return;
66
+ const data = await res.json();
67
+ if (!data?.length) return;
143
68
 
144
- // Periodic DB sync after reconnect while streaming catches the final bot:response
145
- useEffect(() => {
146
- if (!enabled || !triggerReload || triggerReload === 0 || !streaming)
147
- return;
148
- const interval = setInterval(() => {
149
- if (!streamingRef.current) {
150
- clearInterval(interval);
151
- return;
152
- }
153
- loadFromDb();
154
- }, 3000);
155
- return () => clearInterval(interval);
156
- }, [enabled, triggerReload, streaming, loadFromDb]);
69
+ const filtered = data.filter((m: any) => m.role === 'user' || m.role === 'assistant');
70
+ setMessages(filtered.map(parseMessage));
71
+ setHasMore(data.length >= limit);
72
+ } catch { /* worker not ready yet */ }
73
+ }, [parseMessage]);
157
74
 
158
- useEffect(() => {
159
- if (!ws) return;
75
+ // Load older messages (cursor-based pagination)
76
+ const loadOlder = useCallback(async () => {
77
+ if (loadingOlder.current || !conversationIdRef.current) return;
78
+ loadingOlder.current = true;
79
+ try {
80
+ const oldestId = messages[0]?.id;
81
+ if (!oldestId) return;
82
+ const limit = 20;
83
+ const res = await authFetch(`/api/conversations/${conversationIdRef.current}/messages?before=${oldestId}&limit=${limit}`);
84
+ if (!res.ok) return;
85
+ const data = await res.json();
86
+ if (!data?.length) { setHasMore(false); return; }
160
87
 
161
- const unsubs = [
162
- ws.on('bot:typing', () => {
163
- setStreaming(true);
164
- setTools([]);
165
- }),
166
- ws.on('bot:token', (data: { token: string }) => {
167
- setStreamBuffer(buf => buf + data.token);
168
- }),
169
- ws.on('bot:tool', (data: { name: string; status?: string }) => {
170
- setTools(prev => {
171
- const existing = prev.find(
172
- t => t.name === data.name && t.status === 'running'
173
- );
174
- if (existing) return prev;
175
- return [...prev, { name: data.name, status: 'running' }];
176
- });
177
- }),
178
- ws.on(
179
- 'bot:response',
180
- (data: {
181
- conversationId: string;
182
- messageId?: string;
183
- content: string;
184
- }) => {
185
- setConversationId(data.conversationId);
186
- setMessages(msgs => [
187
- ...msgs,
188
- {
189
- id: data.messageId || Date.now().toString(),
190
- role: 'assistant',
191
- content: data.content,
192
- timestamp: new Date().toISOString()
193
- }
194
- ]);
195
- setStreamBuffer('');
196
- setStreaming(false);
197
- setTools([]);
198
- }
199
- ),
200
- ws.on('bot:error', (data: { error: string }) => {
201
- setStreamBuffer('');
202
- setStreaming(false);
203
- setTools([]);
204
- setMessages(msgs => [
205
- ...msgs,
206
- {
207
- id: Date.now().toString(),
208
- role: 'assistant',
209
- content: `Error: ${data.error}`,
210
- timestamp: new Date().toISOString()
211
- }
212
- ]);
213
- }),
214
- // Cross-device sync: append message from another client
215
- ws.on(
216
- 'chat:sync',
217
- (data: {
218
- conversationId: string;
219
- message: {
220
- role: string;
221
- content: string;
222
- timestamp: string;
223
- };
224
- }) => {
225
- if (
226
- conversationIdRef.current &&
227
- data.conversationId !== conversationIdRef.current
228
- )
229
- return;
230
- setMessages(msgs => [
231
- ...msgs,
232
- {
233
- id: Date.now().toString(),
234
- role: data.message.role as 'user' | 'assistant',
235
- content: data.message.content,
236
- timestamp: data.message.timestamp
237
- }
238
- ]);
239
- }
240
- ),
241
- // Server created a new conversation
242
- ws.on(
243
- 'chat:conversation-created',
244
- (data: { conversationId: string }) => {
245
- setConversationId(data.conversationId);
246
- }
247
- ),
248
- // Server sends current streaming state on (re)connect
249
- ws.on(
250
- 'chat:state',
251
- (data: {
252
- streaming: boolean;
253
- conversationId?: string;
254
- buffer?: string;
255
- }) => {
256
- if (data.streaming) {
257
- setStreaming(true);
258
- if (data.conversationId)
259
- setConversationId(data.conversationId);
260
- if (data.buffer) setStreamBuffer(data.buffer);
261
- }
262
- }
263
- ),
264
- // Context cleared (from any client)
265
- ws.on('chat:cleared', () => {
266
- setMessages([]);
267
- setConversationId(null);
268
- setStreamBuffer('');
269
- setStreaming(false);
270
- setTools([]);
271
- loaded.current = false;
272
- })
273
- ];
88
+ const filtered = data
89
+ .filter((m: any) => m.role === 'user' || m.role === 'assistant')
90
+ .map(parseMessage);
91
+ setMessages((prev) => [...filtered, ...prev]);
92
+ if (data.length < limit) setHasMore(false);
93
+ } catch { /* ignore */ } finally {
94
+ loadingOlder.current = false;
95
+ }
96
+ }, [messages, parseMessage]);
274
97
 
275
- return () => unsubs.forEach(u => u());
276
- }, [ws]);
98
+ // Load on mount (only when enabled/authenticated)
99
+ useEffect(() => {
100
+ if (!enabled || loaded.current) return;
101
+ loaded.current = true;
102
+ loadFromDb();
103
+ }, [enabled, loadFromDb]);
277
104
 
278
- const sendMessage = useCallback(
279
- (content: string, attachments?: Attachment[], audioData?: string) => {
280
- if (
281
- !ws ||
282
- (!content.trim() && (!attachments || attachments.length === 0))
283
- )
284
- return;
105
+ // Reload on reconnect (triggerReload changes)
106
+ useEffect(() => {
107
+ if (enabled && triggerReload && triggerReload > 0) {
108
+ loadFromDb();
109
+ }
110
+ }, [enabled, triggerReload, loadFromDb]);
285
111
 
286
- const userMsg: ChatMessage = {
287
- id: Date.now().toString(),
288
- role: 'user',
289
- content,
290
- timestamp: new Date().toISOString(),
291
- hasAttachments: !!(attachments && attachments.length > 0),
292
- audioData: audioData
293
- ? audioData.startsWith('data:')
294
- ? audioData
295
- : `data:audio/webm;base64,${audioData}`
296
- : undefined
297
- };
298
- setMessages(msgs => [...msgs, userMsg]);
112
+ // Periodic DB sync after reconnect while streaming — catches the final bot:response
113
+ useEffect(() => {
114
+ if (!enabled || !triggerReload || triggerReload === 0 || !streaming) return;
115
+ const interval = setInterval(() => {
116
+ if (!streamingRef.current) {
117
+ clearInterval(interval);
118
+ return;
119
+ }
120
+ loadFromDb();
121
+ }, 3000);
122
+ return () => clearInterval(interval);
123
+ }, [enabled, triggerReload, streaming, loadFromDb]);
299
124
 
300
- const payload: any = { conversationId, content };
301
- if (audioData) {
302
- payload.audioData = audioData.includes(',')
303
- ? audioData.split(',')[1]
304
- : audioData;
305
- }
306
- if (attachments?.length) {
307
- payload.attachments = attachments.map(att => {
308
- const match = att.preview.match(
309
- /^data:([^;]+);base64,(.+)$/
310
- );
311
- return {
312
- type: att.type,
313
- name: att.name,
314
- mediaType: match?.[1] || 'application/octet-stream',
315
- data: match?.[2] || ''
316
- };
317
- });
318
- }
319
- ws.send('user:message', payload);
320
- },
321
- [ws, conversationId]
322
- );
125
+ useEffect(() => {
126
+ if (!ws) return;
323
127
 
324
- const stopStreaming = useCallback(() => {
325
- if (!ws) return;
326
- ws.send('user:stop', { conversationId });
128
+ const unsubs = [
129
+ ws.on('bot:typing', () => {
130
+ setStreaming(true);
131
+ setTools([]);
132
+ }),
133
+ ws.on('bot:token', (data: { token: string }) => {
134
+ setStreamBuffer((buf) => buf + data.token);
135
+ }),
136
+ ws.on('bot:tool', (data: { name: string; status?: string }) => {
137
+ setTools((prev) => {
138
+ const existing = prev.find((t) => t.name === data.name && t.status === 'running');
139
+ if (existing) return prev;
140
+ return [...prev, { name: data.name, status: 'running' }];
141
+ });
142
+ }),
143
+ ws.on('bot:response', (data: { conversationId: string; messageId?: string; content: string }) => {
144
+ setConversationId(data.conversationId);
145
+ setMessages((msgs) => [
146
+ ...msgs,
147
+ {
148
+ id: data.messageId || Date.now().toString(),
149
+ role: 'assistant',
150
+ content: data.content,
151
+ timestamp: new Date().toISOString(),
152
+ },
153
+ ]);
154
+ setStreamBuffer('');
327
155
  setStreaming(false);
156
+ setTools([]);
157
+ }),
158
+ ws.on('bot:error', (data: { error: string }) => {
328
159
  setStreamBuffer('');
160
+ setStreaming(false);
329
161
  setTools([]);
330
- }, [ws, conversationId]);
331
-
332
- const clearContext = useCallback(() => {
333
- // Send clear to server (which broadcasts to all clients + clears Agent SDK session)
334
- if (ws) ws.send('user:clear-context', {});
335
- // Optimistic local clear
162
+ setMessages((msgs) => [
163
+ ...msgs,
164
+ {
165
+ id: Date.now().toString(),
166
+ role: 'assistant',
167
+ content: `Error: ${data.error}`,
168
+ timestamp: new Date().toISOString(),
169
+ },
170
+ ]);
171
+ }),
172
+ // Cross-device sync: append message from another client
173
+ ws.on('chat:sync', (data: { conversationId: string; message: { role: string; content: string; timestamp: string } }) => {
174
+ if (conversationIdRef.current && data.conversationId !== conversationIdRef.current) return;
175
+ setMessages((msgs) => [
176
+ ...msgs,
177
+ {
178
+ id: Date.now().toString(),
179
+ role: data.message.role as 'user' | 'assistant',
180
+ content: data.message.content,
181
+ timestamp: data.message.timestamp,
182
+ },
183
+ ]);
184
+ }),
185
+ // Server created a new conversation
186
+ ws.on('chat:conversation-created', (data: { conversationId: string }) => {
187
+ setConversationId(data.conversationId);
188
+ }),
189
+ // Server sends current streaming state on (re)connect
190
+ ws.on('chat:state', (data: { streaming: boolean; conversationId?: string; buffer?: string }) => {
191
+ if (data.streaming) {
192
+ setStreaming(true);
193
+ if (data.conversationId) setConversationId(data.conversationId);
194
+ if (data.buffer) setStreamBuffer(data.buffer);
195
+ }
196
+ }),
197
+ // Context cleared (from any client)
198
+ ws.on('chat:cleared', () => {
336
199
  setMessages([]);
337
200
  setConversationId(null);
338
201
  setStreamBuffer('');
339
202
  setStreaming(false);
340
203
  setTools([]);
341
204
  loaded.current = false;
342
- }, [ws]);
205
+ }),
206
+ ];
343
207
 
344
- return {
345
- messages,
346
- streaming,
347
- streamBuffer,
348
- conversationId,
349
- tools,
350
- hasMore,
351
- loadOlder,
352
- sendMessage,
353
- stopStreaming,
354
- clearContext
355
- };
208
+ return () => unsubs.forEach((u) => u());
209
+ }, [ws]);
210
+
211
+ const sendMessage = useCallback(
212
+ (content: string, attachments?: Attachment[], audioData?: string) => {
213
+ if (!ws || (!content.trim() && (!attachments || attachments.length === 0))) return;
214
+
215
+ const userMsg: ChatMessage = {
216
+ id: Date.now().toString(),
217
+ role: 'user',
218
+ content,
219
+ timestamp: new Date().toISOString(),
220
+ hasAttachments: !!(attachments && attachments.length > 0),
221
+ audioData: audioData ? (audioData.startsWith('data:') ? audioData : `data:audio/webm;base64,${audioData}`) : undefined,
222
+ };
223
+ setMessages((msgs) => [...msgs, userMsg]);
224
+
225
+ const payload: any = { conversationId, content };
226
+ if (audioData) {
227
+ payload.audioData = audioData.includes(',') ? audioData.split(',')[1] : audioData;
228
+ }
229
+ if (attachments?.length) {
230
+ payload.attachments = attachments.map((att) => {
231
+ const match = att.preview.match(/^data:([^;]+);base64,(.+)$/);
232
+ return {
233
+ type: att.type,
234
+ name: att.name,
235
+ mediaType: match?.[1] || 'application/octet-stream',
236
+ data: match?.[2] || '',
237
+ };
238
+ });
239
+ }
240
+ ws.send('user:message', payload);
241
+ },
242
+ [ws, conversationId],
243
+ );
244
+
245
+ const stopStreaming = useCallback(() => {
246
+ if (!ws) return;
247
+ ws.send('user:stop', { conversationId });
248
+ setStreaming(false);
249
+ setStreamBuffer('');
250
+ setTools([]);
251
+ }, [ws, conversationId]);
252
+
253
+ const clearContext = useCallback(() => {
254
+ // Send clear to server (which broadcasts to all clients + clears Agent SDK session)
255
+ if (ws) ws.send('user:clear-context', {});
256
+ // Optimistic local clear
257
+ setMessages([]);
258
+ setConversationId(null);
259
+ setStreamBuffer('');
260
+ setStreaming(false);
261
+ setTools([]);
262
+ loaded.current = false;
263
+ }, [ws]);
264
+
265
+ return { messages, streaming, streamBuffer, conversationId, tools, hasMore, loadOlder, sendMessage, stopStreaming, clearContext };
356
266
  }