| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- import * as React from "react";
- import {
- Activity,
- CheckCircle2,
- Cpu,
- FlaskConical,
- Plus,
- Power,
- RefreshCw,
- Save,
- Trash2,
- } from "lucide-react";
- import {
- createModel,
- deleteModel,
- listModels,
- testModel,
- updateModel,
- updateModelStatus,
- } from "@/api";
- import { ApiErrorState } from "@/components/shared/ApiErrorState";
- import { EmptyState } from "@/components/shared/EmptyState";
- import { EntityListItem } from "@/components/shared/EntityListItem";
- import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
- import { MetricCard } from "@/components/shared/MetricCard";
- import { PageHeader } from "@/components/shared/PageHeader";
- import { SearchInput } from "@/components/shared/SearchInput";
- import { StatusBadge } from "@/components/shared/StatusBadge";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
- import { Dialog } from "@/components/ui/dialog";
- import { Input, Textarea } from "@/components/ui/input";
- import { Select } from "@/components/ui/select";
- import { toast } from "@/components/ui/toaster";
- import { formatDateTime } from "@/lib/utils";
- import type { ModelCreateRequest, ModelDefinition, ModelStatus } from "@/types";
- type ModelFormState = {
- code: string;
- name: string;
- provider_type: string;
- provider_base_url: string;
- provider_api_key: string;
- model_name: string;
- status: ModelStatus;
- description: string;
- capabilities: string;
- context_window: string;
- max_output_tokens: string;
- default_temperature: string;
- timeout_seconds: string;
- };
- const emptyForm: ModelFormState = {
- code: "",
- name: "",
- provider_type: "openai_compatible",
- provider_base_url: "http://127.0.0.1:11434/v1",
- provider_api_key: "",
- model_name: "",
- status: "active",
- description: "",
- capabilities: "chat",
- context_window: "",
- max_output_tokens: "",
- default_temperature: "",
- timeout_seconds: "60",
- };
- export function ModelsPage() {
- const [models, setModels] = React.useState<ModelDefinition[]>([]);
- const [selectedId, setSelectedId] = React.useState<string>();
- const [search, setSearch] = React.useState("");
- const [statusFilter, setStatusFilter] = React.useState("all");
- const [loading, setLoading] = React.useState(true);
- const [saving, setSaving] = React.useState(false);
- const [testing, setTesting] = React.useState(false);
- const [error, setError] = React.useState<string>();
- const [createOpen, setCreateOpen] = React.useState(false);
- const [form, setForm] = React.useState<ModelFormState>(emptyForm);
- const [testPrompt, setTestPrompt] = React.useState("Say OK in one short sentence.");
- const [testOutput, setTestOutput] = React.useState<string>();
- const selected = models.find((model) => model.id === selectedId);
- const providers = Array.from(new Set(models.map((model) => model.provider_type))).sort();
- const activeCount = models.filter((model) => model.status === "active").length;
- const chatReadyCount = models.filter((model) => model.capabilities_json.includes("chat")).length;
- const filtered = models.filter((model) => {
- const haystack = `${model.name} ${model.code} ${model.model_name} ${model.provider_type}`.toLowerCase();
- const matchesSearch = haystack.includes(search.toLowerCase());
- const matchesStatus = statusFilter === "all" || model.status === statusFilter;
- return matchesSearch && matchesStatus;
- });
- const load = React.useCallback(async () => {
- setLoading(true);
- setError(undefined);
- try {
- const data = await listModels();
- setModels(data);
- setSelectedId((current) => current ?? data[0]?.id);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load models");
- } finally {
- setLoading(false);
- }
- }, []);
- React.useEffect(() => {
- void load();
- }, [load]);
- React.useEffect(() => {
- if (selected) setForm(fromModel(selected));
- }, [selected]);
- async function createFromDialog(payload: ModelCreateRequest) {
- const created = await createModel(payload);
- setModels((current) => [created, ...current]);
- setSelectedId(created.id);
- setCreateOpen(false);
- toast.success("Model created");
- }
- async function saveSelected() {
- if (!selected) return;
- setSaving(true);
- try {
- const payload = toPayload(form);
- if (!form.provider_api_key.trim()) delete payload.provider_api_key;
- const updated = await updateModel(selected.id, payload);
- setModels((current) => current.map((model) => (model.id === updated.id ? updated : model)));
- toast.success("Model saved");
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "Failed to save model");
- } finally {
- setSaving(false);
- }
- }
- async function toggleSelected() {
- if (!selected) return;
- const nextStatus: ModelStatus = selected.status === "active" ? "disabled" : "active";
- const updated = await updateModelStatus(selected.id, nextStatus);
- setModels((current) => current.map((model) => (model.id === updated.id ? updated : model)));
- toast.success(nextStatus === "active" ? "Model enabled" : "Model disabled");
- }
- async function deleteSelected() {
- if (!selected) return;
- await deleteModel(selected.id);
- setModels((current) => current.filter((model) => model.id !== selected.id));
- setSelectedId(models.find((model) => model.id !== selected.id)?.id);
- setTestOutput(undefined);
- toast.success("Model deleted");
- }
- async function runTest() {
- if (!selected) return;
- setTesting(true);
- setTestOutput(undefined);
- try {
- const result = await testModel(selected.id, { prompt: testPrompt, max_tokens: 128 });
- setTestOutput(result.response.content || JSON.stringify(result.response.raw_response_json, null, 2));
- toast.success("Model test completed");
- } catch (err) {
- setTestOutput(err instanceof Error ? err.message : "Model test failed");
- toast.error("Model test failed");
- } finally {
- setTesting(false);
- }
- }
- if (loading) return <LoadingSpinner label="Loading models" />;
- if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
- return (
- <div className="space-y-6">
- <PageHeader
- title="Models"
- description="Manage model providers, serving names, defaults, and connectivity tests."
- actions={
- <>
- <Button variant="outline" onClick={() => void load()}>
- <RefreshCw className="h-4 w-4" /> Refresh
- </Button>
- <Button onClick={() => setCreateOpen(true)}>
- <Plus className="h-4 w-4" /> New Model
- </Button>
- </>
- }
- />
- <div className="grid gap-4 md:grid-cols-3">
- <MetricCard label="Models" value={models.length} icon={Cpu} />
- <MetricCard label="Active" value={activeCount} icon={CheckCircle2} />
- <MetricCard label="Chat Ready" value={chatReadyCount} icon={Activity} />
- </div>
- <div className="grid gap-6 xl:grid-cols-[380px_1fr]">
- <Card>
- <CardHeader>
- <CardTitle>Model Catalog</CardTitle>
- <CardDescription>{filtered.length} of {models.length} shown</CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <SearchInput value={search} onChange={setSearch} placeholder="Search models" />
- <Select
- value={statusFilter}
- onChange={(event) => setStatusFilter(event.target.value)}
- options={[
- { value: "all", label: "All statuses" },
- { value: "active", label: "Active" },
- { value: "disabled", label: "Disabled" },
- ]}
- />
- {filtered.length ? (
- <div className="space-y-2">
- {filtered.map((model) => (
- <EntityListItem
- key={model.id}
- title={model.name}
- subtitle={`${model.code} - ${model.model_name}`}
- active={model.id === selectedId}
- onClick={() => {
- setSelectedId(model.id);
- setTestOutput(undefined);
- }}
- meta={<StatusBadge status={model.status} />}
- />
- ))}
- </div>
- ) : (
- <EmptyState icon={Cpu} title="No models" description="Create a model configuration to start routing chat completions." />
- )}
- </CardContent>
- </Card>
- {selected ? (
- <div className="space-y-6">
- <Card>
- <CardHeader>
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
- <div>
- <CardTitle>{selected.name}</CardTitle>
- <CardDescription>
- {selected.provider_type} / {selected.model_name}
- </CardDescription>
- </div>
- <div className="flex flex-wrap gap-2">
- <Button variant="outline" onClick={() => void toggleSelected()}>
- <Power className="h-4 w-4" /> {selected.status === "active" ? "Disable" : "Enable"}
- </Button>
- <Button variant="destructive" onClick={() => void deleteSelected()}>
- <Trash2 className="h-4 w-4" /> Delete
- </Button>
- </div>
- </div>
- </CardHeader>
- <CardContent>
- <ModelForm form={form} providers={providers} onChange={setForm} />
- <div className="mt-5 flex justify-end">
- <Button onClick={() => void saveSelected()} disabled={saving}>
- <Save className="h-4 w-4" /> {saving ? "Saving" : "Save"}
- </Button>
- </div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <CardTitle>Connectivity Test</CardTitle>
- <CardDescription>Send a short prompt through this exact provider configuration.</CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <Textarea value={testPrompt} onChange={(event) => setTestPrompt(event.target.value)} />
- <div className="flex justify-end">
- <Button onClick={() => void runTest()} disabled={testing || selected.status !== "active"}>
- <FlaskConical className="h-4 w-4" /> {testing ? "Testing" : "Test Model"}
- </Button>
- </div>
- {testOutput ? (
- <pre className="max-h-72 overflow-auto rounded-md border border-border bg-muted/40 p-3 text-sm whitespace-pre-wrap">
- {testOutput}
- </pre>
- ) : null}
- <div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
- <span>Updated {formatDateTime(selected.updated_time)}</span>
- <span>{selected.has_provider_api_key ? "API key configured" : "No API key configured"}</span>
- </div>
- </CardContent>
- </Card>
- </div>
- ) : (
- <EmptyState icon={Cpu} title="No model selected" description="Select or create a model to edit its configuration." />
- )}
- </div>
- <CreateModelDialog
- open={createOpen}
- onOpenChange={setCreateOpen}
- onCreate={createFromDialog}
- providers={providers}
- />
- </div>
- );
- }
- function ModelForm({
- form,
- providers,
- onChange,
- }: {
- form: ModelFormState;
- providers: string[];
- onChange: (form: ModelFormState) => void;
- }) {
- const set = (key: keyof ModelFormState, value: string) => onChange({ ...form, [key]: value });
- const providerOptions = Array.from(new Set(["openai_compatible", "openai", "ollama", ...providers])).map((value) => ({
- value,
- label: value,
- }));
- return (
- <div className="grid gap-4 md:grid-cols-2">
- <Field label="Code"><Input value={form.code} onChange={(event) => set("code", event.target.value)} /></Field>
- <Field label="Name"><Input value={form.name} onChange={(event) => set("name", event.target.value)} /></Field>
- <Field label="Provider"><Select value={form.provider_type} onChange={(event) => set("provider_type", event.target.value)} options={providerOptions} /></Field>
- <Field label="Provider Base URL"><Input value={form.provider_base_url} onChange={(event) => set("provider_base_url", event.target.value)} /></Field>
- <Field label="Model Name"><Input value={form.model_name} onChange={(event) => set("model_name", event.target.value)} /></Field>
- <Field label="API Key"><Input type="password" value={form.provider_api_key} onChange={(event) => set("provider_api_key", event.target.value)} placeholder="Leave blank to keep unset" /></Field>
- <Field label="Capabilities"><Input value={form.capabilities} onChange={(event) => set("capabilities", event.target.value)} placeholder="chat, tools, vision" /></Field>
- <Field label="Status"><Select value={form.status} onChange={(event) => set("status", event.target.value)} options={[{ value: "active", label: "Active" }, { value: "disabled", label: "Disabled" }]} /></Field>
- <Field label="Context Window"><Input value={form.context_window} onChange={(event) => set("context_window", event.target.value)} inputMode="numeric" /></Field>
- <Field label="Max Output Tokens"><Input value={form.max_output_tokens} onChange={(event) => set("max_output_tokens", event.target.value)} inputMode="numeric" /></Field>
- <Field label="Default Temperature"><Input value={form.default_temperature} onChange={(event) => set("default_temperature", event.target.value)} inputMode="decimal" /></Field>
- <Field label="Timeout Seconds"><Input value={form.timeout_seconds} onChange={(event) => set("timeout_seconds", event.target.value)} inputMode="decimal" /></Field>
- <div className="md:col-span-2">
- <Field label="Description"><Textarea value={form.description} onChange={(event) => set("description", event.target.value)} /></Field>
- </div>
- </div>
- );
- }
- function CreateModelDialog({
- open,
- onOpenChange,
- onCreate,
- providers,
- }: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onCreate: (payload: ModelCreateRequest) => Promise<void>;
- providers: string[];
- }) {
- const [form, setForm] = React.useState<ModelFormState>(emptyForm);
- const [saving, setSaving] = React.useState(false);
- React.useEffect(() => {
- if (open) setForm(emptyForm);
- }, [open]);
- async function submit() {
- setSaving(true);
- try {
- await onCreate(toPayload(form) as ModelCreateRequest);
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "Failed to create model");
- } finally {
- setSaving(false);
- }
- }
- return (
- <Dialog open={open} onOpenChange={onOpenChange} title="New Model" description="Add an OpenAI-compatible model endpoint." className="max-w-4xl">
- <ModelForm form={form} providers={providers} onChange={setForm} />
- <div className="mt-5 flex justify-end">
- <Button onClick={() => void submit()} disabled={saving}>
- <Plus className="h-4 w-4" /> {saving ? "Creating" : "Create Model"}
- </Button>
- </div>
- </Dialog>
- );
- }
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return (
- <label className="space-y-1.5 text-sm font-medium">
- <span>{label}</span>
- {children}
- </label>
- );
- }
- function fromModel(model: ModelDefinition): ModelFormState {
- return {
- code: model.code,
- name: model.name,
- provider_type: model.provider_type,
- provider_base_url: model.provider_base_url,
- provider_api_key: "",
- model_name: model.model_name,
- status: model.status,
- description: model.description ?? "",
- capabilities: model.capabilities_json.join(", "),
- context_window: String(model.context_window ?? ""),
- max_output_tokens: String(model.max_output_tokens ?? ""),
- default_temperature: String(model.default_temperature ?? ""),
- timeout_seconds: String(model.timeout_seconds ?? 60),
- };
- }
- function toPayload(form: ModelFormState): ModelCreateRequest {
- return {
- code: form.code.trim(),
- name: form.name.trim(),
- provider_type: form.provider_type.trim() || "openai_compatible",
- provider_base_url: form.provider_base_url.trim(),
- provider_api_key: form.provider_api_key.trim() || null,
- model_name: form.model_name.trim(),
- status: form.status,
- description: form.description.trim() || null,
- capabilities_json: form.capabilities.split(",").map((item) => item.trim()).filter(Boolean),
- context_window: parseOptionalInteger(form.context_window),
- max_output_tokens: parseOptionalInteger(form.max_output_tokens),
- default_temperature: parseOptionalFloat(form.default_temperature),
- timeout_seconds: parseOptionalFloat(form.timeout_seconds) ?? 60,
- metadata_json: {},
- };
- }
- function parseOptionalInteger(value: string) {
- if (!value.trim()) return null;
- const parsed = Number.parseInt(value, 10);
- return Number.isFinite(parsed) ? parsed : null;
- }
- function parseOptionalFloat(value: string) {
- if (!value.trim()) return null;
- const parsed = Number.parseFloat(value);
- return Number.isFinite(parsed) ? parsed : null;
- }
|