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