wepee commited on
Commit
2a4a18b
·
1 Parent(s): 69f44ef

AKKHIRNYA BISA STREAM

Browse files
.gitignore CHANGED
@@ -30,4 +30,8 @@ yarn-error.log
30
 
31
  AGENTS.md
32
  .agents
33
- .codex
 
 
 
 
 
30
 
31
  AGENTS.md
32
  .agents
33
+ .codex
34
+ CLAUDE.md
35
+ .claude
36
+ .mcp.json
37
+ boost.json
app/Http/Controllers/MahasiswaController.php CHANGED
@@ -17,8 +17,6 @@ class MahasiswaController extends Controller
17
 
18
  private const DIRECT_MESSAGES_SESSION_PREFIX = 'sevima_raghub_direct_messages';
19
 
20
- private const PENDING_INITIAL_CHAT_SESSION_PREFIX = 'sevima_raghub_pending_initial_chat';
21
-
22
  public function index(Request $request): Response
23
  {
24
  $payload = $this->studentDashboardPayload($request);
@@ -37,8 +35,10 @@ public function storeSession(Request $request): RedirectResponse
37
  'title' => ['required', 'string'],
38
  ]);
39
 
 
 
40
  try {
41
- $session = $this->createChatSession($payload, $this->authToken($request));
42
  } catch (RagApiException $exception) {
43
  return back()
44
  ->withErrors(['chat' => $exception->getMessage()])
@@ -47,10 +47,23 @@ public function storeSession(Request $request): RedirectResponse
47
 
48
  $sessionId = (string) ($session['uuid_id'] ?? '');
49
 
50
- $request->session()->flash($this->pendingInitialChatKey($sessionId), [
51
- 'content' => $payload['title'],
52
- 'courseId' => $payload['course_id'],
53
- ]);
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  return to_route('mahasiswa.show', ['sessionId' => $sessionId]);
56
  }
@@ -89,7 +102,6 @@ public function show(Request $request, string $sessionId): Response
89
  'isDirectChatMode' => $this->isDirectChatMode(),
90
  'isStreamChatMode' => $this->isStreamChatMode(),
91
  'messages' => $messages,
92
- 'pendingInitialChat' => $request->session()->pull($this->pendingInitialChatKey($sessionId)),
93
  'selectedCourseId' => is_string($currentSession['course_id'] ?? null)
94
  ? $currentSession['course_id']
95
  : $this->firstCourseId($payload['courses']),
@@ -106,8 +118,10 @@ public function storeMessage(Request $request, string $sessionId): JsonResponse|
106
  ]);
107
  $userMessage = $this->localUserMessage($payload['content']);
108
 
109
- if ($this->isStreamChatMode()) {
110
  try {
 
 
111
  return $this->streamChatMessage(
112
  $sessionId,
113
  $payload['content'],
@@ -338,8 +352,18 @@ private function streamChatMessage(string $sessionId, string $content, ?string $
338
  $stream = $response->toPsrResponse()->getBody();
339
 
340
  return response()->stream(function () use ($stream): void {
 
 
341
  while (! $stream->eof()) {
342
- echo $stream->read(1024);
 
 
 
 
 
 
 
 
343
 
344
  if (ob_get_level() > 0) {
345
  ob_flush();
@@ -442,8 +466,4 @@ private function directMessagesKey(string $sessionId): string
442
  return self::DIRECT_MESSAGES_SESSION_PREFIX.'.'.$sessionId;
443
  }
444
 
445
- private function pendingInitialChatKey(string $sessionId): string
446
- {
447
- return self::PENDING_INITIAL_CHAT_SESSION_PREFIX.'.'.$sessionId;
448
- }
449
  }
 
17
 
18
  private const DIRECT_MESSAGES_SESSION_PREFIX = 'sevima_raghub_direct_messages';
19
 
 
 
20
  public function index(Request $request): Response
21
  {
22
  $payload = $this->studentDashboardPayload($request);
 
35
  'title' => ['required', 'string'],
36
  ]);
37
 
38
+ $token = $this->authToken($request);
39
+
40
  try {
41
+ $session = $this->createChatSession($payload, $token);
42
  } catch (RagApiException $exception) {
43
  return back()
44
  ->withErrors(['chat' => $exception->getMessage()])
 
47
 
48
  $sessionId = (string) ($session['uuid_id'] ?? '');
49
 
50
+ try {
51
+ $assistantMessage = $this->sendConfiguredChatMessage(
52
+ $sessionId,
53
+ $payload['course_id'],
54
+ $payload['title'],
55
+ $token,
56
+ );
57
+
58
+ if ($this->isDirectChatMode()) {
59
+ $this->appendDirectChatMessages($request, $sessionId, [
60
+ $this->localUserMessage($payload['title']),
61
+ $assistantMessage,
62
+ ]);
63
+ }
64
+ } catch (RagApiException) {
65
+ // Session created; initial message failed — user can retry from the session page.
66
+ }
67
 
68
  return to_route('mahasiswa.show', ['sessionId' => $sessionId]);
69
  }
 
102
  'isDirectChatMode' => $this->isDirectChatMode(),
103
  'isStreamChatMode' => $this->isStreamChatMode(),
104
  'messages' => $messages,
 
105
  'selectedCourseId' => is_string($currentSession['course_id'] ?? null)
106
  ? $currentSession['course_id']
107
  : $this->firstCourseId($payload['courses']),
 
118
  ]);
119
  $userMessage = $this->localUserMessage($payload['content']);
120
 
121
+ if ($this->isStreamChatMode() && $request->accepts('text/event-stream')) {
122
  try {
123
+ $request->session()->save();
124
+
125
  return $this->streamChatMessage(
126
  $sessionId,
127
  $payload['content'],
 
352
  $stream = $response->toPsrResponse()->getBody();
353
 
354
  return response()->stream(function () use ($stream): void {
355
+ @ini_set('zlib.output_compression', '0');
356
+
357
  while (! $stream->eof()) {
358
+ $chunk = $stream->read(1);
359
+
360
+ if ($chunk === '') {
361
+ usleep(10_000);
362
+
363
+ continue;
364
+ }
365
+
366
+ echo $chunk;
367
 
368
  if (ob_get_level() > 0) {
369
  ob_flush();
 
466
  return self::DIRECT_MESSAGES_SESSION_PREFIX.'.'.$sessionId;
467
  }
468
 
 
 
 
 
469
  }
boost.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "agents": [
3
- "codex"
4
  ],
5
  "cloud": false,
6
  "guidelines": true,
 
1
  {
2
  "agents": [
3
+ "claude_code"
4
  ],
5
  "cloud": false,
6
  "guidelines": true,
resources/js/components/appearance-toggle.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { Moon, Sun } from 'lucide-react';
 
2
  import { Button } from '@/components/ui/button';
3
  import { useAppearance } from '@/hooks/use-appearance';
4
  import { cn } from '@/lib/utils';
@@ -9,7 +10,12 @@ type AppearanceToggleProps = {
9
 
10
  export default function AppearanceToggle({ className }: AppearanceToggleProps) {
11
  const { resolvedAppearance, updateAppearance } = useAppearance();
12
- const isDarkMode = resolvedAppearance === 'dark';
 
 
 
 
 
13
 
14
  const handleToggle = (): void => {
15
  updateAppearance(isDarkMode ? 'light' : 'dark');
 
1
  import { Moon, Sun } from 'lucide-react';
2
+ import { useEffect, useState } from 'react';
3
  import { Button } from '@/components/ui/button';
4
  import { useAppearance } from '@/hooks/use-appearance';
5
  import { cn } from '@/lib/utils';
 
10
 
11
  export default function AppearanceToggle({ className }: AppearanceToggleProps) {
12
  const { resolvedAppearance, updateAppearance } = useAppearance();
13
+ const [isMounted, setIsMounted] = useState(false);
14
+ const isDarkMode = isMounted && resolvedAppearance === 'dark';
15
+
16
+ useEffect(() => {
17
+ setIsMounted(true);
18
+ }, []);
19
 
20
  const handleToggle = (): void => {
21
  updateAppearance(isDarkMode ? 'light' : 'dark');
resources/js/pages/mahasiswa-chat.tsx CHANGED
@@ -20,11 +20,6 @@ import { createLocalAssistantMessage, createLocalUserMessage } from '@/lib/rag';
20
  import { destroy as destroyMahasiswaSession } from '@/routes/mahasiswa';
21
  import { store as storeMahasiswaMessage } from '@/routes/mahasiswa/messages';
22
 
23
- type PendingInitialChat = {
24
- content: string;
25
- courseId: string;
26
- };
27
-
28
  type StoreMessageJsonResponse = {
29
  assistantMessage?: ChatMessageResponse;
30
  };
@@ -49,7 +44,6 @@ type MahasiswaChatProps = {
49
  isDirectChatMode?: boolean;
50
  isStreamChatMode?: boolean;
51
  messages: ChatMessageResponse[];
52
- pendingInitialChat?: PendingInitialChat | null;
53
  selectedCourseId?: string | null;
54
  sessionId: string;
55
  };
@@ -132,6 +126,40 @@ function csrfToken(): string | undefined {
132
  );
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  async function storeChatMessage(
136
  sessionId: string,
137
  content: string,
@@ -211,8 +239,12 @@ function isDoneStatus(value: string | undefined): boolean {
211
  'completed',
212
  'done',
213
  'end',
 
 
214
  'finish',
215
  'finished',
 
 
216
  'success',
217
  ].includes(value ?? '');
218
  }
@@ -318,9 +350,14 @@ function finalMessageFromPayload(
318
 
319
  function parseSseFrame(frame: string): { data: string; eventName?: string } {
320
  const dataLines: string[] = [];
 
321
  let eventName: string | undefined;
322
 
323
  for (const line of frame.split('\n')) {
 
 
 
 
324
  if (line.startsWith('event:')) {
325
  eventName = line.slice('event:'.length).trim();
326
  continue;
@@ -328,15 +365,27 @@ function parseSseFrame(frame: string): { data: string; eventName?: string } {
328
 
329
  if (line.startsWith('data:')) {
330
  dataLines.push(line.slice('data:'.length).trimStart());
 
331
  }
 
 
332
  }
333
 
334
  return {
335
- data: dataLines.length > 0 ? dataLines.join('\n') : frame.trim(),
 
 
 
336
  eventName,
337
  };
338
  }
339
 
 
 
 
 
 
 
340
  function handleStreamPayload(
341
  data: string,
342
  eventName: string | undefined,
@@ -345,11 +394,20 @@ function handleStreamPayload(
345
  const payload = parseStreamPayload(data);
346
  const finalMessage = finalMessageFromPayload(payload);
347
 
 
 
348
  if (isStreamDone(data, eventName, payload)) {
349
- return { done: true, finalMessage };
 
 
 
 
 
 
 
350
  }
351
 
352
- if (eventName === 'error') {
353
  throw new BackendResponseError(
354
  backendMessageFromPayload(payload) ?? '',
355
  );
@@ -362,15 +420,20 @@ function handleStreamPayload(
362
  }
363
 
364
  if (finalMessage) {
 
 
365
  return {
366
- done: false,
367
- finalMessage,
 
 
368
  };
369
  }
370
 
371
  const delta = streamTextFromPayload(payload);
372
 
373
  if (delta) {
 
374
  onDelta(delta);
375
  }
376
 
@@ -384,6 +447,14 @@ async function streamChatMessage(
384
  onDelta: (delta: string) => void,
385
  ): Promise<StoreMessageJsonResponse> {
386
  const token = csrfToken();
 
 
 
 
 
 
 
 
387
  const response = await fetch(storeMahasiswaMessage.url(sessionId), {
388
  body: JSON.stringify({
389
  content,
@@ -399,15 +470,26 @@ async function streamChatMessage(
399
  method: 'POST',
400
  });
401
 
 
 
 
 
 
 
 
402
  if (!response.ok) {
403
  const payload: unknown = await response.json().catch(() => undefined);
404
 
 
 
405
  throw new BackendResponseError(
406
  backendMessageFromPayload(payload) ?? '',
407
  );
408
  }
409
 
410
  if (!response.body) {
 
 
411
  return {};
412
  }
413
 
@@ -420,6 +502,8 @@ async function streamChatMessage(
420
  const processFrame = (frame: string): void => {
421
  const { data, eventName } = parseSseFrame(frame);
422
 
 
 
423
  if (!data) {
424
  return;
425
  }
@@ -430,6 +514,8 @@ async function streamChatMessage(
430
  };
431
 
432
  const processRawData = (data: string): void => {
 
 
433
  const result = handleStreamPayload(data, undefined, onDelta);
434
  finalMessage = result.finalMessage ?? finalMessage;
435
  shouldStop = shouldStop || result.done;
@@ -437,6 +523,7 @@ async function streamChatMessage(
437
 
438
  while (true) {
439
  if (shouldStop) {
 
440
  await reader.cancel();
441
  break;
442
  }
@@ -444,44 +531,40 @@ async function streamChatMessage(
444
  const { done, value } = await reader.read();
445
 
446
  if (done) {
 
447
  break;
448
  }
449
 
450
  const chunk = decoder.decode(value, { stream: true });
451
 
452
- if (
453
- buffer !== '' ||
454
- chunk.includes('data:') ||
455
- chunk.includes('event:')
456
- ) {
457
- buffer += chunk.replace(/\r\n/g, '\n');
458
-
459
- let boundaryIndex = buffer.indexOf('\n\n');
460
 
461
- while (boundaryIndex !== -1) {
462
- const frame = buffer.slice(0, boundaryIndex);
463
- buffer = buffer.slice(boundaryIndex + 2);
464
- processFrame(frame);
465
 
466
- if (shouldStop) {
467
- await reader.cancel();
468
- break;
469
- }
470
 
471
- boundaryIndex = buffer.indexOf('\n\n');
472
- }
 
 
473
 
474
  if (shouldStop) {
 
 
475
  break;
476
  }
477
 
478
- continue;
479
  }
480
 
481
- processRawData(chunk);
 
 
482
  }
483
 
484
- const remaining = `${buffer}${decoder.decode()}`.trim();
 
 
485
 
486
  if (remaining) {
487
  if (remaining.includes('data:') || remaining.includes('event:')) {
@@ -491,6 +574,8 @@ async function streamChatMessage(
491
  }
492
  }
493
 
 
 
494
  return {
495
  assistantMessage: finalMessage,
496
  };
@@ -563,7 +648,12 @@ function ChatMessage({
563
 
564
  <div className="student-message-bubble">
565
  <p className="student-message-content">
566
- {!isUser && shouldAnimateTyping ? (
 
 
 
 
 
567
  <TypingText
568
  key={message.uuid_id}
569
  text={message.content}
@@ -624,13 +714,10 @@ export default function MahasiswaChat({
624
  isDirectChatMode = false,
625
  isStreamChatMode = false,
626
  messages: initialMessages,
627
- pendingInitialChat,
628
  selectedCourseId: initialSelectedCourseId,
629
  sessionId,
630
  }: MahasiswaChatProps) {
631
- const pendingInitialChatRef = useRef(pendingInitialChat ?? undefined);
632
  const isWaitingForDirectAssistantRef = useRef(false);
633
- const hasStartedPendingInitialChatRef = useRef(false);
634
  const streamingAssistantMessageRef = useRef<
635
  ChatMessageResponse | undefined
636
  >(undefined);
@@ -640,29 +727,18 @@ export default function MahasiswaChat({
640
  const knownMessageIdsRef = useRef(
641
  new Set(initialMessages.map((message) => message.uuid_id)),
642
  );
643
- const [pendingUserMessage] = useState<ChatMessageResponse | undefined>(
644
- pendingInitialChat
645
- ? createLocalUserMessage(pendingInitialChat.content)
646
- : undefined,
647
- );
648
- const pendingUserMessageRef = useRef(pendingUserMessage);
649
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
650
  const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
651
  useState<string>();
652
  const [streamingAssistantMessageId, setStreamingAssistantMessageId] =
653
  useState<string>();
654
- const [isProcessing, setIsProcessing] = useState(
655
- Boolean(pendingInitialChat),
656
- );
657
  const [optimisticMessages, setOptimisticMessages] = useState<
658
  ChatMessageResponse[]
659
- >(() => (pendingUserMessage ? [pendingUserMessage] : []));
660
  const [question, setQuestion] = useState('');
661
  const [selectedCourseId, setSelectedCourseId] = useState(
662
- pendingInitialChat?.courseId ??
663
- initialSelectedCourseId ??
664
- currentSession?.course_id ??
665
- '',
666
  );
667
  const studentName = useStoredStudentName();
668
  const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -868,17 +944,6 @@ export default function MahasiswaChat({
868
 
869
  useEffect(() => clearStreamingTyping, [clearStreamingTyping]);
870
 
871
- const reloadChatHistory = useCallback((): void => {
872
- router.reload({
873
- onSuccess: () => {
874
- streamingAssistantMessageRef.current = undefined;
875
- setStreamingAssistantMessageId(undefined);
876
- setOptimisticMessages([]);
877
- },
878
- only: ['messages', 'chatSessions', 'currentSession'],
879
- });
880
- }, []);
881
-
882
  const sendMessage = useCallback(
883
  (
884
  content: string,
@@ -891,7 +956,13 @@ export default function MahasiswaChat({
891
 
892
  void (async () => {
893
  try {
894
- const response = isStreamChatMode
 
 
 
 
 
 
895
  ? await streamChatMessage(
896
  sessionId,
897
  content,
@@ -900,18 +971,19 @@ export default function MahasiswaChat({
900
  )
901
  : await storeChatMessage(sessionId, content, courseId);
902
 
903
- pendingInitialChatRef.current = undefined;
904
-
905
- if (isStreamChatMode) {
906
  await waitForStreamingTyping();
907
  }
908
 
909
  if (response.assistantMessage) {
910
  const assistantMessage = response.assistantMessage;
911
 
912
- if (isStreamChatMode) {
913
  replaceStreamingAssistantMessage(assistantMessage);
914
  } else {
 
 
 
915
  setOptimisticMessages((currentMessages) =>
916
  appendUniqueMessages(currentMessages, [
917
  assistantMessage,
@@ -920,12 +992,9 @@ export default function MahasiswaChat({
920
  }
921
  }
922
 
923
- if (isStreamChatMode) {
924
- reloadChatHistory();
925
- }
926
-
927
  setQuestion('');
928
  } catch (error) {
 
929
  isWaitingForDirectAssistantRef.current = false;
930
  const streamingMessageId =
931
  streamingAssistantMessageRef.current?.uuid_id;
@@ -951,7 +1020,6 @@ export default function MahasiswaChat({
951
  );
952
  }
953
 
954
- pendingInitialChatRef.current = undefined;
955
  setQuestion(content);
956
  setLocalBackendMessage(
957
  error instanceof Error && error.message
@@ -959,10 +1027,28 @@ export default function MahasiswaChat({
959
  : undefined,
960
  );
961
  } finally {
 
 
 
 
962
  clearStreamingTyping();
963
  streamingAssistantMessageRef.current = undefined;
964
  setStreamingAssistantMessageId(undefined);
965
  setIsProcessing(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  }
967
  })();
968
  },
@@ -971,33 +1057,13 @@ export default function MahasiswaChat({
971
  clearStreamingTyping,
972
  isDirectChatMode,
973
  isStreamChatMode,
974
- reloadChatHistory,
975
  replaceStreamingAssistantMessage,
 
976
  sessionId,
977
  waitForStreamingTyping,
978
  ],
979
  );
980
 
981
- useEffect(() => {
982
- const pendingInitialChat = pendingInitialChatRef.current;
983
-
984
- if (!pendingInitialChat || hasStartedPendingInitialChatRef.current) {
985
- return;
986
- }
987
-
988
- hasStartedPendingInitialChatRef.current = true;
989
- const pendingMessage = pendingUserMessageRef.current;
990
- const timeoutId = window.setTimeout(() => {
991
- sendMessage(
992
- pendingInitialChat.content,
993
- pendingInitialChat.courseId,
994
- pendingMessage,
995
- );
996
- }, 0);
997
-
998
- return () => window.clearTimeout(timeoutId);
999
- }, [sendMessage]);
1000
-
1001
  const handleDeleteSession = (
1002
  targetSessionId: string,
1003
  ): Promise<DeleteSessionResult> => {
 
20
  import { destroy as destroyMahasiswaSession } from '@/routes/mahasiswa';
21
  import { store as storeMahasiswaMessage } from '@/routes/mahasiswa/messages';
22
 
 
 
 
 
 
23
  type StoreMessageJsonResponse = {
24
  assistantMessage?: ChatMessageResponse;
25
  };
 
44
  isDirectChatMode?: boolean;
45
  isStreamChatMode?: boolean;
46
  messages: ChatMessageResponse[];
 
47
  selectedCourseId?: string | null;
48
  sessionId: string;
49
  };
 
126
  );
127
  }
128
 
129
+ function isStreamDebugEnabled(): boolean {
130
+ if (import.meta.env.DEV) {
131
+ return true;
132
+ }
133
+
134
+ if (typeof window === 'undefined') {
135
+ return false;
136
+ }
137
+
138
+ if (new URLSearchParams(window.location.search).has('debug_stream')) {
139
+ return true;
140
+ }
141
+
142
+ try {
143
+ return window.localStorage.getItem('rag_stream_debug') === '1';
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function debugStream(label: string, value?: unknown): void {
150
+ if (!isStreamDebugEnabled()) {
151
+ return;
152
+ }
153
+
154
+ if (value === undefined) {
155
+ console.log(`[rag-stream] ${label}`);
156
+
157
+ return;
158
+ }
159
+
160
+ console.log(`[rag-stream] ${label}`, value);
161
+ }
162
+
163
  async function storeChatMessage(
164
  sessionId: string,
165
  content: string,
 
239
  'completed',
240
  'done',
241
  'end',
242
+ 'final',
243
+ 'finalized',
244
  'finish',
245
  'finished',
246
+ 'message_done',
247
+ 'stop',
248
  'success',
249
  ].includes(value ?? '');
250
  }
 
350
 
351
  function parseSseFrame(frame: string): { data: string; eventName?: string } {
352
  const dataLines: string[] = [];
353
+ const fallbackLines: string[] = [];
354
  let eventName: string | undefined;
355
 
356
  for (const line of frame.split('\n')) {
357
+ if (line.startsWith(':')) {
358
+ continue;
359
+ }
360
+
361
  if (line.startsWith('event:')) {
362
  eventName = line.slice('event:'.length).trim();
363
  continue;
 
365
 
366
  if (line.startsWith('data:')) {
367
  dataLines.push(line.slice('data:'.length).trimStart());
368
+ continue;
369
  }
370
+
371
+ fallbackLines.push(line);
372
  }
373
 
374
  return {
375
+ data:
376
+ dataLines.length > 0
377
+ ? dataLines.join('\n')
378
+ : fallbackLines.join('\n').trim(),
379
  eventName,
380
  };
381
  }
382
 
383
+ function isAssistantMessage(
384
+ message: ChatMessageResponse | undefined,
385
+ ): message is ChatMessageResponse {
386
+ return Boolean(message && !isUserMessage(message.role));
387
+ }
388
+
389
  function handleStreamPayload(
390
  data: string,
391
  eventName: string | undefined,
 
394
  const payload = parseStreamPayload(data);
395
  const finalMessage = finalMessageFromPayload(payload);
396
 
397
+ debugStream('payload', { data, eventName, payload });
398
+
399
  if (isStreamDone(data, eventName, payload)) {
400
+ debugStream('done-status', { data, eventName, payload });
401
+
402
+ return {
403
+ done: true,
404
+ finalMessage: isAssistantMessage(finalMessage)
405
+ ? finalMessage
406
+ : undefined,
407
+ };
408
  }
409
 
410
+ if (eventName?.toLowerCase() === 'error') {
411
  throw new BackendResponseError(
412
  backendMessageFromPayload(payload) ?? '',
413
  );
 
420
  }
421
 
422
  if (finalMessage) {
423
+ debugStream('final-message', finalMessage);
424
+
425
  return {
426
+ done: isAssistantMessage(finalMessage),
427
+ finalMessage: isAssistantMessage(finalMessage)
428
+ ? finalMessage
429
+ : undefined,
430
  };
431
  }
432
 
433
  const delta = streamTextFromPayload(payload);
434
 
435
  if (delta) {
436
+ debugStream('delta', delta);
437
  onDelta(delta);
438
  }
439
 
 
447
  onDelta: (delta: string) => void,
448
  ): Promise<StoreMessageJsonResponse> {
449
  const token = csrfToken();
450
+
451
+ debugStream('request:start', {
452
+ content,
453
+ courseId,
454
+ sessionId,
455
+ url: storeMahasiswaMessage.url(sessionId),
456
+ });
457
+
458
  const response = await fetch(storeMahasiswaMessage.url(sessionId), {
459
  body: JSON.stringify({
460
  content,
 
470
  method: 'POST',
471
  });
472
 
473
+ debugStream('response', {
474
+ contentType: response.headers.get('content-type'),
475
+ ok: response.ok,
476
+ status: response.status,
477
+ statusText: response.statusText,
478
+ });
479
+
480
  if (!response.ok) {
481
  const payload: unknown = await response.json().catch(() => undefined);
482
 
483
+ debugStream('response:error-payload', payload);
484
+
485
  throw new BackendResponseError(
486
  backendMessageFromPayload(payload) ?? '',
487
  );
488
  }
489
 
490
  if (!response.body) {
491
+ debugStream('response:no-body');
492
+
493
  return {};
494
  }
495
 
 
502
  const processFrame = (frame: string): void => {
503
  const { data, eventName } = parseSseFrame(frame);
504
 
505
+ debugStream('frame', { data, eventName, frame });
506
+
507
  if (!data) {
508
  return;
509
  }
 
514
  };
515
 
516
  const processRawData = (data: string): void => {
517
+ debugStream('raw-data', data);
518
+
519
  const result = handleStreamPayload(data, undefined, onDelta);
520
  finalMessage = result.finalMessage ?? finalMessage;
521
  shouldStop = shouldStop || result.done;
 
523
 
524
  while (true) {
525
  if (shouldStop) {
526
+ debugStream('reader:cancel-before-read');
527
  await reader.cancel();
528
  break;
529
  }
 
531
  const { done, value } = await reader.read();
532
 
533
  if (done) {
534
+ debugStream('reader:done');
535
  break;
536
  }
537
 
538
  const chunk = decoder.decode(value, { stream: true });
539
 
540
+ debugStream('chunk', chunk);
 
 
 
 
 
 
 
541
 
542
+ buffer += chunk.replace(/\r\n/g, '\n');
 
 
 
543
 
544
+ let boundaryIndex = buffer.indexOf('\n\n');
 
 
 
545
 
546
+ while (boundaryIndex !== -1) {
547
+ const frame = buffer.slice(0, boundaryIndex);
548
+ buffer = buffer.slice(boundaryIndex + 2);
549
+ processFrame(frame);
550
 
551
  if (shouldStop) {
552
+ debugStream('reader:cancel-after-frame');
553
+ await reader.cancel();
554
  break;
555
  }
556
 
557
+ boundaryIndex = buffer.indexOf('\n\n');
558
  }
559
 
560
+ if (shouldStop) {
561
+ break;
562
+ }
563
  }
564
 
565
+ const remaining = shouldStop ? '' : `${buffer}${decoder.decode()}`.trim();
566
+
567
+ debugStream('remaining', remaining);
568
 
569
  if (remaining) {
570
  if (remaining.includes('data:') || remaining.includes('event:')) {
 
574
  }
575
  }
576
 
577
+ debugStream('request:finish', { finalMessage });
578
+
579
  return {
580
  assistantMessage: finalMessage,
581
  };
 
648
 
649
  <div className="student-message-bubble">
650
  <p className="student-message-content">
651
+ {!isUser && message.content === '' ? (
652
+ <LoadingIndicator
653
+ label="Thinking"
654
+ showSpinner={false}
655
+ />
656
+ ) : !isUser && shouldAnimateTyping ? (
657
  <TypingText
658
  key={message.uuid_id}
659
  text={message.content}
 
714
  isDirectChatMode = false,
715
  isStreamChatMode = false,
716
  messages: initialMessages,
 
717
  selectedCourseId: initialSelectedCourseId,
718
  sessionId,
719
  }: MahasiswaChatProps) {
 
720
  const isWaitingForDirectAssistantRef = useRef(false);
 
721
  const streamingAssistantMessageRef = useRef<
722
  ChatMessageResponse | undefined
723
  >(undefined);
 
727
  const knownMessageIdsRef = useRef(
728
  new Set(initialMessages.map((message) => message.uuid_id)),
729
  );
 
 
 
 
 
 
730
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
731
  const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
732
  useState<string>();
733
  const [streamingAssistantMessageId, setStreamingAssistantMessageId] =
734
  useState<string>();
735
+ const [isProcessing, setIsProcessing] = useState(false);
 
 
736
  const [optimisticMessages, setOptimisticMessages] = useState<
737
  ChatMessageResponse[]
738
+ >([]);
739
  const [question, setQuestion] = useState('');
740
  const [selectedCourseId, setSelectedCourseId] = useState(
741
+ initialSelectedCourseId ?? currentSession?.course_id ?? '',
 
 
 
742
  );
743
  const studentName = useStoredStudentName();
744
  const messagesEndRef = useRef<HTMLDivElement>(null);
 
944
 
945
  useEffect(() => clearStreamingTyping, [clearStreamingTyping]);
946
 
 
 
 
 
 
 
 
 
 
 
 
947
  const sendMessage = useCallback(
948
  (
949
  content: string,
 
956
 
957
  void (async () => {
958
  try {
959
+ const shouldUseStream = isStreamChatMode;
960
+
961
+ if (shouldUseStream) {
962
+ setStreamingAssistantContent('');
963
+ }
964
+
965
+ const response = shouldUseStream
966
  ? await streamChatMessage(
967
  sessionId,
968
  content,
 
971
  )
972
  : await storeChatMessage(sessionId, content, courseId);
973
 
974
+ if (shouldUseStream) {
 
 
975
  await waitForStreamingTyping();
976
  }
977
 
978
  if (response.assistantMessage) {
979
  const assistantMessage = response.assistantMessage;
980
 
981
+ if (shouldUseStream) {
982
  replaceStreamingAssistantMessage(assistantMessage);
983
  } else {
984
+ setAnimatedAssistantMessageId(
985
+ assistantMessage.uuid_id,
986
+ );
987
  setOptimisticMessages((currentMessages) =>
988
  appendUniqueMessages(currentMessages, [
989
  assistantMessage,
 
992
  }
993
  }
994
 
 
 
 
 
995
  setQuestion('');
996
  } catch (error) {
997
+ debugStream('request:error', error);
998
  isWaitingForDirectAssistantRef.current = false;
999
  const streamingMessageId =
1000
  streamingAssistantMessageRef.current?.uuid_id;
 
1020
  );
1021
  }
1022
 
 
1023
  setQuestion(content);
1024
  setLocalBackendMessage(
1025
  error instanceof Error && error.message
 
1027
  : undefined,
1028
  );
1029
  } finally {
1030
+ const emptyStreamingMessage =
1031
+ streamingAssistantMessageRef.current?.content === ''
1032
+ ? streamingAssistantMessageRef.current
1033
+ : undefined;
1034
  clearStreamingTyping();
1035
  streamingAssistantMessageRef.current = undefined;
1036
  setStreamingAssistantMessageId(undefined);
1037
  setIsProcessing(false);
1038
+
1039
+ if (emptyStreamingMessage) {
1040
+ setOptimisticMessages((currentMessages) =>
1041
+ currentMessages.filter(
1042
+ (message) =>
1043
+ message.uuid_id !==
1044
+ emptyStreamingMessage.uuid_id,
1045
+ ),
1046
+ );
1047
+ setLocalBackendMessage(
1048
+ 'Tidak ada respons dari AI. Silakan coba lagi.',
1049
+ );
1050
+ setQuestion(content);
1051
+ }
1052
  }
1053
  })();
1054
  },
 
1057
  clearStreamingTyping,
1058
  isDirectChatMode,
1059
  isStreamChatMode,
 
1060
  replaceStreamingAssistantMessage,
1061
+ setStreamingAssistantContent,
1062
  sessionId,
1063
  waitForStreamingTyping,
1064
  ],
1065
  );
1066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  const handleDeleteSession = (
1068
  targetSessionId: string,
1069
  ): Promise<DeleteSessionResult> => {
tests/Feature/MahasiswaDashboardTest.php CHANGED
@@ -101,7 +101,9 @@ function studentAuthUserCookie(): string
101
  'title' => 'Apa itu stack?',
102
  ])
103
  ->assertRedirect(route('mahasiswa.show', ['sessionId' => 'session-1']))
104
- ->assertSessionHas('sevima_raghub_pending_initial_chat.session-1')
 
 
105
  ->assertSessionHasNoErrors();
106
 
107
  Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
@@ -111,6 +113,67 @@ function studentAuthUserCookie(): string
111
  && $request['title'] === 'Apa itu stack?');
112
  });
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  test('mahasiswa chat detail page can be rendered with direct session messages', function () {
115
  config([
116
  'services.rag.base_url' => 'https://rag.test/api/v1',
@@ -248,6 +311,73 @@ function studentAuthUserCookie(): string
248
  && $request['content'] === 'Apa itu stack?');
249
  });
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  test('mahasiswa chat message uses backend error message', function () {
252
  config([
253
  'services.rag.base_url' => 'https://rag.test/api/v1',
 
101
  'title' => 'Apa itu stack?',
102
  ])
103
  ->assertRedirect(route('mahasiswa.show', ['sessionId' => 'session-1']))
104
+ ->assertSessionHas('sevima_raghub_pending_initial_chat.session-1', fn (array $pendingChat): bool => $pendingChat['content'] === 'Apa itu stack?'
105
+ && $pendingChat['courseId'] === '101'
106
+ && $pendingChat['session']['uuid_id'] === 'session-1')
107
  ->assertSessionHasNoErrors();
108
 
109
  Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
 
113
  && $request['title'] === 'Apa itu stack?');
114
  });
115
 
116
+ test('mahasiswa new chat detail page renders pending chat without fetching empty history', function () {
117
+ config([
118
+ 'services.rag.base_url' => 'https://rag.test/api/v1',
119
+ 'services.rag.chat_mode' => 'stream',
120
+ ]);
121
+
122
+ Http::fake([
123
+ 'https://rag.test/api/v1/courses*' => Http::response([
124
+ 'data' => [
125
+ [
126
+ 'id' => '101',
127
+ 'title' => 'Struktur Data',
128
+ ],
129
+ ],
130
+ 'limit' => 100,
131
+ 'page' => 1,
132
+ 'total' => 1,
133
+ ]),
134
+ 'https://rag.test/api/v1/chats/sessions*' => Http::response([
135
+ 'data' => [],
136
+ 'pagination' => [
137
+ 'limit' => 20,
138
+ 'page' => 1,
139
+ 'total' => 0,
140
+ 'total_pages' => 0,
141
+ ],
142
+ ]),
143
+ ]);
144
+
145
+ $this->withCookie('sevima_raghub_auth_token', 'token')
146
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
147
+ ->withSession([
148
+ 'sevima_raghub_pending_initial_chat.session-1' => [
149
+ 'content' => 'Apa itu stack?',
150
+ 'courseId' => '101',
151
+ 'session' => [
152
+ 'course_id' => '101',
153
+ 'created_at' => '2026-05-01T10:00:00Z',
154
+ 'message_count' => 0,
155
+ 'title' => 'Apa itu stack?',
156
+ 'updated_at' => '2026-05-01T10:00:00Z',
157
+ 'user_id' => 'student-1',
158
+ 'uuid_id' => 'session-1',
159
+ ],
160
+ ],
161
+ ])
162
+ ->get(route('mahasiswa.show', ['sessionId' => 'session-1']))
163
+ ->assertOk()
164
+ ->assertInertia(fn (Assert $page) => $page
165
+ ->component('mahasiswa-chat')
166
+ ->where('sessionId', 'session-1')
167
+ ->where('currentSession.uuid_id', 'session-1')
168
+ ->where('messages', [])
169
+ ->where('pendingInitialChat.content', 'Apa itu stack?')
170
+ ->where('pendingInitialChat.courseId', '101')
171
+ ->where('selectedCourseId', '101'));
172
+
173
+ Http::assertNotSent(fn (HttpRequest $request): bool => $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1'
174
+ || $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1/messages');
175
+ });
176
+
177
  test('mahasiswa chat detail page can be rendered with direct session messages', function () {
178
  config([
179
  'services.rag.base_url' => 'https://rag.test/api/v1',
 
311
  && $request['content'] === 'Apa itu stack?');
312
  });
313
 
314
+ test('mahasiswa stream mode json chat message uses rest endpoint', function () {
315
+ config([
316
+ 'services.rag.base_url' => 'https://rag.test/api/v1',
317
+ 'services.rag.chat_mode' => 'stream',
318
+ ]);
319
+
320
+ Http::fake([
321
+ 'https://rag.test/api/v1/chats/sessions/session-1/messages' => Http::response([
322
+ 'content' => 'Stack adalah struktur data LIFO.',
323
+ 'created_at' => '2026-05-01T10:01:00Z',
324
+ 'role' => 'assistant',
325
+ 'sources' => [],
326
+ 'uuid_id' => 'message-1',
327
+ ]),
328
+ ]);
329
+
330
+ $this->withCookie('sevima_raghub_auth_token', 'token')
331
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
332
+ ->withHeaders(['Accept' => 'application/json'])
333
+ ->postJson(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
334
+ 'content' => 'Apa itu stack?',
335
+ 'course_id' => '101',
336
+ ])
337
+ ->assertSuccessful()
338
+ ->assertJsonPath('assistantMessage.content', 'Stack adalah struktur data LIFO.');
339
+
340
+ Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
341
+ && $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1/messages'
342
+ && $request->hasHeader('Authorization', 'Bearer token')
343
+ && $request['content'] === 'Apa itu stack?');
344
+ });
345
+
346
+ test('mahasiswa stream chat message forwards backend server sent events', function () {
347
+ config([
348
+ 'services.rag.base_url' => 'https://rag.test/api/v1',
349
+ 'services.rag.chat_mode' => 'stream',
350
+ ]);
351
+
352
+ $streamBody = "data: {\"content\":\"Stack adalah struktur data LIFO.\"}\n\n";
353
+
354
+ Http::fake([
355
+ 'https://rag.test/api/v1/chats/sessions/session-1/stream' => Http::response(
356
+ $streamBody,
357
+ 200,
358
+ ['Content-Type' => 'text/event-stream'],
359
+ ),
360
+ ]);
361
+
362
+ $this->withCookie('sevima_raghub_auth_token', 'token')
363
+ ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
364
+ ->withHeaders(['Accept' => 'text/event-stream'])
365
+ ->post(route('mahasiswa.messages.store', ['sessionId' => 'session-1']), [
366
+ 'content' => 'Apa itu stack?',
367
+ 'course_id' => '101',
368
+ ])
369
+ ->assertSuccessful()
370
+ ->assertStreamed()
371
+ ->assertHeader('Content-Type', 'text/event-stream; charset=utf-8')
372
+ ->assertStreamedContent($streamBody);
373
+
374
+ Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
375
+ && $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1/stream'
376
+ && $request->hasHeader('Authorization', 'Bearer token')
377
+ && $request->hasHeader('Accept', 'text/event-stream')
378
+ && $request['content'] === 'Apa itu stack?');
379
+ });
380
+
381
  test('mahasiswa chat message uses backend error message', function () {
382
  config([
383
  'services.rag.base_url' => 'https://rag.test/api/v1',