Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |