fluxy-bot 0.1.19 → 0.1.21

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.
Binary file
Binary file
@@ -37,6 +37,7 @@ export default function App() {
37
37
  const [chatOpen, setChatOpen] = useState(false);
38
38
  const [showOnboard, setShowOnboard] = useState(false);
39
39
  const [botName, setBotName] = useState('Fluxy');
40
+ const [whisperEnabled, setWhisperEnabled] = useState(false);
40
41
  const { ws, connected } = useWebSocket();
41
42
  const clearContextRef = useRef<(() => void) | null>(null);
42
43
 
@@ -46,16 +47,20 @@ export default function App() {
46
47
  .then((s) => {
47
48
  if (s.onboard_complete !== 'true') setShowOnboard(true);
48
49
  if (s.agent_name) setBotName(s.agent_name);
50
+ if (s.whisper_enabled === 'true') setWhisperEnabled(true);
49
51
  })
50
52
  .catch(() => setShowOnboard(true));
51
53
  }, []);
52
54
 
53
- // Refresh bot name after onboarding completes
55
+ // Refresh bot name + whisper after onboarding completes
54
56
  const handleOnboardComplete = () => {
55
57
  setShowOnboard(false);
56
58
  fetch('/api/settings')
57
59
  .then((r) => r.json())
58
- .then((s) => { if (s.agent_name) setBotName(s.agent_name); })
60
+ .then((s) => {
61
+ if (s.agent_name) setBotName(s.agent_name);
62
+ setWhisperEnabled(s.whisper_enabled === 'true');
63
+ })
59
64
  .catch(() => {});
60
65
  };
61
66
 
@@ -104,7 +109,7 @@ export default function App() {
104
109
 
105
110
  {/* Chat body — fills remaining height */}
106
111
  <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
107
- <ChatView ws={ws} clearContextRef={clearContextRef} />
112
+ <ChatView ws={ws} clearContextRef={clearContextRef} whisperEnabled={whisperEnabled} />
108
113
  </div>
109
114
  </SheetContent>
110
115
  </Sheet>
@@ -0,0 +1,117 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { Play, Pause } from 'lucide-react';
3
+
4
+ interface Props {
5
+ audioData: string; // base64 data URL (data:audio/webm;base64,...)
6
+ }
7
+
8
+ function formatDuration(s: number): string {
9
+ if (!isFinite(s) || isNaN(s)) return '0:00';
10
+ const mins = Math.floor(s / 60);
11
+ const secs = Math.floor(s % 60);
12
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
13
+ }
14
+
15
+ export default function AudioBubble({ audioData }: Props) {
16
+ const [playing, setPlaying] = useState(false);
17
+ const [progress, setProgress] = useState(0);
18
+ const [duration, setDuration] = useState(0);
19
+ const audioRef = useRef<HTMLAudioElement | null>(null);
20
+ const barRef = useRef<HTMLDivElement>(null);
21
+
22
+ // Create Audio element once
23
+ useEffect(() => {
24
+ const audio = new Audio(audioData);
25
+
26
+ audio.addEventListener('durationchange', () => {
27
+ // webm often reports Infinity initially — skip until real
28
+ if (isFinite(audio.duration)) setDuration(audio.duration);
29
+ });
30
+
31
+ audio.addEventListener('timeupdate', () => {
32
+ if (isFinite(audio.duration) && audio.duration > 0) {
33
+ setProgress(audio.currentTime / audio.duration);
34
+ // Update duration if we didn't get it from durationchange
35
+ if (duration === 0) setDuration(audio.duration);
36
+ }
37
+ });
38
+
39
+ audio.addEventListener('ended', () => {
40
+ setPlaying(false);
41
+ setProgress(0);
42
+ });
43
+
44
+ // Workaround for webm duration=Infinity: seek to large value to force browser to calculate duration
45
+ audio.addEventListener('loadedmetadata', () => {
46
+ if (!isFinite(audio.duration)) {
47
+ audio.currentTime = 1e10;
48
+ audio.addEventListener('timeupdate', function seekBack() {
49
+ if (isFinite(audio.duration)) {
50
+ setDuration(audio.duration);
51
+ audio.currentTime = 0;
52
+ audio.removeEventListener('timeupdate', seekBack);
53
+ }
54
+ });
55
+ }
56
+ });
57
+
58
+ audioRef.current = audio;
59
+
60
+ return () => {
61
+ audio.pause();
62
+ audio.src = '';
63
+ };
64
+ }, [audioData]);
65
+
66
+ const togglePlay = useCallback(() => {
67
+ const audio = audioRef.current;
68
+ if (!audio) return;
69
+ if (playing) {
70
+ audio.pause();
71
+ setPlaying(false);
72
+ } else {
73
+ audio.play();
74
+ setPlaying(true);
75
+ }
76
+ }, [playing]);
77
+
78
+ const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
79
+ const audio = audioRef.current;
80
+ const bar = barRef.current;
81
+ if (!audio || !bar || !isFinite(audio.duration)) return;
82
+ const rect = bar.getBoundingClientRect();
83
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
84
+ audio.currentTime = ratio * audio.duration;
85
+ setProgress(ratio);
86
+ }, []);
87
+
88
+ return (
89
+ <div className="flex items-center gap-2.5 min-w-[180px]">
90
+ <button
91
+ onClick={togglePlay}
92
+ className="flex items-center justify-center h-8 w-8 shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
93
+ >
94
+ {playing ? (
95
+ <Pause className="h-3.5 w-3.5 fill-current" />
96
+ ) : (
97
+ <Play className="h-3.5 w-3.5 fill-current ml-0.5" />
98
+ )}
99
+ </button>
100
+ <div className="flex-1 flex flex-col gap-1">
101
+ <div
102
+ ref={barRef}
103
+ onClick={handleSeek}
104
+ className="h-1 bg-white/20 rounded-full cursor-pointer relative"
105
+ >
106
+ <div
107
+ className="absolute inset-y-0 left-0 bg-white/70 rounded-full transition-[width] duration-100"
108
+ style={{ width: `${progress * 100}%` }}
109
+ />
110
+ </div>
111
+ <span className="text-[10px] opacity-60 tabular-nums">
112
+ {formatDuration(playing ? progress * duration : duration)}
113
+ </span>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
@@ -7,9 +7,10 @@ interface Props {
7
7
  ws: WsClient | null;
8
8
  onClearContext?: () => void;
9
9
  clearContextRef?: React.MutableRefObject<(() => void) | null>;
10
+ whisperEnabled?: boolean;
10
11
  }
11
12
 
12
- export default function ChatView({ ws, clearContextRef }: Props) {
13
+ export default function ChatView({ ws, clearContextRef, whisperEnabled }: Props) {
13
14
  const { messages, streaming, streamBuffer, tools, sendMessage, stopStreaming, clearContext } = useChat(ws);
14
15
 
15
16
  // Expose clearContext to parent via ref
@@ -18,7 +19,7 @@ export default function ChatView({ ws, clearContextRef }: Props) {
18
19
  return (
19
20
  <div className="flex flex-col h-full overflow-hidden">
20
21
  <MessageList messages={messages} streaming={streaming} streamBuffer={streamBuffer} tools={tools} />
21
- <InputBar onSend={sendMessage} onStop={stopStreaming} streaming={streaming} />
22
+ <InputBar onSend={sendMessage} onStop={stopStreaming} streaming={streaming} whisperEnabled={whisperEnabled} />
22
23
  </div>
23
24
  );
24
25
  }
@@ -0,0 +1,86 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { ChevronLeft, ChevronRight, X } from 'lucide-react';
4
+
5
+ interface Props {
6
+ images: string[];
7
+ index: number;
8
+ onClose: () => void;
9
+ onNavigate: (index: number) => void;
10
+ }
11
+
12
+ export default function ImageLightbox({ images, index, onClose, onNavigate }: Props) {
13
+ const goPrev = useCallback(() => {
14
+ if (index > 0) onNavigate(index - 1);
15
+ }, [index, onNavigate]);
16
+
17
+ const goNext = useCallback(() => {
18
+ if (index < images.length - 1) onNavigate(index + 1);
19
+ }, [index, images.length, onNavigate]);
20
+
21
+ useEffect(() => {
22
+ const handleKey = (e: KeyboardEvent) => {
23
+ if (e.key === 'Escape') onClose();
24
+ if (e.key === 'ArrowLeft') goPrev();
25
+ if (e.key === 'ArrowRight') goNext();
26
+ };
27
+ window.addEventListener('keydown', handleKey);
28
+ return () => window.removeEventListener('keydown', handleKey);
29
+ }, [onClose, goPrev, goNext]);
30
+
31
+ return (
32
+ <AnimatePresence>
33
+ <motion.div
34
+ initial={{ opacity: 0 }}
35
+ animate={{ opacity: 1 }}
36
+ exit={{ opacity: 0 }}
37
+ transition={{ duration: 0.15 }}
38
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
39
+ onClick={onClose}
40
+ >
41
+ {/* Close button */}
42
+ <button
43
+ onClick={onClose}
44
+ className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
45
+ >
46
+ <X className="h-5 w-5" />
47
+ </button>
48
+
49
+ {/* Image counter */}
50
+ {images.length > 1 && (
51
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 text-white/70 text-sm tabular-nums">
52
+ {index + 1} / {images.length}
53
+ </div>
54
+ )}
55
+
56
+ {/* Left arrow */}
57
+ {index > 0 && (
58
+ <button
59
+ onClick={(e) => { e.stopPropagation(); goPrev(); }}
60
+ className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
61
+ >
62
+ <ChevronLeft className="h-6 w-6" />
63
+ </button>
64
+ )}
65
+
66
+ {/* Right arrow */}
67
+ {index < images.length - 1 && (
68
+ <button
69
+ onClick={(e) => { e.stopPropagation(); goNext(); }}
70
+ className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
71
+ >
72
+ <ChevronRight className="h-6 w-6" />
73
+ </button>
74
+ )}
75
+
76
+ {/* Image */}
77
+ <img
78
+ src={images[index]}
79
+ alt=""
80
+ className="max-h-[85vh] max-w-[90vw] object-contain rounded-lg"
81
+ onClick={(e) => e.stopPropagation()}
82
+ />
83
+ </motion.div>
84
+ </AnimatePresence>
85
+ );
86
+ }
@@ -4,9 +4,10 @@ import { motion, AnimatePresence } from 'framer-motion';
4
4
  import type { Attachment } from '../../hooks/useChat';
5
5
 
6
6
  interface Props {
7
- onSend: (msg: string, attachments?: Attachment[]) => void;
7
+ onSend: (msg: string, attachments?: Attachment[], audioData?: string) => void;
8
8
  onStop: () => void;
9
9
  streaming: boolean;
10
+ whisperEnabled?: boolean;
10
11
  }
11
12
 
12
13
  function formatTime(s: number) {
@@ -50,7 +51,7 @@ function compressImage(dataUrl: string, maxBytes = 4 * 1024 * 1024): Promise<str
50
51
  });
51
52
  }
52
53
 
53
- export default function InputBar({ onSend, onStop, streaming }: Props) {
54
+ export default function InputBar({ onSend, onStop, streaming, whisperEnabled }: Props) {
54
55
  const [text, setText] = useState('');
55
56
  const [attachments, setAttachments] = useState<Attachment[]>([]);
56
57
  const [isRecording, setIsRecording] = useState(false);
@@ -68,6 +69,9 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
68
69
  const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69
70
  const isHolding = useRef(false);
70
71
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
72
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
73
+ const audioChunksRef = useRef<Blob[]>([]);
74
+ const streamRef = useRef<MediaStream | null>(null);
71
75
 
72
76
  // Auto-resize textarea up to 4 lines, then scroll
73
77
  useEffect(() => {
@@ -89,14 +93,58 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
89
93
  if (intervalRef.current) clearInterval(intervalRef.current);
90
94
  if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
91
95
  isHolding.current = false;
92
- if (!cancelled) {
93
- // Future: send recorded audio
96
+
97
+ const recorder = mediaRecorderRef.current;
98
+ const stream = streamRef.current;
99
+
100
+ if (cancelled || !recorder || recorder.state === 'inactive') {
101
+ // Clean up mic
102
+ stream?.getTracks().forEach((t) => t.stop());
103
+ mediaRecorderRef.current = null;
104
+ streamRef.current = null;
105
+ audioChunksRef.current = [];
106
+ } else {
107
+ // Stop recorder — ondataavailable + onstop will fire
108
+ recorder.onstop = async () => {
109
+ stream?.getTracks().forEach((t) => t.stop());
110
+ const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
111
+ audioChunksRef.current = [];
112
+ mediaRecorderRef.current = null;
113
+ streamRef.current = null;
114
+
115
+ if (blob.size < 1000) return; // too small, skip
116
+
117
+ // Convert to base64
118
+ const reader = new FileReader();
119
+ reader.onloadend = async () => {
120
+ const dataUrl = reader.result as string;
121
+ const base64 = dataUrl.split(',')[1];
122
+ if (!base64) return;
123
+
124
+ try {
125
+ const res = await fetch('/api/whisper/transcribe', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ audio: base64 }),
129
+ });
130
+ const data = await res.json();
131
+ if (data.transcript?.trim()) {
132
+ onSend(data.transcript.trim(), undefined, dataUrl);
133
+ }
134
+ } catch {
135
+ // Transcription failed silently
136
+ }
137
+ };
138
+ reader.readAsDataURL(blob);
139
+ };
140
+ recorder.stop();
94
141
  }
142
+
95
143
  if (micRef.current) micRef.current.style.transform = '';
96
144
  setIsRecording(false);
97
145
  setRecordingTime(0);
98
146
  dragRef.current = 0;
99
- }, []);
147
+ }, [onSend]);
100
148
 
101
149
  // ── File handling ──
102
150
 
@@ -169,10 +217,25 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
169
217
  dragRef.current = 0;
170
218
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
171
219
 
172
- holdTimerRef.current = setTimeout(() => {
173
- isHolding.current = true;
174
- setIsRecording(true);
175
- setRecordingTime(0);
220
+ holdTimerRef.current = setTimeout(async () => {
221
+ if (!whisperEnabled) return;
222
+ try {
223
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
224
+ streamRef.current = stream;
225
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
226
+ const recorder = new MediaRecorder(stream, { mimeType });
227
+ audioChunksRef.current = [];
228
+ recorder.ondataavailable = (e) => {
229
+ if (e.data.size > 0) audioChunksRef.current.push(e.data);
230
+ };
231
+ mediaRecorderRef.current = recorder;
232
+ recorder.start();
233
+ isHolding.current = true;
234
+ setIsRecording(true);
235
+ setRecordingTime(0);
236
+ } catch {
237
+ // Mic permission denied or not available
238
+ }
176
239
  }, 200);
177
240
  }, []);
178
241
 
@@ -372,6 +435,7 @@ export default function InputBar({ onSend, onStop, streaming }: Props) {
372
435
  </motion.div>
373
436
  )}
374
437
  </AnimatePresence>
438
+
375
439
  </div>
376
440
  );
377
441
  }
@@ -3,12 +3,17 @@ import remarkGfm from 'remark-gfm';
3
3
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
4
4
  import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
5
5
  import { Paperclip } from 'lucide-react';
6
+ import AudioBubble from './AudioBubble';
7
+ import type { StoredAttachment } from '../../hooks/useChat';
6
8
 
7
9
  interface Props {
8
10
  role: 'user' | 'assistant';
9
11
  content: string;
10
12
  timestamp?: string;
11
13
  hasAttachments?: boolean;
14
+ audioData?: string;
15
+ attachments?: StoredAttachment[];
16
+ onImageClick?: (images: string[], index: number) => void;
12
17
  }
13
18
 
14
19
  function formatTime(iso: string): string {
@@ -20,15 +25,58 @@ function formatTime(iso: string): string {
20
25
  }
21
26
  }
22
27
 
23
- export default function MessageBubble({ role, content, timestamp, hasAttachments }: Props) {
28
+ export default function MessageBubble({ role, content, timestamp, hasAttachments, audioData, attachments, onImageClick }: Props) {
24
29
  const isUser = role === 'user';
25
30
  const time = timestamp ? formatTime(timestamp) : '';
26
31
 
32
+ // Separate image and document attachments
33
+ const imageAtts = attachments?.filter((a) => a.mediaType?.startsWith('image/')) || [];
34
+ const docAtts = attachments?.filter((a) => !a.mediaType?.startsWith('image/')) || [];
35
+
36
+ // Resolve image URLs
37
+ const imageUrls = imageAtts.map((a) =>
38
+ a.filePath.startsWith('data:') ? a.filePath : `/api/files/${a.filePath}`
39
+ );
40
+
27
41
  if (isUser) {
42
+ // Voice message: audio-only, no transcript text
43
+ if (audioData) {
44
+ return (
45
+ <div className="flex flex-col items-end gap-0.5">
46
+ <div className="max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed bg-primary text-primary-foreground">
47
+ <AudioBubble audioData={audioData} />
48
+ </div>
49
+ {time && <span className="text-[10px] text-muted-foreground/50 px-1">{time}</span>}
50
+ </div>
51
+ );
52
+ }
53
+
28
54
  return (
29
55
  <div className="flex flex-col items-end gap-0.5">
30
56
  <div className="max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground break-words overflow-hidden" style={{ overflowWrap: 'anywhere' }}>
31
- {hasAttachments && (
57
+ {/* Image thumbnails */}
58
+ {imageUrls.length > 0 && (
59
+ <div className="flex gap-1.5 flex-wrap mb-2">
60
+ {imageUrls.map((url, i) => (
61
+ <img
62
+ key={i}
63
+ src={url}
64
+ alt={imageAtts[i]?.name || 'attachment'}
65
+ className="w-28 h-28 rounded-lg object-cover cursor-pointer border border-white/10 hover:opacity-80 transition-opacity"
66
+ onClick={() => onImageClick?.(imageUrls, i)}
67
+ />
68
+ ))}
69
+ </div>
70
+ )}
71
+ {/* Document attachments */}
72
+ {docAtts.length > 0 && (
73
+ <span className="inline-flex items-center gap-1 text-primary-foreground/60 mr-1.5">
74
+ <Paperclip className="h-3 w-3" />
75
+ {docAtts.length > 1 && <span className="text-xs">{docAtts.length}</span>}
76
+ </span>
77
+ )}
78
+ {/* Fallback paperclip for legacy messages with no parsed attachments */}
79
+ {!attachments?.length && hasAttachments && (
32
80
  <span className="inline-flex items-center gap-1 text-primary-foreground/60 mr-1.5">
33
81
  <Paperclip className="h-3 w-3" />
34
82
  </span>
@@ -1,7 +1,8 @@
1
- import { useEffect, useRef } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { ChatMessage, ToolActivity } from '../../hooks/useChat';
3
3
  import MessageBubble from './MessageBubble';
4
4
  import TypingIndicator from './TypingIndicator';
5
+ import ImageLightbox from './ImageLightbox';
5
6
 
6
7
  interface Props {
7
8
  messages: ChatMessage[];
@@ -12,6 +13,11 @@ interface Props {
12
13
 
13
14
  export default function MessageList({ messages, streaming, streamBuffer, tools }: Props) {
14
15
  const bottomRef = useRef<HTMLDivElement>(null);
16
+ const [lightbox, setLightbox] = useState<{ images: string[]; index: number } | null>(null);
17
+
18
+ const handleImageClick = useCallback((images: string[], index: number) => {
19
+ setLightbox({ images, index });
20
+ }, []);
15
21
 
16
22
  useEffect(() => {
17
23
  bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -40,6 +46,9 @@ export default function MessageList({ messages, streaming, streamBuffer, tools }
40
46
  content={msg.content}
41
47
  timestamp={msg.timestamp}
42
48
  hasAttachments={msg.hasAttachments}
49
+ audioData={msg.audioData}
50
+ attachments={msg.attachments}
51
+ onImageClick={handleImageClick}
43
52
  />
44
53
  ))}
45
54
 
@@ -49,6 +58,15 @@ export default function MessageList({ messages, streaming, streamBuffer, tools }
49
58
  )}
50
59
  </div>
51
60
  <div ref={bottomRef} />
61
+
62
+ {lightbox && (
63
+ <ImageLightbox
64
+ images={lightbox.images}
65
+ index={lightbox.index}
66
+ onClose={() => setLightbox(null)}
67
+ onNavigate={(i) => setLightbox((prev) => prev ? { ...prev, index: i } : null)}
68
+ />
69
+ )}
52
70
  </div>
53
71
  );
54
72
  }
@@ -14,10 +14,14 @@ export default function FluxyFab({ onClick }: Props) {
14
14
  role="button"
15
15
  aria-label="Open Fluxy chat"
16
16
  >
17
- <img
18
- src="/fluxy.png"
19
- alt="Fluxy"
20
- className="h-11 w-auto drop-shadow-lg"
17
+ <video
18
+ src="/fluxy_tilts.webm"
19
+ poster="/fluxy_frame1.png"
20
+ autoPlay
21
+ loop
22
+ muted
23
+ playsInline
24
+ className="h-11 w-auto drop-shadow-lg pointer-events-none"
21
25
  draggable={false}
22
26
  />
23
27
  </motion.div>
@@ -1,12 +1,21 @@
1
1
  import { useCallback, useEffect, useState, useRef } from 'react';
2
2
  import type { WsClient } from '../lib/ws-client';
3
3
 
4
+ export interface StoredAttachment {
5
+ type: string;
6
+ name: string;
7
+ mediaType: string;
8
+ filePath: string;
9
+ }
10
+
4
11
  export interface ChatMessage {
5
12
  id: string;
6
13
  role: 'user' | 'assistant';
7
14
  content: string;
8
15
  timestamp: string; // ISO string
9
16
  hasAttachments?: boolean;
17
+ audioData?: string; // data URL or HTTP URL for voice messages
18
+ attachments?: StoredAttachment[];
10
19
  }
11
20
 
12
21
  export interface ToolActivity {
@@ -56,12 +65,37 @@ export function useChat(ws: WsClient | null) {
56
65
  setMessages(
57
66
  data.messages
58
67
  .filter((m: any) => m.role === 'user' || m.role === 'assistant')
59
- .map((m: any) => ({
60
- id: m.id,
61
- role: m.role,
62
- content: m.content,
63
- timestamp: m.created_at,
64
- })),
68
+ .map((m: any) => {
69
+ // Backward compat for audio_data: file path → URL, data: prefix → legacy, else → prepend data URL
70
+ let audioData: string | undefined;
71
+ if (m.audio_data) {
72
+ if (m.audio_data.startsWith('data:')) {
73
+ audioData = m.audio_data; // legacy data URL
74
+ } else if (m.audio_data.includes('/')) {
75
+ audioData = `/api/files/${m.audio_data}`; // file path → HTTP URL
76
+ } else {
77
+ audioData = `data:audio/webm;base64,${m.audio_data}`; // raw base64
78
+ }
79
+ }
80
+
81
+ // Parse stored attachments
82
+ let attachments: StoredAttachment[] | undefined;
83
+ if (m.attachments) {
84
+ try {
85
+ attachments = JSON.parse(m.attachments);
86
+ } catch { /* ignore malformed */ }
87
+ }
88
+
89
+ return {
90
+ id: m.id,
91
+ role: m.role,
92
+ content: m.content,
93
+ timestamp: m.created_at,
94
+ audioData,
95
+ hasAttachments: !!(attachments && attachments.length > 0),
96
+ attachments,
97
+ };
98
+ }),
65
99
  );
66
100
  }
67
101
  })
@@ -138,20 +172,32 @@ export function useChat(ws: WsClient | null) {
138
172
  }, [ws]);
139
173
 
140
174
  const sendMessage = useCallback(
141
- (content: string, attachments?: Attachment[]) => {
175
+ (content: string, attachments?: Attachment[], audioData?: string) => {
142
176
  if (!ws || (!content.trim() && (!attachments || attachments.length === 0))) return;
143
177
 
178
+ // Build optimistic stored attachments from client previews
179
+ const optimisticAttachments: StoredAttachment[] | undefined = attachments?.map((att) => {
180
+ const match = att.preview.match(/^data:([^;]+);/);
181
+ return { type: att.type, name: att.name, mediaType: match?.[1] || 'application/octet-stream', filePath: att.preview };
182
+ });
183
+
144
184
  const userMsg: ChatMessage = {
145
185
  id: Date.now().toString(),
146
186
  role: 'user',
147
187
  content,
148
188
  timestamp: new Date().toISOString(),
149
189
  hasAttachments: !!(attachments && attachments.length > 0),
190
+ audioData: audioData ? (audioData.startsWith('data:') ? audioData : `data:audio/webm;base64,${audioData}`) : undefined,
191
+ attachments: optimisticAttachments,
150
192
  };
151
193
  setMessages((msgs) => [...msgs, userMsg]);
152
194
 
153
195
  // Build WS payload
154
196
  const payload: any = { conversationId, content };
197
+ if (audioData) {
198
+ // Send raw base64 (strip data URL prefix)
199
+ payload.audioData = audioData.includes(',') ? audioData.split(',')[1] : audioData;
200
+ }
155
201
  if (attachments?.length) {
156
202
  payload.attachments = attachments.map((att) => {
157
203
  const match = att.preview.match(/^data:([^;]+);base64,(.+)$/);