wepee commited on
Commit
ebd361e
·
1 Parent(s): 32177e2

add: page /mahasiswa/uuid

Browse files
.env.example CHANGED
@@ -64,3 +64,4 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
64
 
65
  VITE_APP_NAME="${APP_NAME}"
66
  VITE_API_BASE_URL=https://lifedebugger-raghub-sevima-test.hf.space/api/v1
 
 
64
 
65
  VITE_APP_NAME="${APP_NAME}"
66
  VITE_API_BASE_URL=https://lifedebugger-raghub-sevima-test.hf.space/api/v1
67
+ VITE_CHAT_API_MODE=direct
resources/css/app.css CHANGED
@@ -144,6 +144,7 @@ :root {
144
  --register-input-border: var(--app-border-soft);
145
  --register-button-border: var(--app-border-soft);
146
  --register-placeholder: var(--welcome-subtitle);
 
147
  --student-bg: var(--app-bg);
148
  --student-sidebar-bg: var(--app-sidebar-bg);
149
  --student-surface: var(--app-surface);
@@ -241,6 +242,7 @@ .dark {
241
  --register-input-border: var(--app-border);
242
  --register-button-border: var(--app-border);
243
  --register-placeholder: var(--welcome-subtitle);
 
244
  --student-bg: var(--app-bg);
245
  --student-sidebar-bg: var(--app-sidebar-bg);
246
  --student-surface: var(--app-surface);
@@ -513,6 +515,10 @@ @layer components {
513
  color: var(--welcome-accent);
514
  }
515
 
 
 
 
 
516
  .student-dashboard-shell {
517
  @apply min-h-svh;
518
  background-color: var(--student-bg);
@@ -613,6 +619,10 @@ @layer components {
613
  padding: 12px 20px !important;
614
  }
615
 
 
 
 
 
616
  .student-history-menu-button {
617
  @apply rounded-[8px] text-left shadow-none;
618
  color: var(--student-muted);
@@ -628,6 +638,24 @@ @layer components {
628
  color: var(--student-text);
629
  }
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  .student-history-menu-icon {
632
  @apply hidden;
633
  color: var(--student-text);
@@ -816,7 +844,7 @@ @layer components {
816
 
817
  .student-status {
818
  @apply -mt-5 mb-5 text-center text-xs font-medium;
819
- color: var(--student-primary);
820
  }
821
 
822
  .student-course-row {
@@ -867,4 +895,127 @@ @layer components {
867
  background-color: var(--student-primary) !important;
868
  color: var(--student-primary-foreground) !important;
869
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  }
 
144
  --register-input-border: var(--app-border-soft);
145
  --register-button-border: var(--app-border-soft);
146
  --register-placeholder: var(--welcome-subtitle);
147
+ --register-danger: var(--app-danger);
148
  --student-bg: var(--app-bg);
149
  --student-sidebar-bg: var(--app-sidebar-bg);
150
  --student-surface: var(--app-surface);
 
242
  --register-input-border: var(--app-border);
243
  --register-button-border: var(--app-border);
244
  --register-placeholder: var(--welcome-subtitle);
245
+ --register-danger: var(--app-danger);
246
  --student-bg: var(--app-bg);
247
  --student-sidebar-bg: var(--app-sidebar-bg);
248
  --student-surface: var(--app-surface);
 
515
  color: var(--welcome-accent);
516
  }
517
 
518
+ .login-error {
519
+ color: var(--register-danger);
520
+ }
521
+
522
  .student-dashboard-shell {
523
  @apply min-h-svh;
524
  background-color: var(--student-bg);
 
619
  padding: 12px 20px !important;
620
  }
621
 
622
+ .student-history-menu-item {
623
+ @apply relative;
624
+ }
625
+
626
  .student-history-menu-button {
627
  @apply rounded-[8px] text-left shadow-none;
628
  color: var(--student-muted);
 
638
  color: var(--student-text);
639
  }
640
 
641
+ .student-history-delete {
642
+ @apply absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-[6px] opacity-0 transition-opacity;
643
+ color: var(--student-danger);
644
+ }
645
+
646
+ .student-history-menu-item:hover .student-history-delete,
647
+ .student-history-menu-button[data-active='true'] + .student-history-delete {
648
+ @apply opacity-100;
649
+ }
650
+
651
+ .student-history-delete:hover {
652
+ background-color: color-mix(
653
+ in oklab,
654
+ var(--student-danger) 12%,
655
+ transparent
656
+ );
657
+ }
658
+
659
  .student-history-menu-icon {
660
  @apply hidden;
661
  color: var(--student-text);
 
844
 
845
  .student-status {
846
  @apply -mt-5 mb-5 text-center text-xs font-medium;
847
+ color: var(--student-danger);
848
  }
849
 
850
  .student-course-row {
 
895
  background-color: var(--student-primary) !important;
896
  color: var(--student-primary-foreground) !important;
897
  }
898
+
899
+ .student-chat-stage--session {
900
+ @apply flex-col items-stretch justify-between overflow-hidden px-5 py-0;
901
+ }
902
+
903
+ .student-chat-thread {
904
+ @apply min-h-0 w-full flex-1 overflow-y-auto py-8;
905
+ }
906
+
907
+ .student-chat-messages {
908
+ @apply flex min-h-full w-full flex-col gap-5;
909
+ }
910
+
911
+ .student-chat-loading {
912
+ @apply mt-24 text-center text-sm;
913
+ color: var(--student-muted);
914
+ }
915
+
916
+ .student-chat-status {
917
+ @apply mt-0 mb-4;
918
+ }
919
+
920
+ .student-message {
921
+ @apply flex w-full items-start gap-3;
922
+ }
923
+
924
+ .student-message--assistant {
925
+ @apply justify-start;
926
+ }
927
+
928
+ .student-message--user {
929
+ @apply justify-end;
930
+ }
931
+
932
+ .student-message-avatar {
933
+ @apply flex size-9 shrink-0 items-center justify-center rounded-[10px];
934
+ }
935
+
936
+ .student-message-avatar--assistant {
937
+ background-color: var(--student-primary);
938
+ color: var(--student-primary-foreground);
939
+ }
940
+
941
+ .student-message-avatar--user {
942
+ background-color: var(--student-primary-soft);
943
+ color: var(--student-primary);
944
+ }
945
+
946
+ .student-message-bubble {
947
+ @apply max-w-[min(420px,75%)] rounded-[10px] border px-4 py-3 text-xs shadow-none;
948
+ background-color: var(--student-surface);
949
+ border-color: var(--student-border-strong);
950
+ color: var(--student-primary);
951
+ }
952
+
953
+ .student-message--user .student-message-bubble {
954
+ background-color: var(--student-primary);
955
+ border-color: var(--student-primary);
956
+ color: var(--student-primary-foreground);
957
+ }
958
+
959
+ .student-message-content {
960
+ @apply leading-relaxed whitespace-pre-wrap;
961
+ }
962
+
963
+ .student-message-time {
964
+ @apply mt-2 block text-[10px];
965
+ color: color-mix(in oklab, currentColor 72%, transparent);
966
+ }
967
+
968
+ .student-message-sources {
969
+ @apply mt-3 flex flex-wrap gap-2;
970
+ }
971
+
972
+ .student-message-source {
973
+ @apply rounded-full border px-2 py-1 text-[10px];
974
+ border-color: color-mix(
975
+ in oklab,
976
+ var(--student-primary) 24%,
977
+ transparent
978
+ );
979
+ color: inherit;
980
+ }
981
+
982
+ .student-message-bubble--typing {
983
+ @apply text-[11px];
984
+ color: var(--student-muted);
985
+ }
986
+
987
+ .student-chat-composer {
988
+ @apply w-full shrink-0 pb-8;
989
+ }
990
+
991
+ .student-composer-card--dock {
992
+ @apply w-full;
993
+ }
994
+
995
+ .student-composer-card--dock .student-composer-content {
996
+ @apply min-h-[116px];
997
+ }
998
+
999
+ .student-composer-card--dock .student-question-row textarea {
1000
+ @apply min-h-[38px];
1001
+ }
1002
+
1003
+ .student-chat-updated-at {
1004
+ @apply mt-3 text-center text-[11px];
1005
+ color: var(--student-muted);
1006
+ }
1007
+
1008
+ @media (max-width: 640px) {
1009
+ .student-chat-stage--session {
1010
+ @apply px-3;
1011
+ }
1012
+
1013
+ .student-message-bubble {
1014
+ @apply max-w-[calc(100vw_-_7rem)];
1015
+ }
1016
+
1017
+ .student-chat-composer {
1018
+ @apply pb-4;
1019
+ }
1020
+ }
1021
  }
resources/js/components/auth/register-role-form.tsx CHANGED
@@ -73,14 +73,16 @@ function RegisterField({
73
 
74
  export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
75
  const content = roleContent[role];
76
- const [backendMessage, setBackendMessage] = useState<string>();
 
77
  const [isProcessing, setIsProcessing] = useState(false);
78
 
79
  const handleSubmit = async (
80
  event: FormEvent<HTMLFormElement>,
81
  ): Promise<void> => {
82
  event.preventDefault();
83
- setBackendMessage(undefined);
 
84
  setIsProcessing(true);
85
 
86
  const formData = new FormData(event.currentTarget);
@@ -94,9 +96,9 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
94
  identity_number: String(formData.get('identity_number') ?? ''),
95
  });
96
 
97
- setBackendMessage(response.message);
98
  } catch (error) {
99
- setBackendMessage(getAuthErrorMessage(error));
100
  } finally {
101
  setIsProcessing(false);
102
  }
@@ -166,8 +168,17 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
166
  </Button>
167
  </form>
168
 
169
- {backendMessage && (
170
- <p className="login-status">{backendMessage}</p>
 
 
 
 
 
 
 
 
 
171
  )}
172
  </CardContent>
173
  </Card>
 
73
 
74
  export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
75
  const content = roleContent[role];
76
+ const [backendError, setBackendError] = useState<string>();
77
+ const [backendStatus, setBackendStatus] = useState<string>();
78
  const [isProcessing, setIsProcessing] = useState(false);
79
 
80
  const handleSubmit = async (
81
  event: FormEvent<HTMLFormElement>,
82
  ): Promise<void> => {
83
  event.preventDefault();
84
+ setBackendError(undefined);
85
+ setBackendStatus(undefined);
86
  setIsProcessing(true);
87
 
88
  const formData = new FormData(event.currentTarget);
 
96
  identity_number: String(formData.get('identity_number') ?? ''),
97
  });
98
 
99
+ setBackendStatus(response.message);
100
  } catch (error) {
101
+ setBackendError(getAuthErrorMessage(error));
102
  } finally {
103
  setIsProcessing(false);
104
  }
 
168
  </Button>
169
  </form>
170
 
171
+ {backendError && (
172
+ <p
173
+ className="login-status login-error"
174
+ role="alert"
175
+ >
176
+ {backendError}
177
+ </p>
178
+ )}
179
+
180
+ {backendStatus && (
181
+ <p className="login-status">{backendStatus}</p>
182
  )}
183
  </CardContent>
184
  </Card>
resources/js/components/student/question-composer.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BookOpen, Send, Sparkles } from 'lucide-react';
2
+ import type { FormEvent } from 'react';
3
+
4
+ import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent } from '@/components/ui/card';
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '@/components/ui/select';
13
+ import type { CourseResponse } from '@/lib/rag';
14
+ import { cn } from '@/lib/utils';
15
+
16
+ export function QuestionComposer({
17
+ courses,
18
+ isCourseLocked = false,
19
+ isLoadingCourses,
20
+ isProcessing,
21
+ onCourseChange,
22
+ onQuestionChange,
23
+ onSubmit,
24
+ question,
25
+ selectedCourseId,
26
+ variant = 'empty',
27
+ }: {
28
+ courses: CourseResponse[];
29
+ isCourseLocked?: boolean;
30
+ isLoadingCourses: boolean;
31
+ isProcessing: boolean;
32
+ onCourseChange: (courseId: string) => void;
33
+ onQuestionChange: (question: string) => void;
34
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void;
35
+ question: string;
36
+ selectedCourseId: string;
37
+ variant?: 'dock' | 'empty';
38
+ }) {
39
+ return (
40
+ <Card
41
+ className={cn(
42
+ 'student-composer-card py-0',
43
+ variant === 'dock' && 'student-composer-card--dock',
44
+ )}
45
+ >
46
+ <CardContent className="student-composer-content">
47
+ <form className="student-composer-form" onSubmit={onSubmit}>
48
+ <div className="student-course-row">
49
+ <BookOpen className="student-course-icon size-5" />
50
+ <span className="student-course-label text-sm">
51
+ Mata Kuliah:
52
+ </span>
53
+
54
+ <Select
55
+ disabled={
56
+ isCourseLocked ||
57
+ isLoadingCourses ||
58
+ courses.length === 0
59
+ }
60
+ onValueChange={onCourseChange}
61
+ value={selectedCourseId}
62
+ >
63
+ <SelectTrigger
64
+ className="student-course-select"
65
+ size="sm"
66
+ >
67
+ <SelectValue placeholder="Pilih mata kuliah" />
68
+ </SelectTrigger>
69
+ <SelectContent>
70
+ {courses.map((course) => (
71
+ <SelectItem
72
+ key={course.id}
73
+ value={String(course.id)}
74
+ >
75
+ {course.title}
76
+ </SelectItem>
77
+ ))}
78
+ </SelectContent>
79
+ </Select>
80
+ </div>
81
+
82
+ <div className="student-composer-divider" />
83
+
84
+ <div className="student-question-row">
85
+ <Sparkles className="student-question-icon size-5" />
86
+ <textarea
87
+ aria-label="Pertanyaan"
88
+ className="text-base"
89
+ onChange={(event) =>
90
+ onQuestionChange(event.target.value)
91
+ }
92
+ placeholder="Tulis pertanyaanmu disini..."
93
+ value={question}
94
+ />
95
+ </div>
96
+
97
+ <Button
98
+ className="student-floating-send"
99
+ disabled={
100
+ isProcessing ||
101
+ selectedCourseId.length === 0 ||
102
+ question.trim().length === 0
103
+ }
104
+ size="icon"
105
+ type="submit"
106
+ >
107
+ <Send className="size-5" />
108
+ </Button>
109
+ </form>
110
+ </CardContent>
111
+ </Card>
112
+ );
113
+ }
resources/js/components/student/student-shell.tsx ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { router } from '@inertiajs/react';
2
+ import {
3
+ ChevronDown,
4
+ Clock3,
5
+ LogOut,
6
+ MessageSquare,
7
+ Trash2,
8
+ } from 'lucide-react';
9
+ import type { CSSProperties, ReactNode } from 'react';
10
+ import { useState, useSyncExternalStore } from 'react';
11
+
12
+ import AppearanceToggle from '@/components/appearance-toggle';
13
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
14
+ import { Button } from '@/components/ui/button';
15
+ import {
16
+ Sidebar,
17
+ SidebarContent,
18
+ SidebarFooter,
19
+ SidebarGroup,
20
+ SidebarGroupContent,
21
+ SidebarGroupLabel,
22
+ SidebarHeader,
23
+ SidebarInset,
24
+ SidebarMenu,
25
+ SidebarMenuButton,
26
+ SidebarMenuItem,
27
+ SidebarProvider,
28
+ SidebarRail,
29
+ SidebarTrigger,
30
+ useSidebar,
31
+ } from '@/components/ui/sidebar';
32
+ import { clearAuthSession, getStoredAuthUser } from '@/lib/auth';
33
+ import type { ChatSessionResponse } from '@/lib/rag';
34
+ import { login, mahasiswa } from '@/routes';
35
+ import { show as showMahasiswaSession } from '@/routes/mahasiswa';
36
+
37
+ const sidebarStyle = {
38
+ '--sidebar-width': '212px',
39
+ '--sidebar-width-icon': '64px',
40
+ } as CSSProperties;
41
+
42
+ export function getStoredStudentName(): string {
43
+ return getStoredAuthUser()?.name ?? 'Mahasiswa';
44
+ }
45
+
46
+ function subscribeToAuthStorage(onStoreChange: () => void): () => void {
47
+ if (typeof window === 'undefined') {
48
+ return () => {};
49
+ }
50
+
51
+ window.addEventListener('storage', onStoreChange);
52
+
53
+ return () => window.removeEventListener('storage', onStoreChange);
54
+ }
55
+
56
+ export function useStoredStudentName(): string {
57
+ return useSyncExternalStore(
58
+ subscribeToAuthStorage,
59
+ getStoredStudentName,
60
+ () => 'Mahasiswa',
61
+ );
62
+ }
63
+
64
+ export function isAbortError(error: unknown): boolean {
65
+ return error instanceof DOMException && error.name === 'AbortError';
66
+ }
67
+
68
+ export function formatSessionTime(value?: string): string {
69
+ if (!value) {
70
+ return '';
71
+ }
72
+
73
+ const date = new Date(value);
74
+
75
+ if (Number.isNaN(date.getTime())) {
76
+ return value;
77
+ }
78
+
79
+ return new Intl.DateTimeFormat('en-US', {
80
+ day: 'numeric',
81
+ hour: 'numeric',
82
+ minute: '2-digit',
83
+ month: 'short',
84
+ }).format(date);
85
+ }
86
+
87
+ function getInitials(name: string): string {
88
+ return name
89
+ .split(' ')
90
+ .filter(Boolean)
91
+ .slice(0, 2)
92
+ .map((part) => part[0]?.toUpperCase())
93
+ .join('');
94
+ }
95
+
96
+ function handleLogout(): void {
97
+ clearAuthSession();
98
+ router.visit(login());
99
+ }
100
+
101
+ function StudentSidebarLogo() {
102
+ const { state, toggleSidebar } = useSidebar();
103
+
104
+ const handleLogoClick = (): void => {
105
+ if (state === 'collapsed') {
106
+ toggleSidebar();
107
+ }
108
+ };
109
+
110
+ return (
111
+ <button
112
+ aria-label="Buka sidebar"
113
+ className="student-logo-action"
114
+ onClick={handleLogoClick}
115
+ type="button"
116
+ >
117
+ <span className="student-logo-mark">
118
+ <MessageSquare className="size-5" />
119
+ </span>
120
+ </button>
121
+ );
122
+ }
123
+
124
+ function StudentSidebar({
125
+ activeSessionId,
126
+ chatSessions,
127
+ isLoading,
128
+ onDeleteSession,
129
+ studentName,
130
+ }: {
131
+ activeSessionId?: string;
132
+ chatSessions: ChatSessionResponse[];
133
+ isLoading: boolean;
134
+ onDeleteSession?: (sessionId: string) => void;
135
+ studentName: string;
136
+ }) {
137
+ const [isHistoryOpen, setIsHistoryOpen] = useState(true);
138
+ const { state } = useSidebar();
139
+ const isSidebarCollapsed = state === 'collapsed';
140
+
141
+ return (
142
+ <Sidebar className="student-sidebar" collapsible="icon">
143
+ <SidebarHeader className="student-sidebar-top">
144
+ <StudentSidebarLogo />
145
+ <SidebarTrigger className="student-sidebar-trigger" />
146
+ </SidebarHeader>
147
+
148
+ <SidebarContent className="student-sidebar-content">
149
+ <SidebarGroup className="student-history-group">
150
+ {isSidebarCollapsed ? (
151
+ <SidebarGroupContent>
152
+ <SidebarMenu className="student-history-list">
153
+ <SidebarMenuItem>
154
+ <SidebarMenuButton
155
+ className="student-history-menu-button"
156
+ tooltip="Riwayat Chat"
157
+ >
158
+ <Clock3 className="student-history-menu-icon size-4" />
159
+ <span className="student-history-copy">
160
+ <span className="student-history-title">
161
+ Riwayat Chat
162
+ </span>
163
+ </span>
164
+ </SidebarMenuButton>
165
+ </SidebarMenuItem>
166
+ </SidebarMenu>
167
+ </SidebarGroupContent>
168
+ ) : (
169
+ <>
170
+ <SidebarGroupLabel asChild>
171
+ <button
172
+ aria-expanded={isHistoryOpen}
173
+ className="student-history-label"
174
+ onClick={() =>
175
+ setIsHistoryOpen(
176
+ (currentState) => !currentState,
177
+ )
178
+ }
179
+ type="button"
180
+ >
181
+ <Clock3 className="size-4" />
182
+ <span>Riwayat Chat</span>
183
+ <ChevronDown className="student-history-chevron" />
184
+ </button>
185
+ </SidebarGroupLabel>
186
+
187
+ {isHistoryOpen && (
188
+ <SidebarGroupContent>
189
+ <SidebarMenu className="student-history-list">
190
+ {isLoading && (
191
+ <SidebarMenuItem>
192
+ <SidebarMenuButton
193
+ className="student-history-menu-button"
194
+ disabled
195
+ >
196
+ <Clock3 className="student-history-menu-icon size-4" />
197
+ <span className="student-history-copy">
198
+ <span className="student-history-title">
199
+ Memuat riwayat
200
+ </span>
201
+ </span>
202
+ </SidebarMenuButton>
203
+ </SidebarMenuItem>
204
+ )}
205
+
206
+ {chatSessions.map((session) => {
207
+ const sessionTime =
208
+ formatSessionTime(
209
+ session.last_message_at ??
210
+ session.updated_at ??
211
+ session.created_at,
212
+ );
213
+
214
+ return (
215
+ <SidebarMenuItem
216
+ className="student-history-menu-item"
217
+ key={session.uuid_id}
218
+ >
219
+ <SidebarMenuButton
220
+ className="student-history-menu-button"
221
+ isActive={
222
+ session.uuid_id ===
223
+ activeSessionId
224
+ }
225
+ onClick={() =>
226
+ router.visit(
227
+ showMahasiswaSession(
228
+ session.uuid_id,
229
+ ),
230
+ )
231
+ }
232
+ tooltip={session.title}
233
+ type="button"
234
+ >
235
+ <Clock3 className="student-history-menu-icon size-4" />
236
+ <span className="student-history-copy">
237
+ <span className="student-history-title">
238
+ {session.title}
239
+ </span>
240
+ {sessionTime && (
241
+ <span className="student-history-meta">
242
+ {
243
+ sessionTime
244
+ }
245
+ </span>
246
+ )}
247
+ </span>
248
+ </SidebarMenuButton>
249
+
250
+ {onDeleteSession && (
251
+ <button
252
+ aria-label={`Hapus sesi ${session.title}`}
253
+ className="student-history-delete"
254
+ onClick={(
255
+ event,
256
+ ) => {
257
+ event.stopPropagation();
258
+ onDeleteSession(
259
+ session.uuid_id,
260
+ );
261
+ }}
262
+ type="button"
263
+ >
264
+ <Trash2 className="size-3" />
265
+ </button>
266
+ )}
267
+ </SidebarMenuItem>
268
+ );
269
+ })}
270
+ </SidebarMenu>
271
+ </SidebarGroupContent>
272
+ )}
273
+ </>
274
+ )}
275
+ </SidebarGroup>
276
+ </SidebarContent>
277
+
278
+ <SidebarFooter className="student-sidebar-footer">
279
+ <SidebarMenu>
280
+ <SidebarMenuItem>
281
+ <SidebarMenuButton
282
+ className="student-user-menu-button"
283
+ tooltip={studentName}
284
+ >
285
+ <Avatar className="student-user-avatar">
286
+ <AvatarFallback>
287
+ {getInitials(studentName) || 'AK'}
288
+ </AvatarFallback>
289
+ </Avatar>
290
+ <span className="student-user-copy">
291
+ <span className="student-user-name">
292
+ {studentName}
293
+ </span>
294
+ <span className="student-user-role">
295
+ Mahasiswa
296
+ </span>
297
+ </span>
298
+ </SidebarMenuButton>
299
+ </SidebarMenuItem>
300
+
301
+ <SidebarMenuItem>
302
+ <SidebarMenuButton
303
+ className="student-logout-button"
304
+ onClick={handleLogout}
305
+ tooltip="Logout"
306
+ >
307
+ <LogOut className="size-4" />
308
+ <span>Logout</span>
309
+ </SidebarMenuButton>
310
+ </SidebarMenuItem>
311
+ </SidebarMenu>
312
+ </SidebarFooter>
313
+
314
+ <SidebarRail />
315
+ </Sidebar>
316
+ );
317
+ }
318
+
319
+ export function StudentShell({
320
+ activeSessionId,
321
+ chatSessions,
322
+ children,
323
+ isLoadingSessions,
324
+ onDeleteSession,
325
+ studentName,
326
+ }: {
327
+ activeSessionId?: string;
328
+ chatSessions: ChatSessionResponse[];
329
+ children: ReactNode;
330
+ isLoadingSessions: boolean;
331
+ onDeleteSession?: (sessionId: string) => void;
332
+ studentName: string;
333
+ }) {
334
+ return (
335
+ <SidebarProvider
336
+ className="student-dashboard-shell"
337
+ defaultOpen
338
+ style={sidebarStyle}
339
+ >
340
+ <StudentSidebar
341
+ activeSessionId={activeSessionId}
342
+ chatSessions={chatSessions}
343
+ isLoading={isLoadingSessions}
344
+ onDeleteSession={onDeleteSession}
345
+ studentName={studentName}
346
+ />
347
+
348
+ <SidebarInset className="student-workspace">
349
+ <header className="student-topbar">
350
+ <h1 className="student-brand-title">RAG Hub</h1>
351
+
352
+ <div className="student-topbar-actions">
353
+ <AppearanceToggle className="student-theme-toggle" />
354
+ <Button
355
+ className="student-new-session-button"
356
+ onClick={() => router.visit(mahasiswa())}
357
+ type="button"
358
+ >
359
+ + Sesi Baru
360
+ </Button>
361
+ </div>
362
+ </header>
363
+
364
+ {children}
365
+ </SidebarInset>
366
+ </SidebarProvider>
367
+ );
368
+ }
resources/js/lib/api.ts CHANGED
@@ -1,8 +1,8 @@
1
- const DEFAULT_API_BASE_URL =
2
- 'https://lifedebugger-raghub-sevima-test.hf.space/api/v1';
3
 
4
- const configuredApiBaseUrl =
5
- import.meta.env.VITE_API_BASE_URL?.trim() || DEFAULT_API_BASE_URL;
 
6
 
7
  export const API_BASE_URL = configuredApiBaseUrl.replace(/\/+$/, '');
8
 
 
1
+ const configuredApiBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim();
 
2
 
3
+ if (!configuredApiBaseUrl) {
4
+ throw new Error('VITE_API_BASE_URL is not configured.');
5
+ }
6
 
7
  export const API_BASE_URL = configuredApiBaseUrl.replace(/\/+$/, '');
8
 
resources/js/lib/rag.ts CHANGED
@@ -1,7 +1,18 @@
1
  import { api, ApiError, getBackendErrorMessage } from '@/lib/api';
2
 
 
 
 
 
 
 
 
 
 
 
 
3
  export type CourseResponse = {
4
- id: number;
5
  title: string;
6
  description?: string;
7
  };
@@ -31,6 +42,10 @@ export type ChatSessionListResponse = {
31
  pagination: unknown;
32
  };
33
 
 
 
 
 
34
  export type CreateChatSessionRequest = {
35
  course_id: string;
36
  title: string;
@@ -40,15 +55,30 @@ export type ChatRestRequest = {
40
  content: string;
41
  };
42
 
 
 
 
 
 
 
 
 
 
 
 
43
  export type ChatMessageResponse = {
44
  uuid_id: string;
45
  role: string;
46
  content: string;
47
- sources: {
48
- document_id: string;
49
- filename: string;
50
- excerpt: string;
51
- }[];
 
 
 
 
52
  created_at: string;
53
  };
54
 
@@ -63,6 +93,110 @@ type AuthenticatedListOptions = ListCoursesOptions & {
63
  courseId?: string;
64
  };
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  export const ragApi = {
67
  listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
68
  return api.get<CourseListResponse>('/courses', {
@@ -91,6 +225,35 @@ export const ragApi = {
91
  });
92
  },
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  sendMessage(sessionId: string, payload: ChatRestRequest, token?: string) {
95
  return api.post<ChatMessageResponse>(
96
  `/chats/sessions/${sessionId}/messages`,
@@ -98,6 +261,31 @@ export const ragApi = {
98
  { token },
99
  );
100
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  };
102
 
103
  export function getRagErrorMessage(error: unknown): string | undefined {
 
1
  import { api, ApiError, getBackendErrorMessage } from '@/lib/api';
2
 
3
+ export type ChatApiMode = 'direct' | 'rest';
4
+
5
+ const configuredChatApiMode =
6
+ import.meta.env.VITE_CHAT_API_MODE?.trim().toLowerCase() || 'rest';
7
+
8
+ if (!['direct', 'rest'].includes(configuredChatApiMode)) {
9
+ throw new Error('VITE_CHAT_API_MODE must be either "rest" or "direct".');
10
+ }
11
+
12
+ export const CHAT_API_MODE = configuredChatApiMode as ChatApiMode;
13
+
14
  export type CourseResponse = {
15
+ id: number | string;
16
  title: string;
17
  description?: string;
18
  };
 
42
  pagination: unknown;
43
  };
44
 
45
+ export type ChatHistoryResponse = {
46
+ data: ChatMessageResponse[];
47
+ };
48
+
49
  export type CreateChatSessionRequest = {
50
  course_id: string;
51
  title: string;
 
55
  content: string;
56
  };
57
 
58
+ export type ChatQueryRequest = {
59
+ course_id: string;
60
+ prompt: string;
61
+ };
62
+
63
+ export type DocumentSource = {
64
+ document_id: string;
65
+ filename: string;
66
+ excerpt: string;
67
+ };
68
+
69
  export type ChatMessageResponse = {
70
  uuid_id: string;
71
  role: string;
72
  content: string;
73
+ sources?: DocumentSource[];
74
+ created_at: string;
75
+ };
76
+
77
+ export type ChatQueryResponse = {
78
+ message_id: string;
79
+ role?: string;
80
+ content: string;
81
+ sources?: DocumentSource[];
82
  created_at: string;
83
  };
84
 
 
93
  courseId?: string;
94
  };
95
 
96
+ type AuthenticatedRequestOptions = {
97
+ signal?: AbortSignal;
98
+ token?: string;
99
+ };
100
+
101
+ type SendConfiguredChatMessagePayload = {
102
+ content: string;
103
+ courseId: string;
104
+ sessionId: string;
105
+ token?: string;
106
+ };
107
+
108
+ const DIRECT_CHAT_STORAGE_PREFIX = 'sevima_raghub_direct_messages';
109
+
110
+ function getDirectChatStorageKey(sessionId: string): string {
111
+ return `${DIRECT_CHAT_STORAGE_PREFIX}:${sessionId}`;
112
+ }
113
+
114
+ function getBrowserSessionStorage(): Storage | null {
115
+ if (typeof window === 'undefined') {
116
+ return null;
117
+ }
118
+
119
+ return window.sessionStorage;
120
+ }
121
+
122
+ function createLocalMessageId(prefix: string): string {
123
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
124
+ return `${prefix}-${crypto.randomUUID()}`;
125
+ }
126
+
127
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
128
+ }
129
+
130
+ function normalizeDirectChatMessage(
131
+ response: ChatQueryResponse,
132
+ ): ChatMessageResponse {
133
+ return {
134
+ content: response.content,
135
+ created_at: response.created_at,
136
+ role: response.role ?? 'assistant',
137
+ sources: response.sources ?? [],
138
+ uuid_id: response.message_id,
139
+ };
140
+ }
141
+
142
+ export function isDirectChatMode(): boolean {
143
+ return CHAT_API_MODE === 'direct';
144
+ }
145
+
146
+ export function createLocalUserMessage(content: string): ChatMessageResponse {
147
+ return {
148
+ content,
149
+ created_at: new Date().toISOString(),
150
+ role: 'user',
151
+ sources: [],
152
+ uuid_id: createLocalMessageId('local'),
153
+ };
154
+ }
155
+
156
+ export function getStoredDirectChatMessages(
157
+ sessionId: string,
158
+ ): ChatMessageResponse[] {
159
+ const rawMessages = getBrowserSessionStorage()?.getItem(
160
+ getDirectChatStorageKey(sessionId),
161
+ );
162
+
163
+ if (!rawMessages) {
164
+ return [];
165
+ }
166
+
167
+ try {
168
+ const messages = JSON.parse(rawMessages) as ChatMessageResponse[];
169
+
170
+ return Array.isArray(messages) ? messages : [];
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ export function saveDirectChatMessages(
177
+ sessionId: string,
178
+ messages: ChatMessageResponse[],
179
+ ): void {
180
+ getBrowserSessionStorage()?.setItem(
181
+ getDirectChatStorageKey(sessionId),
182
+ JSON.stringify(messages),
183
+ );
184
+ }
185
+
186
+ export function appendDirectChatMessages(
187
+ sessionId: string,
188
+ messages: ChatMessageResponse[],
189
+ ): void {
190
+ saveDirectChatMessages(sessionId, [
191
+ ...getStoredDirectChatMessages(sessionId),
192
+ ...messages,
193
+ ]);
194
+ }
195
+
196
+ export function clearDirectChatMessages(sessionId: string): void {
197
+ getBrowserSessionStorage()?.removeItem(getDirectChatStorageKey(sessionId));
198
+ }
199
+
200
  export const ragApi = {
201
  listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
202
  return api.get<CourseListResponse>('/courses', {
 
225
  });
226
  },
227
 
228
+ getChatSession(
229
+ sessionId: string,
230
+ { signal, token }: AuthenticatedRequestOptions = {},
231
+ ) {
232
+ return api.get<ChatSessionResponse>(`/chats/sessions/${sessionId}`, {
233
+ signal,
234
+ token,
235
+ });
236
+ },
237
+
238
+ deleteChatSession(sessionId: string, token?: string) {
239
+ return api.delete<Record<string, never>>(
240
+ `/chats/sessions/${sessionId}`,
241
+ {
242
+ token,
243
+ },
244
+ );
245
+ },
246
+
247
+ getChatHistory(
248
+ sessionId: string,
249
+ { signal, token }: AuthenticatedRequestOptions = {},
250
+ ) {
251
+ return api.get<ChatHistoryResponse>(
252
+ `/chats/sessions/${sessionId}/messages`,
253
+ { signal, token },
254
+ );
255
+ },
256
+
257
  sendMessage(sessionId: string, payload: ChatRestRequest, token?: string) {
258
  return api.post<ChatMessageResponse>(
259
  `/chats/sessions/${sessionId}/messages`,
 
261
  { token },
262
  );
263
  },
264
+
265
+ queryAiDirect(payload: ChatQueryRequest, token?: string) {
266
+ return api.post<ChatQueryResponse>('/ai/query', payload, { token });
267
+ },
268
+
269
+ async sendConfiguredChatMessage({
270
+ content,
271
+ courseId,
272
+ sessionId,
273
+ token,
274
+ }: SendConfiguredChatMessagePayload) {
275
+ if (CHAT_API_MODE === 'direct') {
276
+ const response = await this.queryAiDirect(
277
+ {
278
+ course_id: courseId,
279
+ prompt: content,
280
+ },
281
+ token,
282
+ );
283
+
284
+ return normalizeDirectChatMessage(response);
285
+ }
286
+
287
+ return this.sendMessage(sessionId, { content }, token);
288
+ },
289
  };
290
 
291
  export function getRagErrorMessage(error: unknown): string | undefined {
resources/js/pages/auth/login.tsx CHANGED
@@ -16,7 +16,7 @@ type LoginProps = {
16
  };
17
 
18
  export default function Login({ canRegister = true, status }: LoginProps) {
19
- const [backendMessage, setBackendMessage] = useState<string>();
20
  const [isProcessing, setIsProcessing] = useState(false);
21
  const [remember, setRemember] = useState(true);
22
 
@@ -24,7 +24,7 @@ export default function Login({ canRegister = true, status }: LoginProps) {
24
  event: FormEvent<HTMLFormElement>,
25
  ): Promise<void> => {
26
  event.preventDefault();
27
- setBackendMessage(undefined);
28
  setIsProcessing(true);
29
 
30
  const formData = new FormData(event.currentTarget);
@@ -41,7 +41,7 @@ export default function Login({ canRegister = true, status }: LoginProps) {
41
  response.user.role === 'student' ? mahasiswa() : dashboard(),
42
  );
43
  } catch (error) {
44
- setBackendMessage(getAuthErrorMessage(error));
45
  } finally {
46
  setIsProcessing(false);
47
  }
@@ -131,9 +131,16 @@ export default function Login({ canRegister = true, status }: LoginProps) {
131
  </Button>
132
  </form>
133
 
134
- {(backendMessage ?? status) && (
135
- <p className="login-status">
136
- {backendMessage ?? status}
 
 
 
 
 
 
 
137
  </p>
138
  )}
139
 
 
16
  };
17
 
18
  export default function Login({ canRegister = true, status }: LoginProps) {
19
+ const [backendError, setBackendError] = useState<string>();
20
  const [isProcessing, setIsProcessing] = useState(false);
21
  const [remember, setRemember] = useState(true);
22
 
 
24
  event: FormEvent<HTMLFormElement>,
25
  ): Promise<void> => {
26
  event.preventDefault();
27
+ setBackendError(undefined);
28
  setIsProcessing(true);
29
 
30
  const formData = new FormData(event.currentTarget);
 
41
  response.user.role === 'student' ? mahasiswa() : dashboard(),
42
  );
43
  } catch (error) {
44
+ setBackendError(getAuthErrorMessage(error));
45
  } finally {
46
  setIsProcessing(false);
47
  }
 
131
  </Button>
132
  </form>
133
 
134
+ {(backendError ?? status) && (
135
+ <p
136
+ className={
137
+ backendError
138
+ ? 'login-status login-error'
139
+ : 'login-status'
140
+ }
141
+ role={backendError ? 'alert' : undefined}
142
+ >
143
+ {backendError ?? status}
144
  </p>
145
  )}
146
 
resources/js/pages/mahasiswa-chat.tsx ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, router } from '@inertiajs/react';
2
+ import { Bot, UserRound } from 'lucide-react';
3
+ import type { FormEvent } from 'react';
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+
6
+ import { QuestionComposer } from '@/components/student/question-composer';
7
+ import {
8
+ formatSessionTime,
9
+ isAbortError,
10
+ StudentShell,
11
+ useStoredStudentName,
12
+ } from '@/components/student/student-shell';
13
+ import { getStoredAuthToken } from '@/lib/auth';
14
+ import type {
15
+ ChatHistoryResponse,
16
+ ChatMessageResponse,
17
+ ChatSessionResponse,
18
+ CourseResponse,
19
+ } from '@/lib/rag';
20
+ import {
21
+ appendDirectChatMessages,
22
+ clearDirectChatMessages,
23
+ createLocalUserMessage,
24
+ getRagErrorMessage,
25
+ getStoredDirectChatMessages,
26
+ isDirectChatMode,
27
+ ragApi,
28
+ } from '@/lib/rag';
29
+ import { mahasiswa } from '@/routes';
30
+
31
+ type MahasiswaChatProps = {
32
+ sessionId: string;
33
+ };
34
+
35
+ function isUserMessage(role: string): boolean {
36
+ return ['human', 'student', 'user'].includes(role.toLowerCase());
37
+ }
38
+
39
+ function formatMessageTime(value: string): string {
40
+ const date = new Date(value);
41
+
42
+ if (Number.isNaN(date.getTime())) {
43
+ return value;
44
+ }
45
+
46
+ return new Intl.DateTimeFormat('en-US', {
47
+ hour: 'numeric',
48
+ minute: '2-digit',
49
+ }).format(date);
50
+ }
51
+
52
+ function ChatMessage({ message }: { message: ChatMessageResponse }) {
53
+ const isUser = isUserMessage(message.role);
54
+
55
+ return (
56
+ <article
57
+ className={
58
+ isUser
59
+ ? 'student-message student-message--user'
60
+ : 'student-message student-message--assistant'
61
+ }
62
+ >
63
+ {!isUser && (
64
+ <span className="student-message-avatar student-message-avatar--assistant">
65
+ <Bot className="size-4" />
66
+ </span>
67
+ )}
68
+
69
+ <div className="student-message-bubble">
70
+ <p className="student-message-content">{message.content}</p>
71
+ <span className="student-message-time">
72
+ {formatMessageTime(message.created_at)}
73
+ </span>
74
+
75
+ {(message.sources?.length ?? 0) > 0 && (
76
+ <div className="student-message-sources">
77
+ {message.sources?.map((source) => (
78
+ <span
79
+ className="student-message-source"
80
+ key={`${message.uuid_id}-${source.document_id}`}
81
+ >
82
+ {source.filename}
83
+ </span>
84
+ ))}
85
+ </div>
86
+ )}
87
+ </div>
88
+
89
+ {isUser && (
90
+ <span className="student-message-avatar student-message-avatar--user">
91
+ <UserRound className="size-4" />
92
+ </span>
93
+ )}
94
+ </article>
95
+ );
96
+ }
97
+
98
+ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
99
+ const [backendMessage, setBackendMessage] = useState<string>();
100
+ const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
101
+ const [courses, setCourses] = useState<CourseResponse[]>([]);
102
+ const [currentSession, setCurrentSession] = useState<ChatSessionResponse>();
103
+ const [isLoading, setIsLoading] = useState(true);
104
+ const [isProcessing, setIsProcessing] = useState(false);
105
+ const [messages, setMessages] = useState<ChatMessageResponse[]>([]);
106
+ const [question, setQuestion] = useState('');
107
+ const [selectedCourseId, setSelectedCourseId] = useState('');
108
+ const studentName = useStoredStudentName();
109
+ const messagesEndRef = useRef<HTMLDivElement>(null);
110
+
111
+ const composerCourses = useMemo(() => {
112
+ if (
113
+ !currentSession ||
114
+ !selectedCourseId ||
115
+ courses.some((course) => String(course.id) === selectedCourseId)
116
+ ) {
117
+ return courses;
118
+ }
119
+
120
+ return [
121
+ {
122
+ id: currentSession.course_id,
123
+ title: currentSession.course_name ?? currentSession.course_id,
124
+ },
125
+ ...courses,
126
+ ];
127
+ }, [courses, currentSession, selectedCourseId]);
128
+
129
+ useEffect(() => {
130
+ messagesEndRef.current?.scrollIntoView({ block: 'end' });
131
+ }, [messages, isProcessing]);
132
+
133
+ const applyHistoryResponse = useCallback(
134
+ (response: ChatHistoryResponse): void => {
135
+ if (isDirectChatMode()) {
136
+ setMessages([
137
+ ...response.data,
138
+ ...getStoredDirectChatMessages(sessionId),
139
+ ]);
140
+
141
+ return;
142
+ }
143
+
144
+ setMessages(response.data);
145
+ },
146
+ [sessionId],
147
+ );
148
+
149
+ useEffect(() => {
150
+ const controller = new AbortController();
151
+
152
+ async function loadChatData() {
153
+ setIsLoading(true);
154
+ setBackendMessage(undefined);
155
+
156
+ const token = getStoredAuthToken() ?? undefined;
157
+ const [
158
+ coursesResult,
159
+ sessionsResult,
160
+ sessionResult,
161
+ historyResult,
162
+ ] = await Promise.allSettled([
163
+ ragApi.listCourses({ signal: controller.signal }),
164
+ ragApi.listChatSessions({ signal: controller.signal, token }),
165
+ ragApi.getChatSession(sessionId, {
166
+ signal: controller.signal,
167
+ token,
168
+ }),
169
+ ragApi.getChatHistory(sessionId, {
170
+ signal: controller.signal,
171
+ token,
172
+ }),
173
+ ]);
174
+
175
+ if (controller.signal.aborted) {
176
+ return;
177
+ }
178
+
179
+ if (coursesResult.status === 'fulfilled') {
180
+ setCourses(coursesResult.value.data);
181
+ } else if (!isAbortError(coursesResult.reason)) {
182
+ setBackendMessage(getRagErrorMessage(coursesResult.reason));
183
+ }
184
+
185
+ if (sessionsResult.status === 'fulfilled') {
186
+ setChatSessions(sessionsResult.value.data);
187
+ } else if (!isAbortError(sessionsResult.reason)) {
188
+ setBackendMessage(getRagErrorMessage(sessionsResult.reason));
189
+ }
190
+
191
+ if (sessionResult.status === 'fulfilled') {
192
+ setCurrentSession(sessionResult.value);
193
+ setSelectedCourseId(sessionResult.value.course_id);
194
+ } else if (!isAbortError(sessionResult.reason)) {
195
+ setBackendMessage(getRagErrorMessage(sessionResult.reason));
196
+ }
197
+
198
+ if (historyResult.status === 'fulfilled') {
199
+ applyHistoryResponse(historyResult.value);
200
+ } else if (!isAbortError(historyResult.reason)) {
201
+ setBackendMessage(getRagErrorMessage(historyResult.reason));
202
+ }
203
+
204
+ setIsLoading(false);
205
+ }
206
+
207
+ void loadChatData();
208
+
209
+ return () => {
210
+ controller.abort();
211
+ };
212
+ }, [applyHistoryResponse, sessionId]);
213
+
214
+ const handleDeleteSession = async (targetSessionId: string) => {
215
+ try {
216
+ const token = getStoredAuthToken() ?? undefined;
217
+ await ragApi.deleteChatSession(targetSessionId, token);
218
+ clearDirectChatMessages(targetSessionId);
219
+
220
+ setChatSessions((currentSessions) =>
221
+ currentSessions.filter(
222
+ (session) => session.uuid_id !== targetSessionId,
223
+ ),
224
+ );
225
+
226
+ if (targetSessionId === sessionId) {
227
+ router.visit(mahasiswa());
228
+ }
229
+ } catch (error) {
230
+ setBackendMessage(getRagErrorMessage(error));
231
+ }
232
+ };
233
+
234
+ const handleQuestionSubmit = async (
235
+ event: FormEvent<HTMLFormElement>,
236
+ ): Promise<void> => {
237
+ event.preventDefault();
238
+
239
+ const trimmedQuestion = question.trim();
240
+
241
+ if (!selectedCourseId || !trimmedQuestion) {
242
+ return;
243
+ }
244
+
245
+ setIsProcessing(true);
246
+ setBackendMessage(undefined);
247
+ setQuestion('');
248
+
249
+ const userMessage = createLocalUserMessage(trimmedQuestion);
250
+
251
+ if (isDirectChatMode()) {
252
+ setMessages((currentMessages) => [...currentMessages, userMessage]);
253
+ }
254
+
255
+ try {
256
+ const token = getStoredAuthToken() ?? undefined;
257
+ const assistantMessage = await ragApi.sendConfiguredChatMessage({
258
+ content: trimmedQuestion,
259
+ courseId: selectedCourseId,
260
+ sessionId,
261
+ token,
262
+ });
263
+
264
+ if (isDirectChatMode()) {
265
+ appendDirectChatMessages(sessionId, [
266
+ userMessage,
267
+ assistantMessage,
268
+ ]);
269
+ setMessages((currentMessages) => [
270
+ ...currentMessages,
271
+ assistantMessage,
272
+ ]);
273
+
274
+ return;
275
+ }
276
+
277
+ const history = await ragApi.getChatHistory(sessionId, { token });
278
+ applyHistoryResponse(history);
279
+ } catch (error) {
280
+ if (isDirectChatMode()) {
281
+ setMessages((currentMessages) =>
282
+ currentMessages.filter(
283
+ (message) => message.uuid_id !== userMessage.uuid_id,
284
+ ),
285
+ );
286
+ }
287
+
288
+ setQuestion(trimmedQuestion);
289
+ setBackendMessage(getRagErrorMessage(error));
290
+ } finally {
291
+ setIsProcessing(false);
292
+ }
293
+ };
294
+
295
+ return (
296
+ <>
297
+ <Head title={currentSession?.title ?? 'RAG Hub'} />
298
+
299
+ <StudentShell
300
+ activeSessionId={sessionId}
301
+ chatSessions={chatSessions}
302
+ isLoadingSessions={isLoading}
303
+ onDeleteSession={(targetSessionId) => {
304
+ void handleDeleteSession(targetSessionId);
305
+ }}
306
+ studentName={studentName}
307
+ >
308
+ <div className="student-chat-stage student-chat-stage--session">
309
+ <section className="student-chat-thread">
310
+ {backendMessage && (
311
+ <p className="student-status student-chat-status">
312
+ {backendMessage}
313
+ </p>
314
+ )}
315
+
316
+ {isLoading ? (
317
+ <p className="student-chat-loading">
318
+ Memuat percakapan...
319
+ </p>
320
+ ) : (
321
+ <div className="student-chat-messages">
322
+ {messages.map((message) => (
323
+ <ChatMessage
324
+ key={message.uuid_id}
325
+ message={message}
326
+ />
327
+ ))}
328
+
329
+ {isProcessing && (
330
+ <article className="student-message student-message--assistant">
331
+ <span className="student-message-avatar student-message-avatar--assistant">
332
+ <Bot className="size-4" />
333
+ </span>
334
+ <div className="student-message-bubble student-message-bubble--typing">
335
+ Menyiapkan jawaban...
336
+ </div>
337
+ </article>
338
+ )}
339
+
340
+ <div ref={messagesEndRef} />
341
+ </div>
342
+ )}
343
+ </section>
344
+
345
+ <div className="student-chat-composer">
346
+ <QuestionComposer
347
+ courses={composerCourses}
348
+ isCourseLocked
349
+ isLoadingCourses={isLoading}
350
+ isProcessing={isProcessing}
351
+ onCourseChange={setSelectedCourseId}
352
+ onQuestionChange={setQuestion}
353
+ onSubmit={handleQuestionSubmit}
354
+ question={question}
355
+ selectedCourseId={selectedCourseId}
356
+ variant="dock"
357
+ />
358
+
359
+ {currentSession?.last_message_at && (
360
+ <p className="student-chat-updated-at">
361
+ Terakhir diperbarui{' '}
362
+ {formatSessionTime(
363
+ currentSession.last_message_at,
364
+ )}
365
+ </p>
366
+ )}
367
+ </div>
368
+ </div>
369
+ </StudentShell>
370
+ </>
371
+ );
372
+ }
resources/js/pages/mahasiswa.tsx CHANGED
@@ -1,364 +1,24 @@
1
  import { Head, router } from '@inertiajs/react';
2
- import {
3
- BookOpen,
4
- ChevronDown,
5
- Clock3,
6
- LogOut,
7
- MessageSquare,
8
- Send,
9
- Sparkles,
10
- } from 'lucide-react';
11
- import type { CSSProperties, FormEvent } from 'react';
12
  import { useEffect, useState } from 'react';
13
 
14
- import AppearanceToggle from '@/components/appearance-toggle';
15
- import { Avatar, AvatarFallback } from '@/components/ui/avatar';
16
- import { Button } from '@/components/ui/button';
17
- import { Card, CardContent } from '@/components/ui/card';
18
- import {
19
- Select,
20
- SelectContent,
21
- SelectItem,
22
- SelectTrigger,
23
- SelectValue,
24
- } from '@/components/ui/select';
25
  import {
26
- Sidebar,
27
- SidebarContent,
28
- SidebarFooter,
29
- SidebarGroup,
30
- SidebarGroupContent,
31
- SidebarGroupLabel,
32
- SidebarHeader,
33
- SidebarInset,
34
- SidebarMenu,
35
- SidebarMenuButton,
36
- SidebarMenuItem,
37
- SidebarProvider,
38
- SidebarRail,
39
- SidebarTrigger,
40
- useSidebar,
41
- } from '@/components/ui/sidebar';
42
- import {
43
- clearAuthSession,
44
- getStoredAuthToken,
45
- getStoredAuthUser,
46
- } from '@/lib/auth';
47
  import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
48
- import { getRagErrorMessage, ragApi } from '@/lib/rag';
49
- import { login } from '@/routes';
50
-
51
- const sidebarStyle = {
52
- '--sidebar-width': '212px',
53
- '--sidebar-width-icon': '64px',
54
- } as CSSProperties;
55
-
56
- function getStoredStudentName(): string {
57
- return getStoredAuthUser()?.name ?? 'Mahasiswa';
58
- }
59
-
60
- function getInitials(name: string): string {
61
- return name
62
- .split(' ')
63
- .filter(Boolean)
64
- .slice(0, 2)
65
- .map((part) => part[0]?.toUpperCase())
66
- .join('');
67
- }
68
-
69
- function handleLogout(): void {
70
- clearAuthSession();
71
- router.visit(login());
72
- }
73
-
74
- function isAbortError(error: unknown): boolean {
75
- return error instanceof DOMException && error.name === 'AbortError';
76
- }
77
-
78
- function formatSessionTime(value?: string): string {
79
- if (!value) {
80
- return '';
81
- }
82
-
83
- const date = new Date(value);
84
-
85
- if (Number.isNaN(date.getTime())) {
86
- return value;
87
- }
88
-
89
- return new Intl.DateTimeFormat('en-US', {
90
- day: 'numeric',
91
- hour: 'numeric',
92
- minute: '2-digit',
93
- month: 'short',
94
- }).format(date);
95
- }
96
-
97
- function StudentSidebarLogo() {
98
- const { state, toggleSidebar } = useSidebar();
99
-
100
- const handleLogoClick = (): void => {
101
- if (state === 'collapsed') {
102
- toggleSidebar();
103
- }
104
- };
105
-
106
- return (
107
- <button
108
- aria-label="Buka sidebar"
109
- className="student-logo-action"
110
- onClick={handleLogoClick}
111
- type="button"
112
- >
113
- <span className="student-logo-mark">
114
- <MessageSquare className="size-5" />
115
- </span>
116
- </button>
117
- );
118
- }
119
-
120
- function StudentSidebar({
121
- chatSessions,
122
- isLoading,
123
- studentName,
124
- }: {
125
- chatSessions: ChatSessionResponse[];
126
- isLoading: boolean;
127
- studentName: string;
128
- }) {
129
- const [isHistoryOpen, setIsHistoryOpen] = useState(true);
130
- const { state } = useSidebar();
131
- const isSidebarCollapsed = state === 'collapsed';
132
-
133
- return (
134
- <Sidebar className="student-sidebar" collapsible="icon">
135
- <SidebarHeader className="student-sidebar-top">
136
- <StudentSidebarLogo />
137
- <SidebarTrigger className="student-sidebar-trigger" />
138
- </SidebarHeader>
139
-
140
- <SidebarContent className="student-sidebar-content">
141
- <SidebarGroup className="student-history-group">
142
- {isSidebarCollapsed ? (
143
- <SidebarGroupContent>
144
- <SidebarMenu className="student-history-list">
145
- <SidebarMenuItem>
146
- <SidebarMenuButton
147
- className="student-history-menu-button"
148
- tooltip="Riwayat Chat"
149
- >
150
- <Clock3 className="student-history-menu-icon size-4" />
151
- <span className="student-history-copy">
152
- <span className="student-history-title">
153
- Riwayat Chat
154
- </span>
155
- </span>
156
- </SidebarMenuButton>
157
- </SidebarMenuItem>
158
- </SidebarMenu>
159
- </SidebarGroupContent>
160
- ) : (
161
- <>
162
- <SidebarGroupLabel asChild>
163
- <button
164
- aria-expanded={isHistoryOpen}
165
- className="student-history-label"
166
- onClick={() =>
167
- setIsHistoryOpen(
168
- (currentState) => !currentState,
169
- )
170
- }
171
- type="button"
172
- >
173
- <Clock3 className="size-4" />
174
- <span>Riwayat Chat</span>
175
- <ChevronDown className="student-history-chevron" />
176
- </button>
177
- </SidebarGroupLabel>
178
-
179
- {isHistoryOpen && (
180
- <SidebarGroupContent>
181
- <SidebarMenu className="student-history-list">
182
- {isLoading && (
183
- <SidebarMenuItem>
184
- <SidebarMenuButton
185
- className="student-history-menu-button"
186
- disabled
187
- >
188
- <Clock3 className="student-history-menu-icon size-4" />
189
- <span className="student-history-copy">
190
- <span className="student-history-title">
191
- Memuat riwayat
192
- </span>
193
- </span>
194
- </SidebarMenuButton>
195
- </SidebarMenuItem>
196
- )}
197
-
198
- {chatSessions.map((session) => (
199
- <SidebarMenuItem
200
- key={session.uuid_id}
201
- >
202
- <SidebarMenuButton
203
- className="student-history-menu-button"
204
- tooltip={session.title}
205
- >
206
- <Clock3 className="student-history-menu-icon size-4" />
207
- <span className="student-history-copy">
208
- <span className="student-history-title">
209
- {session.title}
210
- </span>
211
- {formatSessionTime(
212
- session.last_message_at ??
213
- session.updated_at ??
214
- session.created_at,
215
- ) && (
216
- <span className="student-history-meta">
217
- {formatSessionTime(
218
- session.last_message_at ??
219
- session.updated_at ??
220
- session.created_at,
221
- )}
222
- </span>
223
- )}
224
- </span>
225
- </SidebarMenuButton>
226
- </SidebarMenuItem>
227
- ))}
228
- </SidebarMenu>
229
- </SidebarGroupContent>
230
- )}
231
- </>
232
- )}
233
- </SidebarGroup>
234
- </SidebarContent>
235
-
236
- <SidebarFooter className="student-sidebar-footer">
237
- <SidebarMenu>
238
- <SidebarMenuItem>
239
- <SidebarMenuButton
240
- className="student-user-menu-button"
241
- tooltip={studentName}
242
- >
243
- <Avatar className="student-user-avatar">
244
- <AvatarFallback>
245
- {getInitials(studentName) || 'AK'}
246
- </AvatarFallback>
247
- </Avatar>
248
- <span className="student-user-copy">
249
- <span className="student-user-name">
250
- {studentName}
251
- </span>
252
- <span className="student-user-role">
253
- Mahasiswa
254
- </span>
255
- </span>
256
- </SidebarMenuButton>
257
- </SidebarMenuItem>
258
-
259
- <SidebarMenuItem>
260
- <SidebarMenuButton
261
- className="student-logout-button"
262
- onClick={handleLogout}
263
- tooltip="Logout"
264
- >
265
- <LogOut className="size-4" />
266
- <span>Logout</span>
267
- </SidebarMenuButton>
268
- </SidebarMenuItem>
269
- </SidebarMenu>
270
- </SidebarFooter>
271
-
272
- <SidebarRail />
273
- </Sidebar>
274
- );
275
- }
276
-
277
- function QuestionComposer({
278
- courses,
279
- isLoadingCourses,
280
- isProcessing,
281
- onCourseChange,
282
- onQuestionChange,
283
- onSubmit,
284
- question,
285
- selectedCourseId,
286
- }: {
287
- courses: CourseResponse[];
288
- isLoadingCourses: boolean;
289
- isProcessing: boolean;
290
- onCourseChange: (courseId: string) => void;
291
- onQuestionChange: (question: string) => void;
292
- onSubmit: (event: FormEvent<HTMLFormElement>) => void;
293
- question: string;
294
- selectedCourseId: string;
295
- }) {
296
- return (
297
- <Card className="student-composer-card py-0">
298
- <CardContent className="student-composer-content">
299
- <form className="student-composer-form" onSubmit={onSubmit}>
300
- <div className="student-course-row">
301
- <BookOpen className="student-course-icon size-5" />
302
- <span className="student-course-label text-sm">
303
- Mata Kuliah:
304
- </span>
305
-
306
- <Select
307
- disabled={isLoadingCourses || courses.length === 0}
308
- onValueChange={onCourseChange}
309
- value={selectedCourseId}
310
- >
311
- <SelectTrigger
312
- className="student-course-select"
313
- size="sm"
314
- >
315
- <SelectValue placeholder="Pilih mata kuliah" />
316
- </SelectTrigger>
317
- <SelectContent>
318
- {courses.map((course) => (
319
- <SelectItem
320
- key={course.id}
321
- value={String(course.id)}
322
- >
323
- {course.title}
324
- </SelectItem>
325
- ))}
326
- </SelectContent>
327
- </Select>
328
- </div>
329
-
330
- <div className="student-composer-divider" />
331
-
332
- <div className="student-question-row">
333
- <Sparkles className="student-question-icon size-5" />
334
- <textarea
335
- className="text-base"
336
- aria-label="Pertanyaan"
337
- onChange={(event) =>
338
- onQuestionChange(event.target.value)
339
- }
340
- placeholder="Tulis pertanyaanmu disini..."
341
- value={question}
342
- />
343
- </div>
344
-
345
- <Button
346
- className="student-floating-send"
347
- disabled={
348
- isProcessing ||
349
- selectedCourseId.length === 0 ||
350
- question.trim().length === 0
351
- }
352
- size="icon"
353
- type="submit"
354
- >
355
- <Send className="size-5" />
356
- </Button>
357
- </form>
358
- </CardContent>
359
- </Card>
360
- );
361
- }
362
 
363
  export default function Mahasiswa() {
364
  const [backendMessage, setBackendMessage] = useState<string>();
@@ -368,7 +28,7 @@ export default function Mahasiswa() {
368
  const [isProcessing, setIsProcessing] = useState(false);
369
  const [question, setQuestion] = useState('');
370
  const [selectedCourseId, setSelectedCourseId] = useState('');
371
- const [studentName] = useState(getStoredStudentName);
372
 
373
  useEffect(() => {
374
  const controller = new AbortController();
@@ -439,14 +99,22 @@ export default function Mahasiswa() {
439
  token,
440
  );
441
 
442
- await ragApi.sendMessage(
443
- session.uuid_id,
444
- { content: trimmedQuestion },
 
 
445
  token,
446
- );
 
 
 
 
 
 
 
447
 
448
- setChatSessions((currentSessions) => [session, ...currentSessions]);
449
- setQuestion('');
450
  } catch (error) {
451
  setBackendMessage(getRagErrorMessage(error));
452
  } finally {
@@ -458,66 +126,42 @@ export default function Mahasiswa() {
458
  <>
459
  <Head title="RAG Hub" />
460
 
461
- <SidebarProvider
462
- className="student-dashboard-shell"
463
- defaultOpen
464
- style={sidebarStyle}
465
  >
466
- <StudentSidebar
467
- chatSessions={chatSessions}
468
- isLoading={isLoading}
469
- studentName={studentName}
470
- />
471
-
472
- <SidebarInset className="student-workspace">
473
- <header className="student-topbar">
474
- <h1 className="student-brand-title">RAG Hub</h1>
475
-
476
- <div className="student-topbar-actions">
477
- <AppearanceToggle className="student-theme-toggle" />
478
- <Button
479
- className="student-new-session-button"
480
- type="button"
481
- >
482
- + Sesi Baru
483
- </Button>
484
  </div>
485
- </header>
486
-
487
- <div className="student-chat-stage">
488
- <section className="student-empty-state">
489
- <div className="student-empty-icon">
490
- <MessageSquare className="size-8" />
491
- </div>
492
 
493
- <div className="student-greeting">
494
- <h2>Good Morning, {studentName}</h2>
495
- <p>
496
- Pilih mata kuliah, lalu kirim pertanyaan
497
- pertama untuk membuat sesi baru
498
- </p>
499
- </div>
500
-
501
- {backendMessage && (
502
- <p className="student-status">
503
- {backendMessage}
504
- </p>
505
- )}
506
 
507
- <QuestionComposer
508
- courses={courses}
509
- isLoadingCourses={isLoading}
510
- isProcessing={isProcessing}
511
- onCourseChange={setSelectedCourseId}
512
- onQuestionChange={setQuestion}
513
- onSubmit={handleQuestionSubmit}
514
- question={question}
515
- selectedCourseId={selectedCourseId}
516
- />
517
- </section>
518
- </div>
519
- </SidebarInset>
520
- </SidebarProvider>
 
 
 
521
  </>
522
  );
523
  }
 
1
  import { Head, router } from '@inertiajs/react';
2
+ import { MessageSquare } from 'lucide-react';
3
+ import type { FormEvent } from 'react';
 
 
 
 
 
 
 
 
4
  import { useEffect, useState } from 'react';
5
 
6
+ import { QuestionComposer } from '@/components/student/question-composer';
 
 
 
 
 
 
 
 
 
 
7
  import {
8
+ isAbortError,
9
+ StudentShell,
10
+ useStoredStudentName,
11
+ } from '@/components/student/student-shell';
12
+ import { getStoredAuthToken } from '@/lib/auth';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
14
+ import {
15
+ appendDirectChatMessages,
16
+ createLocalUserMessage,
17
+ getRagErrorMessage,
18
+ isDirectChatMode,
19
+ ragApi,
20
+ } from '@/lib/rag';
21
+ import { show as showMahasiswaSession } from '@/routes/mahasiswa';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  export default function Mahasiswa() {
24
  const [backendMessage, setBackendMessage] = useState<string>();
 
28
  const [isProcessing, setIsProcessing] = useState(false);
29
  const [question, setQuestion] = useState('');
30
  const [selectedCourseId, setSelectedCourseId] = useState('');
31
+ const studentName = useStoredStudentName();
32
 
33
  useEffect(() => {
34
  const controller = new AbortController();
 
99
  token,
100
  );
101
 
102
+ const userMessage = createLocalUserMessage(trimmedQuestion);
103
+ const assistantMessage = await ragApi.sendConfiguredChatMessage({
104
+ content: trimmedQuestion,
105
+ courseId: selectedCourseId,
106
+ sessionId: session.uuid_id,
107
  token,
108
+ });
109
+
110
+ if (isDirectChatMode()) {
111
+ appendDirectChatMessages(session.uuid_id, [
112
+ userMessage,
113
+ assistantMessage,
114
+ ]);
115
+ }
116
 
117
+ router.visit(showMahasiswaSession(session.uuid_id));
 
118
  } catch (error) {
119
  setBackendMessage(getRagErrorMessage(error));
120
  } finally {
 
126
  <>
127
  <Head title="RAG Hub" />
128
 
129
+ <StudentShell
130
+ chatSessions={chatSessions}
131
+ isLoadingSessions={isLoading}
132
+ studentName={studentName}
133
  >
134
+ <div className="student-chat-stage">
135
+ <section className="student-empty-state">
136
+ <div className="student-empty-icon">
137
+ <MessageSquare className="size-8" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  </div>
 
 
 
 
 
 
 
139
 
140
+ <div className="student-greeting">
141
+ <h2>Good Morning, {studentName}</h2>
142
+ <p>
143
+ Pilih mata kuliah, lalu kirim pertanyaan pertama
144
+ untuk membuat sesi baru
145
+ </p>
146
+ </div>
 
 
 
 
 
 
147
 
148
+ {backendMessage && (
149
+ <p className="student-status">{backendMessage}</p>
150
+ )}
151
+
152
+ <QuestionComposer
153
+ courses={courses}
154
+ isLoadingCourses={isLoading}
155
+ isProcessing={isProcessing}
156
+ onCourseChange={setSelectedCourseId}
157
+ onQuestionChange={setQuestion}
158
+ onSubmit={handleQuestionSubmit}
159
+ question={question}
160
+ selectedCourseId={selectedCourseId}
161
+ />
162
+ </section>
163
+ </div>
164
+ </StudentShell>
165
  </>
166
  );
167
  }
resources/js/types/vite-env.d.ts CHANGED
@@ -3,6 +3,7 @@
3
  interface ImportMetaEnv {
4
  readonly VITE_APP_NAME?: string;
5
  readonly VITE_API_BASE_URL?: string;
 
6
  }
7
 
8
  interface ImportMeta {
 
3
  interface ImportMetaEnv {
4
  readonly VITE_APP_NAME?: string;
5
  readonly VITE_API_BASE_URL?: string;
6
+ readonly VITE_CHAT_API_MODE?: 'direct' | 'rest';
7
  }
8
 
9
  interface ImportMeta {
routes/web.php CHANGED
@@ -1,6 +1,7 @@
1
  <?php
2
 
3
  use Illuminate\Support\Facades\Route;
 
4
  use Laravel\Fortify\Features;
5
 
6
  Route::inertia('/', 'welcome', [
@@ -15,4 +16,8 @@
15
 
16
  Route::inertia('mahasiswa', 'mahasiswa')->name('mahasiswa');
17
 
 
 
 
 
18
  require __DIR__.'/settings.php';
 
1
  <?php
2
 
3
  use Illuminate\Support\Facades\Route;
4
+ use Inertia\Inertia;
5
  use Laravel\Fortify\Features;
6
 
7
  Route::inertia('/', 'welcome', [
 
16
 
17
  Route::inertia('mahasiswa', 'mahasiswa')->name('mahasiswa');
18
 
19
+ Route::get('mahasiswa/{uuid}', fn (string $uuid) => Inertia::render('mahasiswa-chat', [
20
+ 'sessionId' => $uuid,
21
+ ]))->name('mahasiswa.show');
22
+
23
  require __DIR__.'/settings.php';