CAPS04-FE-REFACTOR / resources /js /components /admin /admin-llm-form.tsx
wepee's picture
fix: admin dashboard
695ff78
Raw
History Blame Contribute Delete
27.6 kB
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>
);
}