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:
2026-07-11 15:50:18 +02:00
parent b82ac76f8b
commit 72476a650a
23 changed files with 1893 additions and 15 deletions

View File

@ -15,8 +15,18 @@
* `./profile`.
*/
import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain";
import type {
AgentProfile,
HttpChatConfig,
LocalModelServerConfig,
OpenCodeConfig,
} from "@/domain";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import {
ModelServersPanel,
ModelServerSelect,
useModelServers,
} from "@/features/model-servers";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultHttpChatConfig,
@ -48,6 +58,7 @@ export function FirstRunWizard({
forceOpen?: boolean;
}) {
const vm = useFirstRun();
const modelServers = useModelServers();
if (vm.isFirstRun === null) return null;
if (!forceOpen && vm.isFirstRun === false) return null;
@ -93,16 +104,40 @@ export function FirstRunWizard({
Detecting
</span>
)}
{/* F36: declare several local OpenCode profiles. Each click clones the
canonical `opencode-llamacpp` seed into a new, editable row. */}
<Button
onClick={() => void vm.addOpenCodeProfile()}
disabled={vm.busy}
className="whitespace-nowrap"
>
Add OpenCode profile
</Button>
</Toolbar>
{/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode
profile can bind to. Sits above the profile list so a server exists
before it is picked in the OpenCode dropdown. */}
<ModelServersPanel vm={modelServers} />
<ul className="flex list-none flex-col gap-3 p-0">
{vm.entries.map((entry) => (
<ProfileRow
key={entry.profile.id}
entry={entry}
servers={modelServers.servers}
onToggle={() => vm.toggle(entry.profile.id)}
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
onRemove={() => vm.remove(entry.profile.id)}
onDuplicate={
entry.profile.structuredAdapter === "openCode"
? () =>
vm.addOpenCodeProfile({
name: `${entry.profile.name} (copy)`,
opencode: entry.profile.opencode,
})
: undefined
}
/>
))}
</ul>
@ -120,17 +155,24 @@ export function FirstRunWizard({
/** One editable candidate row: select, edit command/args, see availability. */
function ProfileRow({
entry,
servers,
onToggle,
onChange,
onRemove,
onDuplicate,
}: {
entry: WizardEntry;
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
servers: LocalModelServerConfig[];
onToggle: () => void;
onChange: (p: AgentProfile) => void;
onRemove: () => void;
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
onDuplicate?: () => void;
}) {
const { profile, selected, available } = entry;
const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode";
return (
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
@ -158,16 +200,41 @@ function ProfileRow({
>
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
</span>
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
onClick={onRemove}
className="ml-auto"
>
×
</IconButton>
<div className="ml-auto flex items-center gap-1">
{onDuplicate && (
<Button
size="sm"
aria-label={`duplicate ${profile.name}`}
onClick={onDuplicate}
>
Duplicate
</Button>
)}
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
onClick={onRemove}
>
×
</IconButton>
</div>
</div>
{/* F36: an OpenCode profile's name is identity-neutral but user-facing, so
it is editable per profile (several local models coexist). */}
{isOpenCode && (
<label className="flex flex-col gap-1">
<Caption>Name</Caption>
<Input
aria-label={`${profile.name} name`}
value={profile.name}
invalid={Boolean(errors.name)}
onChange={(e) => onChange({ ...profile, name: e.target.value })}
/>
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
</label>
)}
<label className="flex flex-col gap-1">
<Caption>Command</Caption>
<Input
@ -193,7 +260,12 @@ function ProfileRow({
)}
{profile.structuredAdapter === "openCode" && (
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
<OpenCodeFields
profile={profile}
errors={errors}
servers={servers}
onChange={onChange}
/>
)}
</li>
);
@ -208,10 +280,13 @@ function ProfileRow({
function OpenCodeFields({
profile,
errors,
servers,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
/** Declared local model servers (F35.2), for the binding dropdown. */
servers: LocalModelServerConfig[];
onChange: (p: AgentProfile) => void;
}) {
const opencode = profile.opencode ?? defaultOpenCodeConfig();
@ -262,6 +337,44 @@ function OpenCodeFields({
}}
/>
</label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} reasoning`}
// Effective backend default is `true`; treat omitted as enabled.
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
// Effective backend default is `false`.
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}
/>
</label>
</fieldset>
);
}