wepee commited on
Commit
cc20f66
·
1 Parent(s): 9f608d9

change: consume api in resources

Browse files
app/Http/Controllers/AdminController.php CHANGED
@@ -2,490 +2,48 @@
2
 
3
  namespace App\Http\Controllers;
4
 
5
- use App\Exceptions\RagApiException;
6
- use Illuminate\Http\RedirectResponse;
7
- use Illuminate\Http\Request;
8
- use Illuminate\Validation\Rule;
9
  use Inertia\Inertia;
10
  use Inertia\Response;
11
 
12
  class AdminController extends Controller
13
  {
14
- private const AUTH_TOKEN_COOKIE = 'sevima_raghub_auth_token';
15
-
16
- public function index(Request $request): Response
17
  {
18
- $users = [];
19
- $token = $this->authToken($request);
20
- $userFilters = $this->userFilters($request);
21
- $configPayload = $this->resolveRagConfig($token);
22
- $backendMessage = $configPayload['backendMessage'];
23
-
24
- try {
25
- $users = $this->listUsers($userFilters, $token)['founds'] ?? [];
26
- } catch (RagApiException $exception) {
27
- $backendMessage ??= $exception->getMessage() ?: null;
28
- }
29
-
30
- return Inertia::render('admin/dashboard', [
31
- 'backendMessage' => $backendMessage,
32
- 'generatedApiKey' => $request->session()->get('generatedApiKey'),
33
- 'ragConfig' => $configPayload['ragConfig'],
34
- 'userFilters' => $userFilters,
35
- 'users' => $users,
36
- ]);
37
  }
38
 
39
- public function ragConfig(Request $request): Response
40
  {
41
- $configPayload = $this->resolveRagConfig($this->authToken($request));
42
-
43
- return Inertia::render('admin/rag-config', $configPayload);
44
  }
45
 
46
- public function llmConfig(Request $request): Response
47
  {
48
- $configPayload = $this->resolveRagConfig($this->authToken($request), ['llm']);
49
-
50
- return Inertia::render('admin/llm-config', $configPayload);
51
  }
52
 
53
- public function vectorRetrievalConfig(Request $request): Response
54
  {
55
- $configPayload = $this->resolveRagConfig($this->authToken($request), ['vector_db', 'retrieval']);
56
-
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');
84
  }
85
-
86
- public function generateApiKey(Request $request): RedirectResponse
87
- {
88
- try {
89
- $apiKey = $this->generateApiKeyOnApi($this->authToken($request));
90
- } catch (RagApiException $exception) {
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.',
98
- 'type' => 'success',
99
- ]);
100
- }
101
-
102
- public function updateLlmConfig(Request $request): RedirectResponse
103
- {
104
- $payload = $this->cleanPayload($request->validate([
105
- 'api_key_env' => ['nullable', 'string'],
106
- 'base_url' => ['nullable', 'string'],
107
- 'max_tokens' => ['nullable', 'integer', 'min:1'],
108
- 'model' => ['nullable', 'string'],
109
- 'provider' => ['nullable', 'string'],
110
- 'reasoning_effort' => ['nullable', 'string'],
111
- 'request_timeout' => ['nullable', 'integer', 'min:1'],
112
- 'system_prompt' => ['nullable', 'string'],
113
- 'temperature' => ['nullable', 'numeric', 'min:0'],
114
- ]));
115
-
116
- try {
117
- $this->updateLlmConfigOnApi($payload, $this->authToken($request));
118
- } catch (RagApiException $exception) {
119
- return back()
120
- ->withErrors(['llm' => $exception->getMessage()])
121
- ->withInput();
122
- }
123
-
124
- return $this->adminSuccess('Konfigurasi LLM berhasil diperbarui.', 'admin.llm-config');
125
- }
126
-
127
- public function updateVectorDbConfig(Request $request): RedirectResponse
128
- {
129
- $payload = $this->cleanPayload($request->validate([
130
- 'chunk_overlap' => ['nullable', 'integer', 'min:0'],
131
- 'chunk_size' => ['nullable', 'integer', 'min:1'],
132
- 'embedding_model' => ['nullable', 'string'],
133
- 'persist_path' => ['nullable', 'string'],
134
- ]));
135
-
136
- try {
137
- $this->updateVectorDbConfigOnApi($payload, $this->authToken($request));
138
- } catch (RagApiException $exception) {
139
- return back()
140
- ->withErrors(['vectorDb' => $exception->getMessage()])
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
148
- {
149
- $payload = $this->cleanPayload($request->validate([
150
- 'bm25_weight' => ['nullable', 'numeric', 'min:0', 'max:1'],
151
- 'candidate_pool_size' => ['nullable', 'integer', 'min:1'],
152
- 'dense_weight' => ['nullable', 'numeric', 'min:0', 'max:1'],
153
- 'enable_reranker' => ['sometimes', 'boolean'],
154
- 'history_turns' => ['nullable', 'integer', 'min:0'],
155
- 'lexical_weight' => ['nullable', 'numeric', 'min:0', 'max:1'],
156
- 'max_context_chars' => ['nullable', 'integer', 'min:1'],
157
- 'neighbor_window' => ['nullable', 'integer', 'min:0'],
158
- 'reranker_model' => ['nullable', 'string'],
159
- 'similarity_threshold' => ['nullable', 'numeric', 'min:0'],
160
- 'top_k' => ['nullable', 'integer', 'min:1'],
161
- ]));
162
-
163
- try {
164
- $this->updateRetrievalConfigOnApi($payload, $this->authToken($request));
165
- } catch (RagApiException $exception) {
166
- return back()
167
- ->withErrors(['retrieval' => $exception->getMessage()])
168
- ->withInput();
169
- }
170
-
171
- return $this->adminSuccess('Konfigurasi retrieval berhasil diperbarui.', 'admin.retrieval-config');
172
- }
173
-
174
- public function storeUser(Request $request): RedirectResponse
175
- {
176
- $payload = $this->cleanPayload($this->validatedUserPayload($request));
177
-
178
- try {
179
- $this->createUser($payload, $this->authToken($request));
180
- } catch (RagApiException $exception) {
181
- return back()
182
- ->withErrors(['user' => $exception->getMessage()])
183
- ->withInput();
184
- }
185
-
186
- return $this->adminSuccess('User berhasil dibuat.');
187
- }
188
-
189
- public function updateUser(Request $request, string $userId): RedirectResponse
190
- {
191
- $payload = $this->cleanPayload($this->validatedUserPayload($request));
192
-
193
- try {
194
- $this->updateUserOnApi($userId, $payload, $this->authToken($request));
195
- } catch (RagApiException $exception) {
196
- return back()
197
- ->withErrors(['user' => $exception->getMessage()])
198
- ->withInput();
199
- }
200
-
201
- return $this->adminSuccess('User berhasil diperbarui.');
202
- }
203
-
204
- public function destroyUser(Request $request, string $userId): RedirectResponse
205
- {
206
- try {
207
- $this->deleteUser($userId, $this->authToken($request));
208
- } catch (RagApiException $exception) {
209
- return back()->withErrors(['user' => $exception->getMessage()]);
210
- }
211
-
212
- return $this->adminSuccess('User berhasil dihapus.');
213
- }
214
-
215
- private function authToken(Request $request): ?string
216
- {
217
- $token = $request->cookie(self::AUTH_TOKEN_COOKIE);
218
-
219
- return is_string($token) && $token !== '' ? $token : null;
220
- }
221
-
222
- /**
223
- * @return array<string, mixed>
224
- */
225
- private function userFilters(Request $request): array
226
- {
227
- return $this->cleanPayload($request->validate([
228
- 'email' => ['nullable', 'string'],
229
- 'identity_number' => ['nullable', 'string'],
230
- 'is_active' => ['nullable', 'boolean'],
231
- 'is_superuser' => ['nullable', 'boolean'],
232
- 'name' => ['nullable', 'string'],
233
- 'ordering' => ['nullable', 'string'],
234
- 'page' => ['nullable', 'integer', 'min:1'],
235
- 'page_size' => ['nullable'],
236
- 'phone' => ['nullable', 'string'],
237
- 'role' => ['nullable', Rule::in(['student', 'lecturer', 'admin'])],
238
- 'user_token' => ['nullable', 'string'],
239
- ]));
240
- }
241
-
242
- /**
243
- * @return array<string, mixed>
244
- */
245
- private function validatedUserPayload(Request $request): array
246
- {
247
- return $request->validate([
248
- 'email' => ['nullable', 'email'],
249
- 'identity_number' => ['nullable', 'string'],
250
- 'is_active' => ['sometimes', 'boolean'],
251
- 'is_superuser' => ['sometimes', 'boolean'],
252
- 'name' => ['nullable', 'string'],
253
- 'phone' => ['nullable', 'string'],
254
- 'role' => ['nullable', Rule::in(['student', 'lecturer', 'admin'])],
255
- 'user_token' => ['nullable', 'string'],
256
- ]);
257
- }
258
-
259
- /**
260
- * @param array<string, mixed> $payload
261
- * @return array<string, mixed>
262
- */
263
- private function cleanPayload(array $payload): array
264
- {
265
- return array_filter(
266
- $payload,
267
- fn (mixed $value): bool => $value !== null && $value !== '',
268
- );
269
- }
270
-
271
- private function adminSuccess(string $message, string $routeName = 'admin.dashboard'): RedirectResponse
272
- {
273
- return to_route($routeName)->with('toast', [
274
- 'message' => $message,
275
- 'type' => 'success',
276
- ]);
277
- }
278
-
279
- /**
280
- * @param array<string, mixed>|null $config
281
- * @param array<string, mixed> $section
282
- * @return array<string, mixed>
283
- */
284
- private function withConfigSection(?array $config, string $key, array $section): array
285
- {
286
- $config ??= [];
287
- $config[$key] = $section;
288
-
289
- return $config;
290
- }
291
-
292
- /**
293
- * @param array<int, 'llm'|'retrieval'|'vector_db'> $sections
294
- * @return array{backendMessage: string|null, ragConfig: array<string, mixed>|null}
295
- */
296
- private function resolveRagConfig(?string $token, array $sections = ['llm', 'vector_db', 'retrieval']): array
297
- {
298
- $backendMessage = null;
299
- $ragConfig = null;
300
-
301
- try {
302
- $ragConfig = $this->getRagConfig($token);
303
- } catch (RagApiException $exception) {
304
- $backendMessage = $exception->getMessage() ?: null;
305
- }
306
-
307
- foreach ($sections as $section) {
308
- try {
309
- $ragConfig = match ($section) {
310
- 'llm' => $this->withConfigSection($ragConfig, 'llm', $this->getLlmConfig($token)),
311
- 'retrieval' => $this->withConfigSection($ragConfig, 'retrieval', $this->getRetrievalConfig($token)),
312
- 'vector_db' => $this->withConfigSection($ragConfig, 'vector_db', $this->getVectorDbConfig($token)),
313
- default => $ragConfig,
314
- };
315
- } catch (RagApiException $exception) {
316
- $backendMessage ??= $exception->getMessage() ?: null;
317
- }
318
- }
319
-
320
- return [
321
- 'backendMessage' => $backendMessage,
322
- 'ragConfig' => $ragConfig,
323
- ];
324
- }
325
-
326
- /**
327
- * @return array{api_key: string, expires_at?: string|null}
328
- */
329
- private function generateApiKeyOnApi(?string $token = null): array
330
- {
331
- $response = $this->ragRequest($token)->post('/auth/api-keys');
332
-
333
- $this->throwIfRagFailed($response);
334
-
335
- /** @var array{api_key: string, expires_at?: string|null} */
336
- return $response->json();
337
- }
338
-
339
- /**
340
- * @return array<string, mixed>
341
- */
342
- private function getRagConfig(?string $token = null): array
343
- {
344
- $response = $this->ragRequest($token)->get('/admin/rag/config');
345
-
346
- $this->throwIfRagFailed($response);
347
-
348
- /** @var array<string, mixed> */
349
- return $response->json();
350
- }
351
-
352
- /**
353
- * @return array<string, mixed>
354
- */
355
- private function getLlmConfig(?string $token = null): array
356
- {
357
- $response = $this->ragRequest($token)->get('/admin/rag/config/llm');
358
-
359
- $this->throwIfRagFailed($response);
360
-
361
- /** @var array<string, mixed> */
362
- return $response->json();
363
- }
364
-
365
- /**
366
- * @param array<string, mixed> $payload
367
- * @return array<string, mixed>
368
- */
369
- private function updateLlmConfigOnApi(array $payload, ?string $token = null): array
370
- {
371
- $response = $this->ragRequest($token)
372
- ->asJson()
373
- ->patch('/admin/rag/config/llm', $payload);
374
-
375
- $this->throwIfRagFailed($response);
376
-
377
- /** @var array<string, mixed> */
378
- return $response->json();
379
- }
380
-
381
- /**
382
- * @return array<string, mixed>
383
- */
384
- private function getVectorDbConfig(?string $token = null): array
385
- {
386
- $response = $this->ragRequest($token)->get('/admin/rag/config/vector_db');
387
-
388
- $this->throwIfRagFailed($response);
389
-
390
- /** @var array<string, mixed> */
391
- return $response->json();
392
- }
393
-
394
- /**
395
- * @param array<string, mixed> $payload
396
- * @return array<string, mixed>
397
- */
398
- private function updateVectorDbConfigOnApi(array $payload, ?string $token = null): array
399
- {
400
- $response = $this->ragRequest($token)
401
- ->asJson()
402
- ->patch('/admin/rag/config/vector_db', $payload);
403
-
404
- $this->throwIfRagFailed($response);
405
-
406
- /** @var array<string, mixed> */
407
- return $response->json();
408
- }
409
-
410
- /**
411
- * @return array<string, mixed>
412
- */
413
- private function getRetrievalConfig(?string $token = null): array
414
- {
415
- $response = $this->ragRequest($token)->get('/admin/rag/config/retrieval');
416
-
417
- $this->throwIfRagFailed($response);
418
-
419
- /** @var array<string, mixed> */
420
- return $response->json();
421
- }
422
-
423
- /**
424
- * @param array<string, mixed> $payload
425
- * @return array<string, mixed>
426
- */
427
- private function updateRetrievalConfigOnApi(array $payload, ?string $token = null): array
428
- {
429
- $response = $this->ragRequest($token)
430
- ->asJson()
431
- ->patch('/admin/rag/config/retrieval', $payload);
432
-
433
- $this->throwIfRagFailed($response);
434
-
435
- /** @var array<string, mixed> */
436
- return $response->json();
437
- }
438
-
439
- /**
440
- * @param array<string, mixed> $query
441
- * @return array{founds?: array<int, array<string, mixed>>, search_options?: array<string, mixed>}
442
- */
443
- private function listUsers(array $query = [], ?string $token = null): array
444
- {
445
- $response = $this->ragRequest($token)->get('/user', $query);
446
-
447
- $this->throwIfRagFailed($response);
448
-
449
- /** @var array{founds?: array<int, array<string, mixed>>, search_options?: array<string, mixed>} */
450
- return $response->json();
451
- }
452
-
453
- /**
454
- * @param array<string, mixed> $payload
455
- * @return array<string, mixed>
456
- */
457
- private function createUser(array $payload, ?string $token = null): array
458
- {
459
- $response = $this->ragRequest($token)
460
- ->asJson()
461
- ->post('/user', $payload);
462
-
463
- $this->throwIfRagFailed($response);
464
-
465
- /** @var array<string, mixed> */
466
- return $response->json();
467
- }
468
-
469
- /**
470
- * @param array<string, mixed> $payload
471
- * @return array<string, mixed>
472
- */
473
- private function updateUserOnApi(string $userId, array $payload, ?string $token = null): array
474
- {
475
- $response = $this->ragRequest($token)
476
- ->asJson()
477
- ->patch('/user/'.$this->encodePathSegment($userId), $payload);
478
-
479
- $this->throwIfRagFailed($response);
480
-
481
- /** @var array<string, mixed> */
482
- return $response->json();
483
- }
484
-
485
- private function deleteUser(string $userId, ?string $token = null): void
486
- {
487
- $response = $this->ragRequest($token)->delete('/user/'.$this->encodePathSegment($userId));
488
-
489
- $this->throwIfRagFailed($response);
490
- }
491
  }
 
2
 
3
  namespace App\Http\Controllers;
4
 
 
 
 
 
5
  use Inertia\Inertia;
6
  use Inertia\Response;
7
 
8
  class AdminController extends Controller
9
  {
10
+ public function index(): Response
 
 
11
  {
12
+ return Inertia::render('admin/dashboard');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
+ public function ragConfig(): Response
16
  {
17
+ return Inertia::render('admin/rag-config');
 
 
18
  }
19
 
20
+ public function llmConfig(): Response
21
  {
22
+ return Inertia::render('admin/llm-config');
 
 
23
  }
24
 
25
+ public function vectorRetrievalConfig(): Response
26
  {
27
+ return Inertia::render('admin/vector-retrieval-config');
 
 
28
  }
29
 
30
+ public function vectorDbConfig(): Response
31
  {
32
+ return Inertia::render('admin/vectordb-config');
 
 
33
  }
34
 
35
+ public function retrievalConfig(): Response
36
  {
37
+ return Inertia::render('admin/retrieval-config');
 
 
38
  }
39
 
40
+ public function apiKeys(): Response
41
  {
42
+ return Inertia::render('admin/api-keys');
 
 
43
  }
44
 
45
  public function settings(): Response
46
  {
47
  return Inertia::render('admin/pengaturan');
48
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
app/Http/Controllers/DosenController.php CHANGED
@@ -2,316 +2,20 @@
2
 
3
  namespace App\Http\Controllers;
4
 
5
- use App\Exceptions\RagApiException;
6
- use Illuminate\Http\RedirectResponse;
7
- use Illuminate\Http\Request;
8
- use Illuminate\Http\UploadedFile;
9
  use Inertia\Inertia;
10
  use Inertia\Response;
11
- use RuntimeException;
12
 
13
  class DosenController extends Controller
14
  {
15
- private const AUTH_TOKEN_COOKIE = 'sevima_raghub_auth_token';
16
-
17
- public function index(Request $request): Response
18
- {
19
- $token = $this->authToken($request);
20
-
21
- return Inertia::render('dosen', [
22
- 'knowledgeBases' => Inertia::defer(function () use ($token): array {
23
- $knowledgeBases = [];
24
-
25
- try {
26
- $courses = $this->listCourses(limit: 100, token: $token)['data'];
27
-
28
- foreach ($courses as $course) {
29
- $documents = [];
30
-
31
- if ($token !== null) {
32
- try {
33
- $documents = $this->listCourseDocuments((string) $course['id'], $token)['data'];
34
- } catch (RagApiException) {
35
- // skip per-course errors
36
- }
37
- }
38
-
39
- $knowledgeBases[] = $this->knowledgeBaseRow($course, $documents);
40
- }
41
- } catch (RagApiException) {
42
- // return whatever we have
43
- }
44
-
45
- return $knowledgeBases;
46
- }, rescue: true),
47
- ]);
48
- }
49
-
50
- public function storeCourse(Request $request): RedirectResponse
51
  {
52
- /** @var array{title: string, description?: string|null} $payload */
53
- $payload = $request->validate([
54
- 'description' => ['nullable', 'string', 'max:1000'],
55
- 'title' => ['required', 'string', 'max:255'],
56
- ]);
57
-
58
- if (($payload['description'] ?? null) === null || $payload['description'] === '') {
59
- unset($payload['description']);
60
- }
61
-
62
- try {
63
- $this->createCourse($payload, $this->authToken($request));
64
- } catch (RagApiException $exception) {
65
- return back()
66
- ->withErrors(['course' => $exception->getMessage()])
67
- ->withInput();
68
- }
69
-
70
- return to_route('dosen');
71
  }
72
 
73
- public function show(Request $request, string $courseId): Response
74
  {
75
- $backendMessage = null;
76
- $course = null;
77
- $documents = [];
78
- $token = $this->authToken($request);
79
-
80
- try {
81
- $course = $this->getCourse($courseId, $token);
82
- } catch (RagApiException $exception) {
83
- $backendMessage = $exception->getMessage() ?: null;
84
- }
85
-
86
- try {
87
- $documents = $this->listCourseDocuments($courseId, $token)['data'];
88
- } catch (RagApiException $exception) {
89
- $backendMessage ??= $exception->getMessage() ?: null;
90
- }
91
-
92
  return Inertia::render('dosen-detail', [
93
- 'backendMessage' => $backendMessage,
94
- 'course' => $course,
95
  'courseId' => $courseId,
96
- 'documents' => $documents,
97
- ]);
98
- }
99
-
100
- public function updateCourse(Request $request, string $courseId): RedirectResponse
101
- {
102
- /** @var array{title: string, description?: string|null} $payload */
103
- $payload = $request->validate([
104
- 'description' => ['nullable', 'string', 'max:1000'],
105
- 'title' => ['required', 'string', 'max:255'],
106
  ]);
107
-
108
- if (($payload['description'] ?? null) === null || $payload['description'] === '') {
109
- unset($payload['description']);
110
- }
111
-
112
- try {
113
- $this->updateCourseOnApi($courseId, $payload, $this->authToken($request));
114
- } catch (RagApiException $exception) {
115
- return back()
116
- ->withErrors(['course' => $exception->getMessage()])
117
- ->withInput();
118
- }
119
-
120
- return to_route('dosen.show', ['courseId' => $courseId]);
121
- }
122
-
123
- public function destroyCourse(Request $request, string $courseId): RedirectResponse
124
- {
125
- try {
126
- $this->deleteCourse($courseId, $this->authToken($request));
127
- } catch (RagApiException $exception) {
128
- return back()->withErrors(['course' => $exception->getMessage()]);
129
- }
130
-
131
- return to_route('dosen');
132
- }
133
-
134
- public function uploadDocument(Request $request, string $courseId): RedirectResponse
135
- {
136
- $validated = $request->validate([
137
- 'file' => ['required', 'file', 'mimes:pdf,ppt,pptx,txt', 'max:102400'],
138
- ]);
139
- /** @var UploadedFile $file */
140
- $file = $validated['file'];
141
-
142
- try {
143
- $this->uploadDocumentToApi($courseId, $file, $this->authToken($request));
144
- } catch (RagApiException $exception) {
145
- return back()->withErrors(['file' => $exception->getMessage()]);
146
- } catch (RuntimeException $exception) {
147
- return back()->withErrors(['file' => $exception->getMessage()]);
148
- }
149
-
150
- return to_route('dosen.show', ['courseId' => $courseId]);
151
- }
152
-
153
- /**
154
- * @return array{data: array<int, array<string, mixed>>, total?: int, page?: int, limit?: int}
155
- */
156
- private function listCourses(int $page = 1, int $limit = 100, ?string $token = null): array
157
- {
158
- $response = $this->ragRequest($token)->get('/courses', [
159
- 'limit' => $limit,
160
- 'page' => $page,
161
- ]);
162
-
163
- $this->throwIfRagFailed($response);
164
-
165
- /** @var array{data: array<int, array<string, mixed>>, total?: int, page?: int, limit?: int} */
166
- return $response->json();
167
- }
168
-
169
- /**
170
- * @return array{data: array<int, array<string, mixed>>}
171
- */
172
- private function listCourseDocuments(string $courseId, ?string $token = null): array
173
- {
174
- $response = $this->ragRequest($token)->get('/courses/'.$this->encodePathSegment($courseId).'/documents');
175
-
176
- $this->throwIfRagFailed($response);
177
-
178
- /** @var array{data: array<int, array<string, mixed>>} */
179
- return $response->json();
180
- }
181
-
182
- /**
183
- * @param array{title: string, description?: string|null} $payload
184
- * @return array<string, mixed>
185
- */
186
- private function createCourse(array $payload, ?string $token = null): array
187
- {
188
- $response = $this->ragRequest($token)
189
- ->asJson()
190
- ->post('/courses', $payload);
191
-
192
- $this->throwIfRagFailed($response);
193
-
194
- /** @var array<string, mixed> */
195
- return $response->json();
196
- }
197
-
198
- /**
199
- * @return array<string, mixed>
200
- */
201
- private function getCourse(string $courseId, ?string $token = null): array
202
- {
203
- $response = $this->ragRequest($token)->get('/courses/'.$this->encodePathSegment($courseId));
204
-
205
- $this->throwIfRagFailed($response);
206
-
207
- /** @var array<string, mixed> */
208
- return $response->json();
209
- }
210
-
211
- /**
212
- * @param array{title?: string, description?: string|null} $payload
213
- * @return array<string, mixed>
214
- */
215
- private function updateCourseOnApi(string $courseId, array $payload, ?string $token = null): array
216
- {
217
- $response = $this->ragRequest($token)
218
- ->asJson()
219
- ->put('/courses/'.$this->encodePathSegment($courseId), $payload);
220
-
221
- $this->throwIfRagFailed($response);
222
-
223
- /** @var array<string, mixed> */
224
- return $response->json();
225
- }
226
-
227
- private function deleteCourse(string $courseId, ?string $token = null): void
228
- {
229
- $response = $this->ragRequest($token)->delete('/courses/'.$this->encodePathSegment($courseId));
230
-
231
- $this->throwIfRagFailed($response);
232
- }
233
-
234
- private function uploadDocumentToApi(string $courseId, UploadedFile $file, ?string $token = null): void
235
- {
236
- $stream = fopen($file->getRealPath() ?: $file->getPathname(), 'r');
237
-
238
- if ($stream === false) {
239
- throw new RuntimeException('Uploaded file could not be read.');
240
- }
241
-
242
- try {
243
- $response = $this->ragRequest($token)
244
- ->attach(
245
- 'file',
246
- $stream,
247
- $file->getClientOriginalName(),
248
- ['Content-Type' => $file->getMimeType() ?: 'application/octet-stream'],
249
- )
250
- ->post('/documents/upload', [
251
- 'course_id' => $courseId,
252
- ]);
253
- } finally {
254
- fclose($stream);
255
- }
256
-
257
- $this->throwIfRagFailed($response);
258
- }
259
-
260
- private function authToken(Request $request): ?string
261
- {
262
- $token = $request->cookie(self::AUTH_TOKEN_COOKIE);
263
-
264
- return is_string($token) && $token !== '' ? $token : null;
265
- }
266
-
267
- /**
268
- * @param array{id: int|string, title: string, description?: string|null} $course
269
- * @param array<int, array<string, mixed>> $documents
270
- * @return array{id: string, name: string, documentCount: int, failedDocumentCount: int, hasError: bool, updatedAt: string}
271
- */
272
- private function knowledgeBaseRow(array $course, array $documents): array
273
- {
274
- $failedDocumentCount = count(array_filter(
275
- $documents,
276
- fn (array $document): bool => ($document['status'] ?? null) === 'failed'
277
- || filled($document['error'] ?? null),
278
- ));
279
-
280
- return [
281
- 'documentCount' => count($documents),
282
- 'failedDocumentCount' => $failedDocumentCount,
283
- 'hasError' => $failedDocumentCount > 0,
284
- 'id' => (string) $course['id'],
285
- 'name' => $course['title'],
286
- 'updatedAt' => $this->formatUpdatedAt($this->latestDocumentUpdate($documents)),
287
- ];
288
- }
289
-
290
- /**
291
- * @param array<int, array<string, mixed>> $documents
292
- */
293
- private function latestDocumentUpdate(array $documents): ?string
294
- {
295
- $updatedAtValues = array_values(array_filter(
296
- array_map(
297
- fn (array $document): ?string => is_string($document['updated_at'] ?? null)
298
- ? $document['updated_at']
299
- : null,
300
- $documents,
301
- ),
302
- ));
303
-
304
- sort($updatedAtValues);
305
-
306
- return $updatedAtValues[array_key_last($updatedAtValues)] ?? null;
307
- }
308
-
309
- private function formatUpdatedAt(?string $value): string
310
- {
311
- if ($value === null || $value === '') {
312
- return '-';
313
- }
314
-
315
- return explode('T', $value)[0] ?: $value;
316
  }
317
  }
 
2
 
3
  namespace App\Http\Controllers;
4
 
 
 
 
 
5
  use Inertia\Inertia;
6
  use Inertia\Response;
 
7
 
8
  class DosenController extends Controller
9
  {
10
+ public function index(): Response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  {
12
+ return Inertia::render('dosen');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
+ public function show(string $courseId): Response
16
  {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  return Inertia::render('dosen-detail', [
 
 
18
  'courseId' => $courseId,
 
 
 
 
 
 
 
 
 
 
19
  ]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
  }
app/Http/Controllers/MahasiswaController.php CHANGED
@@ -2,455 +2,20 @@
2
 
3
  namespace App\Http\Controllers;
4
 
5
- use App\Exceptions\RagApiException;
6
- use Illuminate\Http\Client\ConnectionException;
7
- use Illuminate\Http\JsonResponse;
8
- use Illuminate\Http\RedirectResponse;
9
- use Illuminate\Http\Request;
10
- use Illuminate\Support\Str;
11
  use Inertia\Inertia;
12
  use Inertia\Response;
13
- use Symfony\Component\HttpFoundation\StreamedResponse;
14
 
15
  class MahasiswaController extends Controller
16
  {
17
- private const AUTH_TOKEN_COOKIE = 'sevima_raghub_auth_token';
18
-
19
- private const DIRECT_MESSAGES_SESSION_PREFIX = 'sevima_raghub_direct_messages';
20
-
21
- public function index(Request $request): Response
22
- {
23
- $payload = $this->studentDashboardPayload($request);
24
-
25
- return Inertia::render('mahasiswa', [
26
- ...$payload,
27
- 'selectedCourseId' => $this->firstCourseId($payload['courses']),
28
- ]);
29
- }
30
-
31
- public function storeSession(Request $request): RedirectResponse
32
  {
33
- /** @var array{course_id: string, title: string} $payload */
34
- $payload = $request->validate([
35
- 'course_id' => ['required', 'string'],
36
- 'title' => ['required', 'string'],
37
- ]);
38
-
39
- try {
40
- $session = $this->createChatSession($payload, $this->authToken($request));
41
- } catch (RagApiException|ConnectionException $exception) {
42
- return back()
43
- ->withErrors(['chat' => $exception->getMessage()])
44
- ->withInput();
45
- }
46
-
47
- $sessionId = (string) ($session['uuid_id'] ?? '');
48
-
49
- return to_route('mahasiswa.show', ['sessionId' => $sessionId])
50
- ->with('initialQuestion', [
51
- 'content' => $payload['title'],
52
- 'courseId' => $payload['course_id'],
53
- ]);
54
  }
55
 
56
- public function show(Request $request, string $sessionId): Response
57
  {
58
- $payload = $this->studentDashboardPayload($request);
59
- $backendMessage = $payload['backendMessage'];
60
- $currentSession = null;
61
- $messages = [];
62
- $token = $this->authToken($request);
63
-
64
- try {
65
- $currentSession = $this->getChatSession($sessionId, $token);
66
- } catch (RagApiException|ConnectionException $exception) {
67
- $backendMessage ??= $exception->getMessage() ?: null;
68
- }
69
-
70
- try {
71
- $messages = $this->getChatHistory($sessionId, $token)['data'];
72
- } catch (RagApiException|ConnectionException $exception) {
73
- $backendMessage ??= $exception->getMessage() ?: null;
74
- }
75
-
76
- if ($this->isDirectChatMode()) {
77
- $messages = [
78
- ...$messages,
79
- ...$this->directChatMessages($request, $sessionId),
80
- ];
81
- }
82
-
83
- $initialQuestion = $request->session()->get('initialQuestion');
84
-
85
  return Inertia::render('mahasiswa-chat', [
86
- ...$payload,
87
- 'backendMessage' => $backendMessage,
88
- 'currentSession' => $currentSession,
89
- 'initialQuestion' => is_array($initialQuestion) ? $initialQuestion : null,
90
- 'isDirectChatMode' => $this->isDirectChatMode(),
91
- 'isStreamChatMode' => $this->isStreamChatMode(),
92
- 'messages' => $messages,
93
- 'selectedCourseId' => is_string($currentSession['course_id'] ?? null)
94
- ? $currentSession['course_id']
95
- : $this->firstCourseId($payload['courses']),
96
  'sessionId' => $sessionId,
97
  ]);
98
  }
99
-
100
- public function storeMessage(Request $request, string $sessionId): JsonResponse|RedirectResponse|StreamedResponse
101
- {
102
- /** @var array{content: string, course_id: string} $payload */
103
- $payload = $request->validate([
104
- 'content' => ['required', 'string'],
105
- 'course_id' => ['required', 'string'],
106
- ]);
107
- $userMessage = $this->localUserMessage($payload['content']);
108
-
109
- if ($this->isStreamChatMode() && $request->accepts('text/event-stream')) {
110
- try {
111
- $request->session()->save();
112
-
113
- return $this->streamChatMessage(
114
- $sessionId,
115
- $payload['content'],
116
- $this->authToken($request),
117
- );
118
- } catch (RagApiException|ConnectionException $exception) {
119
- if ($request->expectsJson() || $request->accepts('text/event-stream')) {
120
- return response()->json([
121
- 'errors' => [
122
- 'message' => [$exception->getMessage()],
123
- ],
124
- 'message' => $exception->getMessage(),
125
- ], 422);
126
- }
127
-
128
- return back()
129
- ->withErrors(['message' => $exception->getMessage()])
130
- ->withInput();
131
- }
132
- }
133
-
134
- try {
135
- $assistantMessage = $this->sendConfiguredChatMessage(
136
- $sessionId,
137
- $payload['course_id'],
138
- $payload['content'],
139
- $this->authToken($request),
140
- );
141
- } catch (RagApiException|ConnectionException $exception) {
142
- if ($request->expectsJson()) {
143
- return response()->json([
144
- 'errors' => [
145
- 'message' => [$exception->getMessage()],
146
- ],
147
- 'message' => $exception->getMessage(),
148
- ], 422);
149
- }
150
-
151
- return back()
152
- ->withErrors(['message' => $exception->getMessage()])
153
- ->withInput();
154
- }
155
-
156
- if ($this->isDirectChatMode()) {
157
- $this->appendDirectChatMessages($request, $sessionId, [
158
- $userMessage,
159
- $assistantMessage,
160
- ]);
161
- }
162
-
163
- if ($request->expectsJson()) {
164
- return response()->json([
165
- 'assistantMessage' => $assistantMessage,
166
- ]);
167
- }
168
-
169
- return to_route('mahasiswa.show', ['sessionId' => $sessionId]);
170
- }
171
-
172
- public function destroySession(Request $request, string $sessionId): RedirectResponse
173
- {
174
- try {
175
- $this->deleteChatSession($sessionId, $this->authToken($request));
176
- } catch (RagApiException $exception) {
177
- return back()->withErrors(['session' => $exception->getMessage()]);
178
- }
179
-
180
- $this->clearDirectChatMessages($request, $sessionId);
181
-
182
- if ($request->string('active_session_id')->toString() === $sessionId) {
183
- return to_route('mahasiswa');
184
- }
185
-
186
- return back();
187
- }
188
-
189
- /**
190
- * @return array{backendMessage: ?string, chatSessions: array<int, array<string, mixed>>, courses: array<int, array<string, mixed>>}
191
- */
192
- private function studentDashboardPayload(Request $request): array
193
- {
194
- $backendMessage = null;
195
- $courses = [];
196
- $chatSessions = [];
197
- $token = $this->authToken($request);
198
-
199
- try {
200
- $courses = $this->listCourses(limit: 100, token: $token)['data'];
201
- } catch (RagApiException|ConnectionException $exception) {
202
- $backendMessage = $exception->getMessage() ?: null;
203
- }
204
-
205
- if ($token !== null) {
206
- try {
207
- $chatSessions = $this->listChatSessions(limit: 20, token: $token)['data'];
208
- } catch (RagApiException|ConnectionException $exception) {
209
- $backendMessage ??= $exception->getMessage() ?: null;
210
- }
211
- }
212
-
213
- return [
214
- 'backendMessage' => $backendMessage,
215
- 'chatSessions' => $chatSessions,
216
- 'courses' => $courses,
217
- ];
218
- }
219
-
220
- /**
221
- * @return array{data: array<int, array<string, mixed>>, total?: int, page?: int, limit?: int}
222
- */
223
- private function listCourses(int $page = 1, int $limit = 100, ?string $token = null): array
224
- {
225
- $response = $this->ragRequest($token)->get('/courses', [
226
- 'limit' => $limit,
227
- 'page' => $page,
228
- ]);
229
-
230
- $this->throwIfRagFailed($response);
231
-
232
- /** @var array{data: array<int, array<string, mixed>>, total?: int, page?: int, limit?: int} */
233
- return $response->json();
234
- }
235
-
236
- /**
237
- * @return array{data: array<int, array<string, mixed>>, pagination?: array<string, mixed>}
238
- */
239
- private function listChatSessions(int $page = 1, int $limit = 20, ?string $token = null): array
240
- {
241
- $response = $this->ragRequest($token)->get('/chats/sessions', [
242
- 'limit' => $limit,
243
- 'page' => $page,
244
- ]);
245
-
246
- $this->throwIfRagFailed($response);
247
-
248
- /** @var array{data: array<int, array<string, mixed>>, pagination?: array<string, mixed>} */
249
- return $response->json();
250
- }
251
-
252
- /**
253
- * @param array{course_id: string, title: string} $payload
254
- * @return array<string, mixed>
255
- */
256
- private function createChatSession(array $payload, ?string $token = null): array
257
- {
258
- $response = $this->ragRequest($token)
259
- ->asJson()
260
- ->post('/chats/sessions', $payload);
261
-
262
- $this->throwIfRagFailed($response);
263
-
264
- /** @var array<string, mixed> */
265
- return $response->json();
266
- }
267
-
268
- /**
269
- * @return array<string, mixed>
270
- */
271
- private function getChatSession(string $sessionId, ?string $token = null): array
272
- {
273
- $response = $this->ragRequest($token)->get('/chats/sessions/'.$this->encodePathSegment($sessionId));
274
-
275
- $this->throwIfRagFailed($response);
276
-
277
- /** @var array<string, mixed> */
278
- return $response->json();
279
- }
280
-
281
- /**
282
- * @return array{data: array<int, array<string, mixed>>}
283
- */
284
- private function getChatHistory(string $sessionId, ?string $token = null): array
285
- {
286
- $response = $this->ragRequest($token)->get('/chats/sessions/'.$this->encodePathSegment($sessionId).'/messages');
287
-
288
- $this->throwIfRagFailed($response);
289
-
290
- /** @var array{data: array<int, array<string, mixed>>} */
291
- return $response->json();
292
- }
293
-
294
- private function deleteChatSession(string $sessionId, ?string $token = null): void
295
- {
296
- $response = $this->ragRequest($token)->delete('/chats/sessions/'.$this->encodePathSegment($sessionId));
297
-
298
- $this->throwIfRagFailed($response);
299
- }
300
-
301
- /**
302
- * @return array<string, mixed>
303
- */
304
- private function sendConfiguredChatMessage(string $sessionId, string $courseId, string $content, ?string $token = null): array
305
- {
306
- if ($this->isDirectChatMode()) {
307
- return $this->queryAiDirect($courseId, $content, $token);
308
- }
309
-
310
- return $this->sendRestChatMessage($sessionId, $content, $token);
311
- }
312
-
313
- /**
314
- * @return array<string, mixed>
315
- */
316
- private function sendRestChatMessage(string $sessionId, string $content, ?string $token = null): array
317
- {
318
- $response = $this->ragRequest($token)
319
- ->asJson()
320
- ->post('/chats/sessions/'.$this->encodePathSegment($sessionId).'/messages', [
321
- 'content' => $content,
322
- ]);
323
-
324
- $this->throwIfRagFailed($response);
325
-
326
- /** @var array<string, mixed> */
327
- return $response->json();
328
- }
329
-
330
- private function streamChatMessage(string $sessionId, string $content, ?string $token = null): StreamedResponse
331
- {
332
- $response = $this->ragStreamRequest($token)
333
- ->asJson()
334
- ->post('/chats/sessions/'.$this->encodePathSegment($sessionId).'/stream', [
335
- 'content' => $content,
336
- ]);
337
-
338
- $this->throwIfRagFailed($response);
339
-
340
- $stream = $response->toPsrResponse()->getBody();
341
-
342
- return response()->stream(function () use ($stream): void {
343
- @ini_set('zlib.output_compression', '0');
344
-
345
- while (! $stream->eof()) {
346
- $chunk = $stream->read(1);
347
-
348
- if ($chunk === '') {
349
- usleep(10_000);
350
-
351
- continue;
352
- }
353
-
354
- echo $chunk;
355
-
356
- if (ob_get_level() > 0) {
357
- ob_flush();
358
- }
359
-
360
- flush();
361
- }
362
- }, 200, [
363
- 'Cache-Control' => 'no-cache, no-transform',
364
- 'Content-Type' => 'text/event-stream; charset=utf-8',
365
- 'X-Accel-Buffering' => 'no',
366
- ]);
367
- }
368
-
369
- /**
370
- * @return array{uuid_id: string, role: string, content: string, sources: array<int, mixed>, created_at: string}
371
- */
372
- private function queryAiDirect(string $courseId, string $content, ?string $token = null): array
373
- {
374
- $response = $this->ragRequest($token)
375
- ->asJson()
376
- ->post('/ai/query', [
377
- 'course_id' => $courseId,
378
- 'prompt' => $content,
379
- ]);
380
-
381
- $this->throwIfRagFailed($response);
382
-
383
- /** @var array{message_id: string, role?: string, content: string, sources?: array<int, mixed>, created_at: string} $payload */
384
- $payload = $response->json();
385
-
386
- return [
387
- 'content' => $payload['content'],
388
- 'created_at' => $payload['created_at'],
389
- 'role' => $payload['role'] ?? 'assistant',
390
- 'sources' => $payload['sources'] ?? [],
391
- 'uuid_id' => $payload['message_id'],
392
- ];
393
- }
394
-
395
- private function authToken(Request $request): ?string
396
- {
397
- $token = $request->cookie(self::AUTH_TOKEN_COOKIE);
398
-
399
- return is_string($token) && $token !== '' ? $token : null;
400
- }
401
-
402
- /**
403
- * @param array<int, array<string, mixed>> $courses
404
- */
405
- private function firstCourseId(array $courses): ?string
406
- {
407
- $courseId = $courses[0]['id'] ?? null;
408
-
409
- return $courseId === null ? null : (string) $courseId;
410
- }
411
-
412
- /**
413
- * @return array{uuid_id: string, role: string, content: string, sources: array<int, mixed>, created_at: string}
414
- */
415
- private function localUserMessage(string $content): array
416
- {
417
- return [
418
- 'content' => $content,
419
- 'created_at' => now()->toISOString(),
420
- 'role' => 'user',
421
- 'sources' => [],
422
- 'uuid_id' => 'local-'.Str::uuid(),
423
- ];
424
- }
425
-
426
- /**
427
- * @return array<int, array<string, mixed>>
428
- */
429
- private function directChatMessages(Request $request, string $sessionId): array
430
- {
431
- $messages = $request->session()->get($this->directMessagesKey($sessionId), []);
432
-
433
- return is_array($messages) ? $messages : [];
434
- }
435
-
436
- /**
437
- * @param array<int, array<string, mixed>> $messages
438
- */
439
- private function appendDirectChatMessages(Request $request, string $sessionId, array $messages): void
440
- {
441
- $request->session()->put($this->directMessagesKey($sessionId), [
442
- ...$this->directChatMessages($request, $sessionId),
443
- ...$messages,
444
- ]);
445
- }
446
-
447
- private function clearDirectChatMessages(Request $request, string $sessionId): void
448
- {
449
- $request->session()->forget($this->directMessagesKey($sessionId));
450
- }
451
-
452
- private function directMessagesKey(string $sessionId): string
453
- {
454
- return self::DIRECT_MESSAGES_SESSION_PREFIX.'.'.$sessionId;
455
- }
456
  }
 
2
 
3
  namespace App\Http\Controllers;
4
 
 
 
 
 
 
 
5
  use Inertia\Inertia;
6
  use Inertia\Response;
 
7
 
8
  class MahasiswaController extends Controller
9
  {
10
+ public function index(): Response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  {
12
+ return Inertia::render('mahasiswa');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
+ public function show(string $sessionId): Response
16
  {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  return Inertia::render('mahasiswa-chat', [
 
 
 
 
 
 
 
 
 
 
18
  'sessionId' => $sessionId,
19
  ]);
20
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
resources/js/components/admin/admin-api-key-card.tsx CHANGED
@@ -1,8 +1,7 @@
1
- import { useForm } from '@inertiajs/react';
2
  import { Copy, KeyRound } from 'lucide-react';
 
3
  import { toast } from 'sonner';
4
 
5
- import { generateApiKey } from '@/actions/App/Http/Controllers/AdminController';
6
  import { ConfigError } from '@/components/admin/admin-form-parts';
7
  import { Button } from '@/components/ui/button';
8
  import {
@@ -15,18 +14,29 @@ import {
15
  import { Input } from '@/components/ui/input';
16
  import { Label } from '@/components/ui/label';
17
  import { Spinner } from '@/components/ui/spinner';
18
- import { readError } from '@/lib/admin';
19
  import type { GeneratedApiKey } from '@/types/admin';
20
 
21
- export function AdminApiKeyCard({
22
- generatedApiKey,
23
- }: {
24
- generatedApiKey?: GeneratedApiKey | null;
25
- }) {
26
- const apiKeyForm = useForm({});
27
 
28
- function handleGenerateApiKey(): void {
29
- apiKeyForm.post(generateApiKey.url(), { preserveScroll: true });
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
 
32
  function handleCopyApiKey(): void {
@@ -42,7 +52,7 @@ export function AdminApiKeyCard({
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>
@@ -51,11 +61,13 @@ export function AdminApiKeyCard({
51
  </CardHeader>
52
  <CardContent className="grid gap-4">
53
  <Button
54
- disabled={apiKeyForm.processing}
55
- onClick={handleGenerateApiKey}
 
 
56
  type="button"
57
  >
58
- {apiKeyForm.processing ? (
59
  <Spinner className="size-4" />
60
  ) : (
61
  <KeyRound className="size-4" />
@@ -63,7 +75,7 @@ export function AdminApiKeyCard({
63
  Generate API Key
64
  </Button>
65
 
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">
@@ -87,6 +99,3 @@ export function AdminApiKeyCard({
87
  </Card>
88
  );
89
  }
90
-
91
-
92
-
 
 
1
  import { Copy, KeyRound } from 'lucide-react';
2
+ import { useState } from 'react';
3
  import { toast } from 'sonner';
4
 
 
5
  import { ConfigError } from '@/components/admin/admin-form-parts';
6
  import { Button } from '@/components/ui/button';
7
  import {
 
14
  import { Input } from '@/components/ui/input';
15
  import { Label } from '@/components/ui/label';
16
  import { Spinner } from '@/components/ui/spinner';
17
+ import { generateApiKey } from '@/lib/rag-client';
18
  import type { GeneratedApiKey } from '@/types/admin';
19
 
20
+ export function AdminApiKeyCard() {
21
+ const [generatedApiKey, setGeneratedApiKey] =
22
+ useState<GeneratedApiKey | null>(null);
23
+ const [isLoading, setIsLoading] = useState(false);
24
+ const [error, setError] = useState<string | undefined>();
 
25
 
26
+ async function handleGenerateApiKey(): Promise<void> {
27
+ setIsLoading(true);
28
+ setError(undefined);
29
+
30
+ try {
31
+ const result = await generateApiKey();
32
+ setGeneratedApiKey(result);
33
+ } catch (e) {
34
+ setError(
35
+ e instanceof Error ? e.message : 'Gagal membuat API key.',
36
+ );
37
+ } finally {
38
+ setIsLoading(false);
39
+ }
40
  }
41
 
42
  function handleCopyApiKey(): void {
 
52
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
53
  <CardHeader>
54
  <CardTitle className="flex items-center gap-2">
55
+ <KeyRound className="size-5 text-(--lecturer-primary)" />
56
  API Key Iframe
57
  </CardTitle>
58
  <CardDescription>
 
61
  </CardHeader>
62
  <CardContent className="grid gap-4">
63
  <Button
64
+ disabled={isLoading}
65
+ onClick={() => {
66
+ void handleGenerateApiKey();
67
+ }}
68
  type="button"
69
  >
70
+ {isLoading ? (
71
  <Spinner className="size-4" />
72
  ) : (
73
  <KeyRound className="size-4" />
 
75
  Generate API Key
76
  </Button>
77
 
78
+ <ConfigError message={error} />
79
 
80
  {generatedApiKey ? (
81
  <div className="grid gap-3 rounded-xl border border-(--lecturer-border) bg-(--lecturer-surface) p-4">
 
99
  </Card>
100
  );
101
  }
 
 
 
resources/js/components/admin/admin-form-parts.tsx CHANGED
@@ -1,9 +1,7 @@
1
- import type { InertiaFormProps } from '@inertiajs/react';
2
  import type { ReactNode } from 'react';
3
 
4
  import { Input } from '@/components/ui/input';
5
  import { Label } from '@/components/ui/label';
6
- import type { RetrievalForm } from '@/types/admin';
7
 
8
  export function ConfigError({ message }: { message?: string }) {
9
  if (!message) {
@@ -29,15 +27,15 @@ export function Field({
29
  }
30
 
31
  export function RetrievalNumberField({
32
- form,
33
  label,
34
- name,
35
  step = '1',
 
36
  }: {
37
- form: InertiaFormProps<RetrievalForm>;
38
  label: string;
39
- name: keyof Omit<RetrievalForm, 'enable_reranker' | 'reranker_model'>;
40
  step?: string;
 
41
  }) {
42
  return (
43
  <Field label={label}>
@@ -45,12 +43,9 @@ export function RetrievalNumberField({
45
  min="0"
46
  step={step}
47
  type="number"
48
- value={form.data[name]}
49
- onChange={(event) => form.setData(name, event.target.value)}
50
  />
51
  </Field>
52
  );
53
  }
54
-
55
-
56
-
 
 
1
  import type { ReactNode } from 'react';
2
 
3
  import { Input } from '@/components/ui/input';
4
  import { Label } from '@/components/ui/label';
 
5
 
6
  export function ConfigError({ message }: { message?: string }) {
7
  if (!message) {
 
27
  }
28
 
29
  export function RetrievalNumberField({
 
30
  label,
31
+ onChange,
32
  step = '1',
33
+ value,
34
  }: {
 
35
  label: string;
36
+ onChange: (value: string) => void;
37
  step?: string;
38
+ value: string;
39
  }) {
40
  return (
41
  <Field label={label}>
 
43
  min="0"
44
  step={step}
45
  type="number"
46
+ value={value}
47
+ onChange={(event) => onChange(event.target.value)}
48
  />
49
  </Field>
50
  );
51
  }
 
 
 
resources/js/components/admin/admin-llm-form.tsx CHANGED
@@ -1,91 +1,127 @@
1
- import { useForm } from '@inertiajs/react';
2
  import { Save } from 'lucide-react';
3
  import type { FormEvent } from 'react';
 
4
 
5
- import { updateLlmConfig } from '@/actions/App/Http/Controllers/AdminController';
6
  import { ConfigError, Field } from '@/components/admin/admin-form-parts';
7
  import { Button } from '@/components/ui/button';
8
  import { Card, CardContent } from '@/components/ui/card';
9
  import { Input } from '@/components/ui/input';
10
  import { Spinner } from '@/components/ui/spinner';
11
- import { readError, stringValue } from '@/lib/admin';
 
12
  import type { LlmConfig, LlmForm } from '@/types/admin';
13
 
14
  export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
15
- const llmConfig = llm ?? {};
16
- const llmForm = useForm<LlmForm>({
17
- api_key_env: stringValue(llmConfig.api_key_env),
18
- base_url: stringValue(llmConfig.base_url),
19
- max_tokens: stringValue(llmConfig.max_tokens),
20
- model: stringValue(llmConfig.model),
21
- provider: stringValue(llmConfig.provider),
22
- reasoning_effort: stringValue(llmConfig.reasoning_effort),
23
- request_timeout: stringValue(llmConfig.request_timeout),
24
- system_prompt: stringValue(llmConfig.system_prompt),
25
- temperature: stringValue(llmConfig.temperature),
26
  });
 
 
27
 
28
- function handleLlmSubmit(event: FormEvent<HTMLFormElement>): void {
 
 
 
 
 
 
29
  event.preventDefault();
30
- llmForm.patch(updateLlmConfig.url(), { preserveScroll: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
 
33
  return (
34
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
35
  <CardContent className="pt-6">
36
- <form className="grid gap-4" onSubmit={handleLlmSubmit}>
37
- <ConfigError message={readError(llmForm.errors, 'llm')} />
 
 
 
 
 
38
 
39
  <div className="grid gap-3 md:grid-cols-2">
40
  <Field label="Provider">
41
  <Input
42
- value={llmForm.data.provider}
43
  onChange={(event) =>
44
- llmForm.setData(
45
- 'provider',
46
- event.target.value,
47
- )
48
  }
49
  />
50
  </Field>
51
  <Field label="Model">
52
  <Input
53
- value={llmForm.data.model}
54
  onChange={(event) =>
55
- llmForm.setData(
56
- 'model',
57
- event.target.value,
58
- )
59
  }
60
  />
61
  </Field>
62
  <Field label="Base URL">
63
  <Input
64
- value={llmForm.data.base_url}
65
  onChange={(event) =>
66
- llmForm.setData(
67
- 'base_url',
68
- event.target.value,
69
- )
70
  }
71
  />
72
  </Field>
73
  <Field label="API Key Env">
74
  <Input
75
- value={llmForm.data.api_key_env}
76
  onChange={(event) =>
77
- llmForm.setData(
78
- 'api_key_env',
79
- event.target.value,
80
- )
81
  }
82
  />
83
  </Field>
84
  <Field label="Reasoning Effort">
85
  <Input
86
- value={llmForm.data.reasoning_effort}
87
  onChange={(event) =>
88
- llmForm.setData(
89
  'reasoning_effort',
90
  event.target.value,
91
  )
@@ -97,12 +133,9 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
97
  min="0"
98
  step="0.01"
99
  type="number"
100
- value={llmForm.data.temperature}
101
  onChange={(event) =>
102
- llmForm.setData(
103
- 'temperature',
104
- event.target.value,
105
- )
106
  }
107
  />
108
  </Field>
@@ -110,12 +143,9 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
110
  <Input
111
  min="1"
112
  type="number"
113
- value={llmForm.data.max_tokens}
114
  onChange={(event) =>
115
- llmForm.setData(
116
- 'max_tokens',
117
- event.target.value,
118
- )
119
  }
120
  />
121
  </Field>
@@ -123,9 +153,9 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
123
  <Input
124
  min="1"
125
  type="number"
126
- value={llmForm.data.request_timeout}
127
  onChange={(event) =>
128
- llmForm.setData(
129
  'request_timeout',
130
  event.target.value,
131
  )
@@ -137,19 +167,16 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
137
  <Field label="System Prompt">
138
  <textarea
139
  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"
140
- value={llmForm.data.system_prompt}
141
  onChange={(event) =>
142
- llmForm.setData(
143
- 'system_prompt',
144
- event.target.value,
145
- )
146
  }
147
  />
148
  </Field>
149
 
150
  <div className="flex justify-end">
151
- <Button disabled={llmForm.processing} type="submit">
152
- {llmForm.processing ? (
153
  <Spinner className="size-4" />
154
  ) : (
155
  <Save className="size-4" />
@@ -162,6 +189,3 @@ export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
162
  </Card>
163
  );
164
  }
165
-
166
-
167
-
 
 
1
  import { Save } from 'lucide-react';
2
  import type { FormEvent } from 'react';
3
+ import { useState } from 'react';
4
 
 
5
  import { ConfigError, Field } from '@/components/admin/admin-form-parts';
6
  import { Button } from '@/components/ui/button';
7
  import { Card, CardContent } from '@/components/ui/card';
8
  import { Input } from '@/components/ui/input';
9
  import { Spinner } from '@/components/ui/spinner';
10
+ import { stringValue } from '@/lib/admin';
11
+ import { patchLlmConfig } from '@/lib/rag-client';
12
  import type { LlmConfig, LlmForm } from '@/types/admin';
13
 
14
  export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
15
+ const cfg = llm ?? {};
16
+ const [formData, setFormData] = useState<LlmForm>({
17
+ api_key_env: stringValue(cfg.api_key_env),
18
+ base_url: stringValue(cfg.base_url),
19
+ max_tokens: stringValue(cfg.max_tokens),
20
+ model: stringValue(cfg.model),
21
+ provider: stringValue(cfg.provider),
22
+ reasoning_effort: stringValue(cfg.reasoning_effort),
23
+ request_timeout: stringValue(cfg.request_timeout),
24
+ system_prompt: stringValue(cfg.system_prompt),
25
+ temperature: stringValue(cfg.temperature),
26
  });
27
+ const [isSubmitting, setIsSubmitting] = useState(false);
28
+ const [error, setError] = useState<string | undefined>();
29
 
30
+ function setField<K extends keyof LlmForm>(key: K, value: LlmForm[K]): void {
31
+ setFormData((prev) => ({ ...prev, [key]: value }));
32
+ }
33
+
34
+ async function handleSubmit(
35
+ event: FormEvent<HTMLFormElement>,
36
+ ): Promise<void> {
37
  event.preventDefault();
38
+ setIsSubmitting(true);
39
+ setError(undefined);
40
+
41
+ try {
42
+ const payload: Record<string, unknown> = {};
43
+ if (formData.api_key_env) payload.api_key_env = formData.api_key_env;
44
+ if (formData.base_url) payload.base_url = formData.base_url;
45
+ if (formData.max_tokens) payload.max_tokens = parseInt(formData.max_tokens, 10);
46
+ if (formData.model) payload.model = formData.model;
47
+ if (formData.provider) payload.provider = formData.provider;
48
+ if (formData.reasoning_effort) payload.reasoning_effort = formData.reasoning_effort;
49
+ if (formData.request_timeout) payload.request_timeout = parseInt(formData.request_timeout, 10);
50
+ if (formData.system_prompt) payload.system_prompt = formData.system_prompt;
51
+ if (formData.temperature) payload.temperature = parseFloat(formData.temperature);
52
+
53
+ const result = await patchLlmConfig(payload);
54
+ setFormData({
55
+ api_key_env: stringValue(result.api_key_env),
56
+ base_url: stringValue(result.base_url),
57
+ max_tokens: stringValue(result.max_tokens),
58
+ model: stringValue(result.model),
59
+ provider: stringValue(result.provider),
60
+ reasoning_effort: stringValue(result.reasoning_effort),
61
+ request_timeout: stringValue(result.request_timeout),
62
+ system_prompt: stringValue(result.system_prompt),
63
+ temperature: stringValue(result.temperature),
64
+ });
65
+ } catch (e) {
66
+ setError(
67
+ e instanceof Error
68
+ ? e.message
69
+ : 'Gagal menyimpan konfigurasi LLM.',
70
+ );
71
+ } finally {
72
+ setIsSubmitting(false);
73
+ }
74
  }
75
 
76
  return (
77
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
78
  <CardContent className="pt-6">
79
+ <form
80
+ className="grid gap-4"
81
+ onSubmit={(e) => {
82
+ void handleSubmit(e);
83
+ }}
84
+ >
85
+ <ConfigError message={error} />
86
 
87
  <div className="grid gap-3 md:grid-cols-2">
88
  <Field label="Provider">
89
  <Input
90
+ value={formData.provider}
91
  onChange={(event) =>
92
+ setField('provider', event.target.value)
 
 
 
93
  }
94
  />
95
  </Field>
96
  <Field label="Model">
97
  <Input
98
+ value={formData.model}
99
  onChange={(event) =>
100
+ setField('model', event.target.value)
 
 
 
101
  }
102
  />
103
  </Field>
104
  <Field label="Base URL">
105
  <Input
106
+ value={formData.base_url}
107
  onChange={(event) =>
108
+ setField('base_url', event.target.value)
 
 
 
109
  }
110
  />
111
  </Field>
112
  <Field label="API Key Env">
113
  <Input
114
+ value={formData.api_key_env}
115
  onChange={(event) =>
116
+ setField('api_key_env', event.target.value)
 
 
 
117
  }
118
  />
119
  </Field>
120
  <Field label="Reasoning Effort">
121
  <Input
122
+ value={formData.reasoning_effort}
123
  onChange={(event) =>
124
+ setField(
125
  'reasoning_effort',
126
  event.target.value,
127
  )
 
133
  min="0"
134
  step="0.01"
135
  type="number"
136
+ value={formData.temperature}
137
  onChange={(event) =>
138
+ setField('temperature', event.target.value)
 
 
 
139
  }
140
  />
141
  </Field>
 
143
  <Input
144
  min="1"
145
  type="number"
146
+ value={formData.max_tokens}
147
  onChange={(event) =>
148
+ setField('max_tokens', event.target.value)
 
 
 
149
  }
150
  />
151
  </Field>
 
153
  <Input
154
  min="1"
155
  type="number"
156
+ value={formData.request_timeout}
157
  onChange={(event) =>
158
+ setField(
159
  'request_timeout',
160
  event.target.value,
161
  )
 
167
  <Field label="System Prompt">
168
  <textarea
169
  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"
170
+ value={formData.system_prompt}
171
  onChange={(event) =>
172
+ setField('system_prompt', event.target.value)
 
 
 
173
  }
174
  />
175
  </Field>
176
 
177
  <div className="flex justify-end">
178
+ <Button disabled={isSubmitting} type="submit">
179
+ {isSubmitting ? (
180
  <Spinner className="size-4" />
181
  ) : (
182
  <Save className="size-4" />
 
189
  </Card>
190
  );
191
  }
 
 
 
resources/js/components/admin/admin-user-management.tsx CHANGED
@@ -1,15 +1,7 @@
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';
5
  import { useMemo, useState } from 'react';
6
 
7
- import {
8
- destroyUser,
9
- index as adminIndex,
10
- storeUser,
11
- updateUser,
12
- } from '@/actions/App/Http/Controllers/AdminController';
13
  import { ConfigError, Field } from '@/components/admin/admin-form-parts';
14
  import { Badge } from '@/components/ui/badge';
15
  import { Button } from '@/components/ui/button';
@@ -43,11 +35,16 @@ import {
43
  emptyUserForm,
44
  formatDate,
45
  isUserActive,
46
- readError,
47
  roleLabel,
48
  stringValue,
49
  userToForm,
50
  } from '@/lib/admin';
 
 
 
 
 
 
51
  import type {
52
  AdminRole,
53
  AdminUser,
@@ -55,22 +52,48 @@ import type {
55
  UserForm,
56
  } from '@/types/admin';
57
 
58
- export function AdminUserCreateCard() {
59
- const userForm = useForm<UserForm>(emptyUserForm);
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- function handleStoreUser(event: FormEvent<HTMLFormElement>): void {
 
 
62
  event.preventDefault();
63
- userForm.post(storeUser.url(), {
64
- onSuccess: () => userForm.reset(),
65
- preserveScroll: true,
66
- });
 
 
 
 
 
 
 
 
 
 
 
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>
@@ -79,9 +102,14 @@ export function AdminUserCreateCard() {
79
  </CardHeader>
80
  <CardContent>
81
  <UserFormFields
82
- form={userForm}
83
- onSubmit={handleStoreUser}
 
 
84
  submitLabel="Buat User"
 
 
 
85
  />
86
  </CardContent>
87
  </Card>
@@ -89,26 +117,22 @@ export function AdminUserCreateCard() {
89
  }
90
 
91
  export function AdminUserManagement({
92
- userFilters,
93
- users = [],
94
  }: {
95
- userFilters?: Record<string, unknown>;
96
  users?: AdminUser[];
97
  }) {
 
98
  const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
99
  const [deletingUser, setDeletingUser] = useState<AdminUser | null>(null);
100
-
101
- const editUserForm = useForm<UserForm>(emptyUserForm);
102
- const deleteUserForm = useForm({});
103
- const filterForm = useForm<UserFilterForm>({
104
- email: stringValue(userFilters?.email),
105
- name: stringValue(userFilters?.name),
106
- role:
107
- userFilters?.role === 'student' ||
108
- userFilters?.role === 'lecturer' ||
109
- userFilters?.role === 'admin'
110
- ? userFilters.role
111
- : 'all',
112
  });
113
 
114
  const userStats = useMemo(() => {
@@ -120,42 +144,80 @@ export function AdminUserManagement({
120
  };
121
  }, [users]);
122
 
123
- function handleUserFilter(event: FormEvent<HTMLFormElement>): void {
124
- event.preventDefault();
125
- router.get(adminIndex.url(), buildFilterQuery(filterForm.data), {
126
- preserveScroll: true,
127
- preserveState: true,
128
- });
129
  }
130
 
131
  function openEditUserDialog(user: AdminUser): void {
132
  setEditingUser(user);
133
- editUserForm.setData(userToForm(user));
134
- editUserForm.clearErrors();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  }
136
 
137
- function handleUpdateUser(event: FormEvent<HTMLFormElement>): void {
 
 
138
  event.preventDefault();
139
 
140
  if (!editingUser) {
141
  return;
142
  }
143
 
144
- editUserForm.patch(updateUser.url(editingUser.id), {
145
- onSuccess: () => setEditingUser(null),
146
- preserveScroll: true,
147
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  }
149
 
150
- function handleDestroyUser(): void {
151
  if (!deletingUser) {
152
  return;
153
  }
154
 
155
- deleteUserForm.delete(destroyUser.url(deletingUser.id), {
156
- onSuccess: () => setDeletingUser(null),
157
- preserveScroll: true,
158
- });
 
 
 
 
 
 
 
159
  }
160
 
161
  return (
@@ -173,35 +235,37 @@ export function AdminUserManagement({
173
 
174
  <form
175
  className="grid gap-2 sm:grid-cols-[1fr_1fr_160px_auto]"
176
- onSubmit={handleUserFilter}
 
 
177
  >
178
  <Input
179
  placeholder="Cari nama..."
180
- value={filterForm.data.name}
181
  onChange={(event) =>
182
- filterForm.setData(
183
- 'name',
184
- event.target.value,
185
- )
186
  }
187
  />
188
  <Input
189
  placeholder="Cari email..."
190
- value={filterForm.data.email}
191
  onChange={(event) =>
192
- filterForm.setData(
193
- 'email',
194
- event.target.value,
195
- )
196
  }
197
  />
198
  <Select
199
- value={filterForm.data.role}
200
  onValueChange={(value) =>
201
- filterForm.setData(
202
- 'role',
203
- value as UserFilterForm['role'],
204
- )
205
  }
206
  >
207
  <SelectTrigger className="w-full">
@@ -220,8 +284,8 @@ export function AdminUserManagement({
220
  <SelectItem value="admin">Admin</SelectItem>
221
  </SelectContent>
222
  </Select>
223
- <Button type="submit">
224
- {filterForm.processing ? (
225
  <Spinner className="size-4" />
226
  ) : (
227
  <Search className="size-4" />
@@ -234,7 +298,7 @@ export function AdminUserManagement({
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">
239
  <tr>
240
  <th className="px-4 py-3">Nama</th>
@@ -272,7 +336,7 @@ export function AdminUserManagement({
272
  <Badge
273
  className={
274
  isUserActive(user)
275
- ? 'bg-[var(--lecturer-primary-soft)] text-[var(--lecturer-primary)]'
276
  : ''
277
  }
278
  variant={
@@ -357,15 +421,22 @@ export function AdminUserManagement({
357
  </DialogHeader>
358
 
359
  <UserFormFields
360
- form={editUserForm}
361
- onSubmit={handleUpdateUser}
 
 
362
  submitLabel="Simpan User"
 
 
 
363
  />
364
  </DialogContent>
365
  </Dialog>
366
 
367
  <Dialog
368
- onOpenChange={(open) => setDeletingUser(open ? deletingUser : null)}
 
 
369
  open={deletingUser !== null}
370
  >
371
  <DialogContent>
@@ -386,11 +457,13 @@ export function AdminUserManagement({
386
  </Button>
387
  <Button
388
  className="bg-destructive text-white hover:bg-destructive/90"
389
- disabled={deleteUserForm.processing}
390
- onClick={handleDestroyUser}
 
 
391
  type="button"
392
  >
393
- {deleteUserForm.processing ? (
394
  <Spinner className="size-4" />
395
  ) : null}
396
  Hapus
@@ -402,61 +475,79 @@ export function AdminUserManagement({
402
  );
403
  }
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  function UserFormFields({
406
- form,
 
 
407
  onSubmit,
 
408
  submitLabel,
409
  }: {
410
- form: InertiaFormProps<UserForm>;
 
 
411
  onSubmit: (event: FormEvent<HTMLFormElement>) => void;
 
412
  submitLabel: string;
413
  }) {
414
  return (
415
  <form className="grid gap-4" onSubmit={onSubmit}>
416
- <ConfigError message={readError(form.errors, 'user')} />
417
 
418
  <div className="grid gap-3 md:grid-cols-2">
419
  <Field label="Nama">
420
  <Input
421
- value={form.data.name}
422
  onChange={(event) =>
423
- form.setData('name', event.target.value)
424
  }
425
  />
426
  </Field>
427
  <Field label="Email">
428
  <Input
429
  type="email"
430
- value={form.data.email}
431
  onChange={(event) =>
432
- form.setData('email', event.target.value)
433
  }
434
  />
435
  </Field>
436
  <Field label="Nomor Identitas">
437
  <Input
438
- value={form.data.identity_number}
439
  onChange={(event) =>
440
- form.setData(
441
- 'identity_number',
442
- event.target.value,
443
- )
444
  }
445
  />
446
  </Field>
447
  <Field label="Telepon">
448
  <Input
449
- value={form.data.phone}
450
  onChange={(event) =>
451
- form.setData('phone', event.target.value)
452
  }
453
  />
454
  </Field>
455
  <Field label="Role">
456
  <Select
457
- value={form.data.role}
458
  onValueChange={(value) =>
459
- form.setData('role', value as AdminRole)
460
  }
461
  >
462
  <SelectTrigger className="w-full">
@@ -471,9 +562,9 @@ function UserFormFields({
471
  </Field>
472
  <Field label="User Token">
473
  <Input
474
- value={form.data.user_token}
475
  onChange={(event) =>
476
- form.setData('user_token', event.target.value)
477
  }
478
  />
479
  </Field>
@@ -482,18 +573,18 @@ function UserFormFields({
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) =>
487
- form.setData('is_active', value === true)
488
  }
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) =>
496
- form.setData('is_superuser', value === true)
497
  }
498
  />
499
  Superuser
@@ -501,8 +592,8 @@ function UserFormFields({
501
  </div>
502
 
503
  <div className="flex justify-end">
504
- <Button disabled={form.processing} type="submit">
505
- {form.processing ? <Spinner className="size-4" /> : null}
506
  {submitLabel}
507
  </Button>
508
  </div>
@@ -510,5 +601,3 @@ function UserFormFields({
510
  );
511
  }
512
 
513
-
514
-
 
 
 
1
  import { Search, Trash2, UserPlus } from 'lucide-react';
2
  import type { FormEvent } from 'react';
3
  import { useMemo, useState } from 'react';
4
 
 
 
 
 
 
 
5
  import { ConfigError, Field } from '@/components/admin/admin-form-parts';
6
  import { Badge } from '@/components/ui/badge';
7
  import { Button } from '@/components/ui/button';
 
35
  emptyUserForm,
36
  formatDate,
37
  isUserActive,
 
38
  roleLabel,
39
  stringValue,
40
  userToForm,
41
  } from '@/lib/admin';
42
+ import {
43
+ createAdminUser,
44
+ deleteAdminUser,
45
+ listAdminUsers,
46
+ updateAdminUser,
47
+ } from '@/lib/rag-client';
48
  import type {
49
  AdminRole,
50
  AdminUser,
 
52
  UserForm,
53
  } from '@/types/admin';
54
 
55
+ export function AdminUserCreateCard({
56
+ onCreated,
57
+ }: {
58
+ onCreated?: (user: AdminUser) => void;
59
+ }) {
60
+ const [formData, setFormData] = useState<UserForm>(emptyUserForm);
61
+ const [isSubmitting, setIsSubmitting] = useState(false);
62
+ const [error, setError] = useState<string | undefined>();
63
+
64
+ function setField<K extends keyof UserForm>(
65
+ key: K,
66
+ value: UserForm[K],
67
+ ): void {
68
+ setFormData((prev) => ({ ...prev, [key]: value }));
69
+ }
70
 
71
+ async function handleSubmit(
72
+ event: FormEvent<HTMLFormElement>,
73
+ ): Promise<void> {
74
  event.preventDefault();
75
+ setIsSubmitting(true);
76
+ setError(undefined);
77
+
78
+ try {
79
+ const payload = buildUserPayload(formData);
80
+ const created = await createAdminUser(payload);
81
+ setFormData(emptyUserForm);
82
+ onCreated?.(created);
83
+ } catch (e) {
84
+ setError(
85
+ e instanceof Error ? e.message : 'Gagal membuat user.',
86
+ );
87
+ } finally {
88
+ setIsSubmitting(false);
89
+ }
90
  }
91
 
92
  return (
93
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
94
  <CardHeader>
95
  <CardTitle className="flex items-center gap-2">
96
+ <UserPlus className="size-5 text-(--lecturer-primary)" />
97
  Tambah User
98
  </CardTitle>
99
  <CardDescription>
 
102
  </CardHeader>
103
  <CardContent>
104
  <UserFormFields
105
+ data={formData}
106
+ error={error}
107
+ isSubmitting={isSubmitting}
108
+ setField={setField}
109
  submitLabel="Buat User"
110
+ onSubmit={(e) => {
111
+ void handleSubmit(e);
112
+ }}
113
  />
114
  </CardContent>
115
  </Card>
 
117
  }
118
 
119
  export function AdminUserManagement({
120
+ users: initialUsers = [],
 
121
  }: {
 
122
  users?: AdminUser[];
123
  }) {
124
+ const [users, setUsers] = useState<AdminUser[]>(initialUsers);
125
  const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
126
  const [deletingUser, setDeletingUser] = useState<AdminUser | null>(null);
127
+ const [editFormData, setEditFormData] = useState<UserForm>(emptyUserForm);
128
+ const [isEditing, setIsEditing] = useState(false);
129
+ const [isDeleting, setIsDeleting] = useState(false);
130
+ const [isFiltering, setIsFiltering] = useState(false);
131
+ const [editError, setEditError] = useState<string | undefined>();
132
+ const [filterForm, setFilterForm] = useState<UserFilterForm>({
133
+ email: '',
134
+ name: '',
135
+ role: 'all',
 
 
 
136
  });
137
 
138
  const userStats = useMemo(() => {
 
144
  };
145
  }, [users]);
146
 
147
+ function setEditField<K extends keyof UserForm>(
148
+ key: K,
149
+ value: UserForm[K],
150
+ ): void {
151
+ setEditFormData((prev) => ({ ...prev, [key]: value }));
 
152
  }
153
 
154
  function openEditUserDialog(user: AdminUser): void {
155
  setEditingUser(user);
156
+ setEditFormData(userToForm(user));
157
+ setEditError(undefined);
158
+ }
159
+
160
+ async function handleUserFilter(
161
+ event: FormEvent<HTMLFormElement>,
162
+ ): Promise<void> {
163
+ event.preventDefault();
164
+ setIsFiltering(true);
165
+
166
+ try {
167
+ const query = buildFilterQuery(filterForm);
168
+ const result = await listAdminUsers(query);
169
+ setUsers(result.founds ?? []);
170
+ } catch {
171
+ // silently keep current list on filter error
172
+ } finally {
173
+ setIsFiltering(false);
174
+ }
175
  }
176
 
177
+ async function handleUpdateUser(
178
+ event: FormEvent<HTMLFormElement>,
179
+ ): Promise<void> {
180
  event.preventDefault();
181
 
182
  if (!editingUser) {
183
  return;
184
  }
185
 
186
+ setIsEditing(true);
187
+ setEditError(undefined);
188
+
189
+ try {
190
+ const payload = buildUserPayload(editFormData);
191
+ const updated = await updateAdminUser(editingUser.id, payload);
192
+ setUsers((prev) =>
193
+ prev.map((u) => (u.id === updated.id ? updated : u)),
194
+ );
195
+ setEditingUser(null);
196
+ } catch (e) {
197
+ setEditError(
198
+ e instanceof Error ? e.message : 'Gagal memperbarui user.',
199
+ );
200
+ } finally {
201
+ setIsEditing(false);
202
+ }
203
  }
204
 
205
+ async function handleDestroyUser(): Promise<void> {
206
  if (!deletingUser) {
207
  return;
208
  }
209
 
210
+ setIsDeleting(true);
211
+
212
+ try {
213
+ await deleteAdminUser(deletingUser.id);
214
+ setUsers((prev) => prev.filter((u) => u.id !== deletingUser.id));
215
+ setDeletingUser(null);
216
+ } catch {
217
+ // keep dialog open on error
218
+ } finally {
219
+ setIsDeleting(false);
220
+ }
221
  }
222
 
223
  return (
 
235
 
236
  <form
237
  className="grid gap-2 sm:grid-cols-[1fr_1fr_160px_auto]"
238
+ onSubmit={(e) => {
239
+ void handleUserFilter(e);
240
+ }}
241
  >
242
  <Input
243
  placeholder="Cari nama..."
244
+ value={filterForm.name}
245
  onChange={(event) =>
246
+ setFilterForm((prev) => ({
247
+ ...prev,
248
+ name: event.target.value,
249
+ }))
250
  }
251
  />
252
  <Input
253
  placeholder="Cari email..."
254
+ value={filterForm.email}
255
  onChange={(event) =>
256
+ setFilterForm((prev) => ({
257
+ ...prev,
258
+ email: event.target.value,
259
+ }))
260
  }
261
  />
262
  <Select
263
+ value={filterForm.role}
264
  onValueChange={(value) =>
265
+ setFilterForm((prev) => ({
266
+ ...prev,
267
+ role: value as UserFilterForm['role'],
268
+ }))
269
  }
270
  >
271
  <SelectTrigger className="w-full">
 
284
  <SelectItem value="admin">Admin</SelectItem>
285
  </SelectContent>
286
  </Select>
287
+ <Button disabled={isFiltering} type="submit">
288
+ {isFiltering ? (
289
  <Spinner className="size-4" />
290
  ) : (
291
  <Search className="size-4" />
 
298
  <CardContent>
299
  <div className="overflow-hidden rounded-xl border border-(--lecturer-border)">
300
  <div className="overflow-x-auto">
301
+ <table className="w-full min-w-190 text-sm">
302
  <thead className="bg-muted/50 text-left text-muted-foreground">
303
  <tr>
304
  <th className="px-4 py-3">Nama</th>
 
336
  <Badge
337
  className={
338
  isUserActive(user)
339
+ ? 'bg-(--lecturer-primary-soft) text-(--lecturer-primary)'
340
  : ''
341
  }
342
  variant={
 
421
  </DialogHeader>
422
 
423
  <UserFormFields
424
+ data={editFormData}
425
+ error={editError}
426
+ isSubmitting={isEditing}
427
+ setField={setEditField}
428
  submitLabel="Simpan User"
429
+ onSubmit={(e) => {
430
+ void handleUpdateUser(e);
431
+ }}
432
  />
433
  </DialogContent>
434
  </Dialog>
435
 
436
  <Dialog
437
+ onOpenChange={(open) =>
438
+ setDeletingUser(open ? deletingUser : null)
439
+ }
440
  open={deletingUser !== null}
441
  >
442
  <DialogContent>
 
457
  </Button>
458
  <Button
459
  className="bg-destructive text-white hover:bg-destructive/90"
460
+ disabled={isDeleting}
461
+ onClick={() => {
462
+ void handleDestroyUser();
463
+ }}
464
  type="button"
465
  >
466
+ {isDeleting ? (
467
  <Spinner className="size-4" />
468
  ) : null}
469
  Hapus
 
475
  );
476
  }
477
 
478
+ function buildUserPayload(form: UserForm): Record<string, unknown> {
479
+ const payload: Record<string, unknown> = {
480
+ is_active: form.is_active,
481
+ is_superuser: form.is_superuser,
482
+ role: form.role,
483
+ };
484
+ if (form.name) payload.name = form.name;
485
+ if (form.email) payload.email = form.email;
486
+ if (form.identity_number) payload.identity_number = form.identity_number;
487
+ if (form.phone) payload.phone = form.phone;
488
+ if (form.user_token) payload.user_token = form.user_token;
489
+
490
+ return payload;
491
+ }
492
+
493
  function UserFormFields({
494
+ data,
495
+ error,
496
+ isSubmitting,
497
  onSubmit,
498
+ setField,
499
  submitLabel,
500
  }: {
501
+ data: UserForm;
502
+ error?: string;
503
+ isSubmitting: boolean;
504
  onSubmit: (event: FormEvent<HTMLFormElement>) => void;
505
+ setField: <K extends keyof UserForm>(key: K, value: UserForm[K]) => void;
506
  submitLabel: string;
507
  }) {
508
  return (
509
  <form className="grid gap-4" onSubmit={onSubmit}>
510
+ <ConfigError message={error} />
511
 
512
  <div className="grid gap-3 md:grid-cols-2">
513
  <Field label="Nama">
514
  <Input
515
+ value={data.name}
516
  onChange={(event) =>
517
+ setField('name', event.target.value)
518
  }
519
  />
520
  </Field>
521
  <Field label="Email">
522
  <Input
523
  type="email"
524
+ value={data.email}
525
  onChange={(event) =>
526
+ setField('email', event.target.value)
527
  }
528
  />
529
  </Field>
530
  <Field label="Nomor Identitas">
531
  <Input
532
+ value={data.identity_number}
533
  onChange={(event) =>
534
+ setField('identity_number', event.target.value)
 
 
 
535
  }
536
  />
537
  </Field>
538
  <Field label="Telepon">
539
  <Input
540
+ value={data.phone}
541
  onChange={(event) =>
542
+ setField('phone', event.target.value)
543
  }
544
  />
545
  </Field>
546
  <Field label="Role">
547
  <Select
548
+ value={data.role}
549
  onValueChange={(value) =>
550
+ setField('role', value as AdminRole)
551
  }
552
  >
553
  <SelectTrigger className="w-full">
 
562
  </Field>
563
  <Field label="User Token">
564
  <Input
565
+ value={data.user_token}
566
  onChange={(event) =>
567
+ setField('user_token', event.target.value)
568
  }
569
  />
570
  </Field>
 
573
  <div className="grid gap-3 sm:grid-cols-2">
574
  <label className="flex items-center gap-3 rounded-lg border border-(--lecturer-border) p-3 text-sm">
575
  <Checkbox
576
+ checked={data.is_active}
577
  onCheckedChange={(value) =>
578
+ setField('is_active', value === true)
579
  }
580
  />
581
  User aktif
582
  </label>
583
  <label className="flex items-center gap-3 rounded-lg border border-(--lecturer-border) p-3 text-sm">
584
  <Checkbox
585
+ checked={data.is_superuser}
586
  onCheckedChange={(value) =>
587
+ setField('is_superuser', value === true)
588
  }
589
  />
590
  Superuser
 
592
  </div>
593
 
594
  <div className="flex justify-end">
595
+ <Button disabled={isSubmitting} type="submit">
596
+ {isSubmitting ? <Spinner className="size-4" /> : null}
597
  {submitLabel}
598
  </Button>
599
  </div>
 
601
  );
602
  }
603
 
 
 
resources/js/components/admin/admin-vector-retrieval-forms.tsx CHANGED
@@ -1,11 +1,7 @@
1
- import { useForm } from '@inertiajs/react';
2
  import { Save } from 'lucide-react';
3
  import type { FormEvent } from 'react';
 
4
 
5
- import {
6
- updateRetrievalConfig,
7
- updateVectorDbConfig,
8
- } from '@/actions/App/Http/Controllers/AdminController';
9
  import {
10
  ConfigError,
11
  Field,
@@ -16,7 +12,8 @@ import { Card, CardContent } from '@/components/ui/card';
16
  import { Checkbox } from '@/components/ui/checkbox';
17
  import { Input } from '@/components/ui/input';
18
  import { Spinner } from '@/components/ui/spinner';
19
- import { readError, stringValue } from '@/lib/admin';
 
20
  import type {
21
  RetrievalConfig,
22
  RetrievalForm,
@@ -29,36 +26,72 @@ export function AdminVectorDbForm({
29
  }: {
30
  vectorDb?: VectorDbConfig | null;
31
  }) {
32
- const vectorDbConfig = vectorDb ?? {};
33
-
34
- const vectorDbForm = useForm<VectorDbForm>({
35
- chunk_overlap: stringValue(vectorDbConfig.chunk_overlap),
36
- chunk_size: stringValue(vectorDbConfig.chunk_size),
37
- embedding_model: stringValue(vectorDbConfig.embedding_model),
38
- persist_path: stringValue(vectorDbConfig.persist_path),
39
  });
 
 
 
 
 
 
 
 
 
40
 
41
- function handleVectorDbSubmit(event: FormEvent<HTMLFormElement>): void {
 
 
42
  event.preventDefault();
43
- vectorDbForm.patch(updateVectorDbConfig.url(), {
44
- preserveScroll: true,
45
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
 
48
  return (
49
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
50
  <CardContent className="pt-6">
51
- <form className="grid gap-4" onSubmit={handleVectorDbSubmit}>
52
- <ConfigError
53
- message={readError(vectorDbForm.errors, 'vectorDb')}
54
- />
 
 
 
55
 
56
  <div className="grid gap-3 md:grid-cols-2">
57
  <Field label="Embedding Model">
58
  <Input
59
- value={vectorDbForm.data.embedding_model}
60
  onChange={(event) =>
61
- vectorDbForm.setData(
62
  'embedding_model',
63
  event.target.value,
64
  )
@@ -67,12 +100,9 @@ export function AdminVectorDbForm({
67
  </Field>
68
  <Field label="Persist Path">
69
  <Input
70
- value={vectorDbForm.data.persist_path}
71
  onChange={(event) =>
72
- vectorDbForm.setData(
73
- 'persist_path',
74
- event.target.value,
75
- )
76
  }
77
  />
78
  </Field>
@@ -80,12 +110,9 @@ export function AdminVectorDbForm({
80
  <Input
81
  min="1"
82
  type="number"
83
- value={vectorDbForm.data.chunk_size}
84
  onChange={(event) =>
85
- vectorDbForm.setData(
86
- 'chunk_size',
87
- event.target.value,
88
- )
89
  }
90
  />
91
  </Field>
@@ -93,23 +120,17 @@ export function AdminVectorDbForm({
93
  <Input
94
  min="0"
95
  type="number"
96
- value={vectorDbForm.data.chunk_overlap}
97
  onChange={(event) =>
98
- vectorDbForm.setData(
99
- 'chunk_overlap',
100
- event.target.value,
101
- )
102
  }
103
  />
104
  </Field>
105
  </div>
106
 
107
  <div className="flex justify-end">
108
- <Button
109
- disabled={vectorDbForm.processing}
110
- type="submit"
111
- >
112
- {vectorDbForm.processing ? (
113
  <Spinner className="size-4" />
114
  ) : (
115
  <Save className="size-4" />
@@ -128,92 +149,147 @@ export function AdminRetrievalForm({
128
  }: {
129
  retrieval?: RetrievalConfig | null;
130
  }) {
131
- const retrievalConfig = retrieval ?? {};
132
-
133
- const retrievalForm = useForm<RetrievalForm>({
134
- bm25_weight: stringValue(retrievalConfig.bm25_weight),
135
- candidate_pool_size: stringValue(retrievalConfig.candidate_pool_size),
136
- dense_weight: stringValue(retrievalConfig.dense_weight),
137
- enable_reranker: Boolean(retrievalConfig.enable_reranker),
138
- history_turns: stringValue(retrievalConfig.history_turns),
139
- lexical_weight: stringValue(retrievalConfig.lexical_weight),
140
- max_context_chars: stringValue(retrievalConfig.max_context_chars),
141
- neighbor_window: stringValue(retrievalConfig.neighbor_window),
142
- reranker_model: stringValue(retrievalConfig.reranker_model),
143
- similarity_threshold: stringValue(retrievalConfig.similarity_threshold),
144
- top_k: stringValue(retrievalConfig.top_k),
145
  });
 
 
146
 
147
- function handleRetrievalSubmit(event: FormEvent<HTMLFormElement>): void {
 
 
 
 
 
 
 
 
 
148
  event.preventDefault();
149
- retrievalForm.patch(updateRetrievalConfig.url(), {
150
- preserveScroll: true,
151
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
  return (
155
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
156
  <CardContent className="pt-6">
157
- <form className="grid gap-4" onSubmit={handleRetrievalSubmit}>
158
- <ConfigError
159
- message={readError(retrievalForm.errors, 'retrieval')}
160
- />
 
 
 
161
 
162
  <div className="grid gap-3 md:grid-cols-2">
163
  <RetrievalNumberField
164
- form={retrievalForm}
165
  label="Top K"
166
- name="top_k"
 
167
  />
168
  <RetrievalNumberField
169
- form={retrievalForm}
170
  label="Max Context Chars"
171
- name="max_context_chars"
 
172
  />
173
  <RetrievalNumberField
174
- form={retrievalForm}
175
  label="BM25 Weight"
176
- name="bm25_weight"
177
  step="0.01"
 
 
178
  />
179
  <RetrievalNumberField
180
- form={retrievalForm}
181
  label="Dense Weight"
182
- name="dense_weight"
183
  step="0.01"
 
 
184
  />
185
  <RetrievalNumberField
186
- form={retrievalForm}
187
  label="Lexical Weight"
188
- name="lexical_weight"
189
  step="0.01"
 
 
190
  />
191
  <RetrievalNumberField
192
- form={retrievalForm}
193
  label="Similarity Threshold"
194
- name="similarity_threshold"
195
  step="0.01"
 
 
 
 
196
  />
197
  <RetrievalNumberField
198
- form={retrievalForm}
199
  label="History Turns"
200
- name="history_turns"
 
201
  />
202
  <RetrievalNumberField
203
- form={retrievalForm}
204
  label="Candidate Pool"
205
- name="candidate_pool_size"
 
 
 
206
  />
207
  <RetrievalNumberField
208
- form={retrievalForm}
209
  label="Neighbor Window"
210
- name="neighbor_window"
 
211
  />
212
  <Field label="Reranker Model">
213
  <Input
214
- value={retrievalForm.data.reranker_model}
215
  onChange={(event) =>
216
- retrievalForm.setData(
217
  'reranker_model',
218
  event.target.value,
219
  )
@@ -224,23 +300,17 @@ export function AdminRetrievalForm({
224
 
225
  <label className="flex items-center gap-3 text-sm">
226
  <Checkbox
227
- checked={retrievalForm.data.enable_reranker}
228
  onCheckedChange={(value) =>
229
- retrievalForm.setData(
230
- 'enable_reranker',
231
- value === true,
232
- )
233
  }
234
  />
235
  Enable reranker
236
  </label>
237
 
238
  <div className="flex justify-end">
239
- <Button
240
- disabled={retrievalForm.processing}
241
- type="submit"
242
- >
243
- {retrievalForm.processing ? (
244
  <Spinner className="size-4" />
245
  ) : (
246
  <Save className="size-4" />
@@ -261,232 +331,10 @@ export function AdminVectorRetrievalForms({
261
  retrieval?: RetrievalConfig | null;
262
  vectorDb?: VectorDbConfig | null;
263
  }) {
264
- const vectorDbConfig = vectorDb ?? {};
265
- const retrievalConfig = retrieval ?? {};
266
-
267
- const vectorDbForm = useForm<VectorDbForm>({
268
- chunk_overlap: stringValue(vectorDbConfig.chunk_overlap),
269
- chunk_size: stringValue(vectorDbConfig.chunk_size),
270
- embedding_model: stringValue(vectorDbConfig.embedding_model),
271
- persist_path: stringValue(vectorDbConfig.persist_path),
272
- });
273
-
274
- const retrievalForm = useForm<RetrievalForm>({
275
- bm25_weight: stringValue(retrievalConfig.bm25_weight),
276
- candidate_pool_size: stringValue(retrievalConfig.candidate_pool_size),
277
- dense_weight: stringValue(retrievalConfig.dense_weight),
278
- enable_reranker: Boolean(retrievalConfig.enable_reranker),
279
- history_turns: stringValue(retrievalConfig.history_turns),
280
- lexical_weight: stringValue(retrievalConfig.lexical_weight),
281
- max_context_chars: stringValue(retrievalConfig.max_context_chars),
282
- neighbor_window: stringValue(retrievalConfig.neighbor_window),
283
- reranker_model: stringValue(retrievalConfig.reranker_model),
284
- similarity_threshold: stringValue(retrievalConfig.similarity_threshold),
285
- top_k: stringValue(retrievalConfig.top_k),
286
- });
287
-
288
- function handleVectorDbSubmit(event: FormEvent<HTMLFormElement>): void {
289
- event.preventDefault();
290
- vectorDbForm.patch(updateVectorDbConfig.url(), {
291
- preserveScroll: true,
292
- });
293
- }
294
-
295
- function handleRetrievalSubmit(event: FormEvent<HTMLFormElement>): void {
296
- event.preventDefault();
297
- retrievalForm.patch(updateRetrievalConfig.url(), {
298
- preserveScroll: true,
299
- });
300
- }
301
-
302
  return (
303
  <section className="grid gap-6 xl:grid-cols-2">
304
- <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
305
- <CardContent className="pt-6">
306
- <form
307
- className="grid gap-4"
308
- onSubmit={handleVectorDbSubmit}
309
- >
310
- <ConfigError
311
- message={readError(vectorDbForm.errors, 'vectorDb')}
312
- />
313
-
314
- <div className="grid gap-3 md:grid-cols-2">
315
- <Field label="Embedding Model">
316
- <Input
317
- value={vectorDbForm.data.embedding_model}
318
- onChange={(event) =>
319
- vectorDbForm.setData(
320
- 'embedding_model',
321
- event.target.value,
322
- )
323
- }
324
- />
325
- </Field>
326
- <Field label="Persist Path">
327
- <Input
328
- value={vectorDbForm.data.persist_path}
329
- onChange={(event) =>
330
- vectorDbForm.setData(
331
- 'persist_path',
332
- event.target.value,
333
- )
334
- }
335
- />
336
- </Field>
337
- <Field label="Chunk Size">
338
- <Input
339
- min="1"
340
- type="number"
341
- value={vectorDbForm.data.chunk_size}
342
- onChange={(event) =>
343
- vectorDbForm.setData(
344
- 'chunk_size',
345
- event.target.value,
346
- )
347
- }
348
- />
349
- </Field>
350
- <Field label="Chunk Overlap">
351
- <Input
352
- min="0"
353
- type="number"
354
- value={vectorDbForm.data.chunk_overlap}
355
- onChange={(event) =>
356
- vectorDbForm.setData(
357
- 'chunk_overlap',
358
- event.target.value,
359
- )
360
- }
361
- />
362
- </Field>
363
- </div>
364
-
365
- <div className="flex justify-end">
366
- <Button
367
- disabled={vectorDbForm.processing}
368
- type="submit"
369
- >
370
- {vectorDbForm.processing ? (
371
- <Spinner className="size-4" />
372
- ) : (
373
- <Save className="size-4" />
374
- )}
375
- Simpan Vector
376
- </Button>
377
- </div>
378
- </form>
379
- </CardContent>
380
- </Card>
381
-
382
- <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
383
- <CardContent className="pt-6">
384
- <form
385
- className="grid gap-4"
386
- onSubmit={handleRetrievalSubmit}
387
- >
388
- <ConfigError
389
- message={readError(
390
- retrievalForm.errors,
391
- 'retrieval',
392
- )}
393
- />
394
-
395
- <div className="grid gap-3 md:grid-cols-2">
396
- <RetrievalNumberField
397
- form={retrievalForm}
398
- label="Top K"
399
- name="top_k"
400
- />
401
- <RetrievalNumberField
402
- form={retrievalForm}
403
- label="Max Context Chars"
404
- name="max_context_chars"
405
- />
406
- <RetrievalNumberField
407
- form={retrievalForm}
408
- label="BM25 Weight"
409
- name="bm25_weight"
410
- step="0.01"
411
- />
412
- <RetrievalNumberField
413
- form={retrievalForm}
414
- label="Dense Weight"
415
- name="dense_weight"
416
- step="0.01"
417
- />
418
- <RetrievalNumberField
419
- form={retrievalForm}
420
- label="Lexical Weight"
421
- name="lexical_weight"
422
- step="0.01"
423
- />
424
- <RetrievalNumberField
425
- form={retrievalForm}
426
- label="Similarity Threshold"
427
- name="similarity_threshold"
428
- step="0.01"
429
- />
430
- <RetrievalNumberField
431
- form={retrievalForm}
432
- label="History Turns"
433
- name="history_turns"
434
- />
435
- <RetrievalNumberField
436
- form={retrievalForm}
437
- label="Candidate Pool"
438
- name="candidate_pool_size"
439
- />
440
- <RetrievalNumberField
441
- form={retrievalForm}
442
- label="Neighbor Window"
443
- name="neighbor_window"
444
- />
445
- <Field label="Reranker Model">
446
- <Input
447
- value={retrievalForm.data.reranker_model}
448
- onChange={(event) =>
449
- retrievalForm.setData(
450
- 'reranker_model',
451
- event.target.value,
452
- )
453
- }
454
- />
455
- </Field>
456
- </div>
457
-
458
- <label className="flex items-center gap-3 text-sm">
459
- <Checkbox
460
- checked={retrievalForm.data.enable_reranker}
461
- onCheckedChange={(value) =>
462
- retrievalForm.setData(
463
- 'enable_reranker',
464
- value === true,
465
- )
466
- }
467
- />
468
- Enable reranker
469
- </label>
470
-
471
- <div className="flex justify-end">
472
- <Button
473
- disabled={retrievalForm.processing}
474
- type="submit"
475
- >
476
- {retrievalForm.processing ? (
477
- <Spinner className="size-4" />
478
- ) : (
479
- <Save className="size-4" />
480
- )}
481
- Simpan Retrieval
482
- </Button>
483
- </div>
484
- </form>
485
- </CardContent>
486
- </Card>
487
  </section>
488
  );
489
  }
490
-
491
-
492
-
 
 
1
  import { Save } from 'lucide-react';
2
  import type { FormEvent } from 'react';
3
+ import { useState } from 'react';
4
 
 
 
 
 
5
  import {
6
  ConfigError,
7
  Field,
 
12
  import { Checkbox } from '@/components/ui/checkbox';
13
  import { Input } from '@/components/ui/input';
14
  import { Spinner } from '@/components/ui/spinner';
15
+ import { stringValue } from '@/lib/admin';
16
+ import { patchRetrievalConfig, patchVectorDbConfig } from '@/lib/rag-client';
17
  import type {
18
  RetrievalConfig,
19
  RetrievalForm,
 
26
  }: {
27
  vectorDb?: VectorDbConfig | null;
28
  }) {
29
+ const cfg = vectorDb ?? {};
30
+ const [formData, setFormData] = useState<VectorDbForm>({
31
+ chunk_overlap: stringValue(cfg.chunk_overlap),
32
+ chunk_size: stringValue(cfg.chunk_size),
33
+ embedding_model: stringValue(cfg.embedding_model),
34
+ persist_path: stringValue(cfg.persist_path),
 
35
  });
36
+ const [isSubmitting, setIsSubmitting] = useState(false);
37
+ const [error, setError] = useState<string | undefined>();
38
+
39
+ function setField<K extends keyof VectorDbForm>(
40
+ key: K,
41
+ value: VectorDbForm[K],
42
+ ): void {
43
+ setFormData((prev) => ({ ...prev, [key]: value }));
44
+ }
45
 
46
+ async function handleSubmit(
47
+ event: FormEvent<HTMLFormElement>,
48
+ ): Promise<void> {
49
  event.preventDefault();
50
+ setIsSubmitting(true);
51
+ setError(undefined);
52
+
53
+ try {
54
+ const payload: Record<string, unknown> = {};
55
+ if (formData.embedding_model) payload.embedding_model = formData.embedding_model;
56
+ if (formData.persist_path) payload.persist_path = formData.persist_path;
57
+ if (formData.chunk_size) payload.chunk_size = parseInt(formData.chunk_size, 10);
58
+ if (formData.chunk_overlap) payload.chunk_overlap = parseInt(formData.chunk_overlap, 10);
59
+
60
+ const result = await patchVectorDbConfig(payload);
61
+ setFormData({
62
+ chunk_overlap: stringValue(result.chunk_overlap),
63
+ chunk_size: stringValue(result.chunk_size),
64
+ embedding_model: stringValue(result.embedding_model),
65
+ persist_path: stringValue(result.persist_path),
66
+ });
67
+ } catch (e) {
68
+ setError(
69
+ e instanceof Error
70
+ ? e.message
71
+ : 'Gagal menyimpan konfigurasi Vector DB.',
72
+ );
73
+ } finally {
74
+ setIsSubmitting(false);
75
+ }
76
  }
77
 
78
  return (
79
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
80
  <CardContent className="pt-6">
81
+ <form
82
+ className="grid gap-4"
83
+ onSubmit={(e) => {
84
+ void handleSubmit(e);
85
+ }}
86
+ >
87
+ <ConfigError message={error} />
88
 
89
  <div className="grid gap-3 md:grid-cols-2">
90
  <Field label="Embedding Model">
91
  <Input
92
+ value={formData.embedding_model}
93
  onChange={(event) =>
94
+ setField(
95
  'embedding_model',
96
  event.target.value,
97
  )
 
100
  </Field>
101
  <Field label="Persist Path">
102
  <Input
103
+ value={formData.persist_path}
104
  onChange={(event) =>
105
+ setField('persist_path', event.target.value)
 
 
 
106
  }
107
  />
108
  </Field>
 
110
  <Input
111
  min="1"
112
  type="number"
113
+ value={formData.chunk_size}
114
  onChange={(event) =>
115
+ setField('chunk_size', event.target.value)
 
 
 
116
  }
117
  />
118
  </Field>
 
120
  <Input
121
  min="0"
122
  type="number"
123
+ value={formData.chunk_overlap}
124
  onChange={(event) =>
125
+ setField('chunk_overlap', event.target.value)
 
 
 
126
  }
127
  />
128
  </Field>
129
  </div>
130
 
131
  <div className="flex justify-end">
132
+ <Button disabled={isSubmitting} type="submit">
133
+ {isSubmitting ? (
 
 
 
134
  <Spinner className="size-4" />
135
  ) : (
136
  <Save className="size-4" />
 
149
  }: {
150
  retrieval?: RetrievalConfig | null;
151
  }) {
152
+ const cfg = retrieval ?? {};
153
+ const [formData, setFormData] = useState<RetrievalForm>({
154
+ bm25_weight: stringValue(cfg.bm25_weight),
155
+ candidate_pool_size: stringValue(cfg.candidate_pool_size),
156
+ dense_weight: stringValue(cfg.dense_weight),
157
+ enable_reranker: Boolean(cfg.enable_reranker),
158
+ history_turns: stringValue(cfg.history_turns),
159
+ lexical_weight: stringValue(cfg.lexical_weight),
160
+ max_context_chars: stringValue(cfg.max_context_chars),
161
+ neighbor_window: stringValue(cfg.neighbor_window),
162
+ reranker_model: stringValue(cfg.reranker_model),
163
+ similarity_threshold: stringValue(cfg.similarity_threshold),
164
+ top_k: stringValue(cfg.top_k),
 
165
  });
166
+ const [isSubmitting, setIsSubmitting] = useState(false);
167
+ const [error, setError] = useState<string | undefined>();
168
 
169
+ function setField<K extends keyof RetrievalForm>(
170
+ key: K,
171
+ value: RetrievalForm[K],
172
+ ): void {
173
+ setFormData((prev) => ({ ...prev, [key]: value }));
174
+ }
175
+
176
+ async function handleSubmit(
177
+ event: FormEvent<HTMLFormElement>,
178
+ ): Promise<void> {
179
  event.preventDefault();
180
+ setIsSubmitting(true);
181
+ setError(undefined);
182
+
183
+ try {
184
+ const payload: Record<string, unknown> = {
185
+ enable_reranker: formData.enable_reranker,
186
+ };
187
+ if (formData.top_k) payload.top_k = parseInt(formData.top_k, 10);
188
+ if (formData.max_context_chars) payload.max_context_chars = parseInt(formData.max_context_chars, 10);
189
+ if (formData.bm25_weight) payload.bm25_weight = parseFloat(formData.bm25_weight);
190
+ if (formData.dense_weight) payload.dense_weight = parseFloat(formData.dense_weight);
191
+ if (formData.lexical_weight) payload.lexical_weight = parseFloat(formData.lexical_weight);
192
+ if (formData.similarity_threshold) payload.similarity_threshold = parseFloat(formData.similarity_threshold);
193
+ if (formData.history_turns) payload.history_turns = parseInt(formData.history_turns, 10);
194
+ if (formData.candidate_pool_size) payload.candidate_pool_size = parseInt(formData.candidate_pool_size, 10);
195
+ if (formData.neighbor_window) payload.neighbor_window = parseInt(formData.neighbor_window, 10);
196
+ if (formData.reranker_model) payload.reranker_model = formData.reranker_model;
197
+
198
+ const result = await patchRetrievalConfig(payload);
199
+ setFormData({
200
+ bm25_weight: stringValue(result.bm25_weight),
201
+ candidate_pool_size: stringValue(result.candidate_pool_size),
202
+ dense_weight: stringValue(result.dense_weight),
203
+ enable_reranker: Boolean(result.enable_reranker),
204
+ history_turns: stringValue(result.history_turns),
205
+ lexical_weight: stringValue(result.lexical_weight),
206
+ max_context_chars: stringValue(result.max_context_chars),
207
+ neighbor_window: stringValue(result.neighbor_window),
208
+ reranker_model: stringValue(result.reranker_model),
209
+ similarity_threshold: stringValue(result.similarity_threshold),
210
+ top_k: stringValue(result.top_k),
211
+ });
212
+ } catch (e) {
213
+ setError(
214
+ e instanceof Error
215
+ ? e.message
216
+ : 'Gagal menyimpan konfigurasi retrieval.',
217
+ );
218
+ } finally {
219
+ setIsSubmitting(false);
220
+ }
221
  }
222
 
223
  return (
224
  <Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
225
  <CardContent className="pt-6">
226
+ <form
227
+ className="grid gap-4"
228
+ onSubmit={(e) => {
229
+ void handleSubmit(e);
230
+ }}
231
+ >
232
+ <ConfigError message={error} />
233
 
234
  <div className="grid gap-3 md:grid-cols-2">
235
  <RetrievalNumberField
 
236
  label="Top K"
237
+ value={formData.top_k}
238
+ onChange={(v) => setField('top_k', v)}
239
  />
240
  <RetrievalNumberField
 
241
  label="Max Context Chars"
242
+ value={formData.max_context_chars}
243
+ onChange={(v) => setField('max_context_chars', v)}
244
  />
245
  <RetrievalNumberField
 
246
  label="BM25 Weight"
 
247
  step="0.01"
248
+ value={formData.bm25_weight}
249
+ onChange={(v) => setField('bm25_weight', v)}
250
  />
251
  <RetrievalNumberField
 
252
  label="Dense Weight"
 
253
  step="0.01"
254
+ value={formData.dense_weight}
255
+ onChange={(v) => setField('dense_weight', v)}
256
  />
257
  <RetrievalNumberField
 
258
  label="Lexical Weight"
 
259
  step="0.01"
260
+ value={formData.lexical_weight}
261
+ onChange={(v) => setField('lexical_weight', v)}
262
  />
263
  <RetrievalNumberField
 
264
  label="Similarity Threshold"
 
265
  step="0.01"
266
+ value={formData.similarity_threshold}
267
+ onChange={(v) =>
268
+ setField('similarity_threshold', v)
269
+ }
270
  />
271
  <RetrievalNumberField
 
272
  label="History Turns"
273
+ value={formData.history_turns}
274
+ onChange={(v) => setField('history_turns', v)}
275
  />
276
  <RetrievalNumberField
 
277
  label="Candidate Pool"
278
+ value={formData.candidate_pool_size}
279
+ onChange={(v) =>
280
+ setField('candidate_pool_size', v)
281
+ }
282
  />
283
  <RetrievalNumberField
 
284
  label="Neighbor Window"
285
+ value={formData.neighbor_window}
286
+ onChange={(v) => setField('neighbor_window', v)}
287
  />
288
  <Field label="Reranker Model">
289
  <Input
290
+ value={formData.reranker_model}
291
  onChange={(event) =>
292
+ setField(
293
  'reranker_model',
294
  event.target.value,
295
  )
 
300
 
301
  <label className="flex items-center gap-3 text-sm">
302
  <Checkbox
303
+ checked={formData.enable_reranker}
304
  onCheckedChange={(value) =>
305
+ setField('enable_reranker', value === true)
 
 
 
306
  }
307
  />
308
  Enable reranker
309
  </label>
310
 
311
  <div className="flex justify-end">
312
+ <Button disabled={isSubmitting} type="submit">
313
+ {isSubmitting ? (
 
 
 
314
  <Spinner className="size-4" />
315
  ) : (
316
  <Save className="size-4" />
 
331
  retrieval?: RetrievalConfig | null;
332
  vectorDb?: VectorDbConfig | null;
333
  }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  return (
335
  <section className="grid gap-6 xl:grid-cols-2">
336
+ <AdminVectorDbForm vectorDb={vectorDb} />
337
+ <AdminRetrievalForm retrieval={retrieval} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  </section>
339
  );
340
  }
 
 
 
resources/js/lib/rag-client.ts ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ ChatMessageResponse,
3
+ ChatSessionResponse,
4
+ CourseResponse,
5
+ } from '@/lib/rag';
6
+ import type {
7
+ AdminUser,
8
+ GeneratedApiKey,
9
+ LlmConfig,
10
+ RagConfig,
11
+ RetrievalConfig,
12
+ VectorDbConfig,
13
+ } from '@/types/admin';
14
+
15
+ export type { ChatMessageResponse, ChatSessionResponse, CourseResponse };
16
+
17
+ export type DocumentResponse = {
18
+ id: string;
19
+ filename: string;
20
+ file_type: string;
21
+ file_size: number;
22
+ status: 'processing' | 'ready' | 'failed';
23
+ course_id: string;
24
+ course_name?: string;
25
+ uploaded_by: string;
26
+ uploader_name?: string;
27
+ summary?: string;
28
+ chunk_count?: number;
29
+ error?: string;
30
+ created_at: string;
31
+ updated_at: string;
32
+ };
33
+
34
+ export class RagApiError extends Error {
35
+ constructor(
36
+ message: string,
37
+ public readonly status: number,
38
+ ) {
39
+ super(message);
40
+ this.name = 'RagApiError';
41
+ }
42
+ }
43
+
44
+ const BASE_URL =
45
+ (import.meta.env.VITE_RAG_API_BASE_URL as string | undefined)?.replace(
46
+ /\/$/,
47
+ '',
48
+ ) ?? '';
49
+
50
+ function getAuthToken(): string | null {
51
+ if (typeof document === 'undefined') {
52
+ return null;
53
+ }
54
+
55
+ const match = document.cookie.match(
56
+ /(?:^|;\s*)sevima_raghub_auth_token=([^;]*)/,
57
+ );
58
+
59
+ return match ? decodeURIComponent(match[1]) : null;
60
+ }
61
+
62
+ function makeHeaders(
63
+ extra: Record<string, string> = {},
64
+ ): Record<string, string> {
65
+ const token = getAuthToken();
66
+
67
+ return {
68
+ Accept: 'application/json',
69
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
70
+ ...extra,
71
+ };
72
+ }
73
+
74
+ async function readErrorMessage(response: Response): Promise<string> {
75
+ try {
76
+ const body = (await response
77
+ .clone()
78
+ .json()) as Record<string, unknown>;
79
+
80
+ if (typeof body.detail === 'string' && body.detail) {
81
+ return body.detail;
82
+ }
83
+
84
+ if (typeof body.message === 'string' && body.message) {
85
+ return body.message;
86
+ }
87
+
88
+ if (typeof body.error === 'string' && body.error) {
89
+ return body.error;
90
+ }
91
+ } catch {
92
+ // ignore
93
+ }
94
+
95
+ return `${response.status} ${response.statusText}`;
96
+ }
97
+
98
+ async function ragGet<T>(path: string): Promise<T> {
99
+ const response = await fetch(`${BASE_URL}${path}`, {
100
+ headers: makeHeaders(),
101
+ });
102
+
103
+ if (!response.ok) {
104
+ throw new RagApiError(await readErrorMessage(response), response.status);
105
+ }
106
+
107
+ return response.json() as Promise<T>;
108
+ }
109
+
110
+ async function ragJson<T>(
111
+ path: string,
112
+ method: string,
113
+ body: unknown,
114
+ ): Promise<T> {
115
+ const response = await fetch(`${BASE_URL}${path}`, {
116
+ body: JSON.stringify(body),
117
+ headers: makeHeaders({ 'Content-Type': 'application/json' }),
118
+ method,
119
+ });
120
+
121
+ if (!response.ok) {
122
+ throw new RagApiError(await readErrorMessage(response), response.status);
123
+ }
124
+
125
+ return response.json() as Promise<T>;
126
+ }
127
+
128
+ async function ragDelete(path: string): Promise<void> {
129
+ const response = await fetch(`${BASE_URL}${path}`, {
130
+ headers: makeHeaders(),
131
+ method: 'DELETE',
132
+ });
133
+
134
+ if (!response.ok) {
135
+ throw new RagApiError(await readErrorMessage(response), response.status);
136
+ }
137
+ }
138
+
139
+ // Courses
140
+
141
+ export function listCourses(
142
+ page = 1,
143
+ limit = 100,
144
+ ): Promise<{ data: CourseResponse[] }> {
145
+ return ragGet(`/courses?page=${page}&limit=${limit}`);
146
+ }
147
+
148
+ export function getCourse(courseId: string): Promise<CourseResponse> {
149
+ return ragGet(`/courses/${encodeURIComponent(courseId)}`);
150
+ }
151
+
152
+ export function createCourse(payload: {
153
+ title: string;
154
+ description?: string;
155
+ }): Promise<CourseResponse> {
156
+ return ragJson('/courses', 'POST', payload);
157
+ }
158
+
159
+ export function updateCourse(
160
+ courseId: string,
161
+ payload: { title?: string; description?: string },
162
+ ): Promise<CourseResponse> {
163
+ return ragJson(`/courses/${encodeURIComponent(courseId)}`, 'PUT', payload);
164
+ }
165
+
166
+ export function deleteCourse(courseId: string): Promise<void> {
167
+ return ragDelete(`/courses/${encodeURIComponent(courseId)}`);
168
+ }
169
+
170
+ export function listCourseDocuments(
171
+ courseId: string,
172
+ ): Promise<{ data: DocumentResponse[] }> {
173
+ return ragGet(`/courses/${encodeURIComponent(courseId)}/documents`);
174
+ }
175
+
176
+ export async function uploadDocument(
177
+ courseId: string,
178
+ file: File,
179
+ ): Promise<void> {
180
+ const token = getAuthToken();
181
+ const formData = new FormData();
182
+ formData.append('file', file);
183
+ formData.append('course_id', courseId);
184
+
185
+ const response = await fetch(`${BASE_URL}/documents/upload`, {
186
+ body: formData,
187
+ headers: {
188
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
189
+ },
190
+ method: 'POST',
191
+ });
192
+
193
+ if (!response.ok) {
194
+ throw new RagApiError(await readErrorMessage(response), response.status);
195
+ }
196
+ }
197
+
198
+ // Chat Sessions
199
+
200
+ export function listChatSessions(
201
+ page = 1,
202
+ limit = 20,
203
+ ): Promise<{ data: ChatSessionResponse[] }> {
204
+ return ragGet(`/chats/sessions?page=${page}&limit=${limit}`);
205
+ }
206
+
207
+ export function getChatSession(
208
+ sessionId: string,
209
+ ): Promise<ChatSessionResponse> {
210
+ return ragGet(`/chats/sessions/${encodeURIComponent(sessionId)}`);
211
+ }
212
+
213
+ export function createChatSession(payload: {
214
+ course_id: string;
215
+ title: string;
216
+ }): Promise<ChatSessionResponse> {
217
+ return ragJson('/chats/sessions', 'POST', payload);
218
+ }
219
+
220
+ export function deleteChatSession(sessionId: string): Promise<void> {
221
+ return ragDelete(`/chats/sessions/${encodeURIComponent(sessionId)}`);
222
+ }
223
+
224
+ export function getChatHistory(
225
+ sessionId: string,
226
+ ): Promise<{ data: ChatMessageResponse[] }> {
227
+ return ragGet(
228
+ `/chats/sessions/${encodeURIComponent(sessionId)}/messages`,
229
+ );
230
+ }
231
+
232
+ export async function openStreamChatMessage(
233
+ sessionId: string,
234
+ content: string,
235
+ ): Promise<Response> {
236
+ const token = getAuthToken();
237
+ const response = await fetch(
238
+ `${BASE_URL}/chats/sessions/${encodeURIComponent(sessionId)}/stream`,
239
+ {
240
+ body: JSON.stringify({ content }),
241
+ headers: {
242
+ Accept: 'text/event-stream',
243
+ 'Content-Type': 'application/json',
244
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
245
+ },
246
+ method: 'POST',
247
+ },
248
+ );
249
+
250
+ if (!response.ok) {
251
+ throw new RagApiError(await readErrorMessage(response), response.status);
252
+ }
253
+
254
+ return response;
255
+ }
256
+
257
+ // Admin: RAG Config
258
+
259
+ export function getRagConfig(): Promise<RagConfig> {
260
+ return ragGet('/admin/rag/config');
261
+ }
262
+
263
+ export function getLlmConfig(): Promise<LlmConfig> {
264
+ return ragGet('/admin/rag/config/llm');
265
+ }
266
+
267
+ export function patchLlmConfig(
268
+ payload: Record<string, unknown>,
269
+ ): Promise<LlmConfig> {
270
+ return ragJson('/admin/rag/config/llm', 'PATCH', payload);
271
+ }
272
+
273
+ export function getVectorDbConfig(): Promise<VectorDbConfig> {
274
+ return ragGet('/admin/rag/config/vector_db');
275
+ }
276
+
277
+ export function patchVectorDbConfig(
278
+ payload: Record<string, unknown>,
279
+ ): Promise<VectorDbConfig> {
280
+ return ragJson('/admin/rag/config/vector_db', 'PATCH', payload);
281
+ }
282
+
283
+ export function getRetrievalConfig(): Promise<RetrievalConfig> {
284
+ return ragGet('/admin/rag/config/retrieval');
285
+ }
286
+
287
+ export function patchRetrievalConfig(
288
+ payload: Record<string, unknown>,
289
+ ): Promise<RetrievalConfig> {
290
+ return ragJson('/admin/rag/config/retrieval', 'PATCH', payload);
291
+ }
292
+
293
+ // Admin: API Keys
294
+
295
+ export function generateApiKey(): Promise<GeneratedApiKey> {
296
+ return ragJson('/auth/api-keys', 'POST', {});
297
+ }
298
+
299
+ // Admin: Users
300
+
301
+ export function listAdminUsers(
302
+ query: Record<string, string> = {},
303
+ ): Promise<{ founds?: AdminUser[] }> {
304
+ const params = new URLSearchParams(query).toString();
305
+ const path = params ? `/user?${params}` : '/user';
306
+
307
+ return ragGet(path);
308
+ }
309
+
310
+ export function createAdminUser(
311
+ payload: Record<string, unknown>,
312
+ ): Promise<AdminUser> {
313
+ return ragJson('/user', 'POST', payload);
314
+ }
315
+
316
+ export function updateAdminUser(
317
+ userId: number | string,
318
+ payload: Record<string, unknown>,
319
+ ): Promise<AdminUser> {
320
+ return ragJson(`/user/${encodeURIComponent(String(userId))}`, 'PATCH', payload);
321
+ }
322
+
323
+ export function deleteAdminUser(userId: number | string): Promise<void> {
324
+ return ragDelete(`/user/${encodeURIComponent(String(userId))}`);
325
+ }
resources/js/pages/admin/api-keys.tsx CHANGED
@@ -1,12 +1,7 @@
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"
@@ -14,7 +9,7 @@ export default function AdminApiKeys({ generatedApiKey }: ApiKeysProps) {
14
  title="Generate API Key"
15
  >
16
  <div className="max-w-lg">
17
- <AdminApiKeyCard generatedApiKey={generatedApiKey} />
18
  </div>
19
  </AdminPage>
20
  );
 
1
  import { AdminApiKeyCard } from '@/components/admin/admin-api-key-card';
2
  import { AdminPage } from '@/components/admin/admin-page';
 
3
 
4
+ export default function AdminApiKeys() {
 
 
 
 
5
  return (
6
  <AdminPage
7
  activeMenu="api-keys"
 
9
  title="Generate API Key"
10
  >
11
  <div className="max-w-lg">
12
+ <AdminApiKeyCard />
13
  </div>
14
  </AdminPage>
15
  );
resources/js/pages/admin/dashboard.tsx CHANGED
@@ -1,12 +1,26 @@
 
 
1
  import { AdminOverviewCards } from '@/components/admin/admin-overview-cards';
2
  import { AdminPage } from '@/components/admin/admin-page';
3
- import type { AdminDashboardProps } from '@/types/admin';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- export default function AdminDashboard({
6
- backendMessage,
7
- ragConfig,
8
- users = [],
9
- }: AdminDashboardProps) {
10
  return (
11
  <AdminPage
12
  activeMenu="dashboard"
@@ -14,7 +28,9 @@ export default function AdminDashboard({
14
  description="Ringkasan konfigurasi RAG dan status sistem."
15
  title="Dashboard"
16
  >
17
- <AdminOverviewCards ragConfig={ragConfig} users={users} />
 
 
18
  </AdminPage>
19
  );
20
  }
 
1
+ import { useEffect, useState } from 'react';
2
+
3
  import { AdminOverviewCards } from '@/components/admin/admin-overview-cards';
4
  import { AdminPage } from '@/components/admin/admin-page';
5
+ import { listAdminUsers, getRagConfig } from '@/lib/rag-client';
6
+ import type { AdminUser, RagConfig } from '@/types/admin';
7
+
8
+ export default function AdminDashboard() {
9
+ const [ragConfig, setRagConfig] = useState<RagConfig | null>(null);
10
+ const [users, setUsers] = useState<AdminUser[]>([]);
11
+ const [isLoading, setIsLoading] = useState(true);
12
+ const [backendMessage, setBackendMessage] = useState<string | null>(null);
13
+
14
+ useEffect(() => {
15
+ Promise.all([getRagConfig(), listAdminUsers()])
16
+ .then(([config, usersResult]) => {
17
+ setRagConfig(config);
18
+ setUsers(usersResult.founds ?? []);
19
+ })
20
+ .catch((e: Error) => setBackendMessage(e.message))
21
+ .finally(() => setIsLoading(false));
22
+ }, []);
23
 
 
 
 
 
 
24
  return (
25
  <AdminPage
26
  activeMenu="dashboard"
 
28
  description="Ringkasan konfigurasi RAG dan status sistem."
29
  title="Dashboard"
30
  >
31
+ {isLoading ? null : (
32
+ <AdminOverviewCards ragConfig={ragConfig} users={users} />
33
+ )}
34
  </AdminPage>
35
  );
36
  }
resources/js/pages/admin/llm-config.tsx CHANGED
@@ -1,11 +1,22 @@
 
 
1
  import { AdminLlmForm } from '@/components/admin/admin-llm-form';
2
  import { AdminPage } from '@/components/admin/admin-page';
3
- import type { AdminConfigPageProps } from '@/types/admin';
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- export default function AdminLlmConfig({
6
- backendMessage,
7
- ragConfig,
8
- }: AdminConfigPageProps) {
9
  return (
10
  <AdminPage
11
  activeMenu="llm-config"
@@ -13,7 +24,7 @@ export default function AdminLlmConfig({
13
  description="Kelola provider, model, dan parameter LLM."
14
  title="LLM Config"
15
  >
16
- <AdminLlmForm llm={ragConfig?.llm} />
17
  </AdminPage>
18
  );
19
  }
 
1
+ import { useEffect, useState } from 'react';
2
+
3
  import { AdminLlmForm } from '@/components/admin/admin-llm-form';
4
  import { AdminPage } from '@/components/admin/admin-page';
5
+ import { getLlmConfig } from '@/lib/rag-client';
6
+ import type { LlmConfig } from '@/types/admin';
7
+
8
+ export default function AdminLlmConfig() {
9
+ const [llm, setLlm] = useState<LlmConfig | null>(null);
10
+ const [isLoading, setIsLoading] = useState(true);
11
+ const [backendMessage, setBackendMessage] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ getLlmConfig()
15
+ .then(setLlm)
16
+ .catch((e: Error) => setBackendMessage(e.message))
17
+ .finally(() => setIsLoading(false));
18
+ }, []);
19
 
 
 
 
 
20
  return (
21
  <AdminPage
22
  activeMenu="llm-config"
 
24
  description="Kelola provider, model, dan parameter LLM."
25
  title="LLM Config"
26
  >
27
+ {isLoading ? null : <AdminLlmForm llm={llm} />}
28
  </AdminPage>
29
  );
30
  }
resources/js/pages/admin/rag-config.tsx CHANGED
@@ -1,11 +1,22 @@
 
 
1
  import { AdminPage } from '@/components/admin/admin-page';
2
  import { AdminRagSummary } from '@/components/admin/admin-rag-summary';
3
- import type { AdminConfigPageProps } from '@/types/admin';
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- export default function AdminRagConfig({
6
- backendMessage,
7
- ragConfig,
8
- }: AdminConfigPageProps) {
9
  return (
10
  <AdminPage
11
  activeMenu="rag-config"
@@ -13,7 +24,7 @@ export default function AdminRagConfig({
13
  description="Lihat ringkasan konfigurasi RAG yang aktif dari backend."
14
  title="RAG Config"
15
  >
16
- <AdminRagSummary ragConfig={ragConfig} />
17
  </AdminPage>
18
  );
19
  }
 
1
+ import { useEffect, useState } from 'react';
2
+
3
  import { AdminPage } from '@/components/admin/admin-page';
4
  import { AdminRagSummary } from '@/components/admin/admin-rag-summary';
5
+ import { getRagConfig } from '@/lib/rag-client';
6
+ import type { RagConfig } from '@/types/admin';
7
+
8
+ export default function AdminRagConfig() {
9
+ const [ragConfig, setRagConfig] = useState<RagConfig | null>(null);
10
+ const [isLoading, setIsLoading] = useState(true);
11
+ const [backendMessage, setBackendMessage] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ getRagConfig()
15
+ .then(setRagConfig)
16
+ .catch((e: Error) => setBackendMessage(e.message))
17
+ .finally(() => setIsLoading(false));
18
+ }, []);
19
 
 
 
 
 
20
  return (
21
  <AdminPage
22
  activeMenu="rag-config"
 
24
  description="Lihat ringkasan konfigurasi RAG yang aktif dari backend."
25
  title="RAG Config"
26
  >
27
+ {isLoading ? null : <AdminRagSummary ragConfig={ragConfig} />}
28
  </AdminPage>
29
  );
30
  }
resources/js/pages/admin/retrieval-config.tsx CHANGED
@@ -1,11 +1,22 @@
 
 
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"
@@ -13,7 +24,7 @@ export default function AdminRetrievalConfig({
13
  description="Atur bobot retrieval, reranker, dan konteks jawaban."
14
  title="Retrieval Config"
15
  >
16
- <AdminRetrievalForm retrieval={ragConfig?.retrieval} />
17
  </AdminPage>
18
  );
19
  }
 
1
+ import { useEffect, useState } from 'react';
2
+
3
  import { AdminPage } from '@/components/admin/admin-page';
4
  import { AdminRetrievalForm } from '@/components/admin/admin-vector-retrieval-forms';
5
+ import { getRetrievalConfig } from '@/lib/rag-client';
6
+ import type { RetrievalConfig } from '@/types/admin';
7
+
8
+ export default function AdminRetrievalConfig() {
9
+ const [retrieval, setRetrieval] = useState<RetrievalConfig | null>(null);
10
+ const [isLoading, setIsLoading] = useState(true);
11
+ const [backendMessage, setBackendMessage] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ getRetrievalConfig()
15
+ .then(setRetrieval)
16
+ .catch((e: Error) => setBackendMessage(e.message))
17
+ .finally(() => setIsLoading(false));
18
+ }, []);
19
 
 
 
 
 
20
  return (
21
  <AdminPage
22
  activeMenu="retrieval-config"
 
24
  description="Atur bobot retrieval, reranker, dan konteks jawaban."
25
  title="Retrieval Config"
26
  >
27
+ {isLoading ? null : <AdminRetrievalForm retrieval={retrieval} />}
28
  </AdminPage>
29
  );
30
  }
resources/js/pages/admin/vector-retrieval-config.tsx CHANGED
@@ -1,11 +1,26 @@
 
 
1
  import { AdminPage } from '@/components/admin/admin-page';
2
  import { AdminVectorRetrievalForms } from '@/components/admin/admin-vector-retrieval-forms';
3
- import type { AdminConfigPageProps } from '@/types/admin';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- export default function AdminVectorRetrievalConfig({
6
- backendMessage,
7
- ragConfig,
8
- }: AdminConfigPageProps) {
9
  return (
10
  <AdminPage
11
  activeMenu="vector-retrieval-config"
@@ -13,10 +28,12 @@ export default function AdminVectorRetrievalConfig({
13
  description="Kelola konfigurasi VectorDB dan Retrieval."
14
  title="VectorDB & Retrieval Config"
15
  >
16
- <AdminVectorRetrievalForms
17
- retrieval={ragConfig?.retrieval}
18
- vectorDb={ragConfig?.vector_db}
19
- />
 
 
20
  </AdminPage>
21
  );
22
  }
 
1
+ import { useEffect, useState } from 'react';
2
+
3
  import { AdminPage } from '@/components/admin/admin-page';
4
  import { AdminVectorRetrievalForms } from '@/components/admin/admin-vector-retrieval-forms';
5
+ import { getRetrievalConfig, getVectorDbConfig } from '@/lib/rag-client';
6
+ import type { RetrievalConfig, VectorDbConfig } from '@/types/admin';
7
+
8
+ export default function AdminVectorRetrievalConfig() {
9
+ const [vectorDb, setVectorDb] = useState<VectorDbConfig | null>(null);
10
+ const [retrieval, setRetrieval] = useState<RetrievalConfig | null>(null);
11
+ const [isLoading, setIsLoading] = useState(true);
12
+ const [backendMessage, setBackendMessage] = useState<string | null>(null);
13
+
14
+ useEffect(() => {
15
+ Promise.all([getVectorDbConfig(), getRetrievalConfig()])
16
+ .then(([vdb, ret]) => {
17
+ setVectorDb(vdb);
18
+ setRetrieval(ret);
19
+ })
20
+ .catch((e: Error) => setBackendMessage(e.message))
21
+ .finally(() => setIsLoading(false));
22
+ }, []);
23
 
 
 
 
 
24
  return (
25
  <AdminPage
26
  activeMenu="vector-retrieval-config"
 
28
  description="Kelola konfigurasi VectorDB dan Retrieval."
29
  title="VectorDB & Retrieval Config"
30
  >
31
+ {isLoading ? null : (
32
+ <AdminVectorRetrievalForms
33
+ retrieval={retrieval}
34
+ vectorDb={vectorDb}
35
+ />
36
+ )}
37
  </AdminPage>
38
  );
39
  }
resources/js/pages/admin/vectordb-config.tsx CHANGED
@@ -1,11 +1,22 @@
 
 
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"
@@ -13,7 +24,7 @@ export default function AdminVectorDbConfig({
13
  description="Atur model embedding dan konfigurasi chunking dokumen."
14
  title="VectorDB Config"
15
  >
16
- <AdminVectorDbForm vectorDb={ragConfig?.vector_db} />
17
  </AdminPage>
18
  );
19
  }
 
1
+ import { useEffect, useState } from 'react';
2
+
3
  import { AdminPage } from '@/components/admin/admin-page';
4
  import { AdminVectorDbForm } from '@/components/admin/admin-vector-retrieval-forms';
5
+ import { getVectorDbConfig } from '@/lib/rag-client';
6
+ import type { VectorDbConfig } from '@/types/admin';
7
+
8
+ export default function AdminVectorDbConfig() {
9
+ const [vectorDb, setVectorDb] = useState<VectorDbConfig | null>(null);
10
+ const [isLoading, setIsLoading] = useState(true);
11
+ const [backendMessage, setBackendMessage] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ getVectorDbConfig()
15
+ .then(setVectorDb)
16
+ .catch((e: Error) => setBackendMessage(e.message))
17
+ .finally(() => setIsLoading(false));
18
+ }, []);
19
 
 
 
 
 
20
  return (
21
  <AdminPage
22
  activeMenu="vectordb-config"
 
24
  description="Atur model embedding dan konfigurasi chunking dokumen."
25
  title="VectorDB Config"
26
  >
27
+ {isLoading ? null : <AdminVectorDbForm vectorDb={vectorDb} />}
28
  </AdminPage>
29
  );
30
  }
resources/js/pages/dosen-detail.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Head, router, useForm } from '@inertiajs/react';
2
  import {
3
  AlertCircle,
4
  ArrowLeft,
@@ -29,45 +29,23 @@ import {
29
  LoadingDots,
30
  LoadingIndicator,
31
  } from '@/components/ui/loading-indicator';
32
- import { dosen } from '@/routes';
33
  import {
34
- destroy as destroyCourse,
35
- update as updateCourse,
36
- } from '@/routes/dosen';
37
- import { store as uploadCourseDocument } from '@/routes/dosen/documents';
38
-
39
- type CourseResponse = {
40
- id: number | string;
41
- title: string;
42
- description?: string | null;
43
- };
44
-
45
- type DocumentStatus = 'processing' | 'ready' | 'failed';
46
-
47
- type DocumentResponse = {
48
- id: string;
49
- filename: string;
50
- file_type: string;
51
- file_size: number;
52
- status: DocumentStatus;
53
- course_id: string;
54
- course_name?: string;
55
- uploaded_by: string;
56
- uploader_name?: string;
57
- summary?: string;
58
- chunk_count?: number;
59
- error?: string;
60
- created_at: string;
61
- updated_at: string;
62
- };
63
 
64
  type DosenDetailProps = {
65
- backendMessage?: string | null;
66
- course?: CourseResponse | null;
67
  courseId: string;
68
- documents: DocumentResponse[];
69
  };
70
 
 
 
71
  type CourseForm = {
72
  title: string;
73
  description: string;
@@ -187,68 +165,63 @@ function DocumentCard({ document }: { document: DocumentResponse }) {
187
  );
188
  }
189
 
190
- export default function DosenDetail({
191
- backendMessage,
192
- course,
193
- courseId,
194
- documents,
195
- }: DosenDetailProps) {
196
  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
197
  const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
198
  const [isUploading, setIsUploading] = useState(false);
199
  const [uploadMessage, setUploadMessage] = useState<string>();
 
 
 
 
200
  const fileInputRef = useRef<HTMLInputElement>(null);
201
- const editForm = useForm<CourseForm>(emptyCourseForm);
202
- const deleteForm = useForm<Record<string, never>>({});
203
- const editErrors = editForm.errors as Partial<
204
- Record<keyof CourseForm | 'course', string>
205
- >;
206
- const deleteErrors = deleteForm.errors as Partial<
207
- Record<'course', string>
208
- >;
209
- const editCourseMessage =
210
- editErrors.course ?? editErrors.title ?? editErrors.description;
211
- const deleteCourseMessage = deleteErrors.course;
212
- const isUpdatingCourse = editForm.processing;
213
- const isDeletingCourse = deleteForm.processing;
214
  const hasProcessingDocuments = documents.some(
215
  (document) => document.status === 'processing',
216
  );
217
 
 
 
 
 
 
 
 
 
 
 
218
  useEffect(() => {
219
  if (!hasProcessingDocuments) {
220
  return;
221
  }
222
 
223
  const intervalId = window.setInterval(() => {
224
- router.reload({
225
- only: ['backendMessage', 'documents'],
226
- });
227
  }, 7000);
228
 
229
  return () => window.clearInterval(intervalId);
230
- }, [hasProcessingDocuments]);
231
 
232
- function handleUploadDocument(file: File): void {
233
  setUploadMessage(undefined);
234
-
235
- router.post(
236
- uploadCourseDocument.url(courseId),
237
- { file },
238
- {
239
- forceFormData: true,
240
- onError: (errors) => {
241
- const message = errors.file ?? errors.document;
242
-
243
- if (typeof message === 'string') {
244
- setUploadMessage(message);
245
- }
246
- },
247
- onFinish: () => setIsUploading(false),
248
- onStart: () => setIsUploading(true),
249
- preserveScroll: true,
250
- },
251
- );
252
  }
253
 
254
  function handleEditDialogOpenChange(open: boolean): void {
@@ -257,23 +230,51 @@ export default function DosenDetail({
257
  }
258
 
259
  setIsEditDialogOpen(open);
260
- editForm.clearErrors();
261
 
262
  if (open) {
263
- editForm.setData({
264
  description: course?.description ?? '',
265
  title: course?.title ?? '',
266
  });
267
  }
268
  }
269
 
270
- function handleUpdateCourseSubmit(event: FormEvent<HTMLFormElement>): void {
 
 
271
  event.preventDefault();
272
 
273
- editForm.put(updateCourse.url(courseId), {
274
- onSuccess: () => handleEditDialogOpenChange(false),
275
- preserveScroll: true,
276
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  }
278
 
279
  function handleDeleteDialogOpenChange(open: boolean): void {
@@ -282,13 +283,22 @@ export default function DosenDetail({
282
  }
283
 
284
  setIsDeleteDialogOpen(open);
285
- deleteForm.clearErrors();
286
  }
287
 
288
- function handleDeleteCourse(): void {
289
- deleteForm.delete(destroyCourse.url(courseId), {
290
- preserveScroll: true,
291
- });
 
 
 
 
 
 
 
 
 
 
292
  }
293
 
294
  function handleFileInputChange(event: ChangeEvent<HTMLInputElement>): void {
@@ -296,7 +306,7 @@ export default function DosenDetail({
296
  event.target.value = '';
297
 
298
  if (file) {
299
- handleUploadDocument(file);
300
  }
301
  }
302
 
@@ -316,7 +326,7 @@ export default function DosenDetail({
316
  const file = event.dataTransfer.files[0];
317
 
318
  if (file) {
319
- handleUploadDocument(file);
320
  }
321
  }
322
 
@@ -468,7 +478,9 @@ export default function DosenDetail({
468
 
469
  <form
470
  className="lecturer-course-form"
471
- onSubmit={handleUpdateCourseSubmit}
 
 
472
  >
473
  <div className="lecturer-form-field">
474
  <Label
@@ -482,13 +494,13 @@ export default function DosenDetail({
482
  disabled={isUpdatingCourse}
483
  id="edit-course-title"
484
  onChange={(event) =>
485
- editForm.setData(
486
- 'title',
487
- event.target.value,
488
- )
489
  }
490
  placeholder="Masukkan nama mata kuliah"
491
- value={editForm.data.title}
492
  />
493
  </div>
494
 
@@ -504,19 +516,19 @@ export default function DosenDetail({
504
  disabled={isUpdatingCourse}
505
  id="edit-course-description"
506
  onChange={(event) =>
507
- editForm.setData(
508
- 'description',
509
- event.target.value,
510
- )
511
  }
512
  placeholder="Masukkan deskripsi mata kuliah"
513
- value={editForm.data.description}
514
  />
515
  </div>
516
 
517
- {editCourseMessage && (
518
  <p className="lecturer-status" role="alert">
519
- {editCourseMessage}
520
  </p>
521
  )}
522
 
@@ -572,12 +584,6 @@ export default function DosenDetail({
572
  dipakai.
573
  </p>
574
 
575
- {deleteCourseMessage && (
576
- <p className="lecturer-status" role="alert">
577
- {deleteCourseMessage}
578
- </p>
579
- )}
580
-
581
  <DialogFooter className="lecturer-course-footer">
582
  <Button
583
  className="lecturer-cancel-button"
@@ -593,7 +599,9 @@ export default function DosenDetail({
593
  <Button
594
  className="lecturer-danger-action-button"
595
  disabled={isDeletingCourse}
596
- onClick={handleDeleteCourse}
 
 
597
  type="button"
598
  >
599
  {isDeletingCourse ? (
 
1
+ import { Head, router } from '@inertiajs/react';
2
  import {
3
  AlertCircle,
4
  ArrowLeft,
 
29
  LoadingDots,
30
  LoadingIndicator,
31
  } from '@/components/ui/loading-indicator';
 
32
  import {
33
+ deleteCourse,
34
+ getCourse,
35
+ listCourseDocuments,
36
+ updateCourse,
37
+ uploadDocument,
38
+ type CourseResponse,
39
+ type DocumentResponse,
40
+ } from '@/lib/rag-client';
41
+ import { dosen } from '@/routes';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  type DosenDetailProps = {
 
 
44
  courseId: string;
 
45
  };
46
 
47
+ type DocumentStatus = 'processing' | 'ready' | 'failed';
48
+
49
  type CourseForm = {
50
  title: string;
51
  description: string;
 
165
  );
166
  }
167
 
168
+ export default function DosenDetail({ courseId }: DosenDetailProps) {
169
+ const [course, setCourse] = useState<CourseResponse | null>(null);
170
+ const [documents, setDocuments] = useState<DocumentResponse[]>([]);
171
+ const [backendMessage, setBackendMessage] = useState<string>();
 
 
172
  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
173
  const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
174
  const [isUploading, setIsUploading] = useState(false);
175
  const [uploadMessage, setUploadMessage] = useState<string>();
176
+ const [editForm, setEditFormData] = useState<CourseForm>(emptyCourseForm);
177
+ const [editFormError, setEditFormError] = useState<string>();
178
+ const [isUpdatingCourse, setIsUpdatingCourse] = useState(false);
179
+ const [isDeletingCourse, setIsDeletingCourse] = useState(false);
180
  const fileInputRef = useRef<HTMLInputElement>(null);
181
+
 
 
 
 
 
 
 
 
 
 
 
 
182
  const hasProcessingDocuments = documents.some(
183
  (document) => document.status === 'processing',
184
  );
185
 
186
+ useEffect(() => {
187
+ getCourse(courseId)
188
+ .then((c) => setCourse(c))
189
+ .catch((e: Error) => setBackendMessage(e.message));
190
+
191
+ listCourseDocuments(courseId)
192
+ .then((r) => setDocuments(r.data))
193
+ .catch(() => {});
194
+ }, [courseId]);
195
+
196
  useEffect(() => {
197
  if (!hasProcessingDocuments) {
198
  return;
199
  }
200
 
201
  const intervalId = window.setInterval(() => {
202
+ listCourseDocuments(courseId)
203
+ .then((r) => setDocuments(r.data))
204
+ .catch(() => {});
205
  }, 7000);
206
 
207
  return () => window.clearInterval(intervalId);
208
+ }, [courseId, hasProcessingDocuments]);
209
 
210
+ async function handleUploadDocument(file: File): Promise<void> {
211
  setUploadMessage(undefined);
212
+ setIsUploading(true);
213
+
214
+ try {
215
+ await uploadDocument(courseId, file);
216
+ const r = await listCourseDocuments(courseId);
217
+ setDocuments(r.data);
218
+ } catch (e) {
219
+ setUploadMessage(
220
+ e instanceof Error ? e.message : 'Gagal mengupload dokumen.',
221
+ );
222
+ } finally {
223
+ setIsUploading(false);
224
+ }
 
 
 
 
 
225
  }
226
 
227
  function handleEditDialogOpenChange(open: boolean): void {
 
230
  }
231
 
232
  setIsEditDialogOpen(open);
233
+ setEditFormError(undefined);
234
 
235
  if (open) {
236
+ setEditFormData({
237
  description: course?.description ?? '',
238
  title: course?.title ?? '',
239
  });
240
  }
241
  }
242
 
243
+ async function handleUpdateCourseSubmit(
244
+ event: FormEvent<HTMLFormElement>,
245
+ ): Promise<void> {
246
  event.preventDefault();
247
 
248
+ const title = editForm.title.trim();
249
+
250
+ if (!title) {
251
+ setEditFormError('Nama mata kuliah wajib diisi.');
252
+
253
+ return;
254
+ }
255
+
256
+ setEditFormError(undefined);
257
+ setIsUpdatingCourse(true);
258
+
259
+ try {
260
+ const payload: { title: string; description?: string } = { title };
261
+
262
+ if (editForm.description.trim()) {
263
+ payload.description = editForm.description.trim();
264
+ }
265
+
266
+ const updated = await updateCourse(courseId, payload);
267
+ setCourse(updated);
268
+ handleEditDialogOpenChange(false);
269
+ } catch (e) {
270
+ setEditFormError(
271
+ e instanceof Error
272
+ ? e.message
273
+ : 'Gagal memperbarui mata kuliah.',
274
+ );
275
+ } finally {
276
+ setIsUpdatingCourse(false);
277
+ }
278
  }
279
 
280
  function handleDeleteDialogOpenChange(open: boolean): void {
 
283
  }
284
 
285
  setIsDeleteDialogOpen(open);
 
286
  }
287
 
288
+ async function handleDeleteCourse(): Promise<void> {
289
+ setIsDeletingCourse(true);
290
+
291
+ try {
292
+ await deleteCourse(courseId);
293
+ router.visit(dosen());
294
+ } catch (e) {
295
+ setBackendMessage(
296
+ e instanceof Error ? e.message : 'Gagal menghapus mata kuliah.',
297
+ );
298
+ handleDeleteDialogOpenChange(false);
299
+ } finally {
300
+ setIsDeletingCourse(false);
301
+ }
302
  }
303
 
304
  function handleFileInputChange(event: ChangeEvent<HTMLInputElement>): void {
 
306
  event.target.value = '';
307
 
308
  if (file) {
309
+ void handleUploadDocument(file);
310
  }
311
  }
312
 
 
326
  const file = event.dataTransfer.files[0];
327
 
328
  if (file) {
329
+ void handleUploadDocument(file);
330
  }
331
  }
332
 
 
478
 
479
  <form
480
  className="lecturer-course-form"
481
+ onSubmit={(e) => {
482
+ void handleUpdateCourseSubmit(e);
483
+ }}
484
  >
485
  <div className="lecturer-form-field">
486
  <Label
 
494
  disabled={isUpdatingCourse}
495
  id="edit-course-title"
496
  onChange={(event) =>
497
+ setEditFormData((prev) => ({
498
+ ...prev,
499
+ title: event.target.value,
500
+ }))
501
  }
502
  placeholder="Masukkan nama mata kuliah"
503
+ value={editForm.title}
504
  />
505
  </div>
506
 
 
516
  disabled={isUpdatingCourse}
517
  id="edit-course-description"
518
  onChange={(event) =>
519
+ setEditFormData((prev) => ({
520
+ ...prev,
521
+ description: event.target.value,
522
+ }))
523
  }
524
  placeholder="Masukkan deskripsi mata kuliah"
525
+ value={editForm.description}
526
  />
527
  </div>
528
 
529
+ {editFormError && (
530
  <p className="lecturer-status" role="alert">
531
+ {editFormError}
532
  </p>
533
  )}
534
 
 
584
  dipakai.
585
  </p>
586
 
 
 
 
 
 
 
587
  <DialogFooter className="lecturer-course-footer">
588
  <Button
589
  className="lecturer-cancel-button"
 
599
  <Button
600
  className="lecturer-danger-action-button"
601
  disabled={isDeletingCourse}
602
+ onClick={() => {
603
+ void handleDeleteCourse();
604
+ }}
605
  type="button"
606
  >
607
  {isDeletingCourse ? (
resources/js/pages/dosen.tsx CHANGED
@@ -1,9 +1,8 @@
1
- import { Deferred, Head, router, useForm } from '@inertiajs/react';
2
  import type { LucideIcon } from 'lucide-react';
3
  import { AlertTriangle, Database, FileText, Plus, Search } from 'lucide-react';
4
- import type { FormEvent } from 'react';
5
- import type { ReactNode } from 'react';
6
- import { useMemo, useState } from 'react';
7
 
8
  import AppearanceToggle from '@/components/appearance-toggle';
9
  import { DosenShell } from '@/components/dosen/dosen-shell';
@@ -20,15 +19,21 @@ import {
20
  import { Input } from '@/components/ui/input';
21
  import { Label } from '@/components/ui/label';
22
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
 
 
 
 
 
 
 
23
  import { show as showDosenCourse } from '@/routes/dosen';
24
- import { store as storeDosenCourse } from '@/routes/dosen/courses';
25
 
26
  type KnowledgeBaseRow = {
27
  id: string;
28
  name: string;
29
  documentCount: number;
30
  failedDocumentCount: number;
31
- hasError?: boolean;
32
  updatedAt: string;
33
  };
34
 
@@ -45,15 +50,48 @@ type CourseForm = {
45
  description: string;
46
  };
47
 
48
- type DosenPageProps = {
49
- knowledgeBases?: KnowledgeBaseRow[];
50
- };
51
-
52
  const emptyCourseForm: CourseForm = {
53
  description: '',
54
  title: '',
55
  };
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  function MetricCard({ metric }: { metric: Metric }) {
58
  const Icon = metric.icon;
59
 
@@ -78,51 +116,47 @@ function MetricCard({ metric }: { metric: Metric }) {
78
  );
79
  }
80
 
81
- export default function Dosen({ knowledgeBases }: DosenPageProps) {
82
- const list = knowledgeBases ?? [];
 
 
 
 
83
  const [isCourseDialogOpen, setIsCourseDialogOpen] = useState(false);
84
  const [searchQuery, setSearchQuery] = useState('');
85
- const {
86
- clearErrors,
87
- data: courseForm,
88
- errors,
89
- post,
90
- processing: isCreatingCourse,
91
- reset,
92
- setData: setCourseForm,
93
- } = useForm<CourseForm>(emptyCourseForm);
94
- const formErrors = errors as Partial<
95
- Record<keyof CourseForm | 'course', string>
96
- >;
97
- const createCourseMessage =
98
- formErrors.course ?? formErrors.title ?? formErrors.description;
99
-
100
- function handleCourseDialogOpenChange(open: boolean): void {
101
- setIsCourseDialogOpen(open);
102
-
103
- if (!open) {
104
- reset();
105
- clearErrors();
106
- }
107
- }
108
-
109
- function handleCreateCourseSubmit(
110
- event: FormEvent<HTMLFormElement>,
111
- ): void {
112
- event.preventDefault();
113
 
114
- post(storeDosenCourse.url(), {
115
- onSuccess: () => handleCourseDialogOpenChange(false),
116
- preserveScroll: true,
117
- });
118
- }
119
 
120
  const metrics = useMemo<Metric[]>(() => {
121
- const failedDocumentCount = list.reduce(
122
  (total, item) => total + item.failedDocumentCount,
123
  0,
124
  );
125
- const documentCount = list.reduce(
126
  (total, item) => total + item.documentCount,
127
  0,
128
  );
@@ -132,7 +166,7 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
132
  icon: Database,
133
  label: 'Total Knowledge Base',
134
  tone: 'primary',
135
- value: String(list.length),
136
  },
137
  {
138
  icon: FileText,
@@ -155,13 +189,215 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
155
  const normalizedQuery = searchQuery.trim().toLowerCase();
156
 
157
  if (!normalizedQuery) {
158
- return list;
159
  }
160
 
161
- return list.filter((item) =>
162
  item.name.toLowerCase().includes(normalizedQuery),
163
  );
164
- }, [list, searchQuery]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  return (
167
  <>
@@ -192,183 +428,13 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
192
  </div>
193
  </section>
194
 
195
- <Deferred
196
- data="knowledgeBases"
197
- fallback={
198
- <>
199
- <section className="lecturer-metric-grid">
200
- {[0, 1, 2].map((i) => (
201
- <Card
202
- className="lecturer-metric-card"
203
- key={i}
204
- >
205
- <CardContent className="lecturer-metric-content">
206
- <span className="lecturer-metric-icon animate-pulse bg-muted" />
207
- <div className="lecturer-metric-copy gap-2">
208
- <div className="animate-pulse h-3 w-24 rounded bg-muted" />
209
- <div className="animate-pulse h-5 w-12 rounded bg-muted" />
210
- </div>
211
- </CardContent>
212
- </Card>
213
- ))}
214
- </section>
215
-
216
- <Card className="lecturer-table-card">
217
- <CardContent className="lecturer-table-content">
218
- <div className="lecturer-table-wrap">
219
- <table className="lecturer-table">
220
- <thead>
221
- <tr>
222
- <th>
223
- Nama Knowledge Base
224
- </th>
225
- <th>Dokumen</th>
226
- <th>
227
- Update Terakhir
228
- </th>
229
- <th>Aksi</th>
230
- </tr>
231
- </thead>
232
- <tbody>
233
- {[0, 1, 2].map((i) => (
234
- <tr key={i}>
235
- <td>
236
- <div className="animate-pulse h-3.5 w-40 rounded bg-muted" />
237
- </td>
238
- <td>
239
- <div className="animate-pulse h-3.5 w-10 rounded bg-muted" />
240
- </td>
241
- <td>
242
- <div className="animate-pulse h-3.5 w-20 rounded bg-muted" />
243
- </td>
244
- <td>
245
- <div className="animate-pulse h-3.5 w-12 rounded bg-muted" />
246
- </td>
247
- </tr>
248
- ))}
249
- </tbody>
250
- </table>
251
- </div>
252
- </CardContent>
253
- </Card>
254
- </>
255
- }
256
- rescue={
257
- <p className="lecturer-status" role="alert">
258
- Gagal memuat data knowledge base.
259
- </p>
260
- }
261
- >
262
- <section className="lecturer-metric-grid">
263
- {metrics.map((metric) => (
264
- <MetricCard key={metric.label} metric={metric} />
265
- ))}
266
- </section>
267
-
268
- <Card className="lecturer-table-card">
269
- <CardContent className="lecturer-table-content">
270
- <div className="lecturer-table-heading">
271
- <h2>Daftar Mata Kuliah / Topik</h2>
272
- <div className="lecturer-search-field">
273
- <Search aria-hidden className="size-4" />
274
- <Input
275
- aria-label="Cari mata kuliah"
276
- className="lecturer-search-input"
277
- onChange={(event) =>
278
- setSearchQuery(
279
- event.target.value,
280
- )
281
- }
282
- placeholder="Cari mata kuliah..."
283
- type="search"
284
- value={searchQuery}
285
- />
286
- </div>
287
- </div>
288
-
289
- <div className="lecturer-table-wrap">
290
- <table className="lecturer-table">
291
- <thead>
292
- <tr>
293
- <th>Nama Knowledge Base</th>
294
- <th>Dokumen</th>
295
- <th>Update Terakhir</th>
296
- <th>Aksi</th>
297
- </tr>
298
- </thead>
299
- <tbody>
300
- {list.length === 0 && (
301
- <tr>
302
- <td
303
- className="lecturer-table-empty"
304
- colSpan={4}
305
- >
306
- Belum ada knowledge
307
- base.
308
- </td>
309
- </tr>
310
- )}
311
-
312
- {list.length > 0 &&
313
- filteredKnowledgeBases.length ===
314
- 0 && (
315
- <tr>
316
- <td
317
- className="lecturer-table-empty"
318
- colSpan={4}
319
- >
320
- Mata kuliah tidak
321
- ditemukan.
322
- </td>
323
- </tr>
324
- )}
325
 
326
- {filteredKnowledgeBases.map(
327
- (item) => (
328
- <tr key={item.id}>
329
- <td>
330
- <span className="lecturer-kb-name">
331
- {item.name}
332
- </span>
333
- </td>
334
- <td>
335
- <span className="lecturer-doc-pill">
336
- <FileText className="size-3.5" />
337
- {
338
- item.documentCount
339
- }
340
- {item.hasError && (
341
- <span className="lecturer-doc-error" />
342
- )}
343
- </span>
344
- </td>
345
- <td>
346
- {item.updatedAt}
347
- </td>
348
- <td>
349
- <button
350
- className="lecturer-manage-button"
351
- onClick={() =>
352
- router.visit(
353
- showDosenCourse(
354
- item.id,
355
- ),
356
- )
357
- }
358
- type="button"
359
- >
360
- Kelola
361
- </button>
362
- </td>
363
- </tr>
364
- ),
365
- )}
366
- </tbody>
367
- </table>
368
- </div>
369
- </CardContent>
370
- </Card>
371
- </Deferred>
372
  </main>
373
 
374
  <Dialog
@@ -387,7 +453,7 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
387
  <form
388
  className="lecturer-course-form"
389
  onSubmit={(event) => {
390
- handleCreateCourseSubmit(event);
391
  }}
392
  >
393
  <div className="lecturer-form-field">
@@ -402,10 +468,10 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
402
  disabled={isCreatingCourse}
403
  id="course-title"
404
  onChange={(event) =>
405
- setCourseForm(
406
- 'title',
407
- event.target.value,
408
- )
409
  }
410
  placeholder="Masukkan nama mata kuliah"
411
  value={courseForm.title}
@@ -424,19 +490,19 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
424
  disabled={isCreatingCourse}
425
  id="course-description"
426
  onChange={(event) =>
427
- setCourseForm(
428
- 'description',
429
- event.target.value,
430
- )
431
  }
432
  placeholder="Masukkan deskripsi mata kuliah"
433
  value={courseForm.description}
434
  />
435
  </div>
436
 
437
- {createCourseMessage && (
438
  <p className="lecturer-status" role="alert">
439
- {createCourseMessage}
440
  </p>
441
  )}
442
 
 
1
+ import { Head, router } from '@inertiajs/react';
2
  import type { LucideIcon } from 'lucide-react';
3
  import { AlertTriangle, Database, FileText, Plus, Search } from 'lucide-react';
4
+ import type { FormEvent, ReactNode } from 'react';
5
+ import { useEffect, useMemo, useState } from 'react';
 
6
 
7
  import AppearanceToggle from '@/components/appearance-toggle';
8
  import { DosenShell } from '@/components/dosen/dosen-shell';
 
19
  import { Input } from '@/components/ui/input';
20
  import { Label } from '@/components/ui/label';
21
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
22
+ import {
23
+ createCourse,
24
+ listCourseDocuments,
25
+ listCourses,
26
+ type CourseResponse,
27
+ type DocumentResponse,
28
+ } from '@/lib/rag-client';
29
  import { show as showDosenCourse } from '@/routes/dosen';
 
30
 
31
  type KnowledgeBaseRow = {
32
  id: string;
33
  name: string;
34
  documentCount: number;
35
  failedDocumentCount: number;
36
+ hasError: boolean;
37
  updatedAt: string;
38
  };
39
 
 
50
  description: string;
51
  };
52
 
 
 
 
 
53
  const emptyCourseForm: CourseForm = {
54
  description: '',
55
  title: '',
56
  };
57
 
58
+ function formatUpdatedAt(value: string | undefined): string {
59
+ if (!value) {
60
+ return '-';
61
+ }
62
+
63
+ return value.split('T')[0] || value;
64
+ }
65
+
66
+ function latestDocumentUpdate(
67
+ documents: DocumentResponse[],
68
+ ): string | undefined {
69
+ const dates = documents
70
+ .map((d) => (typeof d.updated_at === 'string' ? d.updated_at : null))
71
+ .filter((d): d is string => d !== null)
72
+ .sort();
73
+
74
+ return dates[dates.length - 1];
75
+ }
76
+
77
+ function buildKnowledgeBaseRow(
78
+ course: CourseResponse,
79
+ documents: DocumentResponse[],
80
+ ): KnowledgeBaseRow {
81
+ const failedCount = documents.filter(
82
+ (d) => d.status === 'failed' || Boolean(d.error),
83
+ ).length;
84
+
85
+ return {
86
+ documentCount: documents.length,
87
+ failedDocumentCount: failedCount,
88
+ hasError: failedCount > 0,
89
+ id: String(course.id),
90
+ name: course.title,
91
+ updatedAt: formatUpdatedAt(latestDocumentUpdate(documents)),
92
+ };
93
+ }
94
+
95
  function MetricCard({ metric }: { metric: Metric }) {
96
  const Icon = metric.icon;
97
 
 
116
  );
117
  }
118
 
119
+ export default function Dosen() {
120
+ const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseRow[]>(
121
+ [],
122
+ );
123
+ const [isLoading, setIsLoading] = useState(true);
124
+ const [statusMessage, setStatusMessage] = useState<string>();
125
  const [isCourseDialogOpen, setIsCourseDialogOpen] = useState(false);
126
  const [searchQuery, setSearchQuery] = useState('');
127
+ const [courseForm, setCourseFormData] = useState<CourseForm>(emptyCourseForm);
128
+ const [courseFormError, setCourseFormError] = useState<string>();
129
+ const [isCreatingCourse, setIsCreatingCourse] = useState(false);
130
+
131
+ useEffect(() => {
132
+ listCourses()
133
+ .then(async (r) => {
134
+ const rows = await Promise.all(
135
+ r.data.map(async (course) => {
136
+ try {
137
+ const docs = await listCourseDocuments(
138
+ String(course.id),
139
+ );
140
+
141
+ return buildKnowledgeBaseRow(course, docs.data);
142
+ } catch {
143
+ return buildKnowledgeBaseRow(course, []);
144
+ }
145
+ }),
146
+ );
 
 
 
 
 
 
 
 
147
 
148
+ setKnowledgeBases(rows);
149
+ })
150
+ .catch((e: Error) => setStatusMessage(e.message))
151
+ .finally(() => setIsLoading(false));
152
+ }, []);
153
 
154
  const metrics = useMemo<Metric[]>(() => {
155
+ const failedDocumentCount = knowledgeBases.reduce(
156
  (total, item) => total + item.failedDocumentCount,
157
  0,
158
  );
159
+ const documentCount = knowledgeBases.reduce(
160
  (total, item) => total + item.documentCount,
161
  0,
162
  );
 
166
  icon: Database,
167
  label: 'Total Knowledge Base',
168
  tone: 'primary',
169
+ value: String(knowledgeBases.length),
170
  },
171
  {
172
  icon: FileText,
 
189
  const normalizedQuery = searchQuery.trim().toLowerCase();
190
 
191
  if (!normalizedQuery) {
192
+ return knowledgeBases;
193
  }
194
 
195
+ return knowledgeBases.filter((item) =>
196
  item.name.toLowerCase().includes(normalizedQuery),
197
  );
198
+ }, [knowledgeBases, searchQuery]);
199
+
200
+ function handleCourseDialogOpenChange(open: boolean): void {
201
+ setIsCourseDialogOpen(open);
202
+
203
+ if (!open) {
204
+ setCourseFormData(emptyCourseForm);
205
+ setCourseFormError(undefined);
206
+ }
207
+ }
208
+
209
+ async function handleCreateCourseSubmit(
210
+ event: FormEvent<HTMLFormElement>,
211
+ ): Promise<void> {
212
+ event.preventDefault();
213
+
214
+ const title = courseForm.title.trim();
215
+
216
+ if (!title) {
217
+ setCourseFormError('Nama mata kuliah wajib diisi.');
218
+
219
+ return;
220
+ }
221
+
222
+ setCourseFormError(undefined);
223
+ setIsCreatingCourse(true);
224
+
225
+ try {
226
+ const payload: { title: string; description?: string } = { title };
227
+
228
+ if (courseForm.description.trim()) {
229
+ payload.description = courseForm.description.trim();
230
+ }
231
+
232
+ const created = await createCourse(payload);
233
+
234
+ setKnowledgeBases((prev) => [
235
+ ...prev,
236
+ buildKnowledgeBaseRow(created, []),
237
+ ]);
238
+ handleCourseDialogOpenChange(false);
239
+ } catch (e) {
240
+ setCourseFormError(
241
+ e instanceof Error ? e.message : 'Gagal membuat mata kuliah.',
242
+ );
243
+ } finally {
244
+ setIsCreatingCourse(false);
245
+ }
246
+ }
247
+
248
+ const tableBody = isLoading ? (
249
+ <>
250
+ <section className="lecturer-metric-grid">
251
+ {[0, 1, 2].map((i) => (
252
+ <Card className="lecturer-metric-card" key={i}>
253
+ <CardContent className="lecturer-metric-content">
254
+ <span className="lecturer-metric-icon animate-pulse bg-muted" />
255
+ <div className="lecturer-metric-copy gap-2">
256
+ <div className="animate-pulse h-3 w-24 rounded bg-muted" />
257
+ <div className="animate-pulse h-5 w-12 rounded bg-muted" />
258
+ </div>
259
+ </CardContent>
260
+ </Card>
261
+ ))}
262
+ </section>
263
+
264
+ <Card className="lecturer-table-card">
265
+ <CardContent className="lecturer-table-content">
266
+ <div className="lecturer-table-wrap">
267
+ <table className="lecturer-table">
268
+ <thead>
269
+ <tr>
270
+ <th>Nama Knowledge Base</th>
271
+ <th>Dokumen</th>
272
+ <th>Update Terakhir</th>
273
+ <th>Aksi</th>
274
+ </tr>
275
+ </thead>
276
+ <tbody>
277
+ {[0, 1, 2].map((i) => (
278
+ <tr key={i}>
279
+ <td>
280
+ <div className="animate-pulse h-3.5 w-40 rounded bg-muted" />
281
+ </td>
282
+ <td>
283
+ <div className="animate-pulse h-3.5 w-10 rounded bg-muted" />
284
+ </td>
285
+ <td>
286
+ <div className="animate-pulse h-3.5 w-20 rounded bg-muted" />
287
+ </td>
288
+ <td>
289
+ <div className="animate-pulse h-3.5 w-12 rounded bg-muted" />
290
+ </td>
291
+ </tr>
292
+ ))}
293
+ </tbody>
294
+ </table>
295
+ </div>
296
+ </CardContent>
297
+ </Card>
298
+ </>
299
+ ) : (
300
+ <>
301
+ <section className="lecturer-metric-grid">
302
+ {metrics.map((metric) => (
303
+ <MetricCard key={metric.label} metric={metric} />
304
+ ))}
305
+ </section>
306
+
307
+ <Card className="lecturer-table-card">
308
+ <CardContent className="lecturer-table-content">
309
+ <div className="lecturer-table-heading">
310
+ <h2>Daftar Mata Kuliah / Topik</h2>
311
+ <div className="lecturer-search-field">
312
+ <Search aria-hidden className="size-4" />
313
+ <Input
314
+ aria-label="Cari mata kuliah"
315
+ className="lecturer-search-input"
316
+ onChange={(event) =>
317
+ setSearchQuery(event.target.value)
318
+ }
319
+ placeholder="Cari mata kuliah..."
320
+ type="search"
321
+ value={searchQuery}
322
+ />
323
+ </div>
324
+ </div>
325
+
326
+ <div className="lecturer-table-wrap">
327
+ <table className="lecturer-table">
328
+ <thead>
329
+ <tr>
330
+ <th>Nama Knowledge Base</th>
331
+ <th>Dokumen</th>
332
+ <th>Update Terakhir</th>
333
+ <th>Aksi</th>
334
+ </tr>
335
+ </thead>
336
+ <tbody>
337
+ {knowledgeBases.length === 0 && (
338
+ <tr>
339
+ <td
340
+ className="lecturer-table-empty"
341
+ colSpan={4}
342
+ >
343
+ Belum ada knowledge base.
344
+ </td>
345
+ </tr>
346
+ )}
347
+
348
+ {knowledgeBases.length > 0 &&
349
+ filteredKnowledgeBases.length === 0 && (
350
+ <tr>
351
+ <td
352
+ className="lecturer-table-empty"
353
+ colSpan={4}
354
+ >
355
+ Mata kuliah tidak ditemukan.
356
+ </td>
357
+ </tr>
358
+ )}
359
+
360
+ {filteredKnowledgeBases.map((item) => (
361
+ <tr key={item.id}>
362
+ <td>
363
+ <span className="lecturer-kb-name">
364
+ {item.name}
365
+ </span>
366
+ </td>
367
+ <td>
368
+ <span className="lecturer-doc-pill">
369
+ <FileText className="size-3.5" />
370
+ {item.documentCount}
371
+ {item.hasError && (
372
+ <span className="lecturer-doc-error" />
373
+ )}
374
+ </span>
375
+ </td>
376
+ <td>{item.updatedAt}</td>
377
+ <td>
378
+ <button
379
+ className="lecturer-manage-button"
380
+ onClick={() =>
381
+ router.visit(
382
+ showDosenCourse(
383
+ item.id,
384
+ ),
385
+ )
386
+ }
387
+ type="button"
388
+ >
389
+ Kelola
390
+ </button>
391
+ </td>
392
+ </tr>
393
+ ))}
394
+ </tbody>
395
+ </table>
396
+ </div>
397
+ </CardContent>
398
+ </Card>
399
+ </>
400
+ );
401
 
402
  return (
403
  <>
 
428
  </div>
429
  </section>
430
 
431
+ {statusMessage && (
432
+ <p className="lecturer-status" role="alert">
433
+ {statusMessage}
434
+ </p>
435
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
 
437
+ {tableBody}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  </main>
439
 
440
  <Dialog
 
453
  <form
454
  className="lecturer-course-form"
455
  onSubmit={(event) => {
456
+ void handleCreateCourseSubmit(event);
457
  }}
458
  >
459
  <div className="lecturer-form-field">
 
468
  disabled={isCreatingCourse}
469
  id="course-title"
470
  onChange={(event) =>
471
+ setCourseFormData((prev) => ({
472
+ ...prev,
473
+ title: event.target.value,
474
+ }))
475
  }
476
  placeholder="Masukkan nama mata kuliah"
477
  value={courseForm.title}
 
490
  disabled={isCreatingCourse}
491
  id="course-description"
492
  onChange={(event) =>
493
+ setCourseFormData((prev) => ({
494
+ ...prev,
495
+ description: event.target.value,
496
+ }))
497
  }
498
  placeholder="Masukkan deskripsi mata kuliah"
499
  value={courseForm.description}
500
  />
501
  </div>
502
 
503
+ {courseFormError && (
504
  <p className="lecturer-status" role="alert">
505
+ {courseFormError}
506
  </p>
507
  )}
508
 
resources/js/pages/mahasiswa-chat.tsx CHANGED
@@ -10,14 +10,18 @@ import {
10
  useStoredStudentName,
11
  } from '@/components/student/student-shell';
12
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
13
- import type {
14
- ChatMessageResponse,
15
- ChatSessionResponse,
16
- CourseResponse,
17
- } from '@/lib/rag';
 
 
 
 
 
 
18
  import { createLocalAssistantMessage, createLocalUserMessage } from '@/lib/rag';
19
- import { destroy as destroyMahasiswaSession } from '@/routes/mahasiswa';
20
- import { store as storeMahasiswaMessage } from '@/routes/mahasiswa/messages';
21
 
22
  type StoreMessageJsonResponse = {
23
  assistantMessage?: ChatMessageResponse;
@@ -41,18 +45,27 @@ type InitialQuestion = {
41
  };
42
 
43
  type MahasiswaChatProps = {
44
- backendMessage?: string | null;
45
- chatSessions: ChatSessionResponse[];
46
- courses: CourseResponse[];
47
- currentSession?: ChatSessionResponse | null;
48
- initialQuestion?: InitialQuestion | null;
49
- isDirectChatMode?: boolean;
50
- isStreamChatMode?: boolean;
51
- messages: ChatMessageResponse[];
52
- selectedCourseId?: string | null;
53
  sessionId: string;
54
  };
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  function isUserMessage(role: string): boolean {
57
  return ['human', 'student', 'user'].includes(role.toLowerCase());
58
  }
@@ -61,14 +74,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
61
  return typeof value === 'object' && value !== null && !Array.isArray(value);
62
  }
63
 
64
- function firstError(
65
- errors: Record<string, string | string[]>,
66
- ): string | undefined {
67
- const [message] = Object.values(errors);
68
-
69
- return Array.isArray(message) ? message[0] : message;
70
- }
71
-
72
  function firstPayloadError(value: unknown): string | undefined {
73
  if (typeof value === 'string') {
74
  return value;
@@ -123,14 +128,6 @@ function backendMessageFromPayload(payload: unknown): string | undefined {
123
  );
124
  }
125
 
126
- function csrfToken(): string | undefined {
127
- return (
128
- document
129
- .querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
130
- ?.getAttribute('content') ?? undefined
131
- );
132
- }
133
-
134
  function isStreamDebugEnabled(): boolean {
135
  if (import.meta.env.DEV) {
136
  return true;
@@ -165,45 +162,6 @@ function debugStream(label: string, value?: unknown): void {
165
  console.log(`[rag-stream] ${label}`, value);
166
  }
167
 
168
- async function storeChatMessage(
169
- sessionId: string,
170
- content: string,
171
- courseId: string,
172
- ): Promise<StoreMessageJsonResponse> {
173
- const token = csrfToken();
174
- const response = await fetch(storeMahasiswaMessage.url(sessionId), {
175
- body: JSON.stringify({
176
- content,
177
- course_id: courseId,
178
- }),
179
- credentials: 'same-origin',
180
- headers: {
181
- Accept: 'application/json',
182
- 'Content-Type': 'application/json',
183
- 'X-Requested-With': 'XMLHttpRequest',
184
- ...(token ? { 'X-CSRF-TOKEN': token } : {}),
185
- },
186
- method: 'POST',
187
- });
188
- const payload: unknown = await response.json().catch(() => undefined);
189
-
190
- if (!response.ok) {
191
- throw new BackendResponseError(
192
- backendMessageFromPayload(payload) ?? '',
193
- );
194
- }
195
-
196
- if (!isRecord(payload)) {
197
- return {};
198
- }
199
-
200
- return {
201
- assistantMessage: isRecord(payload.assistantMessage)
202
- ? (payload.assistantMessage as ChatMessageResponse)
203
- : undefined,
204
- };
205
- }
206
-
207
  function isChatMessageResponse(value: unknown): value is ChatMessageResponse {
208
  return (
209
  isRecord(value) &&
@@ -448,32 +406,12 @@ function handleStreamPayload(
448
  async function streamChatMessage(
449
  sessionId: string,
450
  content: string,
451
- courseId: string,
452
  onDelta: (delta: string) => void,
453
  ): Promise<StoreMessageJsonResponse> {
454
- const token = csrfToken();
455
-
456
- debugStream('request:start', {
457
- content,
458
- courseId,
459
- sessionId,
460
- url: storeMahasiswaMessage.url(sessionId),
461
- });
462
 
463
- const response = await fetch(storeMahasiswaMessage.url(sessionId), {
464
- body: JSON.stringify({
465
- content,
466
- course_id: courseId,
467
- }),
468
- credentials: 'same-origin',
469
- headers: {
470
- Accept: 'text/event-stream',
471
- 'Content-Type': 'application/json',
472
- 'X-Requested-With': 'XMLHttpRequest',
473
- ...(token ? { 'X-CSRF-TOKEN': token } : {}),
474
- },
475
- method: 'POST',
476
- });
477
 
478
  debugStream('response', {
479
  contentType: response.headers.get('content-type'),
@@ -482,16 +420,6 @@ async function streamChatMessage(
482
  statusText: response.statusText,
483
  });
484
 
485
- if (!response.ok) {
486
- const payload: unknown = await response.json().catch(() => undefined);
487
-
488
- debugStream('response:error-payload', payload);
489
-
490
- throw new BackendResponseError(
491
- backendMessageFromPayload(payload) ?? '',
492
- );
493
- }
494
-
495
  if (!response.body) {
496
  debugStream('response:no-body');
497
 
@@ -587,7 +515,9 @@ async function streamChatMessage(
587
  }
588
 
589
  function formatMessageTime(value: string): string {
590
- const normalized = /[Zz]|[+-]\d{2}:?\d{2}$/.test(value) ? value : value + 'Z';
 
 
591
  const date = new Date(normalized);
592
 
593
  if (Number.isNaN(date.getTime())) {
@@ -671,7 +601,6 @@ function ChatMessage({
671
  <span className="student-message-time">
672
  {formatMessageTime(message.created_at)}
673
  </span>
674
-
675
  </div>
676
 
677
  {isUser && (
@@ -700,33 +629,26 @@ function appendUniqueMessages(
700
  const STREAM_TYPING_CHARS_PER_TICK = 2;
701
  const STREAM_TYPING_INTERVAL_MS = 14;
702
 
703
- export default function MahasiswaChat({
704
- backendMessage: initialBackendMessage,
705
- chatSessions,
706
- courses,
707
- currentSession,
708
- initialQuestion,
709
- isDirectChatMode = false,
710
- isStreamChatMode = false,
711
- messages: initialMessages,
712
- selectedCourseId: initialSelectedCourseId,
713
- sessionId,
714
- }: MahasiswaChatProps) {
715
- const initialQuestionRef = useRef(initialQuestion ?? undefined);
716
  const hasStartedInitialQuestionRef = useRef(false);
717
- const isWaitingForDirectAssistantRef = useRef(false);
718
  const streamingAssistantMessageRef = useRef<
719
  ChatMessageResponse | undefined
720
  >(undefined);
721
  const streamingTextQueueRef = useRef('');
722
  const streamingTypingTimerRef = useRef<number | undefined>(undefined);
723
  const streamingTypingWaitersRef = useRef<Array<() => void>>([]);
724
- const knownMessageIdsRef = useRef(
725
- new Set(initialMessages.map((message) => message.uuid_id)),
 
 
 
 
 
726
  );
727
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
728
- const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
729
- useState<string>();
730
  const [streamingAssistantMessageId, setStreamingAssistantMessageId] =
731
  useState<string>();
732
  const [isProcessing, setIsProcessing] = useState(false);
@@ -734,17 +656,36 @@ export default function MahasiswaChat({
734
  ChatMessageResponse[]
735
  >([]);
736
  const [question, setQuestion] = useState('');
737
- const [selectedCourseId, setSelectedCourseId] = useState(
738
- initialSelectedCourseId ?? currentSession?.course_id ?? '',
739
- );
740
  const studentName = useStoredStudentName();
741
  const messagesEndRef = useRef<HTMLDivElement>(null);
742
- const backendMessage = localBackendMessage ?? initialBackendMessage;
743
  const messages = useMemo(
744
- () => appendUniqueMessages(initialMessages, optimisticMessages),
745
- [initialMessages, optimisticMessages],
746
  );
747
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  const composerCourses = useMemo(() => {
749
  if (
750
  !currentSession ||
@@ -763,25 +704,6 @@ export default function MahasiswaChat({
763
  ];
764
  }, [courses, currentSession, selectedCourseId]);
765
 
766
- useEffect(() => {
767
- if (isDirectChatMode && isWaitingForDirectAssistantRef.current) {
768
- const newAssistantMessage = messages.find(
769
- (message) =>
770
- !knownMessageIdsRef.current.has(message.uuid_id) &&
771
- !isUserMessage(message.role),
772
- );
773
-
774
- if (newAssistantMessage) {
775
- setAnimatedAssistantMessageId(newAssistantMessage.uuid_id);
776
- isWaitingForDirectAssistantRef.current = false;
777
- }
778
- }
779
-
780
- for (const message of messages) {
781
- knownMessageIdsRef.current.add(message.uuid_id);
782
- }
783
- }, [isDirectChatMode, messages]);
784
-
785
  useEffect(() => {
786
  const scrollFrame = window.requestAnimationFrame(() => {
787
  messagesEndRef.current?.scrollIntoView({
@@ -792,51 +714,6 @@ export default function MahasiswaChat({
792
  return () => window.cancelAnimationFrame(scrollFrame);
793
  }, [messages, isProcessing]);
794
 
795
- useEffect(() => {
796
- const initial = initialQuestionRef.current;
797
-
798
- if (!initial || hasStartedInitialQuestionRef.current) {
799
- return;
800
- }
801
-
802
- hasStartedInitialQuestionRef.current = true;
803
-
804
- const userMessage = createLocalUserMessage(initial.content);
805
- setOptimisticMessages([userMessage]);
806
- setIsProcessing(true);
807
-
808
- void (async () => {
809
- try {
810
- const response = await storeChatMessage(
811
- sessionId,
812
- initial.content,
813
- initial.courseId,
814
- );
815
-
816
- if (response.assistantMessage) {
817
- setAnimatedAssistantMessageId(
818
- response.assistantMessage.uuid_id,
819
- );
820
- setOptimisticMessages((currentMessages) =>
821
- appendUniqueMessages(currentMessages, [
822
- response.assistantMessage!,
823
- ]),
824
- );
825
- }
826
- } catch (error) {
827
- setOptimisticMessages([]);
828
- setQuestion(initial.content);
829
- setLocalBackendMessage(
830
- error instanceof Error && error.message
831
- ? error.message
832
- : 'Gagal mengirim pesan. Silakan coba lagi.',
833
- );
834
- } finally {
835
- setIsProcessing(false);
836
- }
837
- })();
838
- }, [sessionId]);
839
-
840
  const resolveStreamingTypingWaiters = useCallback((): void => {
841
  const waiters = streamingTypingWaitersRef.current;
842
  streamingTypingWaitersRef.current = [];
@@ -993,51 +870,29 @@ export default function MahasiswaChat({
993
  optimisticUserMessage?: ChatMessageResponse,
994
  ): void => {
995
  setLocalBackendMessage(undefined);
996
- isWaitingForDirectAssistantRef.current = isDirectChatMode;
997
  setIsProcessing(true);
 
998
 
999
  void (async () => {
1000
  try {
1001
- const shouldUseStream = isStreamChatMode;
1002
-
1003
- if (shouldUseStream) {
1004
- setStreamingAssistantContent('');
1005
- }
 
1006
 
1007
- const response = shouldUseStream
1008
- ? await streamChatMessage(
1009
- sessionId,
1010
- content,
1011
- courseId,
1012
- appendStreamingAssistantDelta,
1013
- )
1014
- : await storeChatMessage(sessionId, content, courseId);
1015
-
1016
- if (shouldUseStream) {
1017
- await waitForStreamingTyping();
1018
- }
1019
 
1020
  if (response.assistantMessage) {
1021
- const assistantMessage = response.assistantMessage;
1022
-
1023
- if (shouldUseStream) {
1024
- replaceStreamingAssistantMessage(assistantMessage);
1025
- } else {
1026
- setAnimatedAssistantMessageId(
1027
- assistantMessage.uuid_id,
1028
- );
1029
- setOptimisticMessages((currentMessages) =>
1030
- appendUniqueMessages(currentMessages, [
1031
- assistantMessage,
1032
- ]),
1033
- );
1034
- }
1035
  }
1036
 
1037
  setQuestion('');
1038
  } catch (error) {
1039
  debugStream('request:error', error);
1040
- isWaitingForDirectAssistantRef.current = false;
1041
  const streamingMessageId =
1042
  streamingAssistantMessageRef.current?.uuid_id;
1043
  clearStreamingTyping();
@@ -1101,8 +956,6 @@ export default function MahasiswaChat({
1101
  [
1102
  appendStreamingAssistantDelta,
1103
  clearStreamingTyping,
1104
- isDirectChatMode,
1105
- isStreamChatMode,
1106
  replaceStreamingAssistantMessage,
1107
  setStreamingAssistantContent,
1108
  sessionId,
@@ -1110,22 +963,102 @@ export default function MahasiswaChat({
1110
  ],
1111
  );
1112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1113
  const handleDeleteSession = (
1114
  targetSessionId: string,
1115
- ): Promise<DeleteSessionResult> => {
1116
- return new Promise((resolve) => {
1117
- router.delete(destroyMahasiswaSession.url(targetSessionId), {
1118
- data: { active_session_id: sessionId },
1119
- onError: (errors) => {
1120
- const message = firstError(errors);
1121
- setLocalBackendMessage(message);
1122
- resolve({ message, ok: false });
1123
- },
1124
- onSuccess: () => resolve({ ok: true }),
1125
- preserveScroll: true,
1126
- });
1127
- });
1128
- };
 
 
 
1129
 
1130
  const handleQuestionSubmit = (event: FormEvent<HTMLFormElement>): void => {
1131
  event.preventDefault();
@@ -1145,6 +1078,8 @@ export default function MahasiswaChat({
1145
  sendMessage(trimmedQuestion, selectedCourseId, userMessage);
1146
  };
1147
 
 
 
1148
  return (
1149
  <>
1150
  <Head title={currentSession?.title ?? 'RAG Hub'} />
@@ -1169,10 +1104,7 @@ export default function MahasiswaChat({
1169
  <ChatMessage
1170
  key={message.uuid_id}
1171
  message={message}
1172
- shouldAnimateTyping={
1173
- message.uuid_id ===
1174
- animatedAssistantMessageId
1175
- }
1176
  />
1177
  ))}
1178
 
@@ -1209,7 +1141,6 @@ export default function MahasiswaChat({
1209
  selectedCourseId={selectedCourseId}
1210
  variant="dock"
1211
  />
1212
-
1213
  </div>
1214
  </div>
1215
  </StudentShell>
 
10
  useStoredStudentName,
11
  } from '@/components/student/student-shell';
12
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
13
+ import {
14
+ deleteChatSession,
15
+ getChatHistory,
16
+ getChatSession,
17
+ listChatSessions,
18
+ listCourses,
19
+ openStreamChatMessage,
20
+ type ChatMessageResponse,
21
+ type ChatSessionResponse,
22
+ type CourseResponse,
23
+ } from '@/lib/rag-client';
24
  import { createLocalAssistantMessage, createLocalUserMessage } from '@/lib/rag';
 
 
25
 
26
  type StoreMessageJsonResponse = {
27
  assistantMessage?: ChatMessageResponse;
 
45
  };
46
 
47
  type MahasiswaChatProps = {
 
 
 
 
 
 
 
 
 
48
  sessionId: string;
49
  };
50
 
51
+ function readAndClearInitialQuestion(
52
+ sessionId: string,
53
+ ): InitialQuestion | undefined {
54
+ try {
55
+ const stored = sessionStorage.getItem(`rag_init_${sessionId}`);
56
+
57
+ if (!stored) {
58
+ return undefined;
59
+ }
60
+
61
+ sessionStorage.removeItem(`rag_init_${sessionId}`);
62
+
63
+ return JSON.parse(stored) as InitialQuestion;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
  function isUserMessage(role: string): boolean {
70
  return ['human', 'student', 'user'].includes(role.toLowerCase());
71
  }
 
74
  return typeof value === 'object' && value !== null && !Array.isArray(value);
75
  }
76
 
 
 
 
 
 
 
 
 
77
  function firstPayloadError(value: unknown): string | undefined {
78
  if (typeof value === 'string') {
79
  return value;
 
128
  );
129
  }
130
 
 
 
 
 
 
 
 
 
131
  function isStreamDebugEnabled(): boolean {
132
  if (import.meta.env.DEV) {
133
  return true;
 
162
  console.log(`[rag-stream] ${label}`, value);
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  function isChatMessageResponse(value: unknown): value is ChatMessageResponse {
166
  return (
167
  isRecord(value) &&
 
406
  async function streamChatMessage(
407
  sessionId: string,
408
  content: string,
409
+ _courseId: string,
410
  onDelta: (delta: string) => void,
411
  ): Promise<StoreMessageJsonResponse> {
412
+ debugStream('request:start', { content, sessionId });
 
 
 
 
 
 
 
413
 
414
+ const response = await openStreamChatMessage(sessionId, content);
 
 
 
 
 
 
 
 
 
 
 
 
 
415
 
416
  debugStream('response', {
417
  contentType: response.headers.get('content-type'),
 
420
  statusText: response.statusText,
421
  });
422
 
 
 
 
 
 
 
 
 
 
 
423
  if (!response.body) {
424
  debugStream('response:no-body');
425
 
 
515
  }
516
 
517
  function formatMessageTime(value: string): string {
518
+ const normalized = /[Zz]|[+-]\d{2}:?\d{2}$/.test(value)
519
+ ? value
520
+ : value + 'Z';
521
  const date = new Date(normalized);
522
 
523
  if (Number.isNaN(date.getTime())) {
 
601
  <span className="student-message-time">
602
  {formatMessageTime(message.created_at)}
603
  </span>
 
604
  </div>
605
 
606
  {isUser && (
 
629
  const STREAM_TYPING_CHARS_PER_TICK = 2;
630
  const STREAM_TYPING_INTERVAL_MS = 14;
631
 
632
+ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
633
+ const initialQuestionRef = useRef<InitialQuestion | undefined>(
634
+ readAndClearInitialQuestion(sessionId),
635
+ );
 
 
 
 
 
 
 
 
 
636
  const hasStartedInitialQuestionRef = useRef(false);
 
637
  const streamingAssistantMessageRef = useRef<
638
  ChatMessageResponse | undefined
639
  >(undefined);
640
  const streamingTextQueueRef = useRef('');
641
  const streamingTypingTimerRef = useRef<number | undefined>(undefined);
642
  const streamingTypingWaitersRef = useRef<Array<() => void>>([]);
643
+
644
+ const [courses, setCourses] = useState<CourseResponse[]>([]);
645
+ const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
646
+ const [currentSession, setCurrentSession] =
647
+ useState<ChatSessionResponse | null>(null);
648
+ const [serverMessages, setServerMessages] = useState<ChatMessageResponse[]>(
649
+ [],
650
  );
651
  const [localBackendMessage, setLocalBackendMessage] = useState<string>();
 
 
652
  const [streamingAssistantMessageId, setStreamingAssistantMessageId] =
653
  useState<string>();
654
  const [isProcessing, setIsProcessing] = useState(false);
 
656
  ChatMessageResponse[]
657
  >([]);
658
  const [question, setQuestion] = useState('');
659
+ const [selectedCourseId, setSelectedCourseId] = useState('');
 
 
660
  const studentName = useStoredStudentName();
661
  const messagesEndRef = useRef<HTMLDivElement>(null);
662
+
663
  const messages = useMemo(
664
+ () => appendUniqueMessages(serverMessages, optimisticMessages),
665
+ [serverMessages, optimisticMessages],
666
  );
667
 
668
+ useEffect(() => {
669
+ listCourses()
670
+ .then((r) => setCourses(r.data))
671
+ .catch(() => {});
672
+
673
+ listChatSessions()
674
+ .then((r) => setChatSessions(r.data))
675
+ .catch(() => {});
676
+
677
+ getChatSession(sessionId)
678
+ .then((s) => {
679
+ setCurrentSession(s);
680
+ setSelectedCourseId(s.course_id);
681
+ })
682
+ .catch((e: Error) => setLocalBackendMessage(e.message));
683
+
684
+ getChatHistory(sessionId)
685
+ .then((r) => setServerMessages(r.data))
686
+ .catch(() => {});
687
+ }, [sessionId]);
688
+
689
  const composerCourses = useMemo(() => {
690
  if (
691
  !currentSession ||
 
704
  ];
705
  }, [courses, currentSession, selectedCourseId]);
706
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
707
  useEffect(() => {
708
  const scrollFrame = window.requestAnimationFrame(() => {
709
  messagesEndRef.current?.scrollIntoView({
 
714
  return () => window.cancelAnimationFrame(scrollFrame);
715
  }, [messages, isProcessing]);
716
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  const resolveStreamingTypingWaiters = useCallback((): void => {
718
  const waiters = streamingTypingWaitersRef.current;
719
  streamingTypingWaitersRef.current = [];
 
870
  optimisticUserMessage?: ChatMessageResponse,
871
  ): void => {
872
  setLocalBackendMessage(undefined);
 
873
  setIsProcessing(true);
874
+ setStreamingAssistantContent('');
875
 
876
  void (async () => {
877
  try {
878
+ const response = await streamChatMessage(
879
+ sessionId,
880
+ content,
881
+ courseId,
882
+ appendStreamingAssistantDelta,
883
+ );
884
 
885
+ await waitForStreamingTyping();
 
 
 
 
 
 
 
 
 
 
 
886
 
887
  if (response.assistantMessage) {
888
+ replaceStreamingAssistantMessage(
889
+ response.assistantMessage,
890
+ );
 
 
 
 
 
 
 
 
 
 
 
891
  }
892
 
893
  setQuestion('');
894
  } catch (error) {
895
  debugStream('request:error', error);
 
896
  const streamingMessageId =
897
  streamingAssistantMessageRef.current?.uuid_id;
898
  clearStreamingTyping();
 
956
  [
957
  appendStreamingAssistantDelta,
958
  clearStreamingTyping,
 
 
959
  replaceStreamingAssistantMessage,
960
  setStreamingAssistantContent,
961
  sessionId,
 
963
  ],
964
  );
965
 
966
+ useEffect(() => {
967
+ const initial = initialQuestionRef.current;
968
+
969
+ if (!initial || hasStartedInitialQuestionRef.current) {
970
+ return;
971
+ }
972
+
973
+ hasStartedInitialQuestionRef.current = true;
974
+
975
+ const userMessage = createLocalUserMessage(initial.content);
976
+ setOptimisticMessages([userMessage]);
977
+ setIsProcessing(true);
978
+ setStreamingAssistantContent('');
979
+
980
+ void (async () => {
981
+ try {
982
+ const response = await streamChatMessage(
983
+ sessionId,
984
+ initial.content,
985
+ initial.courseId,
986
+ appendStreamingAssistantDelta,
987
+ );
988
+
989
+ await waitForStreamingTyping();
990
+
991
+ if (response.assistantMessage) {
992
+ replaceStreamingAssistantMessage(response.assistantMessage);
993
+ }
994
+ } catch (error) {
995
+ const streamingMessageId =
996
+ streamingAssistantMessageRef.current?.uuid_id;
997
+ clearStreamingTyping();
998
+ streamingAssistantMessageRef.current = undefined;
999
+ setStreamingAssistantMessageId(undefined);
1000
+ setOptimisticMessages([]);
1001
+ setQuestion(initial.content);
1002
+ const errorMessage =
1003
+ error instanceof Error ? error.message : '';
1004
+ const isGenericError =
1005
+ !errorMessage ||
1006
+ errorMessage === 'Internal Server Error' ||
1007
+ errorMessage === 'Server Error';
1008
+
1009
+ if (streamingMessageId) {
1010
+ setOptimisticMessages((prev) =>
1011
+ prev.filter((m) => m.uuid_id !== streamingMessageId),
1012
+ );
1013
+ }
1014
+
1015
+ setLocalBackendMessage(
1016
+ isGenericError
1017
+ ? 'Gagal mengirim pesan. Silakan coba lagi.'
1018
+ : errorMessage,
1019
+ );
1020
+ } finally {
1021
+ const emptyStreamingMessage =
1022
+ streamingAssistantMessageRef.current?.content === ''
1023
+ ? streamingAssistantMessageRef.current
1024
+ : undefined;
1025
+ clearStreamingTyping();
1026
+ streamingAssistantMessageRef.current = undefined;
1027
+ setStreamingAssistantMessageId(undefined);
1028
+ setIsProcessing(false);
1029
+
1030
+ if (emptyStreamingMessage) {
1031
+ setOptimisticMessages((currentMessages) =>
1032
+ currentMessages.filter(
1033
+ (message) =>
1034
+ message.uuid_id !== emptyStreamingMessage.uuid_id,
1035
+ ),
1036
+ );
1037
+ }
1038
+ }
1039
+ })();
1040
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1041
+ }, [sessionId]);
1042
+
1043
  const handleDeleteSession = (
1044
  targetSessionId: string,
1045
+ ): Promise<DeleteSessionResult> =>
1046
+ deleteChatSession(targetSessionId)
1047
+ .then(() => {
1048
+ setChatSessions((prev) =>
1049
+ prev.filter((s) => s.uuid_id !== targetSessionId),
1050
+ );
1051
+
1052
+ if (targetSessionId === sessionId) {
1053
+ router.visit('/mahasiswa');
1054
+ }
1055
+
1056
+ return { ok: true } as DeleteSessionResult;
1057
+ })
1058
+ .catch((e: Error) => ({
1059
+ message: e.message,
1060
+ ok: false,
1061
+ }));
1062
 
1063
  const handleQuestionSubmit = (event: FormEvent<HTMLFormElement>): void => {
1064
  event.preventDefault();
 
1078
  sendMessage(trimmedQuestion, selectedCourseId, userMessage);
1079
  };
1080
 
1081
+ const backendMessage = localBackendMessage;
1082
+
1083
  return (
1084
  <>
1085
  <Head title={currentSession?.title ?? 'RAG Hub'} />
 
1104
  <ChatMessage
1105
  key={message.uuid_id}
1106
  message={message}
1107
+ shouldAnimateTyping={false}
 
 
 
1108
  />
1109
  ))}
1110
 
 
1141
  selectedCourseId={selectedCourseId}
1142
  variant="dock"
1143
  />
 
1144
  </div>
1145
  </div>
1146
  </StudentShell>
resources/js/pages/mahasiswa.tsx CHANGED
@@ -1,7 +1,7 @@
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
 
6
  import { QuestionComposer } from '@/components/student/question-composer';
7
  import type { DeleteSessionResult } from '@/components/student/student-shell';
@@ -10,59 +10,62 @@ import {
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';
16
-
17
- type MahasiswaProps = {
18
- backendMessage?: string | null;
19
- chatSessions: ChatSessionResponse[];
20
- courses: CourseResponse[];
21
- selectedCourseId?: string | null;
22
- };
23
-
24
- function firstError(
25
- errors: Record<string, string | string[]>,
26
- ): string | undefined {
27
- const [message] = Object.values(errors);
28
-
29
- return Array.isArray(message) ? message[0] : message;
30
- }
31
-
32
- export default function Mahasiswa({
33
- backendMessage: initialBackendMessage,
34
- chatSessions,
35
- courses,
36
- selectedCourseId: initialSelectedCourseId,
37
- }: MahasiswaProps) {
38
- const [localBackendMessage, setLocalBackendMessage] = useState<string>();
39
  const [isProcessing, setIsProcessing] = useState(false);
40
  const [question, setQuestion] = useState('');
41
- const [selectedCourseId, setSelectedCourseId] = useState(
42
- initialSelectedCourseId ??
43
- (courses[0]?.id === undefined ? '' : String(courses[0].id)),
44
- );
45
  const studentName = useStoredStudentName();
46
- const backendMessage = localBackendMessage ?? initialBackendMessage;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  const handleDeleteSession = (
49
  targetSessionId: string,
50
- ): Promise<DeleteSessionResult> => {
51
- return new Promise((resolve) => {
52
- router.delete(destroyMahasiswaSession.url(targetSessionId), {
53
- data: { active_session_id: '' },
54
- onError: (errors) => {
55
- const message = firstError(errors);
56
- setLocalBackendMessage(message);
57
- resolve({ message, ok: false });
58
- },
59
- onSuccess: () => resolve({ ok: true }),
60
- preserveScroll: true,
61
- });
62
- });
63
- };
64
-
65
- const handleQuestionSubmit = (event: FormEvent<HTMLFormElement>): void => {
 
66
  event.preventDefault();
67
 
68
  const trimmedQuestion = question.trim();
@@ -71,22 +74,30 @@ export default function Mahasiswa({
71
  return;
72
  }
73
 
74
- setLocalBackendMessage(undefined);
 
75
 
76
- router.post(
77
- storeMahasiswaSession.url(),
78
- {
79
  course_id: selectedCourseId,
80
  title: trimmedQuestion,
81
- },
82
- {
83
- onError: (errors) => {
84
- setLocalBackendMessage(firstError(errors));
85
- },
86
- onFinish: () => setIsProcessing(false),
87
- onStart: () => setIsProcessing(true),
88
- },
89
- );
 
 
 
 
 
 
 
 
90
  };
91
 
92
  return (
@@ -142,7 +153,7 @@ export default function Mahasiswa({
142
 
143
  <QuestionComposer
144
  courses={courses}
145
- isLoadingCourses={false}
146
  isProcessing={isProcessing}
147
  onCourseChange={setSelectedCourseId}
148
  onQuestionChange={setQuestion}
 
1
  import { Head, router } from '@inertiajs/react';
2
  import { Bot, 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 type { DeleteSessionResult } from '@/components/student/student-shell';
 
10
  useStoredStudentName,
11
  } from '@/components/student/student-shell';
12
  import { LoadingIndicator } from '@/components/ui/loading-indicator';
13
+ import {
14
+ createChatSession,
15
+ deleteChatSession,
16
+ listChatSessions,
17
+ listCourses,
18
+ type ChatSessionResponse,
19
+ type CourseResponse,
20
+ } from '@/lib/rag-client';
21
+ import { show as showMahasiswaChat } from '@/routes/mahasiswa';
22
+
23
+ export default function Mahasiswa() {
24
+ const [courses, setCourses] = useState<CourseResponse[]>([]);
25
+ const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
26
+ const [isLoadingCourses, setIsLoadingCourses] = useState(true);
27
+ 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
+ listCourses()
35
+ .then((r) => {
36
+ setCourses(r.data);
37
+
38
+ if (r.data[0] !== undefined) {
39
+ setSelectedCourseId(String(r.data[0].id));
40
+ }
41
+ })
42
+ .catch((e: Error) => setBackendMessage(e.message))
43
+ .finally(() => setIsLoadingCourses(false));
44
+
45
+ listChatSessions()
46
+ .then((r) => setChatSessions(r.data))
47
+ .catch(() => {});
48
+ }, []);
49
 
50
  const handleDeleteSession = (
51
  targetSessionId: string,
52
+ ): Promise<DeleteSessionResult> =>
53
+ deleteChatSession(targetSessionId)
54
+ .then(() => {
55
+ setChatSessions((prev) =>
56
+ prev.filter((s) => s.uuid_id !== targetSessionId),
57
+ );
58
+
59
+ return { ok: true } as DeleteSessionResult;
60
+ })
61
+ .catch((e: Error) => ({
62
+ message: e.message,
63
+ ok: false,
64
+ }));
65
+
66
+ const handleQuestionSubmit = async (
67
+ event: FormEvent<HTMLFormElement>,
68
+ ): Promise<void> => {
69
  event.preventDefault();
70
 
71
  const trimmedQuestion = question.trim();
 
74
  return;
75
  }
76
 
77
+ setBackendMessage(undefined);
78
+ setIsProcessing(true);
79
 
80
+ try {
81
+ const session = await createChatSession({
 
82
  course_id: selectedCourseId,
83
  title: trimmedQuestion,
84
+ });
85
+
86
+ sessionStorage.setItem(
87
+ `rag_init_${session.uuid_id}`,
88
+ JSON.stringify({
89
+ content: trimmedQuestion,
90
+ courseId: selectedCourseId,
91
+ }),
92
+ );
93
+
94
+ router.visit(showMahasiswaChat.url(session.uuid_id));
95
+ } catch (e) {
96
+ setBackendMessage(
97
+ e instanceof Error ? e.message : 'Gagal membuat sesi.',
98
+ );
99
+ setIsProcessing(false);
100
+ }
101
  };
102
 
103
  return (
 
153
 
154
  <QuestionComposer
155
  courses={courses}
156
+ isLoadingCourses={isLoadingCourses}
157
  isProcessing={isProcessing}
158
  onCourseChange={setSelectedCourseId}
159
  onQuestionChange={setQuestion}
resources/js/types/admin.ts CHANGED
@@ -60,19 +60,6 @@ export type GeneratedApiKey = {
60
  expires_at?: string | null;
61
  };
62
 
63
- export type AdminDashboardProps = {
64
- backendMessage?: string | null;
65
- generatedApiKey?: GeneratedApiKey | null;
66
- ragConfig?: RagConfig | null;
67
- userFilters?: Record<string, unknown>;
68
- users?: AdminUser[];
69
- };
70
-
71
- export type AdminConfigPageProps = {
72
- backendMessage?: string | null;
73
- ragConfig?: RagConfig | null;
74
- };
75
-
76
  export type LlmForm = {
77
  api_key_env: string;
78
  base_url: string;
 
60
  expires_at?: string | null;
61
  };
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  export type LlmForm = {
64
  api_key_env: string;
65
  base_url: string;
routes/web.php CHANGED
@@ -49,49 +49,21 @@
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');
55
-
56
- Route::patch('rag/retrieval', [AdminController::class, 'updateRetrievalConfig'])->name('rag.retrieval.update');
57
-
58
- Route::patch('rag/vector-db', [AdminController::class, 'updateVectorDbConfig'])->name('rag.vector-db.update');
59
-
60
- Route::post('users', [AdminController::class, 'storeUser'])->name('users.store');
61
-
62
- Route::patch('users/{userId}', [AdminController::class, 'updateUser'])->name('users.update');
63
-
64
- Route::delete('users/{userId}', [AdminController::class, 'destroyUser'])->name('users.destroy');
65
-
66
  Route::get('pengaturan', [AdminController::class, 'settings'])->name('pengaturan');
67
  });
68
 
69
  Route::middleware('rag.auth:student')->group(function (): void {
70
  Route::get('mahasiswa', [MahasiswaController::class, 'index'])->name('mahasiswa');
71
 
72
- Route::post('mahasiswa/sessions', [MahasiswaController::class, 'storeSession'])->name('mahasiswa.sessions.store');
73
-
74
  Route::get('mahasiswa/{sessionId}', [MahasiswaController::class, 'show'])->name('mahasiswa.show');
75
-
76
- Route::post('mahasiswa/{sessionId}/messages', [MahasiswaController::class, 'storeMessage'])->name('mahasiswa.messages.store');
77
-
78
- Route::delete('mahasiswa/{sessionId}', [MahasiswaController::class, 'destroySession'])->name('mahasiswa.destroy');
79
  });
80
 
81
  Route::middleware('rag.auth:lecturer')->group(function (): void {
82
  Route::get('dosen', [DosenController::class, 'index'])->name('dosen');
83
 
84
- Route::post('dosen/courses', [DosenController::class, 'storeCourse'])->name('dosen.courses.store');
85
-
86
  Route::inertia('dosen/pengaturan', 'dosen/pengaturan')->name('dosen.pengaturan');
87
 
88
- Route::post('dosen/{courseId}/documents', [DosenController::class, 'uploadDocument'])->name('dosen.documents.store');
89
-
90
  Route::get('dosen/{courseId}', [DosenController::class, 'show'])->name('dosen.show');
91
-
92
- Route::put('dosen/{courseId}', [DosenController::class, 'updateCourse'])->name('dosen.update');
93
-
94
- Route::delete('dosen/{courseId}', [DosenController::class, 'destroyCourse'])->name('dosen.destroy');
95
  });
96
 
97
  require __DIR__.'/settings.php';
 
49
 
50
  Route::get('api-keys', [AdminController::class, 'apiKeys'])->name('api-keys.index');
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  Route::get('pengaturan', [AdminController::class, 'settings'])->name('pengaturan');
53
  });
54
 
55
  Route::middleware('rag.auth:student')->group(function (): void {
56
  Route::get('mahasiswa', [MahasiswaController::class, 'index'])->name('mahasiswa');
57
 
 
 
58
  Route::get('mahasiswa/{sessionId}', [MahasiswaController::class, 'show'])->name('mahasiswa.show');
 
 
 
 
59
  });
60
 
61
  Route::middleware('rag.auth:lecturer')->group(function (): void {
62
  Route::get('dosen', [DosenController::class, 'index'])->name('dosen');
63
 
 
 
64
  Route::inertia('dosen/pengaturan', 'dosen/pengaturan')->name('dosen.pengaturan');
65
 
 
 
66
  Route::get('dosen/{courseId}', [DosenController::class, 'show'])->name('dosen.show');
 
 
 
 
67
  });
68
 
69
  require __DIR__.'/settings.php';
sevima-raghub.json CHANGED
@@ -267,6 +267,28 @@
267
  },
268
  "name": "course_id",
269
  "in": "path"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }
271
  ],
272
  "responses": {
@@ -1083,6 +1105,28 @@
1083
  },
1084
  "name": "session_id",
1085
  "in": "path"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1086
  }
1087
  ],
1088
  "responses": {
@@ -1495,6 +1539,300 @@
1495
  }
1496
  ]
1497
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1498
  }
1499
  },
1500
  "components": {
@@ -1842,10 +2180,13 @@
1842
  },
1843
  "type": "array",
1844
  "title": "Data"
 
 
 
1845
  }
1846
  },
1847
  "type": "object",
1848
- "required": ["data"],
1849
  "title": "DocumentListResponse",
1850
  "description": "GET /courses/{course_id}/documents — 200 response wrapper."
1851
  },
@@ -1970,6 +2311,63 @@
1970
  "type": "object",
1971
  "title": "FindUserResult"
1972
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1973
  "HTTPValidationError": {
1974
  "properties": {
1975
  "detail": {
@@ -2443,6 +2841,30 @@
2443
  "type": "object",
2444
  "title": "UpsertUser"
2445
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2446
  "User": {
2447
  "properties": {
2448
  "email": {
 
267
  },
268
  "name": "course_id",
269
  "in": "path"
270
+ },
271
+ {
272
+ "required": false,
273
+ "schema": {
274
+ "type": "integer",
275
+ "minimum": 1.0,
276
+ "title": "Page",
277
+ "default": 1
278
+ },
279
+ "name": "page",
280
+ "in": "query"
281
+ },
282
+ {
283
+ "required": false,
284
+ "schema": {
285
+ "type": "integer",
286
+ "minimum": 1.0,
287
+ "title": "Limit",
288
+ "default": 10
289
+ },
290
+ "name": "limit",
291
+ "in": "query"
292
  }
293
  ],
294
  "responses": {
 
1105
  },
1106
  "name": "session_id",
1107
  "in": "path"
1108
+ },
1109
+ {
1110
+ "required": false,
1111
+ "schema": {
1112
+ "type": "integer",
1113
+ "minimum": 1.0,
1114
+ "title": "Page",
1115
+ "default": 1
1116
+ },
1117
+ "name": "page",
1118
+ "in": "query"
1119
+ },
1120
+ {
1121
+ "required": false,
1122
+ "schema": {
1123
+ "type": "integer",
1124
+ "minimum": 1.0,
1125
+ "title": "Limit",
1126
+ "default": 20
1127
+ },
1128
+ "name": "limit",
1129
+ "in": "query"
1130
  }
1131
  ],
1132
  "responses": {
 
1539
  }
1540
  ]
1541
  }
1542
+ },
1543
+ "/api/v1/auth/api-key/generate": {
1544
+ "post": {
1545
+ "tags": ["v1", "auth-iframe"],
1546
+ "summary": "Generate Api Key",
1547
+ "description": "Generate API key for Iframe SSO.\nValidates source domain against whitelist.\n\nUser data fields (email, username, phone, name, identity_number) are passed\nbut only email, username, and name are stored in the User table.",
1548
+ "operationId": "generate_api_key_api_v1_auth_api_key_generate_post",
1549
+ "requestBody": {
1550
+ "content": {
1551
+ "application/json": {
1552
+ "schema": {
1553
+ "$ref": "#/components/schemas/GenerateAPIKeyRequest"
1554
+ }
1555
+ }
1556
+ },
1557
+ "required": true
1558
+ },
1559
+ "responses": {
1560
+ "200": {
1561
+ "description": "Successful Response",
1562
+ "content": {
1563
+ "application/json": {
1564
+ "schema": {
1565
+ "$ref": "#/components/schemas/GenerateAPIKeyResponse"
1566
+ }
1567
+ }
1568
+ }
1569
+ },
1570
+ "422": {
1571
+ "description": "Validation Error",
1572
+ "content": {
1573
+ "application/json": {
1574
+ "schema": {
1575
+ "$ref": "#/components/schemas/HTTPValidationError"
1576
+ }
1577
+ }
1578
+ }
1579
+ }
1580
+ }
1581
+ }
1582
+ },
1583
+ "/api/v1/auth/api-key/verify": {
1584
+ "post": {
1585
+ "tags": ["v1", "auth-iframe"],
1586
+ "summary": "Verify Api Key And Login",
1587
+ "description": "Verify API key and create JWT token for Iframe.\nStores token in secure HTTP-only cookie.",
1588
+ "operationId": "verify_api_key_and_login_api_v1_auth_api_key_verify_post",
1589
+ "parameters": [
1590
+ {
1591
+ "required": true,
1592
+ "schema": {
1593
+ "type": "string",
1594
+ "title": "Api Key"
1595
+ },
1596
+ "name": "api_key",
1597
+ "in": "query"
1598
+ }
1599
+ ],
1600
+ "responses": {
1601
+ "200": {
1602
+ "description": "Successful Response",
1603
+ "content": {
1604
+ "application/json": {
1605
+ "schema": {}
1606
+ }
1607
+ }
1608
+ },
1609
+ "422": {
1610
+ "description": "Validation Error",
1611
+ "content": {
1612
+ "application/json": {
1613
+ "schema": {
1614
+ "$ref": "#/components/schemas/HTTPValidationError"
1615
+ }
1616
+ }
1617
+ }
1618
+ }
1619
+ }
1620
+ }
1621
+ },
1622
+ "/api/v1/auth/iframe/status": {
1623
+ "get": {
1624
+ "tags": ["v1", "auth-iframe"],
1625
+ "summary": "Check Iframe Login Status",
1626
+ "description": "Check if user is already logged in via Iframe.\nReturns user info if logged in.",
1627
+ "operationId": "check_iframe_login_status_api_v1_auth_iframe_status_get",
1628
+ "responses": {
1629
+ "200": {
1630
+ "description": "Successful Response",
1631
+ "content": {
1632
+ "application/json": {
1633
+ "schema": {}
1634
+ }
1635
+ }
1636
+ }
1637
+ },
1638
+ "security": [
1639
+ {
1640
+ "JWTBearer": []
1641
+ }
1642
+ ]
1643
+ }
1644
+ },
1645
+ "/api/v1/auth/whitelist": {
1646
+ "get": {
1647
+ "tags": ["v1", "auth-iframe"],
1648
+ "summary": "Get Whitelist",
1649
+ "description": "List all whitelist entries (Admin only)",
1650
+ "operationId": "get_whitelist_api_v1_auth_whitelist_get",
1651
+ "parameters": [
1652
+ {
1653
+ "required": false,
1654
+ "schema": {
1655
+ "type": "integer",
1656
+ "title": "Page",
1657
+ "default": 1
1658
+ },
1659
+ "name": "page",
1660
+ "in": "query"
1661
+ },
1662
+ {
1663
+ "required": false,
1664
+ "schema": {
1665
+ "type": "integer",
1666
+ "title": "Page Size",
1667
+ "default": 100
1668
+ },
1669
+ "name": "page_size",
1670
+ "in": "query"
1671
+ }
1672
+ ],
1673
+ "responses": {
1674
+ "200": {
1675
+ "description": "Successful Response",
1676
+ "content": {
1677
+ "application/json": {
1678
+ "schema": {}
1679
+ }
1680
+ }
1681
+ },
1682
+ "422": {
1683
+ "description": "Validation Error",
1684
+ "content": {
1685
+ "application/json": {
1686
+ "schema": {
1687
+ "$ref": "#/components/schemas/HTTPValidationError"
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+ },
1693
+ "security": [
1694
+ {
1695
+ "JWTBearer": []
1696
+ }
1697
+ ]
1698
+ },
1699
+ "post": {
1700
+ "tags": ["v1", "auth-iframe"],
1701
+ "summary": "Add Whitelist",
1702
+ "description": "Add new domain to whitelist (Admin only)",
1703
+ "operationId": "add_whitelist_api_v1_auth_whitelist_post",
1704
+ "requestBody": {
1705
+ "content": {
1706
+ "application/json": {
1707
+ "schema": {
1708
+ "$ref": "#/components/schemas/UpsertWhitelist"
1709
+ }
1710
+ }
1711
+ },
1712
+ "required": true
1713
+ },
1714
+ "responses": {
1715
+ "200": {
1716
+ "description": "Successful Response",
1717
+ "content": {
1718
+ "application/json": {
1719
+ "schema": {}
1720
+ }
1721
+ }
1722
+ },
1723
+ "422": {
1724
+ "description": "Validation Error",
1725
+ "content": {
1726
+ "application/json": {
1727
+ "schema": {
1728
+ "$ref": "#/components/schemas/HTTPValidationError"
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+ },
1734
+ "security": [
1735
+ {
1736
+ "JWTBearer": []
1737
+ }
1738
+ ]
1739
+ }
1740
+ },
1741
+ "/api/v1/auth/whitelist/{whitelist_id}": {
1742
+ "delete": {
1743
+ "tags": ["v1", "auth-iframe"],
1744
+ "summary": "Delete Whitelist",
1745
+ "description": "Delete whitelist entry (Admin only)",
1746
+ "operationId": "delete_whitelist_api_v1_auth_whitelist__whitelist_id__delete",
1747
+ "parameters": [
1748
+ {
1749
+ "required": true,
1750
+ "schema": {
1751
+ "type": "integer",
1752
+ "title": "Whitelist Id"
1753
+ },
1754
+ "name": "whitelist_id",
1755
+ "in": "path"
1756
+ }
1757
+ ],
1758
+ "responses": {
1759
+ "200": {
1760
+ "description": "Successful Response",
1761
+ "content": {
1762
+ "application/json": {
1763
+ "schema": {}
1764
+ }
1765
+ }
1766
+ },
1767
+ "422": {
1768
+ "description": "Validation Error",
1769
+ "content": {
1770
+ "application/json": {
1771
+ "schema": {
1772
+ "$ref": "#/components/schemas/HTTPValidationError"
1773
+ }
1774
+ }
1775
+ }
1776
+ }
1777
+ },
1778
+ "security": [
1779
+ {
1780
+ "JWTBearer": []
1781
+ }
1782
+ ]
1783
+ },
1784
+ "patch": {
1785
+ "tags": ["v1", "auth-iframe"],
1786
+ "summary": "Update Whitelist",
1787
+ "description": "Update whitelist entry (Admin only)",
1788
+ "operationId": "update_whitelist_api_v1_auth_whitelist__whitelist_id__patch",
1789
+ "parameters": [
1790
+ {
1791
+ "required": true,
1792
+ "schema": {
1793
+ "type": "integer",
1794
+ "title": "Whitelist Id"
1795
+ },
1796
+ "name": "whitelist_id",
1797
+ "in": "path"
1798
+ }
1799
+ ],
1800
+ "requestBody": {
1801
+ "content": {
1802
+ "application/json": {
1803
+ "schema": {
1804
+ "$ref": "#/components/schemas/UpsertWhitelist"
1805
+ }
1806
+ }
1807
+ },
1808
+ "required": true
1809
+ },
1810
+ "responses": {
1811
+ "200": {
1812
+ "description": "Successful Response",
1813
+ "content": {
1814
+ "application/json": {
1815
+ "schema": {}
1816
+ }
1817
+ }
1818
+ },
1819
+ "422": {
1820
+ "description": "Validation Error",
1821
+ "content": {
1822
+ "application/json": {
1823
+ "schema": {
1824
+ "$ref": "#/components/schemas/HTTPValidationError"
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1829
+ },
1830
+ "security": [
1831
+ {
1832
+ "JWTBearer": []
1833
+ }
1834
+ ]
1835
+ }
1836
  }
1837
  },
1838
  "components": {
 
2180
  },
2181
  "type": "array",
2182
  "title": "Data"
2183
+ },
2184
+ "pagination": {
2185
+ "$ref": "#/components/schemas/PaginationDTO"
2186
  }
2187
  },
2188
  "type": "object",
2189
+ "required": ["data", "pagination"],
2190
  "title": "DocumentListResponse",
2191
  "description": "GET /courses/{course_id}/documents — 200 response wrapper."
2192
  },
 
2311
  "type": "object",
2312
  "title": "FindUserResult"
2313
  },
2314
+ "GenerateAPIKeyRequest": {
2315
+ "properties": {
2316
+ "email": {
2317
+ "type": "string",
2318
+ "title": "Email"
2319
+ },
2320
+ "username": {
2321
+ "type": "string",
2322
+ "title": "Username"
2323
+ },
2324
+ "phone": {
2325
+ "type": "string",
2326
+ "title": "Phone"
2327
+ },
2328
+ "name": {
2329
+ "type": "string",
2330
+ "title": "Name"
2331
+ },
2332
+ "identity_number": {
2333
+ "type": "string",
2334
+ "title": "Identity Number"
2335
+ },
2336
+ "source_domain": {
2337
+ "type": "string",
2338
+ "title": "Source Domain"
2339
+ },
2340
+ "role": {
2341
+ "$ref": "#/components/schemas/app__schema__auth_schema__Role"
2342
+ }
2343
+ },
2344
+ "type": "object",
2345
+ "required": [
2346
+ "email",
2347
+ "username",
2348
+ "phone",
2349
+ "name",
2350
+ "identity_number",
2351
+ "source_domain",
2352
+ "role"
2353
+ ],
2354
+ "title": "GenerateAPIKeyRequest"
2355
+ },
2356
+ "GenerateAPIKeyResponse": {
2357
+ "properties": {
2358
+ "api_key": {
2359
+ "type": "string",
2360
+ "title": "Api Key"
2361
+ },
2362
+ "message": {
2363
+ "type": "string",
2364
+ "title": "Message"
2365
+ }
2366
+ },
2367
+ "type": "object",
2368
+ "required": ["api_key", "message"],
2369
+ "title": "GenerateAPIKeyResponse"
2370
+ },
2371
  "HTTPValidationError": {
2372
  "properties": {
2373
  "detail": {
 
2841
  "type": "object",
2842
  "title": "UpsertUser"
2843
  },
2844
+ "UpsertWhitelist": {
2845
+ "properties": {
2846
+ "source_type": {
2847
+ "type": "string",
2848
+ "title": "Source Type"
2849
+ },
2850
+ "value": {
2851
+ "type": "string",
2852
+ "title": "Value"
2853
+ },
2854
+ "description": {
2855
+ "type": "string",
2856
+ "title": "Description"
2857
+ },
2858
+ "is_active": {
2859
+ "type": "boolean",
2860
+ "title": "Is Active",
2861
+ "default": true
2862
+ }
2863
+ },
2864
+ "type": "object",
2865
+ "required": ["source_type", "value"],
2866
+ "title": "UpsertWhitelist"
2867
+ },
2868
  "User": {
2869
  "properties": {
2870
  "email": {