|
|
@@ -0,0 +1,442 @@
|
|
|
+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;
|
|
|
+}
|