ishaq101 commited on
Commit
7fb5553
·
1 Parent(s): 5a5061a

[NOTICKET] Fix rendering answer in bubble chat answer

Browse files
src/app/components/Main.tsx CHANGED
@@ -103,6 +103,13 @@ export default function Main() {
103
  timestamp: new Date(m.created_at).getTime(),
104
  sources: m.sources ?? [],
105
  }));
 
 
 
 
 
 
 
106
  console.log("[loadRoomMessages] room", roomId, "→", messages.length, "messages from server");
107
  setChats((prev) =>
108
  prev.map((chat) =>
@@ -171,7 +178,7 @@ export default function Main() {
171
  abortControllerRef.current?.abort();
172
  };
173
 
174
- const handleSend = useCallback(async (text: string) => {
175
  console.log("[handleSend] called, user:", user?.user_id ?? "null", "text:", text.slice(0, 40));
176
  if (!user) {
177
  console.warn("[handleSend] early return: no user");
@@ -240,6 +247,58 @@ export default function Main() {
240
  const decoder = new TextDecoder();
241
  let buffer = "";
242
  let currentEvent = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
  while (true) {
245
  const { done, value } = await reader.read();
@@ -250,79 +309,57 @@ export default function Main() {
250
  buffer = lines.pop() ?? "";
251
 
252
  for (const line of lines) {
253
- if (line.startsWith("event:")) {
 
 
 
 
 
 
 
254
  currentEvent = line.replace("event:", "").trim();
255
  } else if (line.startsWith("data:")) {
256
- const data = line.replace(/^data: ?/, "");
257
-
258
- if (currentEvent === "sources" && data) {
259
- const sources: ChatSource[] = JSON.parse(data);
260
- setChats((prev) =>
261
- prev.map((chat) =>
262
- chat.id === roomId
263
- ? {
264
- ...chat,
265
- messages: chat.messages.map((m) =>
266
- m.id === assistantMsgId ? { ...m, sources } : m
267
- ),
268
- }
269
- : chat
270
- )
271
- );
272
- } else if (currentEvent === "chunk" && data) {
273
- setChats((prev) =>
274
- prev.map((chat) =>
275
- chat.id === roomId
276
- ? {
277
- ...chat,
278
- messages: chat.messages.map((m) =>
279
- m.id === assistantMsgId
280
- ? { ...m, content: m.content + data }
281
- : m
282
- ),
283
- }
284
- : chat
285
- )
286
- );
287
- } else if (currentEvent === "message" && data) {
288
- setChats((prev) =>
289
- prev.map((chat) =>
290
- chat.id === roomId
291
- ? {
292
- ...chat,
293
- messages: chat.messages.map((m) =>
294
- m.id === assistantMsgId ? { ...m, content: data } : m
295
- ),
296
- }
297
- : chat
298
- )
299
- );
300
- } else if (currentEvent === "audio_text" && data) {
301
- audioText = data;
302
- } else if (currentEvent === "done") {
303
- break;
304
- }
305
  }
306
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  }
308
 
309
- const loadedMessages = await loadRoomMessages(roomId);
310
- const lastAssistantMsg = [...loadedMessages].reverse().find((m) => m.role === "assistant");
311
- if (audioText && lastAssistantMsg) {
312
  setChats((prev) =>
313
  prev.map((chat) =>
314
  chat.id === roomId
315
  ? {
316
  ...chat,
317
  messages: chat.messages.map((m) =>
318
- m.id === lastAssistantMsg.id ? { ...m, audioText } : m
319
  ),
320
  }
321
  : chat
322
  )
323
  );
324
  }
325
- return { audioText, lastAssistantId: lastAssistantMsg?.id };
326
  } catch (err: unknown) {
327
  if ((err as Error).name !== "AbortError") {
328
  console.error("[handleSend] streamChat error:", err);
@@ -346,11 +383,12 @@ export default function Main() {
346
  audioText = "";
347
  }
348
  } finally {
 
349
  setIsStreaming(false);
350
  setStreamingMsgId(null);
351
  abortControllerRef.current = null;
352
  }
353
- }, [user, ensureRoom, loadRoomMessages]);
354
 
355
  const playTtsAudio = useCallback(async (
356
  ttsText: string,
@@ -389,21 +427,23 @@ export default function Main() {
389
  const { voiceState, start, stop, stopRecording, setStateExternal, isActive: isVoiceActive } = useVoiceSession({
390
  onTranscript: async (text: string) => {
391
  console.log("[onTranscript] received:", text);
392
- const result = await handleSend(text);
393
- const { audioText = "", lastAssistantId } = result ?? {};
 
 
394
  console.log("[onTranscript] handleSend done, audioText:", audioText ? audioText.slice(0, 40) : "(empty)");
395
  if (audioText && isVoiceActiveRef.current) {
396
  const { chunks, sampleRate } = await playTtsAudio(audioText, () => {
397
  if (isVoiceActiveRef.current) setVoiceStateRef.current?.("SPEAKING");
398
  });
399
- if (lastAssistantId && chunks.length > 0) {
400
  setChats((prev) =>
401
  prev.map((chat) =>
402
  chat.id === currentChatIdRef.current
403
  ? {
404
  ...chat,
405
  messages: chat.messages.map((m) =>
406
- m.id === lastAssistantId
407
  ? { ...m, audioChunks: chunks, audioSampleRate: sampleRate }
408
  : m
409
  ),
 
103
  timestamp: new Date(m.created_at).getTime(),
104
  sources: m.sources ?? [],
105
  }));
106
+ const asstMsgs = messages.filter(m => m.role === "assistant");
107
+ const lastAsst = asstMsgs[asstMsgs.length - 1];
108
+ if (lastAsst) {
109
+ const hasCR = lastAsst.content.includes("\r");
110
+ console.log("[loadRoomMessages] last assistant contentLen=", lastAsst.content.length, "hasCR=", hasCR);
111
+ console.log("[loadRoomMessages] CONTENT JSON →", JSON.stringify(lastAsst.content.slice(0, 600)));
112
+ }
113
  console.log("[loadRoomMessages] room", roomId, "→", messages.length, "messages from server");
114
  setChats((prev) =>
115
  prev.map((chat) =>
 
178
  abortControllerRef.current?.abort();
179
  };
180
 
181
+ const handleSend = useCallback(async (text: string, skipReload = false) => {
182
  console.log("[handleSend] called, user:", user?.user_id ?? "null", "text:", text.slice(0, 40));
183
  if (!user) {
184
  console.warn("[handleSend] early return: no user");
 
247
  const decoder = new TextDecoder();
248
  let buffer = "";
249
  let currentEvent = "";
250
+ let currentDataLines: string[] = [];
251
+ let streamDone = false;
252
+
253
+ const dispatchEvent = (eventType: string, data: string) => {
254
+ if (eventType === "sources" && data) {
255
+ const sources: ChatSource[] = JSON.parse(data);
256
+ setChats((prev) =>
257
+ prev.map((chat) =>
258
+ chat.id === roomId
259
+ ? {
260
+ ...chat,
261
+ messages: chat.messages.map((m) =>
262
+ m.id === assistantMsgId ? { ...m, sources } : m
263
+ ),
264
+ }
265
+ : chat
266
+ )
267
+ );
268
+ } else if (eventType === "chunk") {
269
+ setChats((prev) =>
270
+ prev.map((chat) =>
271
+ chat.id === roomId
272
+ ? {
273
+ ...chat,
274
+ messages: chat.messages.map((m) =>
275
+ m.id === assistantMsgId
276
+ ? { ...m, content: m.content + data }
277
+ : m
278
+ ),
279
+ }
280
+ : chat
281
+ )
282
+ );
283
+ } else if (eventType === "message" && data) {
284
+ setChats((prev) =>
285
+ prev.map((chat) =>
286
+ chat.id === roomId
287
+ ? {
288
+ ...chat,
289
+ messages: chat.messages.map((m) =>
290
+ m.id === assistantMsgId ? { ...m, content: data } : m
291
+ ),
292
+ }
293
+ : chat
294
+ )
295
+ );
296
+ } else if (eventType === "audio_text" && data) {
297
+ audioText = data;
298
+ } else if (eventType === "done") {
299
+ streamDone = true;
300
+ }
301
+ };
302
 
303
  while (true) {
304
  const { done, value } = await reader.read();
 
309
  buffer = lines.pop() ?? "";
310
 
311
  for (const line of lines) {
312
+ if (line === "") {
313
+ // Blank line = end of SSE event — dispatch with all accumulated data lines joined by \n
314
+ if (currentEvent) {
315
+ dispatchEvent(currentEvent, currentDataLines.join("\n"));
316
+ }
317
+ currentEvent = "";
318
+ currentDataLines = [];
319
+ } else if (line.startsWith("event:")) {
320
  currentEvent = line.replace("event:", "").trim();
321
  } else if (line.startsWith("data:")) {
322
+ currentDataLines.push(line.replace(/^data: ?/, ""));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  }
324
  }
325
+ if (streamDone) break;
326
+ }
327
+
328
+ if (skipReload) {
329
+ // Voice mode: skip GET /room, return immediately so TTS can start without delay.
330
+ // audioText and audioChunks will be stored on the temp assistantMsgId.
331
+ if (audioText) {
332
+ setChats((prev) =>
333
+ prev.map((chat) =>
334
+ chat.id === roomId
335
+ ? {
336
+ ...chat,
337
+ messages: chat.messages.map((m) =>
338
+ m.id === assistantMsgId ? { ...m, audioText } : m
339
+ ),
340
+ }
341
+ : chat
342
+ )
343
+ );
344
+ }
345
+ return { audioText, assistantMsgId };
346
  }
347
 
348
+ if (audioText) {
 
 
349
  setChats((prev) =>
350
  prev.map((chat) =>
351
  chat.id === roomId
352
  ? {
353
  ...chat,
354
  messages: chat.messages.map((m) =>
355
+ m.id === assistantMsgId ? { ...m, audioText } : m
356
  ),
357
  }
358
  : chat
359
  )
360
  );
361
  }
362
+ return { audioText, assistantMsgId };
363
  } catch (err: unknown) {
364
  if ((err as Error).name !== "AbortError") {
365
  console.error("[handleSend] streamChat error:", err);
 
383
  audioText = "";
384
  }
385
  } finally {
386
+ console.log("[handleSend] finally: clearing streamingMsgId →", assistantMsgId, "| skipReload:", skipReload);
387
  setIsStreaming(false);
388
  setStreamingMsgId(null);
389
  abortControllerRef.current = null;
390
  }
391
+ }, [user, ensureRoom]);
392
 
393
  const playTtsAudio = useCallback(async (
394
  ttsText: string,
 
427
  const { voiceState, start, stop, stopRecording, setStateExternal, isActive: isVoiceActive } = useVoiceSession({
428
  onTranscript: async (text: string) => {
429
  console.log("[onTranscript] received:", text);
430
+ // Pass skipReload=true so handleSend returns immediately after streaming
431
+ // without waiting for GET /room TTS starts with zero extra delay.
432
+ const result = await handleSend(text, true);
433
+ const { audioText = "", assistantMsgId } = result ?? {};
434
  console.log("[onTranscript] handleSend done, audioText:", audioText ? audioText.slice(0, 40) : "(empty)");
435
  if (audioText && isVoiceActiveRef.current) {
436
  const { chunks, sampleRate } = await playTtsAudio(audioText, () => {
437
  if (isVoiceActiveRef.current) setVoiceStateRef.current?.("SPEAKING");
438
  });
439
+ if (assistantMsgId && chunks.length > 0) {
440
  setChats((prev) =>
441
  prev.map((chat) =>
442
  chat.id === currentChatIdRef.current
443
  ? {
444
  ...chat,
445
  messages: chat.messages.map((m) =>
446
+ m.id === assistantMsgId
447
  ? { ...m, audioChunks: chunks, audioSampleRate: sampleRate }
448
  : m
449
  ),
src/app/components/chat/MessageBubble.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { motion } from "motion/react";
2
  import { Bot } from "lucide-react";
3
  import type { Message } from "./types";
@@ -11,6 +12,14 @@ interface MessageBubbleProps {
11
  }
12
 
13
  export default function MessageBubble({ message, isStreamingPlaceholder }: MessageBubbleProps) {
 
 
 
 
 
 
 
 
14
  if (message.role === "user") {
15
  return (
16
  <motion.div
@@ -43,7 +52,11 @@ export default function MessageBubble({ message, isStreamingPlaceholder }: Messa
43
  </div>
44
 
45
  <div className="max-w-[92%] sm:max-w-[88%] xl:max-w-[80%] bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 xl:px-5 py-3 xl:py-4">
46
- <MarkdownRenderer content={message.content} skipPreprocess={isStreamingPlaceholder} />
 
 
 
 
47
 
48
  {message.sources && message.sources.length > 0 && (
49
  <div className="mt-3 pt-2 border-t border-neutral-100">
 
1
+ import { useEffect } from "react";
2
  import { motion } from "motion/react";
3
  import { Bot } from "lucide-react";
4
  import type { Message } from "./types";
 
12
  }
13
 
14
  export default function MessageBubble({ message, isStreamingPlaceholder }: MessageBubbleProps) {
15
+ useEffect(() => {
16
+ if (message.role === "assistant") {
17
+ console.log(
18
+ `[MessageBubble] id=...${message.id.slice(-6)} isStreamingPlaceholder=${isStreamingPlaceholder} contentLen=${message.content.length}`
19
+ );
20
+ }
21
+ }, [isStreamingPlaceholder, message.id, message.role, message.content.length]);
22
+
23
  if (message.role === "user") {
24
  return (
25
  <motion.div
 
52
  </div>
53
 
54
  <div className="max-w-[92%] sm:max-w-[88%] xl:max-w-[80%] bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 xl:px-5 py-3 xl:py-4">
55
+ <MarkdownRenderer
56
+ key={isStreamingPlaceholder ? "streaming" : "final"}
57
+ content={message.content}
58
+ skipPreprocess={isStreamingPlaceholder}
59
+ />
60
 
61
  {message.sources && message.sources.length > 0 && (
62
  <div className="mt-3 pt-2 border-t border-neutral-100">
src/app/components/chat/renderers/MarkdownRenderer.tsx CHANGED
@@ -113,6 +113,14 @@ interface MarkdownRendererProps {
113
 
114
  export default function MarkdownRenderer({ content, skipPreprocess }: MarkdownRendererProps) {
115
  const processed = skipPreprocess ? content : preprocessMarkdown(content);
 
 
 
 
 
 
 
 
116
 
117
  return (
118
  <ReactMarkdown
 
113
 
114
  export default function MarkdownRenderer({ content, skipPreprocess }: MarkdownRendererProps) {
115
  const processed = skipPreprocess ? content : preprocessMarkdown(content);
116
+ if (!skipPreprocess) {
117
+ const hasCR = content.includes("\r");
118
+ const hasDoubleNewline = content.includes("\n\n");
119
+ console.log(
120
+ `[MarkdownRenderer] skipPreprocess=false contentLen=${content.length} same=${content === processed} hasCR=${hasCR} hasDoubleNewline=${hasDoubleNewline}`
121
+ );
122
+ console.log("[MarkdownRenderer] CONTENT JSON →", JSON.stringify(content.slice(0, 600)));
123
+ }
124
 
125
  return (
126
  <ReactMarkdown
src/audio/AudioPlayer.ts CHANGED
@@ -40,16 +40,20 @@ export class AudioPlayer {
40
  this.pendingBuffers.push(audioBuffer);
41
  if (this.pendingBytes >= this.bufferThresholdBytes) {
42
  this.started = true;
43
- this.nextPlayTime = this.context.currentTime;
44
- onStarted?.();
45
- for (const buf of this.pendingBuffers) {
46
- const source = this.context.createBufferSource();
47
- source.buffer = buf;
48
- source.connect(this.context.destination);
49
- source.start(this.nextPlayTime);
50
- this.nextPlayTime += buf.duration;
51
- }
52
  this.pendingBuffers = [];
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
  } else {
55
  const source = this.context.createBufferSource();
 
40
  this.pendingBuffers.push(audioBuffer);
41
  if (this.pendingBytes >= this.bufferThresholdBytes) {
42
  this.started = true;
43
+ const buffered = this.pendingBuffers.splice(0);
 
 
 
 
 
 
 
 
44
  this.pendingBuffers = [];
45
+ void this.context.resume().then(() => {
46
+ if (!this.context) return;
47
+ this.nextPlayTime = this.context.currentTime;
48
+ onStarted?.();
49
+ for (const buf of buffered) {
50
+ const source = this.context.createBufferSource();
51
+ source.buffer = buf;
52
+ source.connect(this.context.destination);
53
+ source.start(this.nextPlayTime);
54
+ this.nextPlayTime += buf.duration;
55
+ }
56
+ });
57
  }
58
  } else {
59
  const source = this.context.createBufferSource();
src/hooks/useVoiceSession.ts CHANGED
@@ -33,8 +33,11 @@ export interface UseVoiceSessionReturn {
33
 
34
  const BUFFER_SOUNDS = [
35
  "/sounds/01_Pertanyaan_bagus_mohon_ditunggu_sebentar.wav",
 
 
36
  "/sounds/02_Oke_menararik_banget_Sebentar_ya_saya_se.wav",
37
- "/sounds/03_Sip_aku_sudah_dengar_pertanyaanmu_Tunggu.wav",
 
38
  ];
39
 
40
  const RECORDER_SAMPLE_RATE = 16000;
 
33
 
34
  const BUFFER_SOUNDS = [
35
  "/sounds/01_Pertanyaan_bagus_mohon_ditunggu_sebentar.wav",
36
+ "/sounds/03_Siap_saya_tangkap_Sebentar_ya_saya_lagi.wav",
37
+ "/sounds/05_Oke_terima_kasih_sudah_bertanya_Mohon_tu.wav",
38
  "/sounds/02_Oke_menararik_banget_Sebentar_ya_saya_se.wav",
39
+ "/sounds/04_Baik_sebentar_ya_Saya_sedang_memproses_p.wav",
40
+ "/sounds/06_Sip_aku_sudah_dengar_pertanyaanmu_Tunggu.wav",
41
  ];
42
 
43
  const RECORDER_SAMPLE_RATE = 16000;
src/services/voiceApi.ts CHANGED
@@ -47,11 +47,14 @@ export async function textToSpeechStreaming(
47
  text: string,
48
  provider = "gemini"
49
  ): Promise<{ sampleRate: number; stream: ReadableStream<Uint8Array> }> {
 
 
50
  const res = await fetch(`${VOICE_BASE_URL}/tts`, {
51
  method: "POST",
52
  headers: { "Content-Type": "application/json" },
53
  body: JSON.stringify({ text, provider }),
54
- });
 
55
  if (!res.ok) throw new Error(`TTS error: ${res.status}`);
56
  if (!res.body) throw new Error("TTS response has no body");
57
  const sampleRate = parseInt(res.headers.get("X-Sample-Rate") ?? "24000", 10);
@@ -62,11 +65,14 @@ export async function textToSpeech(
62
  text: string,
63
  provider = "gemini"
64
  ): Promise<{ pcm: ArrayBuffer; sampleRate: number }> {
 
 
65
  const response = await fetch(`${VOICE_BASE_URL}/tts`, {
66
  method: "POST",
67
  headers: { "Content-Type": "application/json" },
68
  body: JSON.stringify({ text, provider }),
69
- });
 
70
  if (!response.ok) throw new Error(`TTS error: ${response.status}`);
71
  const sampleRate = parseInt(
72
  response.headers.get("X-Sample-Rate") ?? "24000",
 
47
  text: string,
48
  provider = "gemini"
49
  ): Promise<{ sampleRate: number; stream: ReadableStream<Uint8Array> }> {
50
+ const abort = new AbortController();
51
+ const timer = setTimeout(() => abort.abort(), 90_000);
52
  const res = await fetch(`${VOICE_BASE_URL}/tts`, {
53
  method: "POST",
54
  headers: { "Content-Type": "application/json" },
55
  body: JSON.stringify({ text, provider }),
56
+ signal: abort.signal,
57
+ }).finally(() => clearTimeout(timer));
58
  if (!res.ok) throw new Error(`TTS error: ${res.status}`);
59
  if (!res.body) throw new Error("TTS response has no body");
60
  const sampleRate = parseInt(res.headers.get("X-Sample-Rate") ?? "24000", 10);
 
65
  text: string,
66
  provider = "gemini"
67
  ): Promise<{ pcm: ArrayBuffer; sampleRate: number }> {
68
+ const abort = new AbortController();
69
+ const timer = setTimeout(() => abort.abort(), 90_000);
70
  const response = await fetch(`${VOICE_BASE_URL}/tts`, {
71
  method: "POST",
72
  headers: { "Content-Type": "application/json" },
73
  body: JSON.stringify({ text, provider }),
74
+ signal: abort.signal,
75
+ }).finally(() => clearTimeout(timer));
76
  if (!response.ok) throw new Error(`TTS error: ${response.status}`);
77
  const sampleRate = parseInt(
78
  response.headers.get("X-Sample-Rate") ?? "24000",