| import { Save } from 'lucide-react'; |
| import type { FormEvent } from 'react'; |
| import { useState } from 'react'; |
|
|
| import { |
| ConfigError, |
| Field, |
| RetrievalNumberField, |
| } from '@/components/admin/admin-form-parts'; |
| import { Button } from '@/components/ui/button'; |
| import { Card, CardContent } from '@/components/ui/card'; |
| import { Checkbox } from '@/components/ui/checkbox'; |
| import { Input } from '@/components/ui/input'; |
| import { Spinner } from '@/components/ui/spinner'; |
| import { stringValue } from '@/lib/admin'; |
| import { patchRetrievalConfig, patchVectorDbConfig } from '@/lib/rag-client'; |
| import type { |
| RetrievalConfig, |
| RetrievalForm, |
| VectorDbConfig, |
| VectorDbForm, |
| } from '@/types/admin'; |
|
|
| export function AdminVectorDbForm({ |
| vectorDb, |
| }: { |
| vectorDb?: VectorDbConfig | null; |
| }) { |
| const cfg = vectorDb ?? {}; |
| const [formData, setFormData] = useState<VectorDbForm>({ |
| chunk_overlap: stringValue(cfg.chunk_overlap), |
| chunk_size: stringValue(cfg.chunk_size), |
| embedding_model: stringValue(cfg.embedding_model), |
| persist_path: stringValue(cfg.persist_path), |
| }); |
| const [isSubmitting, setIsSubmitting] = useState(false); |
| const [error, setError] = useState<string | undefined>(); |
|
|
| function setField<K extends keyof VectorDbForm>( |
| key: K, |
| value: VectorDbForm[K], |
| ): void { |
| setFormData((prev) => ({ ...prev, [key]: value })); |
| } |
|
|
| async function handleSubmit( |
| event: FormEvent<HTMLFormElement>, |
| ): Promise<void> { |
| event.preventDefault(); |
| setIsSubmitting(true); |
| setError(undefined); |
|
|
| try { |
| const payload: Record<string, unknown> = {}; |
| if (formData.embedding_model) payload.embedding_model = formData.embedding_model; |
| if (formData.persist_path) payload.persist_path = formData.persist_path; |
| if (formData.chunk_size) payload.chunk_size = parseInt(formData.chunk_size, 10); |
| if (formData.chunk_overlap) payload.chunk_overlap = parseInt(formData.chunk_overlap, 10); |
|
|
| const result = await patchVectorDbConfig(payload); |
| setFormData({ |
| chunk_overlap: stringValue(result.chunk_overlap), |
| chunk_size: stringValue(result.chunk_size), |
| embedding_model: stringValue(result.embedding_model), |
| persist_path: stringValue(result.persist_path), |
| }); |
| } catch (e) { |
| setError( |
| e instanceof Error |
| ? e.message |
| : 'Gagal menyimpan konfigurasi Vector DB.', |
| ); |
| } finally { |
| setIsSubmitting(false); |
| } |
| } |
|
|
| return ( |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> |
| <CardContent className="pt-6"> |
| <form |
| className="grid gap-4" |
| onSubmit={(e) => { |
| void handleSubmit(e); |
| }} |
| > |
| <ConfigError message={error} /> |
| |
| <div className="grid gap-3 md:grid-cols-2"> |
| <Field |
| hint="Model yang mengubah teks menjadi vektor untuk pencarian semantik, contoh: text-embedding-3-small." |
| label="Embedding Model" |
| > |
| <Input |
| value={formData.embedding_model} |
| onChange={(event) => |
| setField( |
| 'embedding_model', |
| event.target.value, |
| ) |
| } |
| /> |
| </Field> |
| <Field |
| hint="Lokasi folder penyimpanan database vektor di server." |
| label="Persist Path" |
| > |
| <Input |
| value={formData.persist_path} |
| onChange={(event) => |
| setField('persist_path', event.target.value) |
| } |
| /> |
| </Field> |
| <Field |
| hint="Ukuran potongan teks dokumen saat diindeks (dalam karakter). Makin besar, makin banyak konteks per potongan." |
| label="Chunk Size" |
| > |
| <Input |
| min="1" |
| type="number" |
| value={formData.chunk_size} |
| onChange={(event) => |
| setField('chunk_size', event.target.value) |
| } |
| /> |
| </Field> |
| <Field |
| hint="Jumlah karakter yang tumpang tindih antar potongan agar konteks tidak terputus di batas potongan." |
| label="Chunk Overlap" |
| > |
| <Input |
| min="0" |
| type="number" |
| value={formData.chunk_overlap} |
| onChange={(event) => |
| setField('chunk_overlap', event.target.value) |
| } |
| /> |
| </Field> |
| </div> |
| |
| <div className="flex justify-end"> |
| <Button disabled={isSubmitting} type="submit"> |
| {isSubmitting ? ( |
| <Spinner className="size-4" /> |
| ) : ( |
| <Save className="size-4" /> |
| )} |
| Simpan Vector |
| </Button> |
| </div> |
| </form> |
| </CardContent> |
| </Card> |
| ); |
| } |
|
|
| export function AdminRetrievalForm({ |
| retrieval, |
| }: { |
| retrieval?: RetrievalConfig | null; |
| }) { |
| const cfg = retrieval ?? {}; |
| const [formData, setFormData] = useState<RetrievalForm>({ |
| bm25_weight: stringValue(cfg.bm25_weight), |
| candidate_pool_size: stringValue(cfg.candidate_pool_size), |
| dense_weight: stringValue(cfg.dense_weight), |
| enable_reranker: Boolean(cfg.enable_reranker), |
| history_turns: stringValue(cfg.history_turns), |
| lexical_weight: stringValue(cfg.lexical_weight), |
| max_context_chars: stringValue(cfg.max_context_chars), |
| neighbor_window: stringValue(cfg.neighbor_window), |
| reranker_model: stringValue(cfg.reranker_model), |
| similarity_threshold: stringValue(cfg.similarity_threshold), |
| top_k: stringValue(cfg.top_k), |
| }); |
| const [isSubmitting, setIsSubmitting] = useState(false); |
| const [error, setError] = useState<string | undefined>(); |
|
|
| function setField<K extends keyof RetrievalForm>( |
| key: K, |
| value: RetrievalForm[K], |
| ): void { |
| setFormData((prev) => ({ ...prev, [key]: value })); |
| } |
|
|
| async function handleSubmit( |
| event: FormEvent<HTMLFormElement>, |
| ): Promise<void> { |
| event.preventDefault(); |
| setIsSubmitting(true); |
| setError(undefined); |
|
|
| try { |
| const payload: Record<string, unknown> = { |
| enable_reranker: formData.enable_reranker, |
| }; |
| if (formData.top_k) payload.top_k = parseInt(formData.top_k, 10); |
| if (formData.max_context_chars) payload.max_context_chars = parseInt(formData.max_context_chars, 10); |
| if (formData.bm25_weight) payload.bm25_weight = parseFloat(formData.bm25_weight); |
| if (formData.dense_weight) payload.dense_weight = parseFloat(formData.dense_weight); |
| if (formData.lexical_weight) payload.lexical_weight = parseFloat(formData.lexical_weight); |
| if (formData.similarity_threshold) payload.similarity_threshold = parseFloat(formData.similarity_threshold); |
| if (formData.history_turns) payload.history_turns = parseInt(formData.history_turns, 10); |
| if (formData.candidate_pool_size) payload.candidate_pool_size = parseInt(formData.candidate_pool_size, 10); |
| if (formData.neighbor_window) payload.neighbor_window = parseInt(formData.neighbor_window, 10); |
| if (formData.reranker_model) payload.reranker_model = formData.reranker_model; |
|
|
| const result = await patchRetrievalConfig(payload); |
| setFormData({ |
| bm25_weight: stringValue(result.bm25_weight), |
| candidate_pool_size: stringValue(result.candidate_pool_size), |
| dense_weight: stringValue(result.dense_weight), |
| enable_reranker: Boolean(result.enable_reranker), |
| history_turns: stringValue(result.history_turns), |
| lexical_weight: stringValue(result.lexical_weight), |
| max_context_chars: stringValue(result.max_context_chars), |
| neighbor_window: stringValue(result.neighbor_window), |
| reranker_model: stringValue(result.reranker_model), |
| similarity_threshold: stringValue(result.similarity_threshold), |
| top_k: stringValue(result.top_k), |
| }); |
| } catch (e) { |
| setError( |
| e instanceof Error |
| ? e.message |
| : 'Gagal menyimpan konfigurasi retrieval.', |
| ); |
| } finally { |
| setIsSubmitting(false); |
| } |
| } |
|
|
| return ( |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> |
| <CardContent className="pt-6"> |
| <form |
| className="grid gap-4" |
| onSubmit={(e) => { |
| void handleSubmit(e); |
| }} |
| > |
| <ConfigError message={error} /> |
| |
| <div className="grid gap-3 md:grid-cols-2"> |
| <RetrievalNumberField |
| hint="Jumlah dokumen relevan yang diambil dari database per pertanyaan." |
| label="Top K" |
| value={formData.top_k} |
| onChange={(v) => setField('top_k', v)} |
| /> |
| <RetrievalNumberField |
| hint="Batas maksimum karakter konteks dokumen yang dikirim ke model AI." |
| label="Max Context Chars" |
| value={formData.max_context_chars} |
| onChange={(v) => setField('max_context_chars', v)} |
| /> |
| <RetrievalNumberField |
| hint="Bobot pencarian berbasis kata kunci (0–1). Tinggi = lebih mengutamakan kecocokan kata." |
| label="BM25 Weight" |
| step="0.01" |
| value={formData.bm25_weight} |
| onChange={(v) => setField('bm25_weight', v)} |
| /> |
| <RetrievalNumberField |
| hint="Bobot pencarian berbasis makna/semantik (0–1). Tinggi = lebih mengutamakan kesamaan makna." |
| label="Dense Weight" |
| step="0.01" |
| value={formData.dense_weight} |
| onChange={(v) => setField('dense_weight', v)} |
| /> |
| <RetrievalNumberField |
| hint="Bobot pencarian leksikal untuk keseimbangan antara kata kunci dan semantik." |
| label="Lexical Weight" |
| step="0.01" |
| value={formData.lexical_weight} |
| onChange={(v) => setField('lexical_weight', v)} |
| /> |
| <RetrievalNumberField |
| hint="Ambang batas skor kemiripan minimum (0–1). Dokumen di bawah nilai ini diabaikan." |
| label="Similarity Threshold" |
| step="0.01" |
| value={formData.similarity_threshold} |
| onChange={(v) => |
| setField('similarity_threshold', v) |
| } |
| /> |
| <RetrievalNumberField |
| hint="Jumlah riwayat percakapan sebelumnya yang disertakan sebagai konteks pertanyaan." |
| label="History Turns" |
| value={formData.history_turns} |
| onChange={(v) => setField('history_turns', v)} |
| /> |
| <RetrievalNumberField |
| hint="Jumlah kandidat dokumen awal sebelum proses reranking dilakukan." |
| label="Candidate Pool" |
| value={formData.candidate_pool_size} |
| onChange={(v) => |
| setField('candidate_pool_size', v) |
| } |
| /> |
| <RetrievalNumberField |
| hint="Jumlah dokumen tetangga yang disertakan untuk memperluas konteks jawaban." |
| label="Neighbor Window" |
| value={formData.neighbor_window} |
| onChange={(v) => setField('neighbor_window', v)} |
| /> |
| <Field |
| hint="Nama model yang mengurutkan ulang hasil pencarian agar paling relevan di urutan teratas." |
| label="Reranker Model" |
| > |
| <Input |
| value={formData.reranker_model} |
| onChange={(event) => |
| setField( |
| 'reranker_model', |
| event.target.value, |
| ) |
| } |
| /> |
| </Field> |
| </div> |
| |
| <label className="flex items-center gap-3 text-sm"> |
| <Checkbox |
| checked={formData.enable_reranker} |
| onCheckedChange={(value) => |
| setField('enable_reranker', value === true) |
| } |
| /> |
| <span> |
| Enable reranker |
| <span className="ml-1 text-xs text-muted-foreground"> |
| — aktifkan untuk mengurutkan ulang hasil pencarian agar lebih akurat |
| </span> |
| </span> |
| </label> |
| |
| <div className="flex justify-end"> |
| <Button disabled={isSubmitting} type="submit"> |
| {isSubmitting ? ( |
| <Spinner className="size-4" /> |
| ) : ( |
| <Save className="size-4" /> |
| )} |
| Simpan Retrieval |
| </Button> |
| </div> |
| </form> |
| </CardContent> |
| </Card> |
| ); |
| } |
|
|
| export function AdminVectorRetrievalForms({ |
| retrieval, |
| vectorDb, |
| }: { |
| retrieval?: RetrievalConfig | null; |
| vectorDb?: VectorDbConfig | null; |
| }) { |
| return ( |
| <section className="grid gap-6 xl:grid-cols-2"> |
| <AdminVectorDbForm vectorDb={vectorDb} /> |
| <AdminRetrievalForm retrieval={retrieval} /> |
| </section> |
| ); |
| } |
|
|