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:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user