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.
- package/README.md +53 -84
- package/bin/cli.js +1717 -6
- package/components.json +18 -18
- package/package.json +98 -109
- package/postcss.config.js +3 -3
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/postinstall.js +47 -58
- package/shared/ai.ts +118 -202
- package/shared/config.ts +37 -39
- package/shared/logger.ts +6 -14
- package/shared/paths.ts +10 -16
- package/shared/relay.ts +89 -98
- package/supervisor/backend.ts +77 -89
- package/supervisor/chat/ARCHITECTURE.md +13 -14
- package/supervisor/chat/OnboardWizard.tsx +2194 -3419
- package/supervisor/chat/fluxy-main.tsx +495 -633
- package/supervisor/chat/fluxy.html +24 -36
- package/supervisor/chat/onboard-main.tsx +8 -8
- package/supervisor/chat/onboard.html +10 -13
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
- package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
- package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
- package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
- package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
- package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
- package/supervisor/chat/src/hooks/useChat.ts +210 -259
- package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
- package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
- package/supervisor/chat/src/lib/auth.ts +20 -23
- package/supervisor/chat/src/lib/ws-client.ts +113 -125
- package/supervisor/chat/src/styles/globals.css +76 -85
- package/supervisor/file-saver.ts +39 -52
- package/supervisor/fluxy-agent.ts +213 -292
- package/supervisor/index.ts +895 -1263
- package/supervisor/scheduler.ts +237 -299
- package/supervisor/tunnel.ts +113 -167
- package/supervisor/vite-dev.ts +51 -61
- package/supervisor/widget.js +135 -147
- package/supervisor/worker.ts +45 -49
- package/tsconfig.json +18 -24
- package/vite.config.ts +48 -55
- package/vite.fluxy.config.ts +34 -34
- package/worker/claude-auth.ts +202 -265
- package/worker/codex-auth.ts +144 -181
- package/worker/db.ts +63 -191
- package/worker/index.ts +601 -842
- package/workspace/MYSELF.md +1 -1
- package/workspace/PULSE.json +6 -6
- package/workspace/backend/index.ts +16 -19
- package/workspace/client/index.html +47 -60
- package/workspace/client/public/manifest.json +21 -21
- package/workspace/client/public/sw.js +29 -31
- package/workspace/client/src/App.tsx +91 -136
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
- package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
- package/workspace/client/src/components/Layout/Footer.tsx +12 -12
- package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
- package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
- package/workspace/client/src/components/ui/avatar.tsx +85 -85
- package/workspace/client/src/components/ui/badge.tsx +40 -41
- package/workspace/client/src/components/ui/button.tsx +56 -57
- package/workspace/client/src/components/ui/card.tsx +75 -75
- package/workspace/client/src/components/ui/dialog.tsx +114 -117
- package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
- package/workspace/client/src/components/ui/input.tsx +17 -17
- package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
- package/workspace/client/src/components/ui/select.tsx +145 -151
- package/workspace/client/src/components/ui/separator.tsx +21 -21
- package/workspace/client/src/components/ui/sheet.tsx +102 -102
- package/workspace/client/src/components/ui/skeleton.tsx +10 -10
- package/workspace/client/src/components/ui/switch.tsx +26 -26
- package/workspace/client/src/components/ui/tabs.tsx +69 -69
- package/workspace/client/src/components/ui/textarea.tsx +14 -14
- package/workspace/client/src/components/ui/tooltip.tsx +37 -37
- package/workspace/client/src/lib/utils.ts +1 -1
- package/workspace/client/src/main.tsx +3 -3
- package/workspace/client/src/styles/globals.css +76 -85
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
- package/bin/cli.backup.js +0 -2138
- package/cli/commands/daemon.ts +0 -42
- package/cli/commands/init.ts +0 -32
- package/cli/commands/start.ts +0 -113
- package/cli/commands/tunnel.temp.ts +0 -206
- package/cli/commands/tunnel.ts +0 -227
- package/cli/commands/update.ts +0 -163
- package/cli/core/base-adapter.ts +0 -156
- package/cli/core/cloudflared.ts +0 -113
- package/cli/core/config.ts +0 -73
- package/cli/core/os-detector.ts +0 -43
- package/cli/core/server.ts +0 -109
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -146
- package/cli/platforms/index.ts +0 -21
- package/cli/platforms/linux.ts +0 -156
- package/cli/platforms/win32.ts +0 -34
- package/cli/utils/ui.ts +0 -37
|
@@ -1,759 +1,529 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
useRef,
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
type PointerEvent as RPointerEvent,
|
|
7
|
-
type ChangeEvent
|
|
8
|
-
} from 'react';
|
|
9
|
-
import {
|
|
10
|
-
SendHorizontal,
|
|
11
|
-
Mic,
|
|
12
|
-
Square,
|
|
13
|
-
Trash2,
|
|
14
|
-
Paperclip,
|
|
15
|
-
Camera,
|
|
16
|
-
X
|
|
17
|
-
} from 'lucide-react';
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, type PointerEvent as RPointerEvent, type ChangeEvent } from 'react';
|
|
2
|
+
import { SendHorizontal, Mic, Square, Trash2, Paperclip, Camera, X } from 'lucide-react';
|
|
18
3
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
19
4
|
import type { Attachment } from '../../hooks/useChat';
|
|
20
5
|
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
|
21
6
|
|
|
22
7
|
interface Props {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
onStop: () => void;
|
|
29
|
-
streaming: boolean;
|
|
30
|
-
whisperEnabled?: boolean;
|
|
31
|
-
onTranscribe?: (audio: string) => Promise<{ transcript?: string }>;
|
|
8
|
+
onSend: (msg: string, attachments?: Attachment[], audioData?: string) => void;
|
|
9
|
+
onStop: () => void;
|
|
10
|
+
streaming: boolean;
|
|
11
|
+
whisperEnabled?: boolean;
|
|
12
|
+
onTranscribe?: (audio: string) => Promise<{ transcript?: string }>;
|
|
32
13
|
}
|
|
33
14
|
|
|
34
15
|
function formatTime(s: number) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
16
|
+
const mins = Math.floor(s / 60);
|
|
17
|
+
const secs = s % 60;
|
|
18
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
38
19
|
}
|
|
39
20
|
|
|
40
21
|
/** Compress an image to fit under maxBytes while staying visually clear for an LLM. */
|
|
41
|
-
function compressImage(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
// Fallback: lowest quality
|
|
76
|
-
resolve(canvas.toDataURL('image/jpeg', 0.2));
|
|
77
|
-
};
|
|
78
|
-
img.onerror = () => resolve(dataUrl); // pass through on error
|
|
79
|
-
img.src = dataUrl;
|
|
80
|
-
});
|
|
22
|
+
function compressImage(dataUrl: string, maxBytes = 4 * 1024 * 1024): Promise<string> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const img = new Image();
|
|
25
|
+
img.onload = () => {
|
|
26
|
+
const MAX_DIM = 1600;
|
|
27
|
+
let { width, height } = img;
|
|
28
|
+
|
|
29
|
+
// Scale down if larger than MAX_DIM on either axis
|
|
30
|
+
if (width > MAX_DIM || height > MAX_DIM) {
|
|
31
|
+
const ratio = Math.min(MAX_DIM / width, MAX_DIM / height);
|
|
32
|
+
width = Math.round(width * ratio);
|
|
33
|
+
height = Math.round(height * ratio);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const canvas = document.createElement('canvas');
|
|
37
|
+
canvas.width = width;
|
|
38
|
+
canvas.height = height;
|
|
39
|
+
const ctx = canvas.getContext('2d')!;
|
|
40
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
41
|
+
|
|
42
|
+
// Try quality levels until under maxBytes
|
|
43
|
+
for (const q of [0.8, 0.6, 0.4, 0.3]) {
|
|
44
|
+
const result = canvas.toDataURL('image/jpeg', q);
|
|
45
|
+
const size = Math.round((result.length - 'data:image/jpeg;base64,'.length) * 0.75);
|
|
46
|
+
if (size <= maxBytes) { resolve(result); return; }
|
|
47
|
+
}
|
|
48
|
+
// Fallback: lowest quality
|
|
49
|
+
resolve(canvas.toDataURL('image/jpeg', 0.2));
|
|
50
|
+
};
|
|
51
|
+
img.onerror = () => resolve(dataUrl); // pass through on error
|
|
52
|
+
img.src = dataUrl;
|
|
53
|
+
});
|
|
81
54
|
}
|
|
82
55
|
|
|
83
56
|
const DRAFT_KEY = 'fluxy_draft';
|
|
84
57
|
|
|
85
|
-
export default function InputBar({
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return () => {
|
|
178
|
-
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
179
|
-
};
|
|
180
|
-
}, [isRecording]);
|
|
181
|
-
|
|
182
|
-
const stopRecording = useCallback(
|
|
183
|
-
async (cancelled: boolean) => {
|
|
184
|
-
console.log(
|
|
185
|
-
'[InputBar] stopRecording called, cancelled:',
|
|
186
|
-
cancelled,
|
|
187
|
-
'recorder:',
|
|
188
|
-
!!mediaRecorderRef.current,
|
|
189
|
-
'whisper:',
|
|
190
|
-
whisperEnabled
|
|
191
|
-
);
|
|
192
|
-
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
193
|
-
if (holdTimerRef.current) {
|
|
194
|
-
clearTimeout(holdTimerRef.current);
|
|
195
|
-
holdTimerRef.current = null;
|
|
196
|
-
}
|
|
197
|
-
isHolding.current = false;
|
|
198
|
-
|
|
199
|
-
const recorder = mediaRecorderRef.current;
|
|
200
|
-
const stream = streamRef.current;
|
|
201
|
-
|
|
202
|
-
if (cancelled) {
|
|
203
|
-
stream?.getTracks().forEach(t => t.stop());
|
|
204
|
-
mediaRecorderRef.current = null;
|
|
205
|
-
streamRef.current = null;
|
|
206
|
-
audioChunksRef.current = [];
|
|
207
|
-
abortSpeech();
|
|
208
|
-
} else if (recorder && recorder.state !== 'inactive') {
|
|
209
|
-
// Whisper path: stop MediaRecorder and use its audio
|
|
210
|
-
recorder.onstop = async () => {
|
|
211
|
-
console.log(
|
|
212
|
-
'[InputBar] recorder.onstop fired, chunks:',
|
|
213
|
-
audioChunksRef.current.length
|
|
214
|
-
);
|
|
215
|
-
stream?.getTracks().forEach(t => t.stop());
|
|
216
|
-
const blob = new Blob(audioChunksRef.current, {
|
|
217
|
-
type: 'audio/webm'
|
|
218
|
-
});
|
|
219
|
-
audioChunksRef.current = [];
|
|
220
|
-
mediaRecorderRef.current = null;
|
|
221
|
-
streamRef.current = null;
|
|
222
|
-
|
|
223
|
-
console.log('[InputBar] blob size:', blob.size);
|
|
224
|
-
if (blob.size < 1000) return;
|
|
225
|
-
|
|
226
|
-
const fileReader = new FileReader();
|
|
227
|
-
fileReader.onloadend = async () => {
|
|
228
|
-
const dataUrl = fileReader.result as string;
|
|
229
|
-
const base64 = dataUrl.split(',')[1];
|
|
230
|
-
if (!base64) return;
|
|
231
|
-
|
|
232
|
-
console.log(
|
|
233
|
-
'[InputBar] Whisper path, base64 length:',
|
|
234
|
-
base64.length
|
|
235
|
-
);
|
|
236
|
-
try {
|
|
237
|
-
let data: { transcript?: string };
|
|
238
|
-
if (onTranscribe) {
|
|
239
|
-
data = await onTranscribe(base64);
|
|
240
|
-
} else {
|
|
241
|
-
const res = await fetch(
|
|
242
|
-
'/api/whisper/transcribe',
|
|
243
|
-
{
|
|
244
|
-
method: 'POST',
|
|
245
|
-
headers: {
|
|
246
|
-
'Content-Type': 'application/json'
|
|
247
|
-
},
|
|
248
|
-
body: JSON.stringify({ audio: base64 })
|
|
249
|
-
}
|
|
250
|
-
);
|
|
251
|
-
data = await res.json();
|
|
252
|
-
}
|
|
253
|
-
if (data.transcript?.trim()) {
|
|
254
|
-
const pendingAtts =
|
|
255
|
-
attachments.length > 0
|
|
256
|
-
? attachments
|
|
257
|
-
: undefined;
|
|
258
|
-
onSend(
|
|
259
|
-
data.transcript.trim(),
|
|
260
|
-
pendingAtts,
|
|
261
|
-
dataUrl
|
|
262
|
-
);
|
|
263
|
-
if (pendingAtts) setAttachments([]);
|
|
264
|
-
}
|
|
265
|
-
} catch (err) {
|
|
266
|
-
console.error(
|
|
267
|
-
'[InputBar] Whisper transcription error:',
|
|
268
|
-
err
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
fileReader.readAsDataURL(blob);
|
|
273
|
-
};
|
|
274
|
-
recorder.stop();
|
|
58
|
+
export default function InputBar({ onSend, onStop, streaming, whisperEnabled, onTranscribe }: Props) {
|
|
59
|
+
const { start: startSpeech, stop: stopSpeech, abort: abortSpeech, isSupported: webSpeechSupported } = useSpeechRecognition();
|
|
60
|
+
const voiceEnabled = whisperEnabled || webSpeechSupported;
|
|
61
|
+
console.log('[InputBar] render - whisperEnabled:', whisperEnabled, 'webSpeechSupported:', webSpeechSupported, 'voiceEnabled:', voiceEnabled);
|
|
62
|
+
const [text, setText] = useState(() => {
|
|
63
|
+
try { return localStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; }
|
|
64
|
+
});
|
|
65
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
66
|
+
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
67
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
68
|
+
const [recordingTime, setRecordingTime] = useState(0);
|
|
69
|
+
const hasText = text.trim().length > 0;
|
|
70
|
+
const hasContent = hasText || attachments.length > 0;
|
|
71
|
+
|
|
72
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
73
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
74
|
+
const cameraRef = useRef<HTMLInputElement>(null);
|
|
75
|
+
const trashRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const micRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
const startXRef = useRef(0);
|
|
78
|
+
const dragRef = useRef(0);
|
|
79
|
+
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
80
|
+
const isHolding = useRef(false);
|
|
81
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
82
|
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
83
|
+
const audioChunksRef = useRef<Blob[]>([]);
|
|
84
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
85
|
+
|
|
86
|
+
// Auto-resize textarea up to 4 lines, then scroll
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const el = textareaRef.current;
|
|
89
|
+
if (!el) return;
|
|
90
|
+
el.style.height = '0px';
|
|
91
|
+
// 4 lines ~ 5.5rem = 88px at text-sm (14px * 1.625 line-height * 4)
|
|
92
|
+
el.style.height = `${Math.min(el.scrollHeight, 88)}px`;
|
|
93
|
+
}, [text]);
|
|
94
|
+
|
|
95
|
+
// Debounced draft save to localStorage
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (draftTimerRef.current) clearTimeout(draftTimerRef.current);
|
|
98
|
+
draftTimerRef.current = setTimeout(() => {
|
|
99
|
+
try { localStorage.setItem(DRAFT_KEY, text); } catch {}
|
|
100
|
+
}, 500);
|
|
101
|
+
return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current); };
|
|
102
|
+
}, [text]);
|
|
103
|
+
|
|
104
|
+
// Recording timer
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!isRecording) return;
|
|
107
|
+
intervalRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000);
|
|
108
|
+
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
|
109
|
+
}, [isRecording]);
|
|
110
|
+
|
|
111
|
+
const stopRecording = useCallback(async (cancelled: boolean) => {
|
|
112
|
+
console.log('[InputBar] stopRecording called, cancelled:', cancelled, 'recorder:', !!mediaRecorderRef.current, 'whisper:', whisperEnabled);
|
|
113
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
114
|
+
if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
|
|
115
|
+
isHolding.current = false;
|
|
116
|
+
|
|
117
|
+
const recorder = mediaRecorderRef.current;
|
|
118
|
+
const stream = streamRef.current;
|
|
119
|
+
|
|
120
|
+
if (cancelled) {
|
|
121
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
122
|
+
mediaRecorderRef.current = null;
|
|
123
|
+
streamRef.current = null;
|
|
124
|
+
audioChunksRef.current = [];
|
|
125
|
+
abortSpeech();
|
|
126
|
+
} else if (recorder && recorder.state !== 'inactive') {
|
|
127
|
+
// Whisper path: stop MediaRecorder and use its audio
|
|
128
|
+
recorder.onstop = async () => {
|
|
129
|
+
console.log('[InputBar] recorder.onstop fired, chunks:', audioChunksRef.current.length);
|
|
130
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
131
|
+
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
|
132
|
+
audioChunksRef.current = [];
|
|
133
|
+
mediaRecorderRef.current = null;
|
|
134
|
+
streamRef.current = null;
|
|
135
|
+
|
|
136
|
+
console.log('[InputBar] blob size:', blob.size);
|
|
137
|
+
if (blob.size < 1000) return;
|
|
138
|
+
|
|
139
|
+
const fileReader = new FileReader();
|
|
140
|
+
fileReader.onloadend = async () => {
|
|
141
|
+
const dataUrl = fileReader.result as string;
|
|
142
|
+
const base64 = dataUrl.split(',')[1];
|
|
143
|
+
if (!base64) return;
|
|
144
|
+
|
|
145
|
+
console.log('[InputBar] Whisper path, base64 length:', base64.length);
|
|
146
|
+
try {
|
|
147
|
+
let data: { transcript?: string };
|
|
148
|
+
if (onTranscribe) {
|
|
149
|
+
data = await onTranscribe(base64);
|
|
275
150
|
} else {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
streamRef.current = null;
|
|
283
|
-
audioChunksRef.current = [];
|
|
284
|
-
|
|
285
|
-
try {
|
|
286
|
-
const transcript = await stopSpeech();
|
|
287
|
-
console.log(
|
|
288
|
-
'[InputBar] Web Speech transcript:',
|
|
289
|
-
JSON.stringify(transcript)
|
|
290
|
-
);
|
|
291
|
-
if (transcript.trim()) {
|
|
292
|
-
const pendingAtts =
|
|
293
|
-
attachments.length > 0 ? attachments : undefined;
|
|
294
|
-
onSend(transcript.trim(), pendingAtts);
|
|
295
|
-
if (pendingAtts) setAttachments([]);
|
|
296
|
-
} else {
|
|
297
|
-
console.log(
|
|
298
|
-
'[InputBar] Web Speech transcript was empty'
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
} catch (err) {
|
|
302
|
-
console.error('[InputBar] Web Speech stop error:', err);
|
|
303
|
-
}
|
|
151
|
+
const res = await fetch('/api/whisper/transcribe', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ audio: base64 }),
|
|
155
|
+
});
|
|
156
|
+
data = await res.json();
|
|
304
157
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
dragRef.current = 0;
|
|
310
|
-
},
|
|
311
|
-
[
|
|
312
|
-
onSend,
|
|
313
|
-
onTranscribe,
|
|
314
|
-
attachments,
|
|
315
|
-
whisperEnabled,
|
|
316
|
-
abortSpeech,
|
|
317
|
-
stopSpeech
|
|
318
|
-
]
|
|
319
|
-
);
|
|
320
|
-
|
|
321
|
-
// ── File handling ──
|
|
322
|
-
|
|
323
|
-
const addFile = useCallback((file: File) => {
|
|
324
|
-
const isImage = file.type.startsWith('image/');
|
|
325
|
-
const isPdf = file.type === 'application/pdf';
|
|
326
|
-
if (!isImage && !isPdf) return;
|
|
327
|
-
|
|
328
|
-
const reader = new FileReader();
|
|
329
|
-
reader.onload = async e => {
|
|
330
|
-
let preview = e.target?.result as string;
|
|
331
|
-
|
|
332
|
-
// Compress images to stay under API limits
|
|
333
|
-
if (isImage) {
|
|
334
|
-
preview = await compressImage(preview);
|
|
158
|
+
if (data.transcript?.trim()) {
|
|
159
|
+
const pendingAtts = attachments.length > 0 ? attachments : undefined;
|
|
160
|
+
onSend(data.transcript.trim(), pendingAtts, dataUrl);
|
|
161
|
+
if (pendingAtts) setAttachments([]);
|
|
335
162
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
{
|
|
340
|
-
id: Math.random().toString(36).slice(2),
|
|
341
|
-
type: isImage ? 'image' : 'file',
|
|
342
|
-
name: file.name,
|
|
343
|
-
preview
|
|
344
|
-
}
|
|
345
|
-
]);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error('[InputBar] Whisper transcription error:', err);
|
|
165
|
+
}
|
|
346
166
|
};
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
167
|
+
fileReader.readAsDataURL(blob);
|
|
168
|
+
};
|
|
169
|
+
recorder.stop();
|
|
170
|
+
} else {
|
|
171
|
+
// Web Speech API path (no MediaRecorder): get transcript directly
|
|
172
|
+
console.log('[InputBar] Web Speech path, stopping speech recognition...');
|
|
173
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
174
|
+
mediaRecorderRef.current = null;
|
|
175
|
+
streamRef.current = null;
|
|
176
|
+
audioChunksRef.current = [];
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const transcript = await stopSpeech();
|
|
180
|
+
console.log('[InputBar] Web Speech transcript:', JSON.stringify(transcript));
|
|
181
|
+
if (transcript.trim()) {
|
|
182
|
+
const pendingAtts = attachments.length > 0 ? attachments : undefined;
|
|
183
|
+
onSend(transcript.trim(), pendingAtts);
|
|
184
|
+
if (pendingAtts) setAttachments([]);
|
|
185
|
+
} else {
|
|
186
|
+
console.log('[InputBar] Web Speech transcript was empty');
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error('[InputBar] Web Speech stop error:', err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (micRef.current) micRef.current.style.transform = '';
|
|
194
|
+
setIsRecording(false);
|
|
195
|
+
setRecordingTime(0);
|
|
196
|
+
dragRef.current = 0;
|
|
197
|
+
}, [onSend, onTranscribe, attachments, whisperEnabled, abortSpeech, stopSpeech]);
|
|
198
|
+
|
|
199
|
+
// ── File handling ──
|
|
200
|
+
|
|
201
|
+
const addFile = useCallback((file: File) => {
|
|
202
|
+
const isImage = file.type.startsWith('image/');
|
|
203
|
+
const isPdf = file.type === 'application/pdf';
|
|
204
|
+
if (!isImage && !isPdf) return;
|
|
205
|
+
|
|
206
|
+
const reader = new FileReader();
|
|
207
|
+
reader.onload = async (e) => {
|
|
208
|
+
let preview = e.target?.result as string;
|
|
209
|
+
|
|
210
|
+
// Compress images to stay under API limits
|
|
211
|
+
if (isImage) {
|
|
212
|
+
preview = await compressImage(preview);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
setAttachments((prev) => [
|
|
216
|
+
...prev,
|
|
217
|
+
{
|
|
218
|
+
id: Math.random().toString(36).slice(2),
|
|
219
|
+
type: isImage ? 'image' : 'file',
|
|
220
|
+
name: file.name,
|
|
221
|
+
preview,
|
|
356
222
|
},
|
|
357
|
-
|
|
358
|
-
);
|
|
359
|
-
|
|
360
|
-
const removeAttachment = useCallback((id: string) => {
|
|
361
|
-
setAttachments(prev => prev.filter(a => a.id !== id));
|
|
362
|
-
}, []);
|
|
363
|
-
|
|
364
|
-
// Handle paste for images
|
|
365
|
-
useEffect(() => {
|
|
366
|
-
const handlePaste = (e: ClipboardEvent) => {
|
|
367
|
-
if (!e.clipboardData?.items) return;
|
|
368
|
-
for (const item of e.clipboardData.items) {
|
|
369
|
-
if (item.type.startsWith('image/')) {
|
|
370
|
-
e.preventDefault();
|
|
371
|
-
const file = item.getAsFile();
|
|
372
|
-
if (file) addFile(file);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
};
|
|
376
|
-
document.addEventListener('paste', handlePaste);
|
|
377
|
-
return () => document.removeEventListener('paste', handlePaste);
|
|
378
|
-
}, [addFile]);
|
|
379
|
-
|
|
380
|
-
const handleSend = () => {
|
|
381
|
-
if (!hasContent) return;
|
|
382
|
-
onSend(text, attachments.length > 0 ? attachments : undefined);
|
|
383
|
-
setText('');
|
|
384
|
-
setAttachments([]);
|
|
385
|
-
try {
|
|
386
|
-
localStorage.removeItem(DRAFT_KEY);
|
|
387
|
-
} catch {}
|
|
388
|
-
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
223
|
+
]);
|
|
389
224
|
};
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
console.log('[InputBar] voiceEnabled is false, returning');
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
try {
|
|
417
|
-
if (whisperEnabled) {
|
|
418
|
-
// Whisper path: need getUserMedia + MediaRecorder for audio capture
|
|
419
|
-
console.log(
|
|
420
|
-
'[InputBar] Whisper path: requesting getUserMedia...'
|
|
421
|
-
);
|
|
422
|
-
const stream =
|
|
423
|
-
await navigator.mediaDevices.getUserMedia({
|
|
424
|
-
audio: true
|
|
425
|
-
});
|
|
426
|
-
console.log(
|
|
427
|
-
'[InputBar] getUserMedia succeeded, tracks:',
|
|
428
|
-
stream.getTracks().length
|
|
429
|
-
);
|
|
430
|
-
streamRef.current = stream;
|
|
431
|
-
const mimeType = MediaRecorder.isTypeSupported(
|
|
432
|
-
'audio/webm;codecs=opus'
|
|
433
|
-
)
|
|
434
|
-
? 'audio/webm;codecs=opus'
|
|
435
|
-
: 'audio/webm';
|
|
436
|
-
const recorder = new MediaRecorder(stream, {
|
|
437
|
-
mimeType
|
|
438
|
-
});
|
|
439
|
-
audioChunksRef.current = [];
|
|
440
|
-
recorder.ondataavailable = ev => {
|
|
441
|
-
if (ev.data.size > 0)
|
|
442
|
-
audioChunksRef.current.push(ev.data);
|
|
443
|
-
};
|
|
444
|
-
mediaRecorderRef.current = recorder;
|
|
445
|
-
recorder.start();
|
|
446
|
-
console.log('[InputBar] MediaRecorder started');
|
|
447
|
-
} else {
|
|
448
|
-
// Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
|
|
449
|
-
console.log(
|
|
450
|
-
'[InputBar] Web Speech path: starting SpeechRecognition only...'
|
|
451
|
-
);
|
|
452
|
-
startSpeech();
|
|
453
|
-
console.log('[InputBar] SpeechRecognition started');
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
isHolding.current = true;
|
|
457
|
-
setIsRecording(true);
|
|
458
|
-
setRecordingTime(0);
|
|
459
|
-
console.log('[InputBar] recording started, isHolding=true');
|
|
460
|
-
} catch (err) {
|
|
461
|
-
console.error('[InputBar] recording setup failed:', err);
|
|
462
|
-
}
|
|
463
|
-
}, 200);
|
|
464
|
-
},
|
|
465
|
-
[voiceEnabled, whisperEnabled, startSpeech]
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
const handleMicMove = useCallback(
|
|
469
|
-
(e: RPointerEvent) => {
|
|
470
|
-
if (!isHolding.current) return;
|
|
471
|
-
const dx = Math.min(0, e.clientX - startXRef.current);
|
|
472
|
-
dragRef.current = dx;
|
|
473
|
-
if (micRef.current)
|
|
474
|
-
micRef.current.style.transform = `translateX(${dx}px)`;
|
|
475
|
-
|
|
476
|
-
if (trashRef.current) {
|
|
477
|
-
const trashRect = trashRef.current.getBoundingClientRect();
|
|
478
|
-
const trashCenterX = trashRect.left + trashRect.width / 2;
|
|
479
|
-
if (Math.abs(e.clientX - trashCenterX) < 36) {
|
|
480
|
-
stopRecording(true);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
},
|
|
484
|
-
[stopRecording]
|
|
485
|
-
);
|
|
486
|
-
|
|
487
|
-
const handleMicUp = useCallback(() => {
|
|
488
|
-
console.log('[InputBar] handleMicUp, isHolding:', isHolding.current);
|
|
489
|
-
if (holdTimerRef.current) {
|
|
490
|
-
clearTimeout(holdTimerRef.current);
|
|
491
|
-
holdTimerRef.current = null;
|
|
492
|
-
}
|
|
493
|
-
if (!isHolding.current) {
|
|
494
|
-
console.log('[InputBar] handleMicUp - not holding, ignoring');
|
|
495
|
-
return;
|
|
225
|
+
reader.readAsDataURL(file);
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
229
|
+
const files = e.target.files;
|
|
230
|
+
if (!files) return;
|
|
231
|
+
for (let i = 0; i < files.length; i++) addFile(files[i]);
|
|
232
|
+
e.target.value = '';
|
|
233
|
+
}, [addFile]);
|
|
234
|
+
|
|
235
|
+
const removeAttachment = useCallback((id: string) => {
|
|
236
|
+
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
// Handle paste for images
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const handlePaste = (e: ClipboardEvent) => {
|
|
242
|
+
if (!e.clipboardData?.items) return;
|
|
243
|
+
for (const item of e.clipboardData.items) {
|
|
244
|
+
if (item.type.startsWith('image/')) {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
const file = item.getAsFile();
|
|
247
|
+
if (file) addFile(file);
|
|
496
248
|
}
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
document.addEventListener('paste', handlePaste);
|
|
252
|
+
return () => document.removeEventListener('paste', handlePaste);
|
|
253
|
+
}, [addFile]);
|
|
254
|
+
|
|
255
|
+
const handleSend = () => {
|
|
256
|
+
if (!hasContent) return;
|
|
257
|
+
onSend(text, attachments.length > 0 ? attachments : undefined);
|
|
258
|
+
setText('');
|
|
259
|
+
setAttachments([]);
|
|
260
|
+
try { localStorage.removeItem(DRAFT_KEY); } catch {}
|
|
261
|
+
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// ── Mic pointer handlers ──
|
|
265
|
+
const handleMicDown = useCallback((e: RPointerEvent) => {
|
|
266
|
+
console.log('[InputBar] handleMicDown fired, voiceEnabled:', voiceEnabled, 'whisper:', whisperEnabled);
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
startXRef.current = e.clientX;
|
|
269
|
+
dragRef.current = 0;
|
|
270
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
271
|
+
|
|
272
|
+
holdTimerRef.current = setTimeout(async () => {
|
|
273
|
+
console.log('[InputBar] hold timer fired, voiceEnabled:', voiceEnabled, 'whisper:', whisperEnabled);
|
|
274
|
+
if (!voiceEnabled) {
|
|
275
|
+
console.log('[InputBar] voiceEnabled is false, returning');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
if (whisperEnabled) {
|
|
280
|
+
// Whisper path: need getUserMedia + MediaRecorder for audio capture
|
|
281
|
+
console.log('[InputBar] Whisper path: requesting getUserMedia...');
|
|
282
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
283
|
+
console.log('[InputBar] getUserMedia succeeded, tracks:', stream.getTracks().length);
|
|
284
|
+
streamRef.current = stream;
|
|
285
|
+
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
286
|
+
const recorder = new MediaRecorder(stream, { mimeType });
|
|
287
|
+
audioChunksRef.current = [];
|
|
288
|
+
recorder.ondataavailable = (ev) => {
|
|
289
|
+
if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
|
|
290
|
+
};
|
|
291
|
+
mediaRecorderRef.current = recorder;
|
|
292
|
+
recorder.start();
|
|
293
|
+
console.log('[InputBar] MediaRecorder started');
|
|
294
|
+
} else {
|
|
295
|
+
// Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
|
|
296
|
+
console.log('[InputBar] Web Speech path: starting SpeechRecognition only...');
|
|
297
|
+
startSpeech();
|
|
298
|
+
console.log('[InputBar] SpeechRecognition started');
|
|
510
299
|
}
|
|
511
|
-
if (isHolding.current) stopRecording(true);
|
|
512
|
-
}, [stopRecording]);
|
|
513
300
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
301
|
+
isHolding.current = true;
|
|
302
|
+
setIsRecording(true);
|
|
303
|
+
setRecordingTime(0);
|
|
304
|
+
console.log('[InputBar] recording started, isHolding=true');
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.error('[InputBar] recording setup failed:', err);
|
|
307
|
+
}
|
|
308
|
+
}, 200);
|
|
309
|
+
}, [voiceEnabled, whisperEnabled, startSpeech]);
|
|
310
|
+
|
|
311
|
+
const handleMicMove = useCallback((e: RPointerEvent) => {
|
|
312
|
+
if (!isHolding.current) return;
|
|
313
|
+
const dx = Math.min(0, e.clientX - startXRef.current);
|
|
314
|
+
dragRef.current = dx;
|
|
315
|
+
if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
|
|
316
|
+
|
|
317
|
+
if (trashRef.current) {
|
|
318
|
+
const trashRect = trashRef.current.getBoundingClientRect();
|
|
319
|
+
const trashCenterX = trashRect.left + trashRect.width / 2;
|
|
320
|
+
if (Math.abs(e.clientX - trashCenterX) < 36) {
|
|
321
|
+
stopRecording(true);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}, [stopRecording]);
|
|
325
|
+
|
|
326
|
+
const handleMicUp = useCallback(() => {
|
|
327
|
+
console.log('[InputBar] handleMicUp, isHolding:', isHolding.current);
|
|
328
|
+
if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
|
|
329
|
+
if (!isHolding.current) {
|
|
330
|
+
console.log('[InputBar] handleMicUp - not holding, ignoring');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
stopRecording(false);
|
|
334
|
+
}, [stopRecording]);
|
|
335
|
+
|
|
336
|
+
const handleMicCancel = useCallback(() => {
|
|
337
|
+
console.log('[InputBar] handleMicCancel fired, isHolding:', isHolding.current, 'holdTimer:', !!holdTimerRef.current);
|
|
338
|
+
if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
|
|
339
|
+
if (isHolding.current) stopRecording(true);
|
|
340
|
+
}, [stopRecording]);
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<div className="shrink-0 p-3 relative">
|
|
344
|
+
{/* ── Normal input (always mounted to keep keyboard alive) ── */}
|
|
345
|
+
<div
|
|
346
|
+
className={`flex flex-col transition-opacity duration-100 ${
|
|
347
|
+
isRecording ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
|
348
|
+
}`}
|
|
349
|
+
>
|
|
350
|
+
{/* ── Attachment previews ── */}
|
|
351
|
+
<AnimatePresence>
|
|
352
|
+
{attachments.length > 0 && (
|
|
353
|
+
<motion.div
|
|
354
|
+
initial={{ height: 0, opacity: 0 }}
|
|
355
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
356
|
+
exit={{ height: 0, opacity: 0 }}
|
|
357
|
+
transition={{ duration: 0.15 }}
|
|
358
|
+
className="overflow-hidden"
|
|
523
359
|
>
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
>
|
|
534
|
-
<div className="flex gap-2 px-3 pt-2.5 pb-2 flex-wrap bg-white/[0.04] border border-white/[0.08] rounded-2xl mb-2">
|
|
535
|
-
{attachments.map(att => (
|
|
536
|
-
<div
|
|
537
|
-
key={att.id}
|
|
538
|
-
className="relative group flex-shrink-0"
|
|
539
|
-
>
|
|
540
|
-
{att.type === 'image' ? (
|
|
541
|
-
<img
|
|
542
|
-
src={att.preview}
|
|
543
|
-
alt={att.name}
|
|
544
|
-
className="w-14 h-14 rounded-lg object-cover border border-white/10"
|
|
545
|
-
/>
|
|
546
|
-
) : (
|
|
547
|
-
<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">
|
|
548
|
-
<svg
|
|
549
|
-
className="w-5 h-5 text-muted-foreground"
|
|
550
|
-
fill="none"
|
|
551
|
-
viewBox="0 0 24 24"
|
|
552
|
-
stroke="currentColor"
|
|
553
|
-
strokeWidth={1.5}
|
|
554
|
-
>
|
|
555
|
-
<path
|
|
556
|
-
strokeLinecap="round"
|
|
557
|
-
strokeLinejoin="round"
|
|
558
|
-
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"
|
|
559
|
-
/>
|
|
560
|
-
</svg>
|
|
561
|
-
<span className="text-[8px] text-muted-foreground truncate w-full text-center">
|
|
562
|
-
{att.name.split('.').pop()}
|
|
563
|
-
</span>
|
|
564
|
-
</div>
|
|
565
|
-
)}
|
|
566
|
-
<button
|
|
567
|
-
onClick={() =>
|
|
568
|
-
removeAttachment(att.id)
|
|
569
|
-
}
|
|
570
|
-
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"
|
|
571
|
-
>
|
|
572
|
-
<X className="w-2.5 h-2.5 text-white" />
|
|
573
|
-
</button>
|
|
574
|
-
</div>
|
|
575
|
-
))}
|
|
576
|
-
</div>
|
|
577
|
-
</motion.div>
|
|
578
|
-
)}
|
|
579
|
-
</AnimatePresence>
|
|
580
|
-
|
|
581
|
-
<div className="flex items-end gap-2">
|
|
582
|
-
<div className="flex-1 flex items-end bg-white rounded-2xl px-4">
|
|
583
|
-
<textarea
|
|
584
|
-
ref={textareaRef}
|
|
585
|
-
value={text}
|
|
586
|
-
onChange={e => setText(e.target.value)}
|
|
587
|
-
onKeyDown={e => {
|
|
588
|
-
// Desktop: Enter sends, Shift+Enter inserts newline
|
|
589
|
-
// Mobile: Enter always inserts newline (no hardware keyboard)
|
|
590
|
-
const isMobile =
|
|
591
|
-
'ontouchstart' in window ||
|
|
592
|
-
navigator.maxTouchPoints > 0;
|
|
593
|
-
if (
|
|
594
|
-
e.key === 'Enter' &&
|
|
595
|
-
!e.shiftKey &&
|
|
596
|
-
!isMobile
|
|
597
|
-
) {
|
|
598
|
-
e.preventDefault();
|
|
599
|
-
if (hasContent) handleSend();
|
|
600
|
-
}
|
|
601
|
-
}}
|
|
602
|
-
placeholder="Type a message..."
|
|
603
|
-
rows={1}
|
|
604
|
-
className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400 overflow-y-auto"
|
|
605
|
-
style={{ maxHeight: 88 }}
|
|
606
|
-
/>
|
|
607
|
-
{/* Hidden file inputs for native PWA pickers */}
|
|
608
|
-
<input
|
|
609
|
-
ref={fileRef}
|
|
610
|
-
type="file"
|
|
611
|
-
className="hidden"
|
|
612
|
-
accept="image/*,.pdf"
|
|
613
|
-
multiple
|
|
614
|
-
onChange={handleFileChange}
|
|
615
|
-
/>
|
|
616
|
-
<input
|
|
617
|
-
ref={cameraRef}
|
|
618
|
-
type="file"
|
|
619
|
-
className="hidden"
|
|
620
|
-
accept="image/*"
|
|
621
|
-
capture="environment"
|
|
622
|
-
onChange={handleFileChange}
|
|
623
|
-
/>
|
|
624
|
-
<button
|
|
625
|
-
type="button"
|
|
626
|
-
onClick={() => fileRef.current?.click()}
|
|
627
|
-
className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
628
|
-
>
|
|
629
|
-
<Paperclip className="h-4 w-4" />
|
|
630
|
-
</button>
|
|
631
|
-
<button
|
|
632
|
-
type="button"
|
|
633
|
-
onClick={() => cameraRef.current?.click()}
|
|
634
|
-
className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
635
|
-
>
|
|
636
|
-
<Camera className="h-4 w-4" />
|
|
637
|
-
</button>
|
|
638
|
-
</div>
|
|
639
|
-
{streaming ? (
|
|
640
|
-
<button
|
|
641
|
-
onClick={onStop}
|
|
642
|
-
className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
|
|
643
|
-
>
|
|
644
|
-
<Square className="h-4 w-4" />
|
|
645
|
-
</button>
|
|
646
|
-
) : hasText ? (
|
|
647
|
-
<button
|
|
648
|
-
onClick={handleSend}
|
|
649
|
-
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"
|
|
650
|
-
>
|
|
651
|
-
<SendHorizontal className="h-4 w-4" />
|
|
652
|
-
</button>
|
|
360
|
+
<div className="flex gap-2 px-3 pt-2.5 pb-2 flex-wrap bg-white/[0.04] border border-white/[0.08] rounded-2xl mb-2">
|
|
361
|
+
{attachments.map((att) => (
|
|
362
|
+
<div key={att.id} className="relative group flex-shrink-0">
|
|
363
|
+
{att.type === 'image' ? (
|
|
364
|
+
<img
|
|
365
|
+
src={att.preview}
|
|
366
|
+
alt={att.name}
|
|
367
|
+
className="w-14 h-14 rounded-lg object-cover border border-white/10"
|
|
368
|
+
/>
|
|
653
369
|
) : (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
title={
|
|
661
|
-
!voiceEnabled
|
|
662
|
-
? 'Voice input requires Chrome, Edge, or Safari. Enable Whisper in Settings for any browser.'
|
|
663
|
-
: undefined
|
|
664
|
-
}
|
|
665
|
-
onPointerDown={
|
|
666
|
-
voiceEnabled ? handleMicDown : undefined
|
|
667
|
-
}
|
|
668
|
-
onPointerMove={
|
|
669
|
-
voiceEnabled ? handleMicMove : undefined
|
|
670
|
-
}
|
|
671
|
-
onPointerUp={voiceEnabled ? handleMicUp : undefined}
|
|
672
|
-
onPointerCancel={
|
|
673
|
-
voiceEnabled ? handleMicCancel : undefined
|
|
674
|
-
}
|
|
675
|
-
>
|
|
676
|
-
<Mic className="h-4 w-4" />
|
|
677
|
-
</div>
|
|
370
|
+
<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">
|
|
371
|
+
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
372
|
+
<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" />
|
|
373
|
+
</svg>
|
|
374
|
+
<span className="text-[8px] text-muted-foreground truncate w-full text-center">{att.name.split('.').pop()}</span>
|
|
375
|
+
</div>
|
|
678
376
|
)}
|
|
377
|
+
<button
|
|
378
|
+
onClick={() => removeAttachment(att.id)}
|
|
379
|
+
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"
|
|
380
|
+
>
|
|
381
|
+
<X className="w-2.5 h-2.5 text-white" />
|
|
382
|
+
</button>
|
|
383
|
+
</div>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
</motion.div>
|
|
387
|
+
)}
|
|
388
|
+
</AnimatePresence>
|
|
389
|
+
|
|
390
|
+
<div className="flex items-end gap-2">
|
|
391
|
+
<div className="flex-1 flex items-end bg-white rounded-2xl px-4">
|
|
392
|
+
<textarea
|
|
393
|
+
ref={textareaRef}
|
|
394
|
+
value={text}
|
|
395
|
+
onChange={(e) => setText(e.target.value)}
|
|
396
|
+
onKeyDown={(e) => {
|
|
397
|
+
// Desktop: Enter sends, Shift+Enter inserts newline
|
|
398
|
+
// Mobile: Enter always inserts newline (no hardware keyboard)
|
|
399
|
+
const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
400
|
+
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
|
401
|
+
e.preventDefault();
|
|
402
|
+
if (hasContent) handleSend();
|
|
403
|
+
}
|
|
404
|
+
}}
|
|
405
|
+
placeholder="Type a message..."
|
|
406
|
+
rows={1}
|
|
407
|
+
className="flex-1 resize-none bg-transparent text-gray-900 py-3 text-sm outline-none placeholder:text-gray-400 overflow-y-auto"
|
|
408
|
+
style={{ maxHeight: 88 }}
|
|
409
|
+
/>
|
|
410
|
+
{/* Hidden file inputs for native PWA pickers */}
|
|
411
|
+
<input ref={fileRef} type="file" className="hidden" accept="image/*,.pdf" multiple onChange={handleFileChange} />
|
|
412
|
+
<input ref={cameraRef} type="file" className="hidden" accept="image/*" capture="environment" onChange={handleFileChange} />
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
onClick={() => fileRef.current?.click()}
|
|
416
|
+
className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
417
|
+
>
|
|
418
|
+
<Paperclip className="h-4 w-4" />
|
|
419
|
+
</button>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => cameraRef.current?.click()}
|
|
423
|
+
className="shrink-0 p-1.5 mb-2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
424
|
+
>
|
|
425
|
+
<Camera className="h-4 w-4" />
|
|
426
|
+
</button>
|
|
427
|
+
</div>
|
|
428
|
+
{streaming ? (
|
|
429
|
+
<button
|
|
430
|
+
onClick={onStop}
|
|
431
|
+
className="flex items-center justify-center h-12 w-12 shrink-0 rounded-full bg-destructive text-destructive-foreground transition-colors"
|
|
432
|
+
>
|
|
433
|
+
<Square className="h-4 w-4" />
|
|
434
|
+
</button>
|
|
435
|
+
) : hasText ? (
|
|
436
|
+
<button
|
|
437
|
+
onClick={handleSend}
|
|
438
|
+
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"
|
|
439
|
+
>
|
|
440
|
+
<SendHorizontal className="h-4 w-4" />
|
|
441
|
+
</button>
|
|
442
|
+
) : (
|
|
443
|
+
<div
|
|
444
|
+
className={`flex items-center justify-center h-12 w-12 shrink-0 rounded-full transition-colors touch-none select-none ${
|
|
445
|
+
voiceEnabled
|
|
446
|
+
? 'bg-gradient-brand text-white cursor-pointer'
|
|
447
|
+
: 'bg-gray-200 text-gray-400 cursor-default'
|
|
448
|
+
}`}
|
|
449
|
+
title={!voiceEnabled ? 'Voice input requires Chrome, Edge, or Safari. Enable Whisper in Settings for any browser.' : undefined}
|
|
450
|
+
onPointerDown={voiceEnabled ? handleMicDown : undefined}
|
|
451
|
+
onPointerMove={voiceEnabled ? handleMicMove : undefined}
|
|
452
|
+
onPointerUp={voiceEnabled ? handleMicUp : undefined}
|
|
453
|
+
onPointerCancel={voiceEnabled ? handleMicCancel : undefined}
|
|
454
|
+
>
|
|
455
|
+
<Mic className="h-4 w-4" />
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* ── Recording overlay (absolute so no layout shift) ── */}
|
|
462
|
+
<AnimatePresence>
|
|
463
|
+
{isRecording && (
|
|
464
|
+
<motion.div
|
|
465
|
+
initial={{ opacity: 0 }}
|
|
466
|
+
animate={{ opacity: 1 }}
|
|
467
|
+
exit={{ opacity: 0 }}
|
|
468
|
+
transition={{ duration: 0.1 }}
|
|
469
|
+
className="absolute inset-0 p-3 flex items-center touch-none"
|
|
470
|
+
>
|
|
471
|
+
<div className="flex items-center w-full gap-2">
|
|
472
|
+
{/* Left: pulsing dot + timer (no bg) */}
|
|
473
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
474
|
+
<span className="relative flex h-2 w-2 shrink-0">
|
|
475
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75" />
|
|
476
|
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-destructive" />
|
|
477
|
+
</span>
|
|
478
|
+
<span className="text-sm font-medium tabular-nums">
|
|
479
|
+
{formatTime(recordingTime)}
|
|
480
|
+
</span>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
{/* Pill: trash + centered slide-to-cancel + mic */}
|
|
484
|
+
<div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
|
|
485
|
+
<div
|
|
486
|
+
ref={trashRef}
|
|
487
|
+
className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
|
|
488
|
+
>
|
|
489
|
+
<Trash2 className="h-4 w-4" />
|
|
490
|
+
</div>
|
|
491
|
+
<div className="flex-1 flex justify-center min-w-0">
|
|
492
|
+
<motion.span
|
|
493
|
+
className="text-[13px] whitespace-nowrap select-none font-medium"
|
|
494
|
+
style={{
|
|
495
|
+
backgroundImage: 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
|
|
496
|
+
backgroundSize: '200% 100%',
|
|
497
|
+
WebkitBackgroundClip: 'text',
|
|
498
|
+
WebkitTextFillColor: 'transparent',
|
|
499
|
+
backgroundClip: 'text',
|
|
500
|
+
}}
|
|
501
|
+
animate={{ backgroundPosition: ['200% center', '-200% center'] }}
|
|
502
|
+
transition={{ duration: 2.5, repeat: Infinity, ease: 'linear' }}
|
|
503
|
+
>
|
|
504
|
+
◄◄ slide to cancel
|
|
505
|
+
</motion.span>
|
|
506
|
+
</div>
|
|
507
|
+
{/* Draggable mic (DOM transform for 60fps) */}
|
|
508
|
+
<div
|
|
509
|
+
ref={micRef}
|
|
510
|
+
className="shrink-0 touch-none select-none will-change-transform"
|
|
511
|
+
style={{ transition: 'none' }}
|
|
512
|
+
onPointerDown={handleMicDown}
|
|
513
|
+
onPointerMove={handleMicMove}
|
|
514
|
+
onPointerUp={handleMicUp}
|
|
515
|
+
onPointerCancel={handleMicCancel}
|
|
516
|
+
>
|
|
517
|
+
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
|
|
518
|
+
<Mic className="h-5 w-5" />
|
|
519
|
+
</div>
|
|
679
520
|
</div>
|
|
521
|
+
</div>
|
|
680
522
|
</div>
|
|
523
|
+
</motion.div>
|
|
524
|
+
)}
|
|
525
|
+
</AnimatePresence>
|
|
681
526
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
{isRecording && (
|
|
685
|
-
<motion.div
|
|
686
|
-
initial={{ opacity: 0 }}
|
|
687
|
-
animate={{ opacity: 1 }}
|
|
688
|
-
exit={{ opacity: 0 }}
|
|
689
|
-
transition={{ duration: 0.1 }}
|
|
690
|
-
className="absolute inset-0 p-3 flex items-center touch-none"
|
|
691
|
-
>
|
|
692
|
-
<div className="flex items-center w-full gap-2">
|
|
693
|
-
{/* Left: pulsing dot + timer (no bg) */}
|
|
694
|
-
<div className="flex items-center gap-2 shrink-0">
|
|
695
|
-
<span className="relative flex h-2 w-2 shrink-0">
|
|
696
|
-
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75" />
|
|
697
|
-
<span className="relative inline-flex rounded-full h-2 w-2 bg-destructive" />
|
|
698
|
-
</span>
|
|
699
|
-
<span className="text-sm font-medium tabular-nums">
|
|
700
|
-
{formatTime(recordingTime)}
|
|
701
|
-
</span>
|
|
702
|
-
</div>
|
|
703
|
-
|
|
704
|
-
{/* Pill: trash + centered slide-to-cancel + mic */}
|
|
705
|
-
<div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
|
|
706
|
-
<div
|
|
707
|
-
ref={trashRef}
|
|
708
|
-
className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
|
|
709
|
-
>
|
|
710
|
-
<Trash2 className="h-4 w-4" />
|
|
711
|
-
</div>
|
|
712
|
-
<div className="flex-1 flex justify-center min-w-0">
|
|
713
|
-
<motion.span
|
|
714
|
-
className="text-[13px] whitespace-nowrap select-none font-medium"
|
|
715
|
-
style={{
|
|
716
|
-
backgroundImage:
|
|
717
|
-
'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
|
|
718
|
-
backgroundSize: '200% 100%',
|
|
719
|
-
WebkitBackgroundClip: 'text',
|
|
720
|
-
WebkitTextFillColor: 'transparent',
|
|
721
|
-
backgroundClip: 'text'
|
|
722
|
-
}}
|
|
723
|
-
animate={{
|
|
724
|
-
backgroundPosition: [
|
|
725
|
-
'200% center',
|
|
726
|
-
'-200% center'
|
|
727
|
-
]
|
|
728
|
-
}}
|
|
729
|
-
transition={{
|
|
730
|
-
duration: 2.5,
|
|
731
|
-
repeat: Infinity,
|
|
732
|
-
ease: 'linear'
|
|
733
|
-
}}
|
|
734
|
-
>
|
|
735
|
-
◄◄ slide to cancel
|
|
736
|
-
</motion.span>
|
|
737
|
-
</div>
|
|
738
|
-
{/* Draggable mic (DOM transform for 60fps) */}
|
|
739
|
-
<div
|
|
740
|
-
ref={micRef}
|
|
741
|
-
className="shrink-0 touch-none select-none will-change-transform"
|
|
742
|
-
style={{ transition: 'none' }}
|
|
743
|
-
onPointerDown={handleMicDown}
|
|
744
|
-
onPointerMove={handleMicMove}
|
|
745
|
-
onPointerUp={handleMicUp}
|
|
746
|
-
onPointerCancel={handleMicCancel}
|
|
747
|
-
>
|
|
748
|
-
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
|
|
749
|
-
<Mic className="h-5 w-5" />
|
|
750
|
-
</div>
|
|
751
|
-
</div>
|
|
752
|
-
</div>
|
|
753
|
-
</div>
|
|
754
|
-
</motion.div>
|
|
755
|
-
)}
|
|
756
|
-
</AnimatePresence>
|
|
757
|
-
</div>
|
|
758
|
-
);
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
759
529
|
}
|