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.
- package/client/public/fluxy_frame1.png +0 -0
- package/client/public/fluxy_tilts.webm +0 -0
- package/client/src/App.tsx +8 -3
- package/client/src/components/Chat/AudioBubble.tsx +117 -0
- package/client/src/components/Chat/ChatView.tsx +3 -2
- package/client/src/components/Chat/ImageLightbox.tsx +86 -0
- package/client/src/components/Chat/InputBar.tsx +73 -9
- package/client/src/components/Chat/MessageBubble.tsx +50 -2
- package/client/src/components/Chat/MessageList.tsx +19 -1
- package/client/src/components/FluxyFab.tsx +8 -4
- package/client/src/hooks/useChat.ts +53 -7
- package/dist/assets/index-3x-dxlA1.css +1 -0
- package/dist/assets/{index-BcCYJhOy.js → index-CKCTHjT9.js} +43 -43
- package/dist/fluxy_frame1.png +0 -0
- package/dist/fluxy_tilts.webm +0 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/shared/paths.ts +4 -0
- package/worker/db.ts +15 -3
- package/worker/file-storage.ts +25 -0
- package/worker/index.ts +114 -2
- package/dist/assets/index-BXXNdXo3.css +0 -1
|
Binary file
|
|
Binary file
|
package/client/src/App.tsx
CHANGED
|
@@ -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) => {
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
18
|
-
src="/
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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,(.+)$/);
|