wepee commited on
Commit
53a811b
·
1 Parent(s): 2a4a18b

add: integration admin dashboard

Browse files
app/Http/Controllers/AdminController.php CHANGED
@@ -57,6 +57,27 @@ public function vectorRetrievalConfig(Request $request): Response
57
  return Inertia::render('admin/vector-retrieval-config', $configPayload);
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  public function settings(): Response
61
  {
62
  return Inertia::render('admin/pengaturan');
@@ -70,7 +91,7 @@ public function generateApiKey(Request $request): RedirectResponse
70
  return back()->withErrors(['apiKey' => $exception->getMessage()]);
71
  }
72
 
73
- return to_route('admin.dashboard')
74
  ->with('generatedApiKey', $apiKey)
75
  ->with('toast', [
76
  'message' => 'API key berhasil dibuat.',
@@ -120,7 +141,7 @@ public function updateVectorDbConfig(Request $request): RedirectResponse
120
  ->withInput();
121
  }
122
 
123
- return $this->adminSuccess('Konfigurasi Vector DB berhasil diperbarui.', 'admin.vector-retrieval-config');
124
  }
125
 
126
  public function updateRetrievalConfig(Request $request): RedirectResponse
@@ -147,7 +168,7 @@ public function updateRetrievalConfig(Request $request): RedirectResponse
147
  ->withInput();
148
  }
149
 
150
- return $this->adminSuccess('Konfigurasi retrieval berhasil diperbarui.', 'admin.vector-retrieval-config');
151
  }
152
 
153
  public function storeUser(Request $request): RedirectResponse
 
57
  return Inertia::render('admin/vector-retrieval-config', $configPayload);
58
  }
59
 
60
+ public function vectorDbConfig(Request $request): Response
61
+ {
62
+ $configPayload = $this->resolveRagConfig($this->authToken($request), ['vector_db']);
63
+
64
+ return Inertia::render('admin/vectordb-config', $configPayload);
65
+ }
66
+
67
+ public function retrievalConfig(Request $request): Response
68
+ {
69
+ $configPayload = $this->resolveRagConfig($this->authToken($request), ['retrieval']);
70
+
71
+ return Inertia::render('admin/retrieval-config', $configPayload);
72
+ }
73
+
74
+ public function apiKeys(Request $request): Response
75
+ {
76
+ return Inertia::render('admin/api-keys', [
77
+ 'generatedApiKey' => $request->session()->get('generatedApiKey'),
78
+ ]);
79
+ }
80
+
81
  public function settings(): Response
82
  {
83
  return Inertia::render('admin/pengaturan');
 
91
  return back()->withErrors(['apiKey' => $exception->getMessage()]);
92
  }
93
 
94
+ return to_route('admin.api-keys.index')
95
  ->with('generatedApiKey', $apiKey)
96
  ->with('toast', [
97
  'message' => 'API key berhasil dibuat.',
 
141
  ->withInput();
142
  }
143
 
144
+ return $this->adminSuccess('Konfigurasi Vector DB berhasil diperbarui.', 'admin.vectordb-config');
145
  }
146
 
147
  public function updateRetrievalConfig(Request $request): RedirectResponse
 
168
  ->withInput();
169
  }
170
 
171
+ return $this->adminSuccess('Konfigurasi retrieval berhasil diperbarui.', 'admin.retrieval-config');
172
  }
173
 
174
  public function storeUser(Request $request): RedirectResponse
app/Http/Controllers/EmbedController.php CHANGED
@@ -6,6 +6,7 @@
6
  use Illuminate\Http\RedirectResponse;
7
  use Illuminate\Http\Request;
8
  use Illuminate\Support\Facades\Cache;
 
9
  use Illuminate\Support\Str;
10
  use Inertia\Inertia;
11
  use Inertia\Response;
@@ -19,8 +20,6 @@ class EmbedController extends Controller
19
 
20
  private const DIRECT_MESSAGES_SESSION_PREFIX = 'sevima_raghub_embed_direct_messages';
21
 
22
- private const PENDING_INITIAL_CHAT_CACHE_PREFIX = 'sevima_raghub_embed_pending_initial_chat';
23
-
24
  /**
25
  * @var array<int, string>
26
  */
@@ -61,10 +60,8 @@ public function index(Request $request): Response|RedirectResponse
61
  $courses = [];
62
  $currentSession = null;
63
  $messages = [];
64
- $pendingInitialChat = null;
65
  $selectedCourseId = $config['courseId'];
66
  $sessionId = $this->stringQuery($request, 'session_id');
67
- $pendingId = $this->stringQuery($request, 'pending_id');
68
 
69
  try {
70
  $courses = $this->listCourses(limit: 100, token: $token)['data'];
@@ -85,11 +82,6 @@ public function index(Request $request): Response|RedirectResponse
85
  if (is_string($currentSession['course_id'] ?? null)) {
86
  $selectedCourseId = $currentSession['course_id'];
87
  }
88
-
89
- if ($pendingId !== null) {
90
- $pendingInitialChat = Cache::pull($this->pendingInitialChatKey($pendingId));
91
- $pendingInitialChat = is_array($pendingInitialChat) ? $pendingInitialChat : null;
92
- }
93
  }
94
  } catch (RagApiException $exception) {
95
  $backendMessage = $exception->getMessage() ?: null;
@@ -107,7 +99,6 @@ public function index(Request $request): Response|RedirectResponse
107
  'isAuthorized' => $backendMessage === null,
108
  'isDirectChatMode' => $this->isDirectChatMode(),
109
  'messages' => $messages,
110
- 'pendingInitialChat' => $pendingInitialChat,
111
  'selectedCourseId' => $selectedCourseId,
112
  'sessionId' => $sessionId,
113
  ]);
@@ -121,8 +112,10 @@ public function storeSession(Request $request): RedirectResponse
121
  'title' => ['required', 'string'],
122
  ]);
123
 
 
 
124
  try {
125
- $session = $this->createChatSession($payload, $this->requireEmbedToken($request));
126
  } catch (RagApiException $exception) {
127
  return back()
128
  ->withErrors(['chat' => $exception->getMessage()])
@@ -135,16 +128,22 @@ public function storeSession(Request $request): RedirectResponse
135
  return back()->withErrors(['chat' => 'Session ID tidak tersedia dari backend.']);
136
  }
137
 
138
- $pendingId = (string) Str::uuid();
139
- Cache::put($this->pendingInitialChatKey($pendingId), [
140
- 'content' => $payload['title'],
141
- 'courseId' => $payload['course_id'],
142
- ], now()->addMinutes(5));
 
 
 
 
 
 
 
 
 
143
 
144
- return redirect($this->embedRoute($request, [
145
- 'pending_id' => $pendingId,
146
- 'session_id' => $sessionId,
147
- ]));
148
  }
149
 
150
  public function storeMessage(Request $request, string $sessionId): RedirectResponse
@@ -363,7 +362,6 @@ private function emptyPayload(array $config): array
363
  'currentSession' => null,
364
  'isDirectChatMode' => false,
365
  'messages' => [],
366
- 'pendingInitialChat' => null,
367
  'selectedCourseId' => $config['courseId'],
368
  'sessionId' => null,
369
  ];
@@ -424,7 +422,11 @@ private function resolveEmbedToken(Request $request): ?string
424
  return null;
425
  }
426
 
427
- return $response['token'];
 
 
 
 
428
  }
429
 
430
  private function requireEmbedToken(Request $request): string
@@ -599,9 +601,4 @@ private function directMessagesKey(string $sessionId): string
599
  {
600
  return self::DIRECT_MESSAGES_SESSION_PREFIX.'.'.$sessionId;
601
  }
602
-
603
- private function pendingInitialChatKey(string $pendingId): string
604
- {
605
- return self::PENDING_INITIAL_CHAT_CACHE_PREFIX.'.'.$pendingId;
606
- }
607
  }
 
6
  use Illuminate\Http\RedirectResponse;
7
  use Illuminate\Http\Request;
8
  use Illuminate\Support\Facades\Cache;
9
+ use Illuminate\Support\Facades\Cookie as CookieJar;
10
  use Illuminate\Support\Str;
11
  use Inertia\Inertia;
12
  use Inertia\Response;
 
20
 
21
  private const DIRECT_MESSAGES_SESSION_PREFIX = 'sevima_raghub_embed_direct_messages';
22
 
 
 
23
  /**
24
  * @var array<int, string>
25
  */
 
60
  $courses = [];
61
  $currentSession = null;
62
  $messages = [];
 
63
  $selectedCourseId = $config['courseId'];
64
  $sessionId = $this->stringQuery($request, 'session_id');
 
65
 
66
  try {
67
  $courses = $this->listCourses(limit: 100, token: $token)['data'];
 
82
  if (is_string($currentSession['course_id'] ?? null)) {
83
  $selectedCourseId = $currentSession['course_id'];
84
  }
 
 
 
 
 
85
  }
86
  } catch (RagApiException $exception) {
87
  $backendMessage = $exception->getMessage() ?: null;
 
99
  'isAuthorized' => $backendMessage === null,
100
  'isDirectChatMode' => $this->isDirectChatMode(),
101
  'messages' => $messages,
 
102
  'selectedCourseId' => $selectedCourseId,
103
  'sessionId' => $sessionId,
104
  ]);
 
112
  'title' => ['required', 'string'],
113
  ]);
114
 
115
+ $token = $this->requireEmbedToken($request);
116
+
117
  try {
118
+ $session = $this->createChatSession($payload, $token);
119
  } catch (RagApiException $exception) {
120
  return back()
121
  ->withErrors(['chat' => $exception->getMessage()])
 
128
  return back()->withErrors(['chat' => 'Session ID tidak tersedia dari backend.']);
129
  }
130
 
131
+ try {
132
+ $assistantMessage = $this->sendConfiguredChatMessage(
133
+ $sessionId, $payload['course_id'], $payload['title'], $token,
134
+ );
135
+
136
+ if ($this->isDirectChatMode()) {
137
+ $this->appendDirectChatMessages($sessionId, [
138
+ $this->localUserMessage($payload['title']),
139
+ $assistantMessage,
140
+ ]);
141
+ }
142
+ } catch (RagApiException) {
143
+ // Session created; initial message failed — user can retry from the session page.
144
+ }
145
 
146
+ return redirect($this->embedRoute($request, ['session_id' => $sessionId]));
 
 
 
147
  }
148
 
149
  public function storeMessage(Request $request, string $sessionId): RedirectResponse
 
362
  'currentSession' => null,
363
  'isDirectChatMode' => false,
364
  'messages' => [],
 
365
  'selectedCourseId' => $config['courseId'],
366
  'sessionId' => null,
367
  ];
 
422
  return null;
423
  }
424
 
425
+ $token = $response['token'];
426
+
427
+ CookieJar::queue($this->embedCookie($request, self::EMBED_TOKEN_COOKIE, $token));
428
+
429
+ return $token;
430
  }
431
 
432
  private function requireEmbedToken(Request $request): string
 
601
  {
602
  return self::DIRECT_MESSAGES_SESSION_PREFIX.'.'.$sessionId;
603
  }
 
 
 
 
 
604
  }
resources/css/app.css CHANGED
@@ -990,6 +990,12 @@ @layer components {
990
  color: var(--student-primary);
991
  }
992
 
 
 
 
 
 
 
993
  .student-greeting {
994
  @apply mb-9 text-center;
995
  }
@@ -1132,6 +1138,10 @@ @layer components {
1132
  color: var(--student-primary);
1133
  }
1134
 
 
 
 
 
1135
  .student-message--user .student-message-bubble {
1136
  background-color: var(--student-primary);
1137
  border-color: var(--student-primary);
@@ -1685,6 +1695,16 @@ @layer components {
1685
  @apply w-full px-5 py-8 sm:px-8 lg:px-10;
1686
  }
1687
 
 
 
 
 
 
 
 
 
 
 
1688
  .lecturer-page-header {
1689
  @apply flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between;
1690
  }
 
990
  color: var(--student-primary);
991
  }
992
 
993
+ .student-empty-icon--thinking {
994
+ @apply animate-pulse;
995
+ background-color: var(--student-primary);
996
+ color: var(--student-primary-foreground);
997
+ }
998
+
999
  .student-greeting {
1000
  @apply mb-9 text-center;
1001
  }
 
1138
  color: var(--student-primary);
1139
  }
1140
 
1141
+ .student-message--assistant .student-message-bubble {
1142
+ color: #000;
1143
+ }
1144
+
1145
  .student-message--user .student-message-bubble {
1146
  background-color: var(--student-primary);
1147
  border-color: var(--student-primary);
 
1695
  @apply w-full px-5 py-8 sm:px-8 lg:px-10;
1696
  }
1697
 
1698
+ .lecturer-page input:not([type='checkbox']),
1699
+ .lecturer-page textarea,
1700
+ .lecturer-page select {
1701
+ background-color: color-mix(
1702
+ in oklab,
1703
+ var(--lecturer-primary) 8%,
1704
+ var(--lecturer-surface)
1705
+ );
1706
+ }
1707
+
1708
  .lecturer-page-header {
1709
  @apply flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between;
1710
  }
resources/js/components/admin/admin-api-key-card.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useForm } from '@inertiajs/react';
2
  import { Copy, KeyRound } from 'lucide-react';
3
  import { toast } from 'sonner';
4
 
@@ -39,10 +39,10 @@ export function AdminApiKeyCard({
39
  }
40
 
41
  return (
42
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
43
  <CardHeader>
44
  <CardTitle className="flex items-center gap-2">
45
- <KeyRound className="size-5 text-[var(--app-primary)]" />
46
  API Key Iframe
47
  </CardTitle>
48
  <CardDescription>
@@ -66,7 +66,7 @@ export function AdminApiKeyCard({
66
  <ConfigError message={readError(apiKeyForm.errors, 'apiKey')} />
67
 
68
  {generatedApiKey ? (
69
- <div className="grid gap-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-background)] p-4">
70
  <Label>API Key Baru</Label>
71
  <div className="flex gap-2">
72
  <Input readOnly value={generatedApiKey.api_key} />
@@ -87,3 +87,6 @@ export function AdminApiKeyCard({
87
  </Card>
88
  );
89
  }
 
 
 
 
1
+ import { useForm } from '@inertiajs/react';
2
  import { Copy, KeyRound } from 'lucide-react';
3
  import { toast } from 'sonner';
4
 
 
39
  }
40
 
41
  return (
42
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
43
  <CardHeader>
44
  <CardTitle className="flex items-center gap-2">
45
+ <KeyRound className="size-5 text-[var(--lecturer-primary)]" />
46
  API Key Iframe
47
  </CardTitle>
48
  <CardDescription>
 
66
  <ConfigError message={readError(apiKeyForm.errors, 'apiKey')} />
67
 
68
  {generatedApiKey ? (
69
+ <div className="grid gap-3 rounded-xl border border-(--lecturer-border) bg-(--lecturer-surface) p-4">
70
  <Label>API Key Baru</Label>
71
  <div className="flex gap-2">
72
  <Input readOnly value={generatedApiKey.api_key} />
 
87
  </Card>
88
  );
89
  }
90
+
91
+
92
+
resources/js/components/admin/admin-form-parts.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { InertiaFormProps } from '@inertiajs/react';
2
  import type { ReactNode } from 'react';
3
 
4
  import { Input } from '@/components/ui/input';
@@ -51,3 +51,6 @@ export function RetrievalNumberField({
51
  </Field>
52
  );
53
  }
 
 
 
 
1
+ import type { InertiaFormProps } from '@inertiajs/react';
2
  import type { ReactNode } from 'react';
3
 
4
  import { Input } from '@/components/ui/input';
 
51
  </Field>
52
  );
53
  }
54
+
55
+
56
+
resources/js/components/admin/admin-llm-form.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useForm } from '@inertiajs/react';
2
  import { Save } from 'lucide-react';
3
  import type { FormEvent } from 'react';
4
 
@@ -37,7 +37,7 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
37
  }
38
 
39
  return (
40
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
41
  <CardHeader>
42
  <CardTitle>LLM Config</CardTitle>
43
  <CardDescription>
@@ -148,7 +148,7 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
148
 
149
  <Field label="System Prompt">
150
  <textarea
151
- className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-36 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
152
  value={llmForm.data.system_prompt}
153
  onChange={(event) =>
154
  llmForm.setData(
@@ -174,3 +174,6 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
174
  </Card>
175
  );
176
  }
 
 
 
 
1
+ import { useForm } from '@inertiajs/react';
2
  import { Save } from 'lucide-react';
3
  import type { FormEvent } from 'react';
4
 
 
37
  }
38
 
39
  return (
40
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
41
  <CardHeader>
42
  <CardTitle>LLM Config</CardTitle>
43
  <CardDescription>
 
148
 
149
  <Field label="System Prompt">
150
  <textarea
151
+ className="border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-36 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
152
  value={llmForm.data.system_prompt}
153
  onChange={(event) =>
154
  llmForm.setData(
 
174
  </Card>
175
  );
176
  }
177
+
178
+
179
+
resources/js/components/admin/admin-overview-cards.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Search, Shield, Users } from 'lucide-react';
2
  import { useMemo } from 'react';
3
 
4
  import { Badge } from '@/components/ui/badge';
@@ -31,10 +31,10 @@ export function AdminOverviewCards({
31
 
32
  return (
33
  <section className="grid gap-4 md:grid-cols-3">
34
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
35
  <CardHeader className="gap-3">
36
  <div className="flex items-center justify-between gap-3">
37
- <span className="rounded-xl bg-[var(--app-primary-soft)] p-3 text-[var(--app-primary)]">
38
  <Shield className="size-5" />
39
  </span>
40
  <Badge variant="outline">LLM</Badge>
@@ -48,10 +48,10 @@ export function AdminOverviewCards({
48
  </CardHeader>
49
  </Card>
50
 
51
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
52
  <CardHeader className="gap-3">
53
  <div className="flex items-center justify-between gap-3">
54
- <span className="rounded-xl bg-[var(--app-primary-soft)] p-3 text-[var(--app-primary)]">
55
  <Search className="size-5" />
56
  </span>
57
  <Badge variant="outline">Retrieval</Badge>
@@ -65,10 +65,10 @@ export function AdminOverviewCards({
65
  </CardHeader>
66
  </Card>
67
 
68
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
69
  <CardHeader className="gap-3">
70
  <div className="flex items-center justify-between gap-3">
71
- <span className="rounded-xl bg-[var(--app-primary-soft)] p-3 text-[var(--app-primary)]">
72
  <Users className="size-5" />
73
  </span>
74
  <Badge variant="outline">User</Badge>
@@ -84,3 +84,6 @@ export function AdminOverviewCards({
84
  </section>
85
  );
86
  }
 
 
 
 
1
+ import { Search, Shield, Users } from 'lucide-react';
2
  import { useMemo } from 'react';
3
 
4
  import { Badge } from '@/components/ui/badge';
 
31
 
32
  return (
33
  <section className="grid gap-4 md:grid-cols-3">
34
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
35
  <CardHeader className="gap-3">
36
  <div className="flex items-center justify-between gap-3">
37
+ <span className="rounded-xl bg-[var(--lecturer-primary-soft)] p-3 text-[var(--lecturer-primary)]">
38
  <Shield className="size-5" />
39
  </span>
40
  <Badge variant="outline">LLM</Badge>
 
48
  </CardHeader>
49
  </Card>
50
 
51
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
52
  <CardHeader className="gap-3">
53
  <div className="flex items-center justify-between gap-3">
54
+ <span className="rounded-xl bg-[var(--lecturer-primary-soft)] p-3 text-[var(--lecturer-primary)]">
55
  <Search className="size-5" />
56
  </span>
57
  <Badge variant="outline">Retrieval</Badge>
 
65
  </CardHeader>
66
  </Card>
67
 
68
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
69
  <CardHeader className="gap-3">
70
  <div className="flex items-center justify-between gap-3">
71
+ <span className="rounded-xl bg-[var(--lecturer-primary-soft)] p-3 text-[var(--lecturer-primary)]">
72
  <Users className="size-5" />
73
  </span>
74
  <Badge variant="outline">User</Badge>
 
84
  </section>
85
  );
86
  }
87
+
88
+
89
+
resources/js/components/admin/admin-page.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Head } from '@inertiajs/react';
2
  import { AlertCircle } from 'lucide-react';
3
  import type { ReactNode } from 'react';
4
 
@@ -54,3 +54,6 @@ export function AdminPage({
54
  </>
55
  );
56
  }
 
 
 
 
1
+ import { Head } from '@inertiajs/react';
2
  import { AlertCircle } from 'lucide-react';
3
  import type { ReactNode } from 'react';
4
 
 
54
  </>
55
  );
56
  }
57
+
58
+
59
+
resources/js/components/admin/admin-rag-summary.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Database, FileSliders, Search, Shield } from 'lucide-react';
2
 
3
  import { Badge } from '@/components/ui/badge';
4
  import {
@@ -31,7 +31,7 @@ function ConfigRows({
31
  <dl className="grid gap-3 text-sm">
32
  {rows.map(([label, value]) => (
33
  <div
34
- className="flex items-start justify-between gap-4 rounded-lg border border-[var(--app-border)] px-3 py-2"
35
  key={label}
36
  >
37
  <dt className="text-muted-foreground">{label}</dt>
@@ -55,10 +55,10 @@ export function AdminRagSummary({
55
 
56
  return (
57
  <section className="grid gap-6 xl:grid-cols-2">
58
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)] xl:col-span-2">
59
  <CardHeader className="gap-3">
60
  <div className="flex items-center justify-between gap-3">
61
- <span className="rounded-xl bg-[var(--app-primary-soft)] p-3 text-[var(--app-primary)]">
62
  <FileSliders className="size-5" />
63
  </span>
64
  <Badge variant="outline">RAG</Badge>
@@ -72,10 +72,10 @@ export function AdminRagSummary({
72
  </CardHeader>
73
  </Card>
74
 
75
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
76
  <CardHeader>
77
  <CardTitle className="flex items-center gap-2">
78
- <Shield className="size-5 text-[var(--app-primary)]" />
79
  LLM
80
  </CardTitle>
81
  <CardDescription>Ringkasan model aktif.</CardDescription>
@@ -95,10 +95,10 @@ export function AdminRagSummary({
95
  </CardContent>
96
  </Card>
97
 
98
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
99
  <CardHeader>
100
  <CardTitle className="flex items-center gap-2">
101
- <Database className="size-5 text-[var(--app-primary)]" />
102
  VectorDB
103
  </CardTitle>
104
  <CardDescription>Ringkasan embedding dan chunk.</CardDescription>
@@ -115,10 +115,10 @@ export function AdminRagSummary({
115
  </CardContent>
116
  </Card>
117
 
118
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)] xl:col-span-2">
119
  <CardHeader>
120
  <CardTitle className="flex items-center gap-2">
121
- <Search className="size-5 text-[var(--app-primary)]" />
122
  Retrieval
123
  </CardTitle>
124
  <CardDescription>
@@ -152,3 +152,6 @@ export function AdminRagSummary({
152
  </section>
153
  );
154
  }
 
 
 
 
1
+ import { Database, FileSliders, Search, Shield } from 'lucide-react';
2
 
3
  import { Badge } from '@/components/ui/badge';
4
  import {
 
31
  <dl className="grid gap-3 text-sm">
32
  {rows.map(([label, value]) => (
33
  <div
34
+ className="flex items-start justify-between gap-4 rounded-lg border border-(--lecturer-border) px-3 py-2"
35
  key={label}
36
  >
37
  <dt className="text-muted-foreground">{label}</dt>
 
55
 
56
  return (
57
  <section className="grid gap-6 xl:grid-cols-2">
58
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface) xl:col-span-2">
59
  <CardHeader className="gap-3">
60
  <div className="flex items-center justify-between gap-3">
61
+ <span className="rounded-xl bg-[var(--lecturer-primary-soft)] p-3 text-[var(--lecturer-primary)]">
62
  <FileSliders className="size-5" />
63
  </span>
64
  <Badge variant="outline">RAG</Badge>
 
72
  </CardHeader>
73
  </Card>
74
 
75
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
76
  <CardHeader>
77
  <CardTitle className="flex items-center gap-2">
78
+ <Shield className="size-5 text-[var(--lecturer-primary)]" />
79
  LLM
80
  </CardTitle>
81
  <CardDescription>Ringkasan model aktif.</CardDescription>
 
95
  </CardContent>
96
  </Card>
97
 
98
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
99
  <CardHeader>
100
  <CardTitle className="flex items-center gap-2">
101
+ <Database className="size-5 text-[var(--lecturer-primary)]" />
102
  VectorDB
103
  </CardTitle>
104
  <CardDescription>Ringkasan embedding dan chunk.</CardDescription>
 
115
  </CardContent>
116
  </Card>
117
 
118
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface) xl:col-span-2">
119
  <CardHeader>
120
  <CardTitle className="flex items-center gap-2">
121
+ <Search className="size-5 text-[var(--lecturer-primary)]" />
122
  Retrieval
123
  </CardTitle>
124
  <CardDescription>
 
152
  </section>
153
  );
154
  }
155
+
156
+
157
+
resources/js/components/admin/admin-shell.tsx CHANGED
@@ -1,9 +1,9 @@
1
- import { router } from '@inertiajs/react';
2
  import {
3
  BrainCircuit,
4
  Database,
5
  FileSliders,
6
- LayoutDashboard,
7
  LogOut,
8
  MessageSquare,
9
  } from 'lucide-react';
@@ -38,19 +38,22 @@ import {
38
  } from '@/components/ui/sidebar';
39
  import { clearAuthSession, getStoredAuthUser } from '@/lib/auth';
40
  import {
41
- dashboard as adminDashboard,
42
  llmConfig as adminLlmConfig,
43
  login as adminLogin,
44
- ragConfig as adminRagConfig,
45
- vectorRetrievalConfig as adminVectorRetrievalConfig,
46
  } from '@/routes/admin';
 
47
 
48
  export type AdminMenu =
 
49
  | 'dashboard'
50
  | 'llm-config'
51
  | 'rag-config'
 
52
  | 'settings'
53
- | 'vector-retrieval-config';
 
54
 
55
  const adminSidebarStyle = {
56
  '--sidebar-width': '212px',
@@ -137,61 +140,60 @@ function AdminSidebar({ activeMenu }: { activeMenu: AdminMenu }) {
137
  <SidebarMenuItem>
138
  <SidebarMenuButton
139
  className="lecturer-nav-button"
140
- isActive={activeMenu === 'dashboard'}
141
  onClick={() =>
142
- router.visit(adminDashboard())
143
  }
144
- tooltip="Dashboard Admin"
145
  >
146
- <LayoutDashboard className="size-4" />
147
- <span>Dashboard Admin</span>
148
  </SidebarMenuButton>
149
  </SidebarMenuItem>
150
 
151
  <SidebarMenuItem>
152
  <SidebarMenuButton
153
  className="lecturer-nav-button"
154
- isActive={activeMenu === 'rag-config'}
 
 
155
  onClick={() =>
156
- router.visit(adminRagConfig())
157
  }
158
- tooltip="RAG Config"
159
  >
160
- <FileSliders className="size-4" />
161
- <span>RAG Config</span>
162
  </SidebarMenuButton>
163
  </SidebarMenuItem>
164
 
165
  <SidebarMenuItem>
166
  <SidebarMenuButton
167
  className="lecturer-nav-button"
168
- isActive={activeMenu === 'llm-config'}
 
 
169
  onClick={() =>
170
- router.visit(adminLlmConfig())
171
  }
172
- tooltip="LLM Config"
173
  >
174
- <BrainCircuit className="size-4" />
175
- <span>LLM Config</span>
176
  </SidebarMenuButton>
177
  </SidebarMenuItem>
178
 
179
  <SidebarMenuItem>
180
  <SidebarMenuButton
181
  className="lecturer-nav-button"
182
- isActive={
183
- activeMenu ===
184
- 'vector-retrieval-config'
185
- }
186
  onClick={() =>
187
- router.visit(
188
- adminVectorRetrievalConfig(),
189
- )
190
  }
191
- tooltip="VectorDB & Retrieval"
192
  >
193
- <Database className="size-4" />
194
- <span>VectorDB & Retrieval</span>
195
  </SidebarMenuButton>
196
  </SidebarMenuItem>
197
  </SidebarMenu>
@@ -295,3 +297,6 @@ export function AdminShell({
295
  </SidebarProvider>
296
  );
297
  }
 
 
 
 
1
+ import { router } from '@inertiajs/react';
2
  import {
3
  BrainCircuit,
4
  Database,
5
  FileSliders,
6
+ KeyRound,
7
  LogOut,
8
  MessageSquare,
9
  } from 'lucide-react';
 
38
  } from '@/components/ui/sidebar';
39
  import { clearAuthSession, getStoredAuthUser } from '@/lib/auth';
40
  import {
 
41
  llmConfig as adminLlmConfig,
42
  login as adminLogin,
43
+ retrievalConfig as adminRetrievalConfig,
44
+ vectordbConfig as adminVectorDbConfig,
45
  } from '@/routes/admin';
46
+ import { index as adminApiKeys } from '@/routes/admin/api-keys';
47
 
48
  export type AdminMenu =
49
+ | 'api-keys'
50
  | 'dashboard'
51
  | 'llm-config'
52
  | 'rag-config'
53
+ | 'retrieval-config'
54
  | 'settings'
55
+ | 'vector-retrieval-config'
56
+ | 'vectordb-config';
57
 
58
  const adminSidebarStyle = {
59
  '--sidebar-width': '212px',
 
140
  <SidebarMenuItem>
141
  <SidebarMenuButton
142
  className="lecturer-nav-button"
143
+ isActive={activeMenu === 'llm-config'}
144
  onClick={() =>
145
+ router.visit(adminLlmConfig())
146
  }
147
+ tooltip="LLM Config"
148
  >
149
+ <BrainCircuit className="size-4" />
150
+ <span>LLM Config</span>
151
  </SidebarMenuButton>
152
  </SidebarMenuItem>
153
 
154
  <SidebarMenuItem>
155
  <SidebarMenuButton
156
  className="lecturer-nav-button"
157
+ isActive={
158
+ activeMenu === 'vectordb-config'
159
+ }
160
  onClick={() =>
161
+ router.visit(adminVectorDbConfig())
162
  }
163
+ tooltip="VectorDB Config"
164
  >
165
+ <Database className="size-4" />
166
+ <span>VectorDB Config</span>
167
  </SidebarMenuButton>
168
  </SidebarMenuItem>
169
 
170
  <SidebarMenuItem>
171
  <SidebarMenuButton
172
  className="lecturer-nav-button"
173
+ isActive={
174
+ activeMenu === 'retrieval-config'
175
+ }
176
  onClick={() =>
177
+ router.visit(adminRetrievalConfig())
178
  }
179
+ tooltip="Retrieval Config"
180
  >
181
+ <FileSliders className="size-4" />
182
+ <span>Retrieval Config</span>
183
  </SidebarMenuButton>
184
  </SidebarMenuItem>
185
 
186
  <SidebarMenuItem>
187
  <SidebarMenuButton
188
  className="lecturer-nav-button"
189
+ isActive={activeMenu === 'api-keys'}
 
 
 
190
  onClick={() =>
191
+ router.visit(adminApiKeys())
 
 
192
  }
193
+ tooltip="Generate API Key"
194
  >
195
+ <KeyRound className="size-4" />
196
+ <span>Generate API Key</span>
197
  </SidebarMenuButton>
198
  </SidebarMenuItem>
199
  </SidebarMenu>
 
297
  </SidebarProvider>
298
  );
299
  }
300
+
301
+
302
+
resources/js/components/admin/admin-user-management.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { router, useForm } from '@inertiajs/react';
2
  import type { InertiaFormProps } from '@inertiajs/react';
3
  import { Search, Trash2, UserPlus } from 'lucide-react';
4
  import type { FormEvent } from 'react';
@@ -67,10 +67,10 @@ export function AdminUserCreateCard() {
67
  }
68
 
69
  return (
70
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
71
  <CardHeader>
72
  <CardTitle className="flex items-center gap-2">
73
- <UserPlus className="size-5 text-[var(--app-primary)]" />
74
  Tambah User
75
  </CardTitle>
76
  <CardDescription>
@@ -160,7 +160,7 @@ export function AdminUserManagement({
160
 
161
  return (
162
  <>
163
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
164
  <CardHeader>
165
  <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
166
  <div>
@@ -232,7 +232,7 @@ export function AdminUserManagement({
232
  </div>
233
  </CardHeader>
234
  <CardContent>
235
- <div className="overflow-hidden rounded-xl border border-[var(--app-border)]">
236
  <div className="overflow-x-auto">
237
  <table className="w-full min-w-[760px] text-sm">
238
  <thead className="bg-muted/50 text-left text-muted-foreground">
@@ -252,7 +252,7 @@ export function AdminUserManagement({
252
  <tbody>
253
  {users.map((user) => (
254
  <tr
255
- className="border-t border-[var(--app-border)]"
256
  key={user.id}
257
  >
258
  <td className="px-4 py-3">
@@ -272,7 +272,7 @@ export function AdminUserManagement({
272
  <Badge
273
  className={
274
  isUserActive(user)
275
- ? 'bg-[var(--app-primary-soft)] text-[var(--app-primary)]'
276
  : ''
277
  }
278
  variant={
@@ -480,7 +480,7 @@ function UserFormFields({
480
  </div>
481
 
482
  <div className="grid gap-3 sm:grid-cols-2">
483
- <label className="flex items-center gap-3 rounded-lg border border-[var(--app-border)] p-3 text-sm">
484
  <Checkbox
485
  checked={form.data.is_active}
486
  onCheckedChange={(value) =>
@@ -489,7 +489,7 @@ function UserFormFields({
489
  />
490
  User aktif
491
  </label>
492
- <label className="flex items-center gap-3 rounded-lg border border-[var(--app-border)] p-3 text-sm">
493
  <Checkbox
494
  checked={form.data.is_superuser}
495
  onCheckedChange={(value) =>
@@ -509,3 +509,6 @@ function UserFormFields({
509
  </form>
510
  );
511
  }
 
 
 
 
1
+ import { router, useForm } from '@inertiajs/react';
2
  import type { InertiaFormProps } from '@inertiajs/react';
3
  import { Search, Trash2, UserPlus } from 'lucide-react';
4
  import type { FormEvent } from 'react';
 
67
  }
68
 
69
  return (
70
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
71
  <CardHeader>
72
  <CardTitle className="flex items-center gap-2">
73
+ <UserPlus className="size-5 text-[var(--lecturer-primary)]" />
74
  Tambah User
75
  </CardTitle>
76
  <CardDescription>
 
160
 
161
  return (
162
  <>
163
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
164
  <CardHeader>
165
  <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
166
  <div>
 
232
  </div>
233
  </CardHeader>
234
  <CardContent>
235
+ <div className="overflow-hidden rounded-xl border border-(--lecturer-border)">
236
  <div className="overflow-x-auto">
237
  <table className="w-full min-w-[760px] text-sm">
238
  <thead className="bg-muted/50 text-left text-muted-foreground">
 
252
  <tbody>
253
  {users.map((user) => (
254
  <tr
255
+ className="border-t border-(--lecturer-border)"
256
  key={user.id}
257
  >
258
  <td className="px-4 py-3">
 
272
  <Badge
273
  className={
274
  isUserActive(user)
275
+ ? 'bg-[var(--lecturer-primary-soft)] text-[var(--lecturer-primary)]'
276
  : ''
277
  }
278
  variant={
 
480
  </div>
481
 
482
  <div className="grid gap-3 sm:grid-cols-2">
483
+ <label className="flex items-center gap-3 rounded-lg border border-(--lecturer-border) p-3 text-sm">
484
  <Checkbox
485
  checked={form.data.is_active}
486
  onCheckedChange={(value) =>
 
489
  />
490
  User aktif
491
  </label>
492
+ <label className="flex items-center gap-3 rounded-lg border border-(--lecturer-border) p-3 text-sm">
493
  <Checkbox
494
  checked={form.data.is_superuser}
495
  onCheckedChange={(value) =>
 
509
  </form>
510
  );
511
  }
512
+
513
+
514
+
resources/js/components/admin/admin-vector-retrieval-forms.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useForm } from '@inertiajs/react';
2
  import { Save } from 'lucide-react';
3
  import type { FormEvent } from 'react';
4
 
@@ -30,6 +30,248 @@ import type {
30
  VectorDbForm,
31
  } from '@/types/admin';
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  export function AdminVectorRetrievalForms({
34
  retrieval,
35
  vectorDb,
@@ -77,7 +319,7 @@ export function AdminVectorRetrievalForms({
77
 
78
  return (
79
  <section className="grid gap-6 xl:grid-cols-2">
80
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
81
  <CardHeader>
82
  <CardTitle>VectorDB Config</CardTitle>
83
  <CardDescription>
@@ -161,7 +403,7 @@ export function AdminVectorRetrievalForms({
161
  </CardContent>
162
  </Card>
163
 
164
- <Card className="border-[var(--app-border)] bg-[var(--app-surface)]">
165
  <CardHeader>
166
  <CardTitle>Retrieval Config</CardTitle>
167
  <CardDescription>
@@ -275,3 +517,6 @@ export function AdminVectorRetrievalForms({
275
  </section>
276
  );
277
  }
 
 
 
 
1
+ import { useForm } from '@inertiajs/react';
2
  import { Save } from 'lucide-react';
3
  import type { FormEvent } from 'react';
4
 
 
30
  VectorDbForm,
31
  } from '@/types/admin';
32
 
33
+ export function AdminVectorDbForm({
34
+ vectorDb,
35
+ }: {
36
+ vectorDb?: VectorDbConfig | null;
37
+ }) {
38
+ const vectorDbConfig = vectorDb ?? {};
39
+
40
+ const vectorDbForm = useForm<VectorDbForm>({
41
+ chunk_overlap: stringValue(vectorDbConfig.chunk_overlap),
42
+ chunk_size: stringValue(vectorDbConfig.chunk_size),
43
+ embedding_model: stringValue(vectorDbConfig.embedding_model),
44
+ persist_path: stringValue(vectorDbConfig.persist_path),
45
+ });
46
+
47
+ function handleVectorDbSubmit(event: FormEvent<HTMLFormElement>): void {
48
+ event.preventDefault();
49
+ vectorDbForm.patch(updateVectorDbConfig.url(), {
50
+ preserveScroll: true,
51
+ });
52
+ }
53
+
54
+ return (
55
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
56
+ <CardHeader>
57
+ <CardTitle>VectorDB Config</CardTitle>
58
+ <CardDescription>
59
+ Atur model embedding dan konfigurasi chunking dokumen.
60
+ </CardDescription>
61
+ </CardHeader>
62
+ <CardContent>
63
+ <form className="grid gap-4" onSubmit={handleVectorDbSubmit}>
64
+ <ConfigError
65
+ message={readError(vectorDbForm.errors, 'vectorDb')}
66
+ />
67
+
68
+ <div className="grid gap-3 md:grid-cols-2">
69
+ <Field label="Embedding Model">
70
+ <Input
71
+ value={vectorDbForm.data.embedding_model}
72
+ onChange={(event) =>
73
+ vectorDbForm.setData(
74
+ 'embedding_model',
75
+ event.target.value,
76
+ )
77
+ }
78
+ />
79
+ </Field>
80
+ <Field label="Persist Path">
81
+ <Input
82
+ value={vectorDbForm.data.persist_path}
83
+ onChange={(event) =>
84
+ vectorDbForm.setData(
85
+ 'persist_path',
86
+ event.target.value,
87
+ )
88
+ }
89
+ />
90
+ </Field>
91
+ <Field label="Chunk Size">
92
+ <Input
93
+ min="1"
94
+ type="number"
95
+ value={vectorDbForm.data.chunk_size}
96
+ onChange={(event) =>
97
+ vectorDbForm.setData(
98
+ 'chunk_size',
99
+ event.target.value,
100
+ )
101
+ }
102
+ />
103
+ </Field>
104
+ <Field label="Chunk Overlap">
105
+ <Input
106
+ min="0"
107
+ type="number"
108
+ value={vectorDbForm.data.chunk_overlap}
109
+ onChange={(event) =>
110
+ vectorDbForm.setData(
111
+ 'chunk_overlap',
112
+ event.target.value,
113
+ )
114
+ }
115
+ />
116
+ </Field>
117
+ </div>
118
+
119
+ <div className="flex justify-end">
120
+ <Button
121
+ disabled={vectorDbForm.processing}
122
+ type="submit"
123
+ >
124
+ {vectorDbForm.processing ? (
125
+ <Spinner className="size-4" />
126
+ ) : (
127
+ <Save className="size-4" />
128
+ )}
129
+ Simpan Vector
130
+ </Button>
131
+ </div>
132
+ </form>
133
+ </CardContent>
134
+ </Card>
135
+ );
136
+ }
137
+
138
+ export function AdminRetrievalForm({
139
+ retrieval,
140
+ }: {
141
+ retrieval?: RetrievalConfig | null;
142
+ }) {
143
+ const retrievalConfig = retrieval ?? {};
144
+
145
+ const retrievalForm = useForm<RetrievalForm>({
146
+ bm25_weight: stringValue(retrievalConfig.bm25_weight),
147
+ candidate_pool_size: stringValue(retrievalConfig.candidate_pool_size),
148
+ dense_weight: stringValue(retrievalConfig.dense_weight),
149
+ enable_reranker: Boolean(retrievalConfig.enable_reranker),
150
+ history_turns: stringValue(retrievalConfig.history_turns),
151
+ lexical_weight: stringValue(retrievalConfig.lexical_weight),
152
+ max_context_chars: stringValue(retrievalConfig.max_context_chars),
153
+ neighbor_window: stringValue(retrievalConfig.neighbor_window),
154
+ reranker_model: stringValue(retrievalConfig.reranker_model),
155
+ similarity_threshold: stringValue(retrievalConfig.similarity_threshold),
156
+ top_k: stringValue(retrievalConfig.top_k),
157
+ });
158
+
159
+ function handleRetrievalSubmit(event: FormEvent<HTMLFormElement>): void {
160
+ event.preventDefault();
161
+ retrievalForm.patch(updateRetrievalConfig.url(), {
162
+ preserveScroll: true,
163
+ });
164
+ }
165
+
166
+ return (
167
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
168
+ <CardHeader>
169
+ <CardTitle>Retrieval Config</CardTitle>
170
+ <CardDescription>
171
+ Atur bobot retrieval, reranker, dan konteks jawaban.
172
+ </CardDescription>
173
+ </CardHeader>
174
+ <CardContent>
175
+ <form className="grid gap-4" onSubmit={handleRetrievalSubmit}>
176
+ <ConfigError
177
+ message={readError(retrievalForm.errors, 'retrieval')}
178
+ />
179
+
180
+ <div className="grid gap-3 md:grid-cols-2">
181
+ <RetrievalNumberField
182
+ form={retrievalForm}
183
+ label="Top K"
184
+ name="top_k"
185
+ />
186
+ <RetrievalNumberField
187
+ form={retrievalForm}
188
+ label="Max Context Chars"
189
+ name="max_context_chars"
190
+ />
191
+ <RetrievalNumberField
192
+ form={retrievalForm}
193
+ label="BM25 Weight"
194
+ name="bm25_weight"
195
+ step="0.01"
196
+ />
197
+ <RetrievalNumberField
198
+ form={retrievalForm}
199
+ label="Dense Weight"
200
+ name="dense_weight"
201
+ step="0.01"
202
+ />
203
+ <RetrievalNumberField
204
+ form={retrievalForm}
205
+ label="Lexical Weight"
206
+ name="lexical_weight"
207
+ step="0.01"
208
+ />
209
+ <RetrievalNumberField
210
+ form={retrievalForm}
211
+ label="Similarity Threshold"
212
+ name="similarity_threshold"
213
+ step="0.01"
214
+ />
215
+ <RetrievalNumberField
216
+ form={retrievalForm}
217
+ label="History Turns"
218
+ name="history_turns"
219
+ />
220
+ <RetrievalNumberField
221
+ form={retrievalForm}
222
+ label="Candidate Pool"
223
+ name="candidate_pool_size"
224
+ />
225
+ <RetrievalNumberField
226
+ form={retrievalForm}
227
+ label="Neighbor Window"
228
+ name="neighbor_window"
229
+ />
230
+ <Field label="Reranker Model">
231
+ <Input
232
+ value={retrievalForm.data.reranker_model}
233
+ onChange={(event) =>
234
+ retrievalForm.setData(
235
+ 'reranker_model',
236
+ event.target.value,
237
+ )
238
+ }
239
+ />
240
+ </Field>
241
+ </div>
242
+
243
+ <label className="flex items-center gap-3 text-sm">
244
+ <Checkbox
245
+ checked={retrievalForm.data.enable_reranker}
246
+ onCheckedChange={(value) =>
247
+ retrievalForm.setData(
248
+ 'enable_reranker',
249
+ value === true,
250
+ )
251
+ }
252
+ />
253
+ Enable reranker
254
+ </label>
255
+
256
+ <div className="flex justify-end">
257
+ <Button
258
+ disabled={retrievalForm.processing}
259
+ type="submit"
260
+ >
261
+ {retrievalForm.processing ? (
262
+ <Spinner className="size-4" />
263
+ ) : (
264
+ <Save className="size-4" />
265
+ )}
266
+ Simpan Retrieval
267
+ </Button>
268
+ </div>
269
+ </form>
270
+ </CardContent>
271
+ </Card>
272
+ );
273
+ }
274
+
275
  export function AdminVectorRetrievalForms({
276
  retrieval,
277
  vectorDb,
 
319
 
320
  return (
321
  <section className="grid gap-6 xl:grid-cols-2">
322
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
323
  <CardHeader>
324
  <CardTitle>VectorDB Config</CardTitle>
325
  <CardDescription>
 
403
  </CardContent>
404
  </Card>
405
 
406
+ <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
407
  <CardHeader>
408
  <CardTitle>Retrieval Config</CardTitle>
409
  <CardDescription>
 
517
  </section>
518
  );
519
  }
520
+
521
+
522
+
resources/js/components/student/student-shell.tsx CHANGED
@@ -429,7 +429,7 @@ function StudentSidebar({
429
  {isDeletingSession ? (
430
  <LoadingIndicator
431
  label="Menghapus"
432
- spinnerClassName="size-3.5"
433
  />
434
  ) : (
435
  'Hapus'
 
429
  {isDeletingSession ? (
430
  <LoadingIndicator
431
  label="Menghapus"
432
+ showSpinner={false}
433
  />
434
  ) : (
435
  'Hapus'
resources/js/pages/admin/api-keys.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AdminApiKeyCard } from '@/components/admin/admin-api-key-card';
2
+ import { AdminPage } from '@/components/admin/admin-page';
3
+ import type { GeneratedApiKey } from '@/types/admin';
4
+
5
+ type ApiKeysProps = {
6
+ generatedApiKey?: GeneratedApiKey | null;
7
+ };
8
+
9
+ export default function AdminApiKeys({ generatedApiKey }: ApiKeysProps) {
10
+ return (
11
+ <AdminPage
12
+ activeMenu="api-keys"
13
+ description="Generate API key untuk integrasi iframe pihak ketiga."
14
+ title="Generate API Key"
15
+ >
16
+ <div className="max-w-lg">
17
+ <AdminApiKeyCard generatedApiKey={generatedApiKey} />
18
+ </div>
19
+ </AdminPage>
20
+ );
21
+ }
resources/js/pages/admin/retrieval-config.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AdminPage } from '@/components/admin/admin-page';
2
+ import { AdminRetrievalForm } from '@/components/admin/admin-vector-retrieval-forms';
3
+ import type { AdminConfigPageProps } from '@/types/admin';
4
+
5
+ export default function AdminRetrievalConfig({
6
+ backendMessage,
7
+ ragConfig,
8
+ }: AdminConfigPageProps) {
9
+ return (
10
+ <AdminPage
11
+ activeMenu="retrieval-config"
12
+ backendMessage={backendMessage}
13
+ description="Atur bobot retrieval, reranker, dan konteks jawaban."
14
+ title="Retrieval Config"
15
+ >
16
+ <AdminRetrievalForm retrieval={ragConfig?.retrieval} />
17
+ </AdminPage>
18
+ );
19
+ }
resources/js/pages/admin/vectordb-config.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AdminPage } from '@/components/admin/admin-page';
2
+ import { AdminVectorDbForm } from '@/components/admin/admin-vector-retrieval-forms';
3
+ import type { AdminConfigPageProps } from '@/types/admin';
4
+
5
+ export default function AdminVectorDbConfig({
6
+ backendMessage,
7
+ ragConfig,
8
+ }: AdminConfigPageProps) {
9
+ return (
10
+ <AdminPage
11
+ activeMenu="vectordb-config"
12
+ backendMessage={backendMessage}
13
+ description="Atur model embedding dan konfigurasi chunking dokumen."
14
+ title="VectorDB Config"
15
+ >
16
+ <AdminVectorDbForm vectorDb={ragConfig?.vector_db} />
17
+ </AdminPage>
18
+ );
19
+ }
resources/js/pages/embed.tsx CHANGED
@@ -42,11 +42,6 @@ type EmbedConfig = {
42
  userName: string;
43
  };
44
 
45
- type PendingInitialChat = {
46
- content: string;
47
- courseId: string;
48
- };
49
-
50
  type EmbedProps = {
51
  backendMessage?: string | null;
52
  chatSessions: ChatSessionResponse[];
@@ -56,7 +51,6 @@ type EmbedProps = {
56
  isAuthorized: boolean;
57
  isDirectChatMode?: boolean;
58
  messages: ChatMessageResponse[];
59
- pendingInitialChat?: PendingInitialChat | null;
60
  selectedCourseId?: string | null;
61
  sessionId?: string | null;
62
  };
@@ -360,36 +354,24 @@ export default function Embed({
360
  chatSessions,
361
  config,
362
  courses,
363
- currentSession,
364
  isAuthorized,
365
  isDirectChatMode = false,
366
  messages: initialMessages,
367
- pendingInitialChat,
368
  selectedCourseId: initialSelectedCourseId,
369
  sessionId,
370
  }: EmbedProps) {
371
- const pendingInitialChatRef = useRef(pendingInitialChat ?? undefined);
372
- const hasStartedPendingInitialChatRef = useRef(false);
373
  const isWaitingForDirectAssistantRef = useRef(false);
374
  const knownMessageIdsRef = useRef(
375
  new Set(initialMessages.map((message) => message.uuid_id)),
376
  );
377
- const [pendingUserMessage] = useState<ChatMessageResponse | undefined>(
378
- pendingInitialChat
379
- ? createLocalUserMessage(pendingInitialChat.content)
380
- : undefined,
381
- );
382
- const pendingUserMessageRef = useRef(pendingUserMessage);
383
  const [activeTab, setActiveTab] = useState<'chat' | 'history'>('chat');
384
  const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
385
  useState<string>();
386
- const [isProcessing, setIsProcessing] = useState(
387
- Boolean(pendingInitialChat),
388
- );
389
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
390
  const [optimisticMessages, setOptimisticMessages] = useState<
391
  ChatMessageResponse[]
392
- >(() => (pendingUserMessage ? [pendingUserMessage] : []));
393
  const [question, setQuestion] = useState('');
394
  const [selectedCourseId, setSelectedCourseId] = useState(
395
  initialSelectedCourseId ??
@@ -446,7 +428,6 @@ export default function Embed({
446
  );
447
  }
448
 
449
- pendingInitialChatRef.current = undefined;
450
  setQuestion(content);
451
  setLocalBackendMessage(firstError(errors));
452
  },
@@ -457,7 +438,6 @@ export default function Embed({
457
  setIsProcessing(true);
458
  },
459
  onSuccess: () => {
460
- pendingInitialChatRef.current = undefined;
461
  setOptimisticMessages([]);
462
  setQuestion('');
463
  },
@@ -495,26 +475,6 @@ export default function Embed({
495
  return () => window.cancelAnimationFrame(scrollFrame);
496
  }, [messages, isProcessing]);
497
 
498
- useEffect(() => {
499
- const pendingInitialChat = pendingInitialChatRef.current;
500
-
501
- if (!pendingInitialChat || hasStartedPendingInitialChatRef.current) {
502
- return;
503
- }
504
-
505
- hasStartedPendingInitialChatRef.current = true;
506
- const pendingMessage = pendingUserMessageRef.current;
507
- const timeoutId = window.setTimeout(() => {
508
- sendMessage(
509
- pendingInitialChat.content,
510
- pendingInitialChat.courseId,
511
- pendingMessage,
512
- );
513
- }, 0);
514
-
515
- return () => window.clearTimeout(timeoutId);
516
- }, [sendMessage]);
517
-
518
  function handleQuestionSubmit(event: FormEvent<HTMLFormElement>): void {
519
  event.preventDefault();
520
 
@@ -715,14 +675,6 @@ export default function Embed({
715
  question={question}
716
  selectedCourseId={selectedCourseId}
717
  />
718
- {currentSession?.last_message_at && (
719
- <p className="embed-updated-at">
720
- Terakhir diperbarui{' '}
721
- {formatMessageTime(
722
- currentSession.last_message_at,
723
- )}
724
- </p>
725
- )}
726
  </div>
727
  )}
728
  </section>
 
42
  userName: string;
43
  };
44
 
 
 
 
 
 
45
  type EmbedProps = {
46
  backendMessage?: string | null;
47
  chatSessions: ChatSessionResponse[];
 
51
  isAuthorized: boolean;
52
  isDirectChatMode?: boolean;
53
  messages: ChatMessageResponse[];
 
54
  selectedCourseId?: string | null;
55
  sessionId?: string | null;
56
  };
 
354
  chatSessions,
355
  config,
356
  courses,
 
357
  isAuthorized,
358
  isDirectChatMode = false,
359
  messages: initialMessages,
 
360
  selectedCourseId: initialSelectedCourseId,
361
  sessionId,
362
  }: EmbedProps) {
 
 
363
  const isWaitingForDirectAssistantRef = useRef(false);
364
  const knownMessageIdsRef = useRef(
365
  new Set(initialMessages.map((message) => message.uuid_id)),
366
  );
 
 
 
 
 
 
367
  const [activeTab, setActiveTab] = useState<'chat' | 'history'>('chat');
368
  const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
369
  useState<string>();
370
+ const [isProcessing, setIsProcessing] = useState(false);
 
 
371
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
372
  const [optimisticMessages, setOptimisticMessages] = useState<
373
  ChatMessageResponse[]
374
+ >([]);
375
  const [question, setQuestion] = useState('');
376
  const [selectedCourseId, setSelectedCourseId] = useState(
377
  initialSelectedCourseId ??
 
428
  );
429
  }
430
 
 
431
  setQuestion(content);
432
  setLocalBackendMessage(firstError(errors));
433
  },
 
438
  setIsProcessing(true);
439
  },
440
  onSuccess: () => {
 
441
  setOptimisticMessages([]);
442
  setQuestion('');
443
  },
 
475
  return () => window.cancelAnimationFrame(scrollFrame);
476
  }, [messages, isProcessing]);
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  function handleQuestionSubmit(event: FormEvent<HTMLFormElement>): void {
479
  event.preventDefault();
480
 
 
675
  question={question}
676
  selectedCourseId={selectedCourseId}
677
  />
 
 
 
 
 
 
 
 
678
  </div>
679
  )}
680
  </section>
resources/js/pages/mahasiswa-chat.tsx CHANGED
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
  import { QuestionComposer } from '@/components/student/question-composer';
7
  import type { DeleteSessionResult } from '@/components/student/student-shell';
8
  import {
9
- formatSessionTime,
10
  StudentShell,
11
  useStoredStudentName,
12
  } from '@/components/student/student-shell';
@@ -666,18 +665,6 @@ function ChatMessage({
666
  {formatMessageTime(message.created_at)}
667
  </span>
668
 
669
- {(message.sources?.length ?? 0) > 0 && (
670
- <div className="student-message-sources">
671
- {message.sources?.map((source, index) => (
672
- <span
673
- className="student-message-source"
674
- key={`${message.uuid_id}-${source.document_id}-${index}`}
675
- >
676
- {source.filename}
677
- </span>
678
- ))}
679
- </div>
680
- )}
681
  </div>
682
 
683
  {isUser && (
@@ -1164,14 +1151,6 @@ export default function MahasiswaChat({
1164
  variant="dock"
1165
  />
1166
 
1167
- {currentSession?.last_message_at && (
1168
- <p className="student-chat-updated-at">
1169
- Terakhir diperbarui{' '}
1170
- {formatSessionTime(
1171
- currentSession.last_message_at,
1172
- )}
1173
- </p>
1174
- )}
1175
  </div>
1176
  </div>
1177
  </StudentShell>
 
6
  import { QuestionComposer } from '@/components/student/question-composer';
7
  import type { DeleteSessionResult } from '@/components/student/student-shell';
8
  import {
 
9
  StudentShell,
10
  useStoredStudentName,
11
  } from '@/components/student/student-shell';
 
665
  {formatMessageTime(message.created_at)}
666
  </span>
667
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  </div>
669
 
670
  {isUser && (
 
1151
  variant="dock"
1152
  />
1153
 
 
 
 
 
 
 
 
 
1154
  </div>
1155
  </div>
1156
  </StudentShell>
resources/js/pages/mahasiswa.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { Head, router } from '@inertiajs/react';
2
- import { MessageSquare } from 'lucide-react';
3
  import type { FormEvent } from 'react';
4
  import { useState } from 'react';
5
 
@@ -9,6 +9,7 @@ import {
9
  StudentShell,
10
  useStoredStudentName,
11
  } from '@/components/student/student-shell';
 
12
  import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
13
  import { destroy as destroyMahasiswaSession } from '@/routes/mahasiswa';
14
  import { store as storeMahasiswaSession } from '@/routes/mahasiswa/sessions';
@@ -100,16 +101,39 @@ export default function Mahasiswa({
100
  >
101
  <div className="student-chat-stage">
102
  <section className="student-empty-state">
103
- <div className="student-empty-icon">
104
- <MessageSquare className="size-8" />
 
 
 
 
 
 
 
 
 
 
105
  </div>
106
 
107
  <div className="student-greeting">
108
- <h2>Good Morning, {studentName}</h2>
109
- <p>
110
- Pilih mata kuliah, lalu kirim pertanyaan pertama
111
- untuk membuat sesi baru
112
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </div>
114
 
115
  {backendMessage && (
 
1
  import { Head, router } from '@inertiajs/react';
2
+ import { Bot, MessageSquare } from 'lucide-react';
3
  import type { FormEvent } from 'react';
4
  import { useState } from 'react';
5
 
 
9
  StudentShell,
10
  useStoredStudentName,
11
  } from '@/components/student/student-shell';
12
+ import { LoadingIndicator } from '@/components/ui/loading-indicator';
13
  import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
14
  import { destroy as destroyMahasiswaSession } from '@/routes/mahasiswa';
15
  import { store as storeMahasiswaSession } from '@/routes/mahasiswa/sessions';
 
101
  >
102
  <div className="student-chat-stage">
103
  <section className="student-empty-state">
104
+ <div
105
+ className={
106
+ isProcessing
107
+ ? 'student-empty-icon student-empty-icon--thinking'
108
+ : 'student-empty-icon'
109
+ }
110
+ >
111
+ {isProcessing ? (
112
+ <Bot className="size-8" />
113
+ ) : (
114
+ <MessageSquare className="size-8" />
115
+ )}
116
  </div>
117
 
118
  <div className="student-greeting">
119
+ <h2>
120
+ {isProcessing
121
+ ? 'The Assistant is thinking...'
122
+ : `Good Morning, ${studentName}`}
123
+ </h2>
124
+ {isProcessing ? (
125
+ <p>
126
+ <LoadingIndicator
127
+ label="Bot sedang memikirkan jawaban"
128
+ showSpinner={false}
129
+ />
130
+ </p>
131
+ ) : (
132
+ <p>
133
+ Pilih mata kuliah, lalu kirim pertanyaan
134
+ pertama untuk membuat sesi baru
135
+ </p>
136
+ )}
137
  </div>
138
 
139
  {backendMessage && (
routes/web.php CHANGED
@@ -43,6 +43,12 @@
43
 
44
  Route::get('vector-retrieval-config', [AdminController::class, 'vectorRetrievalConfig'])->name('vector-retrieval-config');
45
 
 
 
 
 
 
 
46
  Route::post('api-keys', [AdminController::class, 'generateApiKey'])->name('api-keys.store');
47
 
48
  Route::patch('rag/llm', [AdminController::class, 'updateLlmConfig'])->name('rag.llm.update');
 
43
 
44
  Route::get('vector-retrieval-config', [AdminController::class, 'vectorRetrievalConfig'])->name('vector-retrieval-config');
45
 
46
+ Route::get('vectordb-config', [AdminController::class, 'vectorDbConfig'])->name('vectordb-config');
47
+
48
+ Route::get('retrieval-config', [AdminController::class, 'retrievalConfig'])->name('retrieval-config');
49
+
50
+ Route::get('api-keys', [AdminController::class, 'apiKeys'])->name('api-keys.index');
51
+
52
  Route::post('api-keys', [AdminController::class, 'generateApiKey'])->name('api-keys.store');
53
 
54
  Route::patch('rag/llm', [AdminController::class, 'updateLlmConfig'])->name('rag.llm.update');
tests/Feature/MahasiswaDashboardTest.php CHANGED
@@ -78,10 +78,20 @@ function studentAuthUserCookie(): string
78
  && $request->hasHeader('Authorization', 'Bearer token'));
79
  });
80
 
81
- test('mahasiswa chat session can be created through laravel proxy', function () {
82
- config(['services.rag.base_url' => 'https://rag.test/api/v1']);
 
 
 
83
 
84
  Http::fake([
 
 
 
 
 
 
 
85
  'https://rag.test/api/v1/chats/sessions' => Http::response([
86
  'course_id' => '101',
87
  'created_at' => '2026-05-01T10:00:00Z',
@@ -101,9 +111,7 @@ 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', 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'
@@ -111,19 +119,24 @@ function studentAuthUserCookie(): string
111
  && $request->hasHeader('Authorization', 'Bearer token')
112
  && $request['course_id'] === '101'
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
  ],
@@ -131,6 +144,33 @@ function studentAuthUserCookie(): string
131
  'page' => 1,
132
  'total' => 1,
133
  ]),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  'https://rag.test/api/v1/chats/sessions*' => Http::response([
135
  'data' => [],
136
  'pagination' => [
@@ -144,34 +184,19 @@ function studentAuthUserCookie(): string
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 () {
 
78
  && $request->hasHeader('Authorization', 'Bearer token'));
79
  });
80
 
81
+ test('mahasiswa chat session can be created and initial message sent through laravel proxy', function () {
82
+ config([
83
+ 'services.rag.base_url' => 'https://rag.test/api/v1',
84
+ 'services.rag.chat_mode' => 'rest',
85
+ ]);
86
 
87
  Http::fake([
88
+ 'https://rag.test/api/v1/chats/sessions/session-1/messages' => Http::response([
89
+ 'content' => 'Stack adalah struktur data LIFO.',
90
+ 'created_at' => '2026-05-01T10:01:00Z',
91
+ 'role' => 'assistant',
92
+ 'sources' => [],
93
+ 'uuid_id' => 'message-1',
94
+ ]),
95
  'https://rag.test/api/v1/chats/sessions' => Http::response([
96
  'course_id' => '101',
97
  'created_at' => '2026-05-01T10:00:00Z',
 
111
  'title' => 'Apa itu stack?',
112
  ])
113
  ->assertRedirect(route('mahasiswa.show', ['sessionId' => 'session-1']))
114
+ ->assertSessionMissing('sevima_raghub_pending_initial_chat.session-1')
 
 
115
  ->assertSessionHasNoErrors();
116
 
117
  Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
 
119
  && $request->hasHeader('Authorization', 'Bearer token')
120
  && $request['course_id'] === '101'
121
  && $request['title'] === 'Apa itu stack?');
122
+
123
+ Http::assertSent(fn (HttpRequest $request): bool => $request->method() === 'POST'
124
+ && $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1/messages'
125
+ && $request->hasHeader('Authorization', 'Bearer token')
126
+ && $request['content'] === 'Apa itu stack?');
127
  });
128
 
129
+ test('mahasiswa chat detail page renders session and history from api', function () {
130
  config([
131
  'services.rag.base_url' => 'https://rag.test/api/v1',
132
+ 'services.rag.chat_mode' => 'rest',
133
  ]);
134
 
135
  Http::fake([
136
  'https://rag.test/api/v1/courses*' => Http::response([
137
  'data' => [
138
  [
139
+ 'id' => 101,
140
  'title' => 'Struktur Data',
141
  ],
142
  ],
 
144
  'page' => 1,
145
  'total' => 1,
146
  ]),
147
+ 'https://rag.test/api/v1/chats/sessions/session-1/messages' => Http::response([
148
+ 'data' => [
149
+ [
150
+ 'content' => 'Apa itu stack?',
151
+ 'created_at' => '2026-05-01T10:00:00Z',
152
+ 'role' => 'user',
153
+ 'sources' => [],
154
+ 'uuid_id' => 'user-message-1',
155
+ ],
156
+ [
157
+ 'content' => 'Stack adalah struktur data LIFO.',
158
+ 'created_at' => '2026-05-01T10:01:00Z',
159
+ 'role' => 'assistant',
160
+ 'sources' => [],
161
+ 'uuid_id' => 'message-1',
162
+ ],
163
+ ],
164
+ ]),
165
+ 'https://rag.test/api/v1/chats/sessions/session-1' => Http::response([
166
+ 'course_id' => '101',
167
+ 'created_at' => '2026-05-01T10:00:00Z',
168
+ 'message_count' => 2,
169
+ 'title' => 'Apa itu stack?',
170
+ 'updated_at' => '2026-05-01T10:01:00Z',
171
+ 'user_id' => 'student-1',
172
+ 'uuid_id' => 'session-1',
173
+ ]),
174
  'https://rag.test/api/v1/chats/sessions*' => Http::response([
175
  'data' => [],
176
  'pagination' => [
 
184
 
185
  $this->withCookie('sevima_raghub_auth_token', 'token')
186
  ->withCookie('sevima_raghub_auth_user', studentAuthUserCookie())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  ->get(route('mahasiswa.show', ['sessionId' => 'session-1']))
188
  ->assertOk()
189
  ->assertInertia(fn (Assert $page) => $page
190
  ->component('mahasiswa-chat')
191
  ->where('sessionId', 'session-1')
192
  ->where('currentSession.uuid_id', 'session-1')
193
+ ->has('messages', 2)
194
+ ->where('messages.0.role', 'user')
195
+ ->where('messages.1.role', 'assistant')
196
  ->where('selectedCourseId', '101'));
197
 
198
+ Http::assertSent(fn (HttpRequest $request): bool => $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1');
199
+ Http::assertSent(fn (HttpRequest $request): bool => $request->url() === 'https://rag.test/api/v1/chats/sessions/session-1/messages');
200
  });
201
 
202
  test('mahasiswa chat detail page can be rendered with direct session messages', function () {