feat(model-server): frontend modèles locaux — badge de statut & CRUD serveurs (#35) et wizard multi-profils OpenCode (#36)
Sprint « Modeles locaux », couche frontend. #35 : - F35.1 badge de statut de lancement du serveur local (ModelServerLaunchBadge + useAgentsModelServer). - F35.2 feature model-servers : CRUD (ModelServersPanel / useModelServers / gateway modelServer) et ModelServerSelect. #36 : - Liste multi-profils OpenCode dans le wizard de premier lancement, gateway de clonage (clone_opencode_profile_from_seed). Tests verts (exécution réelle) : tsc propre, vitest 608/608. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
323
frontend/src/features/model-servers/ModelServersPanel.tsx
Normal file
323
frontend/src/features/model-servers/ModelServersPanel.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
/**
|
||||
* `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp`
|
||||
* servers an OpenCode profile can bind to. Presentational: all I/O state comes
|
||||
* from a {@link ModelServersViewModel} passed in (`useModelServers`), so the
|
||||
* panel renders and is testable in isolation.
|
||||
*
|
||||
* A single editable draft is shown at a time (add or edit); the list underneath
|
||||
* offers Edit / Delete per server. The `model_server_in_use` rejection surfaces
|
||||
* as the vm's error banner (a referenced server can't be removed).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { LocalModelServerConfig, StopPolicy } from "@/domain";
|
||||
import { Button, IconButton, Input, Panel, cn } from "@/shared";
|
||||
import type { ModelServersViewModel } from "./useModelServers";
|
||||
import {
|
||||
emptyModelServer,
|
||||
parseArgs,
|
||||
validateModelServer,
|
||||
type ModelServerErrors,
|
||||
} from "./modelServer";
|
||||
|
||||
/** Small caption above a control. */
|
||||
function Caption({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||
}
|
||||
|
||||
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||||
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
||||
{ value: "keepAlive", label: "Keep alive" },
|
||||
{ value: "stopWhenUnused", label: "Stop when unused" },
|
||||
];
|
||||
|
||||
export interface ModelServersPanelProps {
|
||||
/** The model-server registry view-model (from `useModelServers`). */
|
||||
vm: ModelServersViewModel;
|
||||
}
|
||||
|
||||
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
|
||||
// no editor open (list-only).
|
||||
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
|
||||
|
||||
function startAdd() {
|
||||
vm.clearError();
|
||||
setDraft(emptyModelServer());
|
||||
}
|
||||
|
||||
function startEdit(server: LocalModelServerConfig) {
|
||||
vm.clearError();
|
||||
setDraft({ ...server });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!draft) return;
|
||||
const saved = await vm.save(draft);
|
||||
if (saved) setDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
aria-label="local model servers"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-content">
|
||||
Local model servers
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label="add model server"
|
||||
onClick={startAdd}
|
||||
className="ml-auto"
|
||||
>
|
||||
Add server
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{vm.error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vm.servers.length === 0 && !draft && (
|
||||
<p className="text-xs text-muted">
|
||||
No local server declared yet. Add one to auto-manage a `llama-server`,
|
||||
or keep using an external endpoint.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="flex list-none flex-col gap-2 p-0">
|
||||
{vm.servers.map((server) => (
|
||||
<li
|
||||
key={server.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm text-content">
|
||||
{server.name}
|
||||
</strong>
|
||||
<span className="truncate text-xs text-muted">
|
||||
{server.baseURL} · {server.servedModelName}
|
||||
{server.autoStart ? " · auto-start" : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
aria-label={`edit ${server.name}`}
|
||||
onClick={() => startEdit(server)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`delete ${server.name}`}
|
||||
onClick={() => void vm.remove(server.id)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{draft && (
|
||||
<ServerEditor
|
||||
draft={draft}
|
||||
busy={vm.busy}
|
||||
onChange={setDraft}
|
||||
onCancel={() => {
|
||||
vm.clearError();
|
||||
setDraft(null);
|
||||
}}
|
||||
onSubmit={() => void submit()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** The add/edit form for one server draft. */
|
||||
function ServerEditor({
|
||||
draft,
|
||||
busy,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
draft: LocalModelServerConfig;
|
||||
busy: boolean;
|
||||
onChange: (next: LocalModelServerConfig) => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const errors: ModelServerErrors = validateModelServer(draft);
|
||||
const valid = Object.keys(errors).length === 0;
|
||||
const patch = (next: Partial<LocalModelServerConfig>) =>
|
||||
onChange({ ...draft, ...next });
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
aria-label="model server editor"
|
||||
className="flex flex-col gap-2 rounded-md border border-primary/40 p-3"
|
||||
>
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
||||
</legend>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Name</Caption>
|
||||
<Input
|
||||
aria-label="server name"
|
||||
value={draft.name}
|
||||
invalid={Boolean(errors.name)}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
/>
|
||||
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||
<Caption>Base URL</Caption>
|
||||
<Input
|
||||
aria-label="server base url"
|
||||
value={draft.baseURL}
|
||||
placeholder="http://localhost:8080/v1"
|
||||
invalid={Boolean(errors.baseURL)}
|
||||
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||
/>
|
||||
{errors.baseURL && (
|
||||
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-[6rem] flex-col gap-1">
|
||||
<Caption>Port</Caption>
|
||||
<Input
|
||||
aria-label="server port"
|
||||
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
|
||||
inputMode="numeric"
|
||||
invalid={Boolean(errors.port)}
|
||||
onChange={(e) => patch({ port: Number.parseInt(e.target.value, 10) })}
|
||||
/>
|
||||
{errors.port && <small className="text-xs text-danger">{errors.port}</small>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Served model name (sent to OpenCode as `model`)</Caption>
|
||||
<Input
|
||||
aria-label="server served model name"
|
||||
value={draft.servedModelName}
|
||||
placeholder="qwen3-coder-30b"
|
||||
invalid={Boolean(errors.servedModelName)}
|
||||
onChange={(e) => patch({ servedModelName: e.target.value })}
|
||||
/>
|
||||
{errors.servedModelName && (
|
||||
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Model path (absolute `.gguf` — required for auto-start)</Caption>
|
||||
<Input
|
||||
aria-label="server model path"
|
||||
value={draft.modelPath ?? ""}
|
||||
placeholder="/models/qwen3-coder-30b.gguf"
|
||||
invalid={Boolean(errors.modelPath)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ modelPath: v.length === 0 ? undefined : v });
|
||||
}}
|
||||
/>
|
||||
{errors.modelPath && (
|
||||
<small className="text-xs text-danger">{errors.modelPath}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Binary path (optional — `llama-server` command)</Caption>
|
||||
<Input
|
||||
aria-label="server binary path"
|
||||
value={draft.binaryPath ?? ""}
|
||||
placeholder="llama-server"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
patch({ binaryPath: v.length === 0 ? undefined : v });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Extra arguments</Caption>
|
||||
<Input
|
||||
aria-label="server args"
|
||||
value={draft.args.join(" ")}
|
||||
placeholder="--ctx-size 8192 --n-gpu-layers 99"
|
||||
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="server auto start"
|
||||
checked={draft.autoStart}
|
||||
onChange={(e) => patch({ autoStart: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Caption>Auto-start</Caption>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Caption>Stop policy</Caption>
|
||||
<select
|
||||
aria-label="server stop policy"
|
||||
value={draft.stopPolicy}
|
||||
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
||||
className={cn(
|
||||
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||
"border border-border outline-none focus:border-primary",
|
||||
)}
|
||||
>
|
||||
{STOP_POLICIES.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label="save model server"
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !valid}
|
||||
>
|
||||
Save server
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="cancel model server"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user