fluxy-bot 0.1.16 → 0.1.17
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/client/src/App.tsx +18 -5
- package/client/src/components/Chat/InputBar.tsx +158 -66
- package/client/src/components/Chat/MessageBubble.tsx +16 -3
- package/client/src/components/Chat/MessageList.tsx +1 -1
- package/client/src/hooks/useChat.ts +90 -8
- package/dist/assets/{index-DiKjTYsZ.js → index-CxpZxhWa.js} +42 -42
- package/dist/assets/index-JyQkBhgl.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/worker/claude-agent.ts +46 -3
- package/worker/index.ts +3 -3
- package/dist/assets/index-C1lCjDS9.css +0 -1
package/client/src/App.tsx
CHANGED
|
@@ -29,15 +29,28 @@ function DashboardError() {
|
|
|
29
29
|
export default function App() {
|
|
30
30
|
const [chatOpen, setChatOpen] = useState(false);
|
|
31
31
|
const [showOnboard, setShowOnboard] = useState(false);
|
|
32
|
+
const [botName, setBotName] = useState('Fluxy');
|
|
32
33
|
const { ws, connected } = useWebSocket();
|
|
33
34
|
|
|
34
35
|
useEffect(() => {
|
|
35
36
|
fetch('/api/settings')
|
|
36
37
|
.then((r) => r.json())
|
|
37
|
-
.then((s) => {
|
|
38
|
+
.then((s) => {
|
|
39
|
+
if (s.onboard_complete !== 'true') setShowOnboard(true);
|
|
40
|
+
if (s.agent_name) setBotName(s.agent_name);
|
|
41
|
+
})
|
|
38
42
|
.catch(() => setShowOnboard(true));
|
|
39
43
|
}, []);
|
|
40
44
|
|
|
45
|
+
// Refresh bot name after onboarding completes
|
|
46
|
+
const handleOnboardComplete = () => {
|
|
47
|
+
setShowOnboard(false);
|
|
48
|
+
fetch('/api/settings')
|
|
49
|
+
.then((r) => r.json())
|
|
50
|
+
.then((s) => { if (s.agent_name) setBotName(s.agent_name); })
|
|
51
|
+
.catch(() => {});
|
|
52
|
+
};
|
|
53
|
+
|
|
41
54
|
return (
|
|
42
55
|
<>
|
|
43
56
|
<ErrorBoundary fallback={<DashboardError />}>
|
|
@@ -55,13 +68,13 @@ export default function App() {
|
|
|
55
68
|
>
|
|
56
69
|
{/* Header — pr-12 to avoid overlap with built-in X button */}
|
|
57
70
|
<div className="flex items-center gap-3 px-4 pr-12 py-3 border-b border-border shrink-0">
|
|
58
|
-
<img src="/fluxy.png" alt=
|
|
59
|
-
<SheetTitle className="text-sm font-semibold">
|
|
71
|
+
<img src="/fluxy.png" alt={botName} className="h-5 w-auto" />
|
|
72
|
+
<SheetTitle className="text-sm font-semibold">{botName}</SheetTitle>
|
|
60
73
|
<div
|
|
61
74
|
className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
|
62
75
|
/>
|
|
63
76
|
<SheetDescription className="sr-only">
|
|
64
|
-
Chat with your
|
|
77
|
+
Chat with your {botName} AI assistant
|
|
65
78
|
</SheetDescription>
|
|
66
79
|
</div>
|
|
67
80
|
|
|
@@ -75,7 +88,7 @@ export default function App() {
|
|
|
75
88
|
<FluxyFab onClick={() => setChatOpen((o) => !o)} />
|
|
76
89
|
|
|
77
90
|
{/* Onboarding wizard overlay */}
|
|
78
|
-
{showOnboard && <OnboardWizard onComplete={
|
|
91
|
+
{showOnboard && <OnboardWizard onComplete={handleOnboardComplete} />}
|
|
79
92
|
</>
|
|
80
93
|
);
|
|
81
94
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type PointerEvent as RPointerEvent } from 'react';
|
|
2
|
-
import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera } from 'lucide-react';
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, type KeyboardEvent, type PointerEvent as RPointerEvent, type ChangeEvent } from 'react';
|
|
2
|
+
import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera, X } from 'lucide-react';
|
|
3
3
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
4
|
+
import type { Attachment } from '../../hooks/useChat';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
|
-
onSend: (msg: string) => void;
|
|
7
|
+
onSend: (msg: string, attachments?: Attachment[]) => void;
|
|
7
8
|
onStop: () => void;
|
|
8
9
|
streaming: boolean;
|
|
9
10
|
}
|
|
@@ -16,9 +17,11 @@ function formatTime(s: number) {
|
|
|
16
17
|
|
|
17
18
|
export default function InputBar({ onSend, onStop, streaming }: Props) {
|
|
18
19
|
const [text, setText] = useState('');
|
|
20
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
19
21
|
const [isRecording, setIsRecording] = useState(false);
|
|
20
22
|
const [recordingTime, setRecordingTime] = useState(0);
|
|
21
23
|
const hasText = text.trim().length > 0;
|
|
24
|
+
const hasContent = hasText || attachments.length > 0;
|
|
22
25
|
|
|
23
26
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
24
27
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
@@ -51,11 +54,61 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
|
|
|
51
54
|
dragRef.current = 0;
|
|
52
55
|
}, []);
|
|
53
56
|
|
|
57
|
+
// ── File handling ──
|
|
58
|
+
|
|
59
|
+
const addFile = useCallback((file: File) => {
|
|
60
|
+
const isImage = file.type.startsWith('image/');
|
|
61
|
+
const isPdf = file.type === 'application/pdf';
|
|
62
|
+
if (!isImage && !isPdf) return; // Only images and PDFs allowed
|
|
63
|
+
|
|
64
|
+
const reader = new FileReader();
|
|
65
|
+
reader.onload = (e) => {
|
|
66
|
+
const preview = e.target?.result as string;
|
|
67
|
+
setAttachments((prev) => [
|
|
68
|
+
...prev,
|
|
69
|
+
{
|
|
70
|
+
id: Math.random().toString(36).slice(2),
|
|
71
|
+
type: isImage ? 'image' : 'file',
|
|
72
|
+
name: file.name,
|
|
73
|
+
preview,
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
};
|
|
77
|
+
reader.readAsDataURL(file);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
81
|
+
const files = e.target.files;
|
|
82
|
+
if (!files) return;
|
|
83
|
+
for (let i = 0; i < files.length; i++) addFile(files[i]);
|
|
84
|
+
e.target.value = ''; // Reset so same file can be re-selected
|
|
85
|
+
}, [addFile]);
|
|
86
|
+
|
|
87
|
+
const removeAttachment = useCallback((id: string) => {
|
|
88
|
+
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Handle paste for images
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const handlePaste = (e: ClipboardEvent) => {
|
|
94
|
+
if (!e.clipboardData?.items) return;
|
|
95
|
+
for (const item of e.clipboardData.items) {
|
|
96
|
+
if (item.type.startsWith('image/')) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const file = item.getAsFile();
|
|
99
|
+
if (file) addFile(file);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
document.addEventListener('paste', handlePaste);
|
|
104
|
+
return () => document.removeEventListener('paste', handlePaste);
|
|
105
|
+
}, [addFile]);
|
|
106
|
+
|
|
54
107
|
const handleSend = () => {
|
|
55
|
-
if (!
|
|
56
|
-
onSend(text);
|
|
108
|
+
if (!hasContent) return;
|
|
109
|
+
onSend(text, attachments.length > 0 ? attachments : undefined);
|
|
57
110
|
setText('');
|
|
58
|
-
|
|
111
|
+
setAttachments([]);
|
|
59
112
|
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
60
113
|
};
|
|
61
114
|
|
|
@@ -66,14 +119,13 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
|
|
|
66
119
|
}
|
|
67
120
|
};
|
|
68
121
|
|
|
69
|
-
// ── Mic pointer handlers
|
|
122
|
+
// ── Mic pointer handlers ──
|
|
70
123
|
const handleMicDown = useCallback((e: RPointerEvent) => {
|
|
71
|
-
e.preventDefault();
|
|
124
|
+
e.preventDefault();
|
|
72
125
|
startXRef.current = e.clientX;
|
|
73
126
|
dragRef.current = 0;
|
|
74
127
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
75
128
|
|
|
76
|
-
// Hold delay — 200ms to distinguish click vs hold
|
|
77
129
|
holdTimerRef.current = setTimeout(() => {
|
|
78
130
|
isHolding.current = true;
|
|
79
131
|
setIsRecording(true);
|
|
@@ -83,12 +135,10 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
|
|
|
83
135
|
|
|
84
136
|
const handleMicMove = useCallback((e: RPointerEvent) => {
|
|
85
137
|
if (!isHolding.current) return;
|
|
86
|
-
// Only track horizontal movement
|
|
87
138
|
const dx = Math.min(0, e.clientX - startXRef.current);
|
|
88
139
|
dragRef.current = dx;
|
|
89
140
|
if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
|
|
90
141
|
|
|
91
|
-
// Auto-cancel when pointer nears trash icon
|
|
92
142
|
if (trashRef.current) {
|
|
93
143
|
const trashRect = trashRef.current.getBoundingClientRect();
|
|
94
144
|
const trashCenterX = trashRect.left + trashRect.width / 2;
|
|
@@ -100,8 +150,8 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
|
|
|
100
150
|
|
|
101
151
|
const handleMicUp = useCallback(() => {
|
|
102
152
|
if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
|
|
103
|
-
if (!isHolding.current) return;
|
|
104
|
-
stopRecording(false);
|
|
153
|
+
if (!isHolding.current) return;
|
|
154
|
+
stopRecording(false);
|
|
105
155
|
}, [stopRecording]);
|
|
106
156
|
|
|
107
157
|
const handleMicCancel = useCallback(() => {
|
|
@@ -113,63 +163,105 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
|
|
|
113
163
|
<div className="shrink-0 p-3 relative">
|
|
114
164
|
{/* ── Normal input (always mounted to keep keyboard alive) ── */}
|
|
115
165
|
<div
|
|
116
|
-
className={`flex
|
|
166
|
+
className={`flex flex-col transition-opacity duration-100 ${
|
|
117
167
|
isRecording ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
|
118
168
|
}`}
|
|
119
169
|
>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
{/* ── Attachment previews ── */}
|
|
171
|
+
<AnimatePresence>
|
|
172
|
+
{attachments.length > 0 && (
|
|
173
|
+
<motion.div
|
|
174
|
+
initial={{ height: 0, opacity: 0 }}
|
|
175
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
176
|
+
exit={{ height: 0, opacity: 0 }}
|
|
177
|
+
transition={{ duration: 0.15 }}
|
|
178
|
+
className="overflow-hidden"
|
|
179
|
+
>
|
|
180
|
+
<div className="flex gap-2 px-4 pt-2 pb-1.5 flex-wrap">
|
|
181
|
+
{attachments.map((att) => (
|
|
182
|
+
<div key={att.id} className="relative group flex-shrink-0">
|
|
183
|
+
{att.type === 'image' ? (
|
|
184
|
+
<img
|
|
185
|
+
src={att.preview}
|
|
186
|
+
alt={att.name}
|
|
187
|
+
className="w-14 h-14 rounded-lg object-cover border border-white/10"
|
|
188
|
+
/>
|
|
189
|
+
) : (
|
|
190
|
+
<div className="w-14 h-14 rounded-lg bg-white/5 border border-white/10 flex flex-col items-center justify-center gap-0.5 px-1">
|
|
191
|
+
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
192
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
193
|
+
</svg>
|
|
194
|
+
<span className="text-[8px] text-muted-foreground truncate w-full text-center">{att.name.split('.').pop()}</span>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
<button
|
|
198
|
+
onClick={() => removeAttachment(att.id)}
|
|
199
|
+
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-black/80 border border-white/20 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
200
|
+
>
|
|
201
|
+
<X className="w-2.5 h-2.5 text-white" />
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
</motion.div>
|
|
207
|
+
)}
|
|
208
|
+
</AnimatePresence>
|
|
209
|
+
|
|
210
|
+
<div className="flex items-center gap-2">
|
|
211
|
+
<div className="flex-1 flex items-center bg-white rounded-full px-4">
|
|
212
|
+
<textarea
|
|
213
|
+
ref={textareaRef}
|
|
214
|
+
value={text}
|
|
215
|
+
onChange={(e) => setText(e.target.value)}
|
|
216
|
+
onKeyDown={handleKeyDown}
|
|
217
|
+
placeholder="Type a message..."
|
|
218
|
+
rows={1}
|
|
219
|
+
className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400"
|
|
220
|
+
/>
|
|
221
|
+
{/* Hidden file inputs for native PWA pickers */}
|
|
222
|
+
<input ref={fileRef} type="file" className="hidden" accept="image/*,.pdf" multiple onChange={handleFileChange} />
|
|
223
|
+
<input ref={cameraRef} type="file" className="hidden" accept="image/*" capture="environment" onChange={handleFileChange} />
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={() => fileRef.current?.click()}
|
|
227
|
+
className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
|
228
|
+
>
|
|
229
|
+
<Paperclip className="h-4 w-4" />
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
onClick={() => cameraRef.current?.click()}
|
|
234
|
+
className="shrink-0 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
|
235
|
+
>
|
|
236
|
+
<Camera className="h-4 w-4" />
|
|
237
|
+
</button>
|
|
171
238
|
</div>
|
|
172
|
-
|
|
239
|
+
{streaming ? (
|
|
240
|
+
<button
|
|
241
|
+
onClick={onStop}
|
|
242
|
+
className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
|
|
243
|
+
>
|
|
244
|
+
<Square className="h-4 w-4" />
|
|
245
|
+
</button>
|
|
246
|
+
) : hasContent ? (
|
|
247
|
+
<button
|
|
248
|
+
onClick={handleSend}
|
|
249
|
+
className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100"
|
|
250
|
+
>
|
|
251
|
+
<SendHorizontal className="h-4 w-4" />
|
|
252
|
+
</button>
|
|
253
|
+
) : (
|
|
254
|
+
<div
|
|
255
|
+
className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-white text-gray-900 transition-colors hover:bg-gray-100 cursor-pointer touch-none select-none"
|
|
256
|
+
onPointerDown={handleMicDown}
|
|
257
|
+
onPointerMove={handleMicMove}
|
|
258
|
+
onPointerUp={handleMicUp}
|
|
259
|
+
onPointerCancel={handleMicCancel}
|
|
260
|
+
>
|
|
261
|
+
<Mic className="h-4 w-4" />
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
173
265
|
</div>
|
|
174
266
|
|
|
175
267
|
{/* ── Recording overlay (absolute so no layout shift) ── */}
|
|
@@ -6,23 +6,35 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
|
6
6
|
interface Props {
|
|
7
7
|
role: 'user' | 'assistant';
|
|
8
8
|
content: string;
|
|
9
|
+
timestamp?: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
function formatTime(iso: string): string {
|
|
13
|
+
try {
|
|
14
|
+
const d = new Date(iso);
|
|
15
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
16
|
+
} catch {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function MessageBubble({ role, content, timestamp }: Props) {
|
|
12
22
|
const isUser = role === 'user';
|
|
23
|
+
const time = timestamp ? formatTime(timestamp) : '';
|
|
13
24
|
|
|
14
25
|
if (isUser) {
|
|
15
26
|
return (
|
|
16
|
-
<div className="flex
|
|
27
|
+
<div className="flex flex-col items-end gap-0.5">
|
|
17
28
|
<div className="max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground">
|
|
18
29
|
{content}
|
|
19
30
|
</div>
|
|
31
|
+
{time && <span className="text-[10px] text-muted-foreground/50 px-1">{time}</span>}
|
|
20
32
|
</div>
|
|
21
33
|
);
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
return (
|
|
25
|
-
<div className="flex
|
|
37
|
+
<div className="flex flex-col items-start gap-0.5">
|
|
26
38
|
<div className="max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed bg-muted text-foreground prose prose-sm prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
|
27
39
|
<ReactMarkdown
|
|
28
40
|
remarkPlugins={[remarkGfm]}
|
|
@@ -75,6 +87,7 @@ export default function MessageBubble({ role, content }: Props) {
|
|
|
75
87
|
{content}
|
|
76
88
|
</ReactMarkdown>
|
|
77
89
|
</div>
|
|
90
|
+
{time && <span className="text-[10px] text-muted-foreground/50 px-1">{time}</span>}
|
|
78
91
|
</div>
|
|
79
92
|
);
|
|
80
93
|
}
|
|
@@ -46,7 +46,7 @@ export default function MessageList({ messages, streaming, streamBuffer, tools }
|
|
|
46
46
|
|
|
47
47
|
<div className="space-y-3">
|
|
48
48
|
{messages.map((msg) => (
|
|
49
|
-
<MessageBubble key={msg.id} role={msg.role} content={msg.content} />
|
|
49
|
+
<MessageBubble key={msg.id} role={msg.role} content={msg.content} timestamp={msg.timestamp} />
|
|
50
50
|
))}
|
|
51
51
|
|
|
52
52
|
{/* Tool activity indicators */}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
2
2
|
import type { WsClient } from '../lib/ws-client';
|
|
3
3
|
|
|
4
4
|
export interface ChatMessage {
|
|
5
5
|
id: string;
|
|
6
6
|
role: 'user' | 'assistant';
|
|
7
7
|
content: string;
|
|
8
|
+
timestamp: string; // ISO string
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface ToolActivity {
|
|
@@ -12,12 +13,59 @@ export interface ToolActivity {
|
|
|
12
13
|
status: 'running' | 'done';
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
export interface Attachment {
|
|
17
|
+
id: string;
|
|
18
|
+
type: 'image' | 'file';
|
|
19
|
+
name: string;
|
|
20
|
+
preview: string; // base64 data URL
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CONV_KEY = 'fluxy_conversation_id';
|
|
24
|
+
|
|
15
25
|
export function useChat(ws: WsClient | null) {
|
|
16
26
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
17
|
-
const [conversationId, setConversationId] = useState<string | null>(
|
|
27
|
+
const [conversationId, setConversationId] = useState<string | null>(
|
|
28
|
+
() => localStorage.getItem(CONV_KEY),
|
|
29
|
+
);
|
|
18
30
|
const [streaming, setStreaming] = useState(false);
|
|
19
31
|
const [streamBuffer, setStreamBuffer] = useState('');
|
|
20
32
|
const [tools, setTools] = useState<ToolActivity[]>([]);
|
|
33
|
+
const loaded = useRef(false);
|
|
34
|
+
|
|
35
|
+
// Load existing conversation messages on mount
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (loaded.current || !conversationId) return;
|
|
38
|
+
loaded.current = true;
|
|
39
|
+
fetch(`/api/conversations/${conversationId}`)
|
|
40
|
+
.then((r) => {
|
|
41
|
+
if (!r.ok) throw new Error('not found');
|
|
42
|
+
return r.json();
|
|
43
|
+
})
|
|
44
|
+
.then((data) => {
|
|
45
|
+
if (data.messages?.length) {
|
|
46
|
+
setMessages(
|
|
47
|
+
data.messages
|
|
48
|
+
.filter((m: any) => m.role === 'user' || m.role === 'assistant')
|
|
49
|
+
.map((m: any) => ({
|
|
50
|
+
id: m.id,
|
|
51
|
+
role: m.role,
|
|
52
|
+
content: m.content,
|
|
53
|
+
timestamp: m.created_at,
|
|
54
|
+
})),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.catch(() => {
|
|
59
|
+
// Conversation gone — clear stale ID
|
|
60
|
+
localStorage.removeItem(CONV_KEY);
|
|
61
|
+
setConversationId(null);
|
|
62
|
+
});
|
|
63
|
+
}, [conversationId]);
|
|
64
|
+
|
|
65
|
+
// Persist conversationId to localStorage
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (conversationId) localStorage.setItem(CONV_KEY, conversationId);
|
|
68
|
+
}, [conversationId]);
|
|
21
69
|
|
|
22
70
|
useEffect(() => {
|
|
23
71
|
if (!ws) return;
|
|
@@ -39,7 +87,15 @@ export function useChat(ws: WsClient | null) {
|
|
|
39
87
|
}),
|
|
40
88
|
ws.on('bot:response', (data: { conversationId: string; messageId?: string; content: string }) => {
|
|
41
89
|
setConversationId(data.conversationId);
|
|
42
|
-
setMessages((msgs) => [
|
|
90
|
+
setMessages((msgs) => [
|
|
91
|
+
...msgs,
|
|
92
|
+
{
|
|
93
|
+
id: data.messageId || Date.now().toString(),
|
|
94
|
+
role: 'assistant',
|
|
95
|
+
content: data.content,
|
|
96
|
+
timestamp: new Date().toISOString(),
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
43
99
|
setStreamBuffer('');
|
|
44
100
|
setStreaming(false);
|
|
45
101
|
setTools([]);
|
|
@@ -50,7 +106,12 @@ export function useChat(ws: WsClient | null) {
|
|
|
50
106
|
setTools([]);
|
|
51
107
|
setMessages((msgs) => [
|
|
52
108
|
...msgs,
|
|
53
|
-
{
|
|
109
|
+
{
|
|
110
|
+
id: Date.now().toString(),
|
|
111
|
+
role: 'assistant',
|
|
112
|
+
content: `Error: ${data.error}`,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
},
|
|
54
115
|
]);
|
|
55
116
|
}),
|
|
56
117
|
];
|
|
@@ -59,12 +120,31 @@ export function useChat(ws: WsClient | null) {
|
|
|
59
120
|
}, [ws]);
|
|
60
121
|
|
|
61
122
|
const sendMessage = useCallback(
|
|
62
|
-
(content: string) => {
|
|
63
|
-
if (!ws || !content.trim()) return;
|
|
123
|
+
(content: string, attachments?: Attachment[]) => {
|
|
124
|
+
if (!ws || (!content.trim() && (!attachments || attachments.length === 0))) return;
|
|
64
125
|
|
|
65
|
-
const userMsg: ChatMessage = {
|
|
126
|
+
const userMsg: ChatMessage = {
|
|
127
|
+
id: Date.now().toString(),
|
|
128
|
+
role: 'user',
|
|
129
|
+
content,
|
|
130
|
+
timestamp: new Date().toISOString(),
|
|
131
|
+
};
|
|
66
132
|
setMessages((msgs) => [...msgs, userMsg]);
|
|
67
|
-
|
|
133
|
+
|
|
134
|
+
// Build WS payload
|
|
135
|
+
const payload: any = { conversationId, content };
|
|
136
|
+
if (attachments?.length) {
|
|
137
|
+
payload.attachments = attachments.map((att) => {
|
|
138
|
+
const match = att.preview.match(/^data:([^;]+);base64,(.+)$/);
|
|
139
|
+
return {
|
|
140
|
+
type: att.type,
|
|
141
|
+
name: att.name,
|
|
142
|
+
mediaType: match?.[1] || 'application/octet-stream',
|
|
143
|
+
data: match?.[2] || '',
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
ws.send('user:message', payload);
|
|
68
148
|
},
|
|
69
149
|
[ws, conversationId],
|
|
70
150
|
);
|
|
@@ -83,6 +163,8 @@ export function useChat(ws: WsClient | null) {
|
|
|
83
163
|
setStreamBuffer('');
|
|
84
164
|
setStreaming(false);
|
|
85
165
|
setTools([]);
|
|
166
|
+
localStorage.removeItem(CONV_KEY);
|
|
167
|
+
loaded.current = false;
|
|
86
168
|
}, []);
|
|
87
169
|
|
|
88
170
|
return { messages, streaming, streamBuffer, conversationId, tools, sendMessage, stopStreaming, newChat };
|