Spaces:
Sleeping
Sleeping
| import { ChevronDown, Eye, EyeOff, KeyRound, Save, Trash2 } from 'lucide-react'; | |
| import type { FormEvent } from 'react'; | |
| import { useRef, useState } from 'react'; | |
| import { toast } from 'sonner'; | |
| import { ConfigError, Field } from '@/components/admin/admin-form-parts'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Card, CardContent } from '@/components/ui/card'; | |
| import { Input } from '@/components/ui/input'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { Spinner } from '@/components/ui/spinner'; | |
| import { stringValue } from '@/lib/admin'; | |
| import { cn } from '@/lib/utils'; | |
| import { patchLlmConfig } from '@/lib/rag-client'; | |
| import type { LlmConfig, LlmForm } from '@/types/admin'; | |
| type ApiKeyMode = 'configured' | 'editing' | 'clearing'; | |
| const REASONING_EFFORT_OPTIONS: Record<string, string[]> = { | |
| deepseek: ['high', 'max'], | |
| openai: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'], | |
| }; | |
| function SearchableSelect({ | |
| disabled = false, | |
| onChange, | |
| options, | |
| placeholder = 'Pilih...', | |
| searchPlaceholder = 'Cari...', | |
| value, | |
| }: { | |
| disabled?: boolean; | |
| onChange: (value: string) => void; | |
| options: string[]; | |
| placeholder?: string; | |
| searchPlaceholder?: string; | |
| value: string; | |
| }) { | |
| const [open, setOpen] = useState(false); | |
| const [query, setQuery] = useState(''); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const filtered = options.filter((o) => | |
| o.toLowerCase().includes(query.toLowerCase()), | |
| ); | |
| return ( | |
| <div className="relative" ref={containerRef}> | |
| <button | |
| className={cn( | |
| 'flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background', | |
| 'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none', | |
| disabled && 'cursor-not-allowed opacity-50', | |
| )} | |
| disabled={disabled} | |
| type="button" | |
| onClick={() => { | |
| setOpen((v) => !v); | |
| setQuery(''); | |
| }} | |
| > | |
| <span className={value ? '' : 'text-muted-foreground'}> | |
| {value || placeholder} | |
| </span> | |
| <ChevronDown className="size-4 shrink-0 opacity-50" /> | |
| </button> | |
| {open && ( | |
| <div | |
| className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md" | |
| onMouseDown={(e) => e.stopPropagation()} | |
| > | |
| <div className="border-b p-1"> | |
| <Input | |
| autoFocus | |
| className="h-8 text-sm" | |
| placeholder={searchPlaceholder} | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| onBlur={() => { | |
| setTimeout(() => setOpen(false), 150); | |
| }} | |
| /> | |
| </div> | |
| <div className="max-h-52 overflow-y-auto p-1"> | |
| {filtered.length > 0 ? ( | |
| filtered.map((option) => ( | |
| <button | |
| key={option} | |
| className={cn( | |
| 'flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground', | |
| value === option && | |
| 'bg-accent text-accent-foreground', | |
| )} | |
| type="button" | |
| onClick={() => { | |
| onChange(option); | |
| setOpen(false); | |
| setQuery(''); | |
| }} | |
| > | |
| {option} | |
| </button> | |
| )) | |
| ) : ( | |
| <p className="py-6 text-center text-sm text-muted-foreground"> | |
| Tidak ditemukan. | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function CreatableBaseUrlSelect({ | |
| onChange, | |
| options, | |
| value, | |
| }: { | |
| onChange: (value: string) => void; | |
| options: string[]; | |
| value: string; | |
| }) { | |
| const [customMode, setCustomMode] = useState( | |
| value !== '' && !options.includes(value), | |
| ); | |
| const selectValue = customMode | |
| ? '__custom__' | |
| : options.includes(value) | |
| ? value | |
| : ''; | |
| return ( | |
| <div className="grid gap-2"> | |
| <Select | |
| value={selectValue} | |
| onValueChange={(val) => { | |
| if (val === '__custom__') { | |
| setCustomMode(true); | |
| if (options.includes(value)) onChange(''); | |
| } else { | |
| setCustomMode(false); | |
| onChange(val); | |
| } | |
| }} | |
| > | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Pilih atau masukkan custom URL" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {options.map((url) => ( | |
| <SelectItem key={url} value={url}> | |
| {url} | |
| </SelectItem> | |
| ))} | |
| <SelectItem value="__custom__">Custom URL...</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| {customMode && ( | |
| <Input | |
| placeholder="https://custom-gateway.com/v1" | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function ApiKeyInput({ | |
| mode, | |
| onModeChange, | |
| onChange, | |
| value, | |
| }: { | |
| mode: ApiKeyMode; | |
| onModeChange: (mode: ApiKeyMode) => void; | |
| onChange: (value: string) => void; | |
| value: string; | |
| }) { | |
| const [show, setShow] = useState(false); | |
| if (mode === 'configured') { | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <div className="flex h-9 flex-1 items-center gap-2 rounded-md border border-input bg-muted/50 px-3 text-sm text-muted-foreground"> | |
| <KeyRound className="size-3.5 shrink-0" /> | |
| <span>API key tersimpan</span> | |
| </div> | |
| <Button | |
| size="sm" | |
| type="button" | |
| variant="outline" | |
| onClick={() => onModeChange('editing')} | |
| > | |
| Ubah | |
| </Button> | |
| <Button | |
| className="text-destructive hover:text-destructive" | |
| size="sm" | |
| type="button" | |
| variant="outline" | |
| onClick={() => onModeChange('clearing')} | |
| > | |
| <Trash2 className="size-3.5" /> | |
| </Button> | |
| </div> | |
| ); | |
| } | |
| if (mode === 'clearing') { | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <div className="flex h-9 flex-1 items-center gap-2 rounded-md border border-destructive/50 bg-destructive/5 px-3 text-sm text-destructive"> | |
| <Trash2 className="size-3.5 shrink-0" /> | |
| <span>API key akan dihapus</span> | |
| </div> | |
| <Button | |
| size="sm" | |
| type="button" | |
| variant="outline" | |
| onClick={() => onModeChange('configured')} | |
| > | |
| Batal | |
| </Button> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="relative"> | |
| <Input | |
| autoComplete="off" | |
| autoFocus | |
| className="pr-10" | |
| placeholder="sk-..." | |
| type={show ? 'text' : 'password'} | |
| value={value} | |
| onChange={(e) => onChange(e.target.value)} | |
| /> | |
| <button | |
| className="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground" | |
| type="button" | |
| onClick={() => setShow((v) => !v)} | |
| > | |
| {show ? ( | |
| <EyeOff className="size-4" /> | |
| ) : ( | |
| <Eye className="size-4" /> | |
| )} | |
| </button> | |
| </div> | |
| ); | |
| } | |
| export function AdminLlmForm({ llm }: { llm?: LlmConfig | null }) { | |
| const cfg = llm ?? {}; | |
| const providers = cfg.providers ?? {}; | |
| const systemPromptsMap = cfg.system_prompts ?? {}; | |
| const systemPromptKeys = Object.keys(systemPromptsMap); | |
| const initialSystemPromptKey = systemPromptKeys[0] ?? 'default'; | |
| const initialProviderInfo = providers[cfg.active_provider ?? '']; | |
| const [formData, setFormData] = useState<LlmForm>({ | |
| active_model: stringValue(cfg.active_model), | |
| active_provider: stringValue(cfg.active_provider), | |
| api_key: stringValue(cfg.api_key), | |
| base_url: stringValue(initialProviderInfo?.base_url), | |
| max_tokens: stringValue(cfg.max_tokens), | |
| reasoning_effort: stringValue(cfg.reasoning_effort), | |
| request_timeout: stringValue(cfg.request_timeout), | |
| system_prompt: systemPromptsMap[initialSystemPromptKey] ?? '', | |
| temperature: stringValue(cfg.temperature), | |
| }); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [error, setError] = useState<string | undefined>(); | |
| const [selectedTemplate, setSelectedTemplate] = useState<string>( | |
| initialSystemPromptKey, | |
| ); | |
| const [apiKeyMode, setApiKeyMode] = useState<ApiKeyMode>( | |
| cfg.api_key_set ? 'configured' : 'editing', | |
| ); | |
| function setField<K extends keyof LlmForm>( | |
| key: K, | |
| value: LlmForm[K], | |
| ): void { | |
| setFormData((prev) => ({ ...prev, [key]: value })); | |
| } | |
| function handleProviderChange(provider: string): void { | |
| const config = providers[provider]; | |
| setFormData((prev) => ({ | |
| ...prev, | |
| active_provider: provider, | |
| active_model: '', | |
| reasoning_effort: '', | |
| base_url: config?.base_url ?? '', | |
| })); | |
| } | |
| async function handleSubmit( | |
| event: FormEvent<HTMLFormElement>, | |
| ): Promise<void> { | |
| event.preventDefault(); | |
| setIsSubmitting(true); | |
| setError(undefined); | |
| try { | |
| const payload: Record<string, unknown> = {}; | |
| if (formData.active_provider) { | |
| payload.active_provider = formData.active_provider; | |
| payload.providers = { | |
| [formData.active_provider]: { | |
| base_url: formData.base_url || null, | |
| }, | |
| }; | |
| } | |
| if (formData.active_model) | |
| payload.active_model = formData.active_model; | |
| if (apiKeyMode === 'clearing') { | |
| payload.api_key = null; | |
| } else if (apiKeyMode === 'editing' && formData.api_key) { | |
| payload.api_key = formData.api_key; | |
| } | |
| payload.reasoning_effort = formData.reasoning_effort || null; | |
| if (formData.max_tokens) | |
| payload.max_tokens = parseInt(formData.max_tokens, 10); | |
| if (formData.request_timeout) | |
| payload.request_timeout = parseInt( | |
| formData.request_timeout, | |
| 10, | |
| ); | |
| if (formData.temperature) | |
| payload.temperature = parseFloat(formData.temperature); | |
| if (formData.system_prompt) { | |
| payload.system_prompts = { | |
| [selectedTemplate]: formData.system_prompt, | |
| }; | |
| } | |
| const result = await patchLlmConfig(payload); | |
| const savedProviders = result.providers ?? {}; | |
| const savedSystemPrompts = result.system_prompts ?? {}; | |
| const savedKey = | |
| savedSystemPrompts[selectedTemplate] !== undefined | |
| ? selectedTemplate | |
| : (Object.keys(savedSystemPrompts)[0] ?? selectedTemplate); | |
| setSelectedTemplate(savedKey); | |
| const savedProviderInfo = | |
| savedProviders[result.active_provider ?? '']; | |
| setFormData({ | |
| active_model: stringValue(result.active_model), | |
| active_provider: stringValue(result.active_provider), | |
| api_key: '', | |
| base_url: stringValue(savedProviderInfo?.base_url), | |
| max_tokens: stringValue(result.max_tokens), | |
| reasoning_effort: stringValue(result.reasoning_effort), | |
| request_timeout: stringValue(result.request_timeout), | |
| system_prompt: savedSystemPrompts[savedKey] ?? '', | |
| temperature: stringValue(result.temperature), | |
| }); | |
| setApiKeyMode(result.api_key_set ? 'configured' : 'editing'); | |
| toast.success('Konfigurasi LLM berhasil disimpan'); | |
| } catch (e) { | |
| setError( | |
| e instanceof Error | |
| ? e.message | |
| : 'Gagal menyimpan konfigurasi LLM.', | |
| ); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| } | |
| const providerConfig = providers[formData.active_provider]; | |
| const reasoningEffortOptions = | |
| REASONING_EFFORT_OPTIONS[formData.active_provider] ?? []; | |
| const baseUrlOptions = providerConfig?.base_url | |
| ? [providerConfig.base_url] | |
| : []; | |
| return ( | |
| <form | |
| className="grid gap-4" | |
| onSubmit={(e) => { | |
| void handleSubmit(e); | |
| }} | |
| > | |
| <ConfigError message={error} /> | |
| {/* Model */} | |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> | |
| <CardContent className="px-6"> | |
| <div className="grid gap-3"> | |
| <h3 className="text-base font-semibold text-(--lecturer-text)"> | |
| Model | |
| </h3> | |
| <div className="grid gap-3 md:grid-cols-2"> | |
| <Field | |
| current={llm?.active_provider} | |
| hint="Penyedia layanan AI yang digunakan untuk menjawab pertanyaan." | |
| label="Provider" | |
| > | |
| <Select | |
| value={formData.active_provider} | |
| onValueChange={handleProviderChange} | |
| > | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Pilih provider" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {Object.keys(providers).map((p) => ( | |
| <SelectItem key={p} value={p}> | |
| {p.charAt(0).toUpperCase() + | |
| p.slice(1)} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </Field> | |
| <Field | |
| current={llm?.active_model} | |
| hint="Model AI yang digunakan. Pilih provider terlebih dahulu." | |
| label="Model" | |
| > | |
| <SearchableSelect | |
| disabled={!formData.active_provider} | |
| options={ | |
| providerConfig?.model_options ?? [] | |
| } | |
| placeholder={ | |
| formData.active_provider | |
| ? 'Pilih model' | |
| : 'Pilih provider dulu' | |
| } | |
| searchPlaceholder="Cari model..." | |
| value={formData.active_model} | |
| onChange={(v) => | |
| setField('active_model', v) | |
| } | |
| /> | |
| </Field> | |
| <div className="md:col-span-2"> | |
| <Field | |
| hint="URL endpoint API provider. Pilih dari daftar atau masukkan custom URL." | |
| label="Base URL" | |
| > | |
| <CreatableBaseUrlSelect | |
| key={formData.active_provider} | |
| options={baseUrlOptions} | |
| value={formData.base_url} | |
| onChange={(v) => | |
| setField('base_url', v) | |
| } | |
| /> | |
| </Field> | |
| </div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Parameter */} | |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> | |
| <CardContent className="px-6"> | |
| <div className="grid gap-3"> | |
| <h3 className="text-base font-semibold text-(--lecturer-text)"> | |
| Parameter | |
| </h3> | |
| <div className="grid gap-3 md:grid-cols-2"> | |
| <Field | |
| hint="Tingkat kreativitas jawaban (0–1). Rendah = konsisten, tinggi = lebih variatif." | |
| label="Temperature" | |
| > | |
| <Input | |
| min="0" | |
| step="0.01" | |
| type="number" | |
| value={formData.temperature} | |
| onChange={(event) => | |
| setField( | |
| 'temperature', | |
| event.target.value, | |
| ) | |
| } | |
| /> | |
| </Field> | |
| <Field | |
| hint="Batas maksimum token yang dihasilkan per respons." | |
| label="Max Tokens" | |
| > | |
| <Input | |
| min="1" | |
| type="number" | |
| value={formData.max_tokens} | |
| onChange={(event) => | |
| setField( | |
| 'max_tokens', | |
| event.target.value, | |
| ) | |
| } | |
| /> | |
| </Field> | |
| <Field | |
| hint="Batas waktu menunggu respons dari API dalam detik." | |
| label="Request Timeout" | |
| > | |
| <Input | |
| min="1" | |
| type="number" | |
| value={formData.request_timeout} | |
| onChange={(event) => | |
| setField( | |
| 'request_timeout', | |
| event.target.value, | |
| ) | |
| } | |
| /> | |
| </Field> | |
| <Field | |
| hint="Tingkat usaha reasoning model. Tersedia sesuai provider yang dipilih." | |
| label="Reasoning Effort" | |
| > | |
| <Select | |
| disabled={!formData.active_provider} | |
| value={ | |
| formData.reasoning_effort || '__none__' | |
| } | |
| onValueChange={(val) => | |
| setField( | |
| 'reasoning_effort', | |
| val === '__none__' ? '' : val, | |
| ) | |
| } | |
| > | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Tidak diatur" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="__none__"> | |
| Tidak diatur | |
| </SelectItem> | |
| {reasoningEffortOptions.map( | |
| (effort) => ( | |
| <SelectItem | |
| key={effort} | |
| value={effort} | |
| > | |
| {effort} | |
| </SelectItem> | |
| ), | |
| )} | |
| </SelectContent> | |
| </Select> | |
| </Field> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Autentikasi */} | |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> | |
| <CardContent className="px-6"> | |
| <div className="grid gap-3"> | |
| <h3 className="text-base font-semibold text-(--lecturer-text)"> | |
| Autentikasi | |
| </h3> | |
| <Field | |
| hint="API key untuk autentikasi ke provider. Nilai disimpan terenkripsi." | |
| label="API Key" | |
| > | |
| <ApiKeyInput | |
| mode={apiKeyMode} | |
| value={formData.api_key} | |
| onChange={(v) => setField('api_key', v)} | |
| onModeChange={setApiKeyMode} | |
| /> | |
| </Field> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* System Prompt */} | |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> | |
| <CardContent className="px-6"> | |
| <div className="grid gap-3"> | |
| <h3 className="text-base font-semibold text-(--lecturer-text)"> | |
| System Prompt | |
| </h3> | |
| <Field | |
| hint="Pilih key system prompt yang ingin diedit. Setiap key menyimpan instruksi berbeda." | |
| label="Template" | |
| > | |
| <Select | |
| disabled={systemPromptKeys.length === 0} | |
| value={selectedTemplate} | |
| onValueChange={(key) => { | |
| setSelectedTemplate(key); | |
| setField( | |
| 'system_prompt', | |
| systemPromptsMap[key] ?? '', | |
| ); | |
| }} | |
| > | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Pilih key..." /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {systemPromptKeys.map((key) => ( | |
| <SelectItem key={key} value={key}> | |
| {key} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </Field> | |
| <Field | |
| hint="Instruksi awal yang selalu dikirim ke model sebelum percakapan dimulai." | |
| label="Prompt" | |
| > | |
| <textarea | |
| className="min-h-36 w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none" | |
| value={formData.system_prompt} | |
| onChange={(event) => { | |
| setField( | |
| 'system_prompt', | |
| event.target.value, | |
| ); | |
| }} | |
| /> | |
| </Field> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <div className="flex justify-end"> | |
| <Button disabled={isSubmitting} type="submit"> | |
| {isSubmitting ? ( | |
| <Spinner className="size-4" /> | |
| ) : ( | |
| <Save className="size-4" /> | |
| )} | |
| Simpan LLM | |
| </Button> | |
| </div> | |
| </form> | |
| ); | |
| } | |