ModelsPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import * as React from "react";
  2. import {
  3. Activity,
  4. CheckCircle2,
  5. Cpu,
  6. FlaskConical,
  7. Plus,
  8. Power,
  9. RefreshCw,
  10. Save,
  11. Trash2,
  12. } from "lucide-react";
  13. import {
  14. createModel,
  15. deleteModel,
  16. listModels,
  17. testModel,
  18. updateModel,
  19. updateModelStatus,
  20. } from "@/api";
  21. import { ApiErrorState } from "@/components/shared/ApiErrorState";
  22. import { EmptyState } from "@/components/shared/EmptyState";
  23. import { EntityListItem } from "@/components/shared/EntityListItem";
  24. import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
  25. import { MetricCard } from "@/components/shared/MetricCard";
  26. import { PageHeader } from "@/components/shared/PageHeader";
  27. import { SearchInput } from "@/components/shared/SearchInput";
  28. import { StatusBadge } from "@/components/shared/StatusBadge";
  29. import { Button } from "@/components/ui/button";
  30. import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
  31. import { Dialog } from "@/components/ui/dialog";
  32. import { Input, Textarea } from "@/components/ui/input";
  33. import { Select } from "@/components/ui/select";
  34. import { toast } from "@/components/ui/toaster";
  35. import { formatDateTime } from "@/lib/utils";
  36. import type { ModelCreateRequest, ModelDefinition, ModelStatus } from "@/types";
  37. type ModelFormState = {
  38. code: string;
  39. name: string;
  40. provider_type: string;
  41. provider_base_url: string;
  42. provider_api_key: string;
  43. model_name: string;
  44. status: ModelStatus;
  45. description: string;
  46. capabilities: string;
  47. context_window: string;
  48. max_output_tokens: string;
  49. default_temperature: string;
  50. timeout_seconds: string;
  51. };
  52. const emptyForm: ModelFormState = {
  53. code: "",
  54. name: "",
  55. provider_type: "openai_compatible",
  56. provider_base_url: "http://127.0.0.1:11434/v1",
  57. provider_api_key: "",
  58. model_name: "",
  59. status: "active",
  60. description: "",
  61. capabilities: "chat",
  62. context_window: "",
  63. max_output_tokens: "",
  64. default_temperature: "",
  65. timeout_seconds: "60",
  66. };
  67. export function ModelsPage() {
  68. const [models, setModels] = React.useState<ModelDefinition[]>([]);
  69. const [selectedId, setSelectedId] = React.useState<string>();
  70. const [search, setSearch] = React.useState("");
  71. const [statusFilter, setStatusFilter] = React.useState("all");
  72. const [loading, setLoading] = React.useState(true);
  73. const [saving, setSaving] = React.useState(false);
  74. const [testing, setTesting] = React.useState(false);
  75. const [error, setError] = React.useState<string>();
  76. const [createOpen, setCreateOpen] = React.useState(false);
  77. const [form, setForm] = React.useState<ModelFormState>(emptyForm);
  78. const [testPrompt, setTestPrompt] = React.useState("Say OK in one short sentence.");
  79. const [testOutput, setTestOutput] = React.useState<string>();
  80. const selected = models.find((model) => model.id === selectedId);
  81. const providers = Array.from(new Set(models.map((model) => model.provider_type))).sort();
  82. const activeCount = models.filter((model) => model.status === "active").length;
  83. const chatReadyCount = models.filter((model) => model.capabilities_json.includes("chat")).length;
  84. const filtered = models.filter((model) => {
  85. const haystack = `${model.name} ${model.code} ${model.model_name} ${model.provider_type}`.toLowerCase();
  86. const matchesSearch = haystack.includes(search.toLowerCase());
  87. const matchesStatus = statusFilter === "all" || model.status === statusFilter;
  88. return matchesSearch && matchesStatus;
  89. });
  90. const load = React.useCallback(async () => {
  91. setLoading(true);
  92. setError(undefined);
  93. try {
  94. const data = await listModels();
  95. setModels(data);
  96. setSelectedId((current) => current ?? data[0]?.id);
  97. } catch (err) {
  98. setError(err instanceof Error ? err.message : "Failed to load models");
  99. } finally {
  100. setLoading(false);
  101. }
  102. }, []);
  103. React.useEffect(() => {
  104. void load();
  105. }, [load]);
  106. React.useEffect(() => {
  107. if (selected) setForm(fromModel(selected));
  108. }, [selected]);
  109. async function createFromDialog(payload: ModelCreateRequest) {
  110. const created = await createModel(payload);
  111. setModels((current) => [created, ...current]);
  112. setSelectedId(created.id);
  113. setCreateOpen(false);
  114. toast.success("Model created");
  115. }
  116. async function saveSelected() {
  117. if (!selected) return;
  118. setSaving(true);
  119. try {
  120. const payload = toPayload(form);
  121. if (!form.provider_api_key.trim()) delete payload.provider_api_key;
  122. const updated = await updateModel(selected.id, payload);
  123. setModels((current) => current.map((model) => (model.id === updated.id ? updated : model)));
  124. toast.success("Model saved");
  125. } catch (err) {
  126. toast.error(err instanceof Error ? err.message : "Failed to save model");
  127. } finally {
  128. setSaving(false);
  129. }
  130. }
  131. async function toggleSelected() {
  132. if (!selected) return;
  133. const nextStatus: ModelStatus = selected.status === "active" ? "disabled" : "active";
  134. const updated = await updateModelStatus(selected.id, nextStatus);
  135. setModels((current) => current.map((model) => (model.id === updated.id ? updated : model)));
  136. toast.success(nextStatus === "active" ? "Model enabled" : "Model disabled");
  137. }
  138. async function deleteSelected() {
  139. if (!selected) return;
  140. await deleteModel(selected.id);
  141. setModels((current) => current.filter((model) => model.id !== selected.id));
  142. setSelectedId(models.find((model) => model.id !== selected.id)?.id);
  143. setTestOutput(undefined);
  144. toast.success("Model deleted");
  145. }
  146. async function runTest() {
  147. if (!selected) return;
  148. setTesting(true);
  149. setTestOutput(undefined);
  150. try {
  151. const result = await testModel(selected.id, { prompt: testPrompt, max_tokens: 128 });
  152. setTestOutput(result.response.content || JSON.stringify(result.response.raw_response_json, null, 2));
  153. toast.success("Model test completed");
  154. } catch (err) {
  155. setTestOutput(err instanceof Error ? err.message : "Model test failed");
  156. toast.error("Model test failed");
  157. } finally {
  158. setTesting(false);
  159. }
  160. }
  161. if (loading) return <LoadingSpinner label="Loading models" />;
  162. if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
  163. return (
  164. <div className="space-y-6">
  165. <PageHeader
  166. title="Models"
  167. description="Manage model providers, serving names, defaults, and connectivity tests."
  168. actions={
  169. <>
  170. <Button variant="outline" onClick={() => void load()}>
  171. <RefreshCw className="h-4 w-4" /> Refresh
  172. </Button>
  173. <Button onClick={() => setCreateOpen(true)}>
  174. <Plus className="h-4 w-4" /> New Model
  175. </Button>
  176. </>
  177. }
  178. />
  179. <div className="grid gap-4 md:grid-cols-3">
  180. <MetricCard label="Models" value={models.length} icon={Cpu} />
  181. <MetricCard label="Active" value={activeCount} icon={CheckCircle2} />
  182. <MetricCard label="Chat Ready" value={chatReadyCount} icon={Activity} />
  183. </div>
  184. <div className="grid gap-6 xl:grid-cols-[380px_1fr]">
  185. <Card>
  186. <CardHeader>
  187. <CardTitle>Model Catalog</CardTitle>
  188. <CardDescription>{filtered.length} of {models.length} shown</CardDescription>
  189. </CardHeader>
  190. <CardContent className="space-y-4">
  191. <SearchInput value={search} onChange={setSearch} placeholder="Search models" />
  192. <Select
  193. value={statusFilter}
  194. onChange={(event) => setStatusFilter(event.target.value)}
  195. options={[
  196. { value: "all", label: "All statuses" },
  197. { value: "active", label: "Active" },
  198. { value: "disabled", label: "Disabled" },
  199. ]}
  200. />
  201. {filtered.length ? (
  202. <div className="space-y-2">
  203. {filtered.map((model) => (
  204. <EntityListItem
  205. key={model.id}
  206. title={model.name}
  207. subtitle={`${model.code} - ${model.model_name}`}
  208. active={model.id === selectedId}
  209. onClick={() => {
  210. setSelectedId(model.id);
  211. setTestOutput(undefined);
  212. }}
  213. meta={<StatusBadge status={model.status} />}
  214. />
  215. ))}
  216. </div>
  217. ) : (
  218. <EmptyState icon={Cpu} title="No models" description="Create a model configuration to start routing chat completions." />
  219. )}
  220. </CardContent>
  221. </Card>
  222. {selected ? (
  223. <div className="space-y-6">
  224. <Card>
  225. <CardHeader>
  226. <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
  227. <div>
  228. <CardTitle>{selected.name}</CardTitle>
  229. <CardDescription>
  230. {selected.provider_type} / {selected.model_name}
  231. </CardDescription>
  232. </div>
  233. <div className="flex flex-wrap gap-2">
  234. <Button variant="outline" onClick={() => void toggleSelected()}>
  235. <Power className="h-4 w-4" /> {selected.status === "active" ? "Disable" : "Enable"}
  236. </Button>
  237. <Button variant="destructive" onClick={() => void deleteSelected()}>
  238. <Trash2 className="h-4 w-4" /> Delete
  239. </Button>
  240. </div>
  241. </div>
  242. </CardHeader>
  243. <CardContent>
  244. <ModelForm form={form} providers={providers} onChange={setForm} />
  245. <div className="mt-5 flex justify-end">
  246. <Button onClick={() => void saveSelected()} disabled={saving}>
  247. <Save className="h-4 w-4" /> {saving ? "Saving" : "Save"}
  248. </Button>
  249. </div>
  250. </CardContent>
  251. </Card>
  252. <Card>
  253. <CardHeader>
  254. <CardTitle>Connectivity Test</CardTitle>
  255. <CardDescription>Send a short prompt through this exact provider configuration.</CardDescription>
  256. </CardHeader>
  257. <CardContent className="space-y-4">
  258. <Textarea value={testPrompt} onChange={(event) => setTestPrompt(event.target.value)} />
  259. <div className="flex justify-end">
  260. <Button onClick={() => void runTest()} disabled={testing || selected.status !== "active"}>
  261. <FlaskConical className="h-4 w-4" /> {testing ? "Testing" : "Test Model"}
  262. </Button>
  263. </div>
  264. {testOutput ? (
  265. <pre className="max-h-72 overflow-auto rounded-md border border-border bg-muted/40 p-3 text-sm whitespace-pre-wrap">
  266. {testOutput}
  267. </pre>
  268. ) : null}
  269. <div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
  270. <span>Updated {formatDateTime(selected.updated_time)}</span>
  271. <span>{selected.has_provider_api_key ? "API key configured" : "No API key configured"}</span>
  272. </div>
  273. </CardContent>
  274. </Card>
  275. </div>
  276. ) : (
  277. <EmptyState icon={Cpu} title="No model selected" description="Select or create a model to edit its configuration." />
  278. )}
  279. </div>
  280. <CreateModelDialog
  281. open={createOpen}
  282. onOpenChange={setCreateOpen}
  283. onCreate={createFromDialog}
  284. providers={providers}
  285. />
  286. </div>
  287. );
  288. }
  289. function ModelForm({
  290. form,
  291. providers,
  292. onChange,
  293. }: {
  294. form: ModelFormState;
  295. providers: string[];
  296. onChange: (form: ModelFormState) => void;
  297. }) {
  298. const set = (key: keyof ModelFormState, value: string) => onChange({ ...form, [key]: value });
  299. const providerOptions = Array.from(new Set(["openai_compatible", "openai", "ollama", ...providers])).map((value) => ({
  300. value,
  301. label: value,
  302. }));
  303. return (
  304. <div className="grid gap-4 md:grid-cols-2">
  305. <Field label="Code"><Input value={form.code} onChange={(event) => set("code", event.target.value)} /></Field>
  306. <Field label="Name"><Input value={form.name} onChange={(event) => set("name", event.target.value)} /></Field>
  307. <Field label="Provider"><Select value={form.provider_type} onChange={(event) => set("provider_type", event.target.value)} options={providerOptions} /></Field>
  308. <Field label="Provider Base URL"><Input value={form.provider_base_url} onChange={(event) => set("provider_base_url", event.target.value)} /></Field>
  309. <Field label="Model Name"><Input value={form.model_name} onChange={(event) => set("model_name", event.target.value)} /></Field>
  310. <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>
  311. <Field label="Capabilities"><Input value={form.capabilities} onChange={(event) => set("capabilities", event.target.value)} placeholder="chat, tools, vision" /></Field>
  312. <Field label="Status"><Select value={form.status} onChange={(event) => set("status", event.target.value)} options={[{ value: "active", label: "Active" }, { value: "disabled", label: "Disabled" }]} /></Field>
  313. <Field label="Context Window"><Input value={form.context_window} onChange={(event) => set("context_window", event.target.value)} inputMode="numeric" /></Field>
  314. <Field label="Max Output Tokens"><Input value={form.max_output_tokens} onChange={(event) => set("max_output_tokens", event.target.value)} inputMode="numeric" /></Field>
  315. <Field label="Default Temperature"><Input value={form.default_temperature} onChange={(event) => set("default_temperature", event.target.value)} inputMode="decimal" /></Field>
  316. <Field label="Timeout Seconds"><Input value={form.timeout_seconds} onChange={(event) => set("timeout_seconds", event.target.value)} inputMode="decimal" /></Field>
  317. <div className="md:col-span-2">
  318. <Field label="Description"><Textarea value={form.description} onChange={(event) => set("description", event.target.value)} /></Field>
  319. </div>
  320. </div>
  321. );
  322. }
  323. function CreateModelDialog({
  324. open,
  325. onOpenChange,
  326. onCreate,
  327. providers,
  328. }: {
  329. open: boolean;
  330. onOpenChange: (open: boolean) => void;
  331. onCreate: (payload: ModelCreateRequest) => Promise<void>;
  332. providers: string[];
  333. }) {
  334. const [form, setForm] = React.useState<ModelFormState>(emptyForm);
  335. const [saving, setSaving] = React.useState(false);
  336. React.useEffect(() => {
  337. if (open) setForm(emptyForm);
  338. }, [open]);
  339. async function submit() {
  340. setSaving(true);
  341. try {
  342. await onCreate(toPayload(form) as ModelCreateRequest);
  343. } catch (err) {
  344. toast.error(err instanceof Error ? err.message : "Failed to create model");
  345. } finally {
  346. setSaving(false);
  347. }
  348. }
  349. return (
  350. <Dialog open={open} onOpenChange={onOpenChange} title="New Model" description="Add an OpenAI-compatible model endpoint." className="max-w-4xl">
  351. <ModelForm form={form} providers={providers} onChange={setForm} />
  352. <div className="mt-5 flex justify-end">
  353. <Button onClick={() => void submit()} disabled={saving}>
  354. <Plus className="h-4 w-4" /> {saving ? "Creating" : "Create Model"}
  355. </Button>
  356. </div>
  357. </Dialog>
  358. );
  359. }
  360. function Field({ label, children }: { label: string; children: React.ReactNode }) {
  361. return (
  362. <label className="space-y-1.5 text-sm font-medium">
  363. <span>{label}</span>
  364. {children}
  365. </label>
  366. );
  367. }
  368. function fromModel(model: ModelDefinition): ModelFormState {
  369. return {
  370. code: model.code,
  371. name: model.name,
  372. provider_type: model.provider_type,
  373. provider_base_url: model.provider_base_url,
  374. provider_api_key: "",
  375. model_name: model.model_name,
  376. status: model.status,
  377. description: model.description ?? "",
  378. capabilities: model.capabilities_json.join(", "),
  379. context_window: String(model.context_window ?? ""),
  380. max_output_tokens: String(model.max_output_tokens ?? ""),
  381. default_temperature: String(model.default_temperature ?? ""),
  382. timeout_seconds: String(model.timeout_seconds ?? 60),
  383. };
  384. }
  385. function toPayload(form: ModelFormState): ModelCreateRequest {
  386. return {
  387. code: form.code.trim(),
  388. name: form.name.trim(),
  389. provider_type: form.provider_type.trim() || "openai_compatible",
  390. provider_base_url: form.provider_base_url.trim(),
  391. provider_api_key: form.provider_api_key.trim() || null,
  392. model_name: form.model_name.trim(),
  393. status: form.status,
  394. description: form.description.trim() || null,
  395. capabilities_json: form.capabilities.split(",").map((item) => item.trim()).filter(Boolean),
  396. context_window: parseOptionalInteger(form.context_window),
  397. max_output_tokens: parseOptionalInteger(form.max_output_tokens),
  398. default_temperature: parseOptionalFloat(form.default_temperature),
  399. timeout_seconds: parseOptionalFloat(form.timeout_seconds) ?? 60,
  400. metadata_json: {},
  401. };
  402. }
  403. function parseOptionalInteger(value: string) {
  404. if (!value.trim()) return null;
  405. const parsed = Number.parseInt(value, 10);
  406. return Number.isFinite(parsed) ? parsed : null;
  407. }
  408. function parseOptionalFloat(value: string) {
  409. if (!value.trim()) return null;
  410. const parsed = Number.parseFloat(value);
  411. return Number.isFinite(parsed) ? parsed : null;
  412. }