Spaces:
Sleeping
Sleeping
change: consume api in resources
Browse files- app/Http/Controllers/AdminController.php +14 -456
- app/Http/Controllers/DosenController.php +3 -299
- app/Http/Controllers/MahasiswaController.php +3 -438
- resources/js/components/admin/admin-api-key-card.tsx +28 -19
- resources/js/components/admin/admin-form-parts.tsx +6 -11
- resources/js/components/admin/admin-llm-form.tsx +86 -62
- resources/js/components/admin/admin-user-management.tsx +193 -104
- resources/js/components/admin/admin-vector-retrieval-forms.tsx +167 -319
- resources/js/lib/rag-client.ts +325 -0
- resources/js/pages/admin/api-keys.tsx +2 -7
- resources/js/pages/admin/dashboard.tsx +23 -7
- resources/js/pages/admin/llm-config.tsx +17 -6
- resources/js/pages/admin/rag-config.tsx +17 -6
- resources/js/pages/admin/retrieval-config.tsx +17 -6
- resources/js/pages/admin/vector-retrieval-config.tsx +26 -9
- resources/js/pages/admin/vectordb-config.tsx +17 -6
- resources/js/pages/dosen-detail.tsx +118 -110
- resources/js/pages/dosen.tsx +304 -238
- resources/js/pages/mahasiswa-chat.tsx +179 -248
- resources/js/pages/mahasiswa.tsx +73 -62
- resources/js/types/admin.ts +0 -13
- routes/web.php +0 -28
- sevima-raghub.json +423 -1
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 |
-
|
| 15 |
-
|
| 16 |
-
public function index(Request $request): Response
|
| 17 |
{
|
| 18 |
-
|
| 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(
|
| 40 |
{
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
return Inertia::render('admin/rag-config', $configPayload);
|
| 44 |
}
|
| 45 |
|
| 46 |
-
public function llmConfig(
|
| 47 |
{
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
return Inertia::render('admin/llm-config', $configPayload);
|
| 51 |
}
|
| 52 |
|
| 53 |
-
public function vectorRetrievalConfig(
|
| 54 |
{
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
return Inertia::render('admin/vector-retrieval-config', $configPayload);
|
| 58 |
}
|
| 59 |
|
| 60 |
-
public function vectorDbConfig(
|
| 61 |
{
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
return Inertia::render('admin/vectordb-config', $configPayload);
|
| 65 |
}
|
| 66 |
|
| 67 |
-
public function retrievalConfig(
|
| 68 |
{
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
return Inertia::render('admin/retrieval-config', $configPayload);
|
| 72 |
}
|
| 73 |
|
| 74 |
-
public function apiKeys(
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
| 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 {
|
| 19 |
import type { GeneratedApiKey } from '@/types/admin';
|
| 20 |
|
| 21 |
-
export function AdminApiKeyCard({
|
| 22 |
-
generatedApiKey,
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
const apiKeyForm = useForm({});
|
| 27 |
|
| 28 |
-
function handleGenerateApiKey(): void {
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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={
|
| 55 |
-
onClick={
|
|
|
|
|
|
|
| 56 |
type="button"
|
| 57 |
>
|
| 58 |
-
{
|
| 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={
|
| 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 |
-
|
| 35 |
step = '1',
|
|
|
|
| 36 |
}: {
|
| 37 |
-
form: InertiaFormProps<RetrievalForm>;
|
| 38 |
label: string;
|
| 39 |
-
|
| 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={
|
| 49 |
-
onChange={(event) =>
|
| 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 {
|
|
|
|
| 12 |
import type { LlmConfig, LlmForm } from '@/types/admin';
|
| 13 |
|
| 14 |
export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) {
|
| 15 |
-
const
|
| 16 |
-
const
|
| 17 |
-
api_key_env: stringValue(
|
| 18 |
-
base_url: stringValue(
|
| 19 |
-
max_tokens: stringValue(
|
| 20 |
-
model: stringValue(
|
| 21 |
-
provider: stringValue(
|
| 22 |
-
reasoning_effort: stringValue(
|
| 23 |
-
request_timeout: stringValue(
|
| 24 |
-
system_prompt: stringValue(
|
| 25 |
-
temperature: stringValue(
|
| 26 |
});
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
event.preventDefault();
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
return (
|
| 34 |
<Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
|
| 35 |
<CardContent className="pt-6">
|
| 36 |
-
<form
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
<div className="grid gap-3 md:grid-cols-2">
|
| 40 |
<Field label="Provider">
|
| 41 |
<Input
|
| 42 |
-
value={
|
| 43 |
onChange={(event) =>
|
| 44 |
-
|
| 45 |
-
'provider',
|
| 46 |
-
event.target.value,
|
| 47 |
-
)
|
| 48 |
}
|
| 49 |
/>
|
| 50 |
</Field>
|
| 51 |
<Field label="Model">
|
| 52 |
<Input
|
| 53 |
-
value={
|
| 54 |
onChange={(event) =>
|
| 55 |
-
|
| 56 |
-
'model',
|
| 57 |
-
event.target.value,
|
| 58 |
-
)
|
| 59 |
}
|
| 60 |
/>
|
| 61 |
</Field>
|
| 62 |
<Field label="Base URL">
|
| 63 |
<Input
|
| 64 |
-
value={
|
| 65 |
onChange={(event) =>
|
| 66 |
-
|
| 67 |
-
'base_url',
|
| 68 |
-
event.target.value,
|
| 69 |
-
)
|
| 70 |
}
|
| 71 |
/>
|
| 72 |
</Field>
|
| 73 |
<Field label="API Key Env">
|
| 74 |
<Input
|
| 75 |
-
value={
|
| 76 |
onChange={(event) =>
|
| 77 |
-
|
| 78 |
-
'api_key_env',
|
| 79 |
-
event.target.value,
|
| 80 |
-
)
|
| 81 |
}
|
| 82 |
/>
|
| 83 |
</Field>
|
| 84 |
<Field label="Reasoning Effort">
|
| 85 |
<Input
|
| 86 |
-
value={
|
| 87 |
onChange={(event) =>
|
| 88 |
-
|
| 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={
|
| 101 |
onChange={(event) =>
|
| 102 |
-
|
| 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={
|
| 114 |
onChange={(event) =>
|
| 115 |
-
|
| 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={
|
| 127 |
onChange={(event) =>
|
| 128 |
-
|
| 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={
|
| 141 |
onChange={(event) =>
|
| 142 |
-
|
| 143 |
-
'system_prompt',
|
| 144 |
-
event.target.value,
|
| 145 |
-
)
|
| 146 |
}
|
| 147 |
/>
|
| 148 |
</Field>
|
| 149 |
|
| 150 |
<div className="flex justify-end">
|
| 151 |
-
<Button disabled={
|
| 152 |
-
{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
function
|
|
|
|
|
|
|
| 62 |
event.preventDefault();
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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-
|
| 74 |
Tambah User
|
| 75 |
</CardTitle>
|
| 76 |
<CardDescription>
|
|
@@ -79,9 +102,14 @@ export function AdminUserCreateCard() {
|
|
| 79 |
</CardHeader>
|
| 80 |
<CardContent>
|
| 81 |
<UserFormFields
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
| 84 |
submitLabel="Buat User"
|
|
|
|
|
|
|
|
|
|
| 85 |
/>
|
| 86 |
</CardContent>
|
| 87 |
</Card>
|
|
@@ -89,26 +117,22 @@ export function AdminUserCreateCard() {
|
|
| 89 |
}
|
| 90 |
|
| 91 |
export function AdminUserManagement({
|
| 92 |
-
|
| 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
|
| 102 |
-
const
|
| 103 |
-
const
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
});
|
| 129 |
}
|
| 130 |
|
| 131 |
function openEditUserDialog(user: AdminUser): void {
|
| 132 |
setEditingUser(user);
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
-
function handleUpdateUser(
|
|
|
|
|
|
|
| 138 |
event.preventDefault();
|
| 139 |
|
| 140 |
if (!editingUser) {
|
| 141 |
return;
|
| 142 |
}
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
-
function handleDestroyUser(): void {
|
| 151 |
if (!deletingUser) {
|
| 152 |
return;
|
| 153 |
}
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 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={
|
|
|
|
|
|
|
| 177 |
>
|
| 178 |
<Input
|
| 179 |
placeholder="Cari nama..."
|
| 180 |
-
value={filterForm.
|
| 181 |
onChange={(event) =>
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
event.target.value,
|
| 185 |
-
)
|
| 186 |
}
|
| 187 |
/>
|
| 188 |
<Input
|
| 189 |
placeholder="Cari email..."
|
| 190 |
-
value={filterForm.
|
| 191 |
onChange={(event) =>
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
event.target.value,
|
| 195 |
-
)
|
| 196 |
}
|
| 197 |
/>
|
| 198 |
<Select
|
| 199 |
-
value={filterForm.
|
| 200 |
onValueChange={(value) =>
|
| 201 |
-
|
| 202 |
-
|
| 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 |
-
{
|
| 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-
|
| 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-
|
| 276 |
: ''
|
| 277 |
}
|
| 278 |
variant={
|
|
@@ -357,15 +421,22 @@ export function AdminUserManagement({
|
|
| 357 |
</DialogHeader>
|
| 358 |
|
| 359 |
<UserFormFields
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
submitLabel="Simpan User"
|
|
|
|
|
|
|
|
|
|
| 363 |
/>
|
| 364 |
</DialogContent>
|
| 365 |
</Dialog>
|
| 366 |
|
| 367 |
<Dialog
|
| 368 |
-
onOpenChange={(open) =>
|
|
|
|
|
|
|
| 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={
|
| 390 |
-
onClick={
|
|
|
|
|
|
|
| 391 |
type="button"
|
| 392 |
>
|
| 393 |
-
{
|
| 394 |
<Spinner className="size-4" />
|
| 395 |
) : null}
|
| 396 |
Hapus
|
|
@@ -402,61 +475,79 @@ export function AdminUserManagement({
|
|
| 402 |
);
|
| 403 |
}
|
| 404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
function UserFormFields({
|
| 406 |
-
|
|
|
|
|
|
|
| 407 |
onSubmit,
|
|
|
|
| 408 |
submitLabel,
|
| 409 |
}: {
|
| 410 |
-
|
|
|
|
|
|
|
| 411 |
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
|
|
|
| 412 |
submitLabel: string;
|
| 413 |
}) {
|
| 414 |
return (
|
| 415 |
<form className="grid gap-4" onSubmit={onSubmit}>
|
| 416 |
-
<ConfigError message={
|
| 417 |
|
| 418 |
<div className="grid gap-3 md:grid-cols-2">
|
| 419 |
<Field label="Nama">
|
| 420 |
<Input
|
| 421 |
-
value={
|
| 422 |
onChange={(event) =>
|
| 423 |
-
|
| 424 |
}
|
| 425 |
/>
|
| 426 |
</Field>
|
| 427 |
<Field label="Email">
|
| 428 |
<Input
|
| 429 |
type="email"
|
| 430 |
-
value={
|
| 431 |
onChange={(event) =>
|
| 432 |
-
|
| 433 |
}
|
| 434 |
/>
|
| 435 |
</Field>
|
| 436 |
<Field label="Nomor Identitas">
|
| 437 |
<Input
|
| 438 |
-
value={
|
| 439 |
onChange={(event) =>
|
| 440 |
-
|
| 441 |
-
'identity_number',
|
| 442 |
-
event.target.value,
|
| 443 |
-
)
|
| 444 |
}
|
| 445 |
/>
|
| 446 |
</Field>
|
| 447 |
<Field label="Telepon">
|
| 448 |
<Input
|
| 449 |
-
value={
|
| 450 |
onChange={(event) =>
|
| 451 |
-
|
| 452 |
}
|
| 453 |
/>
|
| 454 |
</Field>
|
| 455 |
<Field label="Role">
|
| 456 |
<Select
|
| 457 |
-
value={
|
| 458 |
onValueChange={(value) =>
|
| 459 |
-
|
| 460 |
}
|
| 461 |
>
|
| 462 |
<SelectTrigger className="w-full">
|
|
@@ -471,9 +562,9 @@ function UserFormFields({
|
|
| 471 |
</Field>
|
| 472 |
<Field label="User Token">
|
| 473 |
<Input
|
| 474 |
-
value={
|
| 475 |
onChange={(event) =>
|
| 476 |
-
|
| 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={
|
| 486 |
onCheckedChange={(value) =>
|
| 487 |
-
|
| 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={
|
| 495 |
onCheckedChange={(value) =>
|
| 496 |
-
|
| 497 |
}
|
| 498 |
/>
|
| 499 |
Superuser
|
|
@@ -501,8 +592,8 @@ function UserFormFields({
|
|
| 501 |
</div>
|
| 502 |
|
| 503 |
<div className="flex justify-end">
|
| 504 |
-
<Button disabled={
|
| 505 |
-
{
|
| 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 {
|
|
|
|
| 20 |
import type {
|
| 21 |
RetrievalConfig,
|
| 22 |
RetrievalForm,
|
|
@@ -29,36 +26,72 @@ export function AdminVectorDbForm({
|
|
| 29 |
}: {
|
| 30 |
vectorDb?: VectorDbConfig | null;
|
| 31 |
}) {
|
| 32 |
-
const
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
persist_path: stringValue(vectorDbConfig.persist_path),
|
| 39 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
function
|
|
|
|
|
|
|
| 42 |
event.preventDefault();
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
return (
|
| 49 |
<Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
|
| 50 |
<CardContent className="pt-6">
|
| 51 |
-
<form
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
<div className="grid gap-3 md:grid-cols-2">
|
| 57 |
<Field label="Embedding Model">
|
| 58 |
<Input
|
| 59 |
-
value={
|
| 60 |
onChange={(event) =>
|
| 61 |
-
|
| 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={
|
| 71 |
onChange={(event) =>
|
| 72 |
-
|
| 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={
|
| 84 |
onChange={(event) =>
|
| 85 |
-
|
| 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={
|
| 97 |
onChange={(event) =>
|
| 98 |
-
|
| 99 |
-
'chunk_overlap',
|
| 100 |
-
event.target.value,
|
| 101 |
-
)
|
| 102 |
}
|
| 103 |
/>
|
| 104 |
</Field>
|
| 105 |
</div>
|
| 106 |
|
| 107 |
<div className="flex justify-end">
|
| 108 |
-
<Button
|
| 109 |
-
|
| 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
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
top_k: stringValue(retrievalConfig.top_k),
|
| 145 |
});
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
event.preventDefault();
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
return (
|
| 155 |
<Card className="border-(--lecturer-border) bg-(--lecturer-surface)">
|
| 156 |
<CardContent className="pt-6">
|
| 157 |
-
<form
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
<div className="grid gap-3 md:grid-cols-2">
|
| 163 |
<RetrievalNumberField
|
| 164 |
-
form={retrievalForm}
|
| 165 |
label="Top K"
|
| 166 |
-
|
|
|
|
| 167 |
/>
|
| 168 |
<RetrievalNumberField
|
| 169 |
-
form={retrievalForm}
|
| 170 |
label="Max Context Chars"
|
| 171 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 201 |
/>
|
| 202 |
<RetrievalNumberField
|
| 203 |
-
form={retrievalForm}
|
| 204 |
label="Candidate Pool"
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
| 206 |
/>
|
| 207 |
<RetrievalNumberField
|
| 208 |
-
form={retrievalForm}
|
| 209 |
label="Neighbor Window"
|
| 210 |
-
|
|
|
|
| 211 |
/>
|
| 212 |
<Field label="Reranker Model">
|
| 213 |
<Input
|
| 214 |
-
value={
|
| 215 |
onChange={(event) =>
|
| 216 |
-
|
| 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={
|
| 228 |
onCheckedChange={(value) =>
|
| 229 |
-
|
| 230 |
-
'enable_reranker',
|
| 231 |
-
value === true,
|
| 232 |
-
)
|
| 233 |
}
|
| 234 |
/>
|
| 235 |
Enable reranker
|
| 236 |
</label>
|
| 237 |
|
| 238 |
<div className="flex justify-end">
|
| 239 |
-
<Button
|
| 240 |
-
|
| 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 |
-
<
|
| 305 |
-
|
| 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 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 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
|
| 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 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
type CourseResponse
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 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 |
-
|
| 192 |
-
|
| 193 |
-
|
| 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 |
-
|
| 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 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
}, 7000);
|
| 228 |
|
| 229 |
return () => window.clearInterval(intervalId);
|
| 230 |
-
}, [hasProcessingDocuments]);
|
| 231 |
|
| 232 |
-
function handleUploadDocument(file: File): void {
|
| 233 |
setUploadMessage(undefined);
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 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 |
-
|
| 261 |
|
| 262 |
if (open) {
|
| 263 |
-
|
| 264 |
description: course?.description ?? '',
|
| 265 |
title: course?.title ?? '',
|
| 266 |
});
|
| 267 |
}
|
| 268 |
}
|
| 269 |
|
| 270 |
-
function handleUpdateCourseSubmit(
|
|
|
|
|
|
|
| 271 |
event.preventDefault();
|
| 272 |
|
| 273 |
-
editForm.
|
| 274 |
-
|
| 275 |
-
|
| 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 |
-
|
| 290 |
-
|
| 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={
|
|
|
|
|
|
|
| 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 |
-
|
| 486 |
-
|
| 487 |
-
event.target.value,
|
| 488 |
-
)
|
| 489 |
}
|
| 490 |
placeholder="Masukkan nama mata kuliah"
|
| 491 |
-
value={editForm.
|
| 492 |
/>
|
| 493 |
</div>
|
| 494 |
|
|
@@ -504,19 +516,19 @@ export default function DosenDetail({
|
|
| 504 |
disabled={isUpdatingCourse}
|
| 505 |
id="edit-course-description"
|
| 506 |
onChange={(event) =>
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
event.target.value,
|
| 510 |
-
)
|
| 511 |
}
|
| 512 |
placeholder="Masukkan deskripsi mata kuliah"
|
| 513 |
-
value={editForm.
|
| 514 |
/>
|
| 515 |
</div>
|
| 516 |
|
| 517 |
-
{
|
| 518 |
<p className="lecturer-status" role="alert">
|
| 519 |
-
{
|
| 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={
|
|
|
|
|
|
|
| 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 {
|
| 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
|
| 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
|
| 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(
|
| 82 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
const [isCourseDialogOpen, setIsCourseDialogOpen] = useState(false);
|
| 84 |
const [searchQuery, setSearchQuery] = useState('');
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
clearErrors();
|
| 106 |
-
}
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
function handleCreateCourseSubmit(
|
| 110 |
-
event: FormEvent<HTMLFormElement>,
|
| 111 |
-
): void {
|
| 112 |
-
event.preventDefault();
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
}
|
| 119 |
|
| 120 |
const metrics = useMemo<Metric[]>(() => {
|
| 121 |
-
const failedDocumentCount =
|
| 122 |
(total, item) => total + item.failedDocumentCount,
|
| 123 |
0,
|
| 124 |
);
|
| 125 |
-
const documentCount =
|
| 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(
|
| 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
|
| 159 |
}
|
| 160 |
|
| 161 |
-
return
|
| 162 |
item.name.toLowerCase().includes(normalizedQuery),
|
| 163 |
);
|
| 164 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
return (
|
| 167 |
<>
|
|
@@ -192,183 +428,13 @@ export default function Dosen({ knowledgeBases }: DosenPageProps) {
|
|
| 192 |
</div>
|
| 193 |
</section>
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 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 |
-
|
| 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 |
-
|
| 406 |
-
|
| 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 |
-
|
| 428 |
-
|
| 429 |
-
event.target.value,
|
| 430 |
-
)
|
| 431 |
}
|
| 432 |
placeholder="Masukkan deskripsi mata kuliah"
|
| 433 |
value={courseForm.description}
|
| 434 |
/>
|
| 435 |
</div>
|
| 436 |
|
| 437 |
-
{
|
| 438 |
<p className="lecturer-status" role="alert">
|
| 439 |
-
{
|
| 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
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 452 |
onDelta: (delta: string) => void,
|
| 453 |
): Promise<StoreMessageJsonResponse> {
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
debugStream('request:start', {
|
| 457 |
-
content,
|
| 458 |
-
courseId,
|
| 459 |
-
sessionId,
|
| 460 |
-
url: storeMahasiswaMessage.url(sessionId),
|
| 461 |
-
});
|
| 462 |
|
| 463 |
-
const response = await
|
| 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)
|
|
|
|
|
|
|
| 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 |
-
|
| 705 |
-
|
| 706 |
-
|
| 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 |
-
|
| 725 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 743 |
const messages = useMemo(
|
| 744 |
-
() => appendUniqueMessages(
|
| 745 |
-
[
|
| 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
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
|
|
|
| 1006 |
|
| 1007 |
-
|
| 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 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 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 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 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
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
const [
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
const handleDeleteSession = (
|
| 49 |
targetSessionId: string,
|
| 50 |
-
): Promise<DeleteSessionResult> =>
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 66 |
event.preventDefault();
|
| 67 |
|
| 68 |
const trimmedQuestion = question.trim();
|
|
@@ -71,22 +74,30 @@ export default function Mahasiswa({
|
|
| 71 |
return;
|
| 72 |
}
|
| 73 |
|
| 74 |
-
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
{
|
| 79 |
course_id: selectedCourseId,
|
| 80 |
title: trimmedQuestion,
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
};
|
| 91 |
|
| 92 |
return (
|
|
@@ -142,7 +153,7 @@ export default function Mahasiswa({
|
|
| 142 |
|
| 143 |
<QuestionComposer
|
| 144 |
courses={courses}
|
| 145 |
-
isLoadingCourses={
|
| 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": {
|