feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14)

Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible,
en parité avec l'adapter backend additif (aab4bca).

- domain: types de profil OpenAI-compatible
- first-run: édition/validation du profil dans le FirstRunWizard
- adapters/mock: mock de profil pour les tests
- terminals: rendu des round-trips et erreurs endpoint (role=alert)

Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests,
0 echec ; couverture timeouts round-trip + erreur endpoint role=alert
prouvees ; pas de regression sur les tests de bail headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:34:50 +02:00
parent aab4bcafb6
commit d89380cdf0
9 changed files with 656 additions and 10 deletions

View File

@ -7,12 +7,26 @@
import type {
AgentProfile,
ContextInjection,
HttpChatConfig,
InjectionStrategy,
} from "@/domain";
/** A field-keyed validation error map (empty ⇒ valid). */
export type ProfileErrors = Partial<
Record<"name" | "command" | "target" | "flag" | "var", string>
Record<
| "name"
| "command"
| "target"
| "flag"
| "var"
| "endpoint"
| "model"
| "apiKeyEnv"
| "requestTimeoutMs"
| "connectTimeoutMs"
| "maxToolIterations",
string
>
>;
/** Whether a path is a relative, traversal-free file name (mirror of backend). */
@ -29,6 +43,58 @@ export function isValidEnvVar(v: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v);
}
/**
* Whether a string is a valid `http://`/`https://` endpoint (mirror of the
* backend `HttpChatConfig::new`: non-empty once trimmed, and the raw value
* starts with `http://` or `https://`).
*/
export function isValidHttpUrl(v: string): boolean {
if (v.trim().length === 0) return false;
return v.startsWith("http://") || v.startsWith("https://");
}
/**
* Whether a value is an acceptable positive-integer timeout/guard (mirror of the
* backend, which rejects `0` and only ever holds `u32`/`u16`). `undefined` means
* "left blank" and is valid (the backend applies its own default).
*/
function isValidPositiveInt(value: number | undefined, max: number): boolean {
if (value === undefined) return true;
return Number.isInteger(value) && value > 0 && value <= max;
}
const MAX_U32 = 0xffff_ffff;
const MAX_U16 = 0xffff;
/**
* Validates an {@link HttpChatConfig} draft the way the backend
* `HttpChatConfig::new` would, so the wizard can surface errors before any
* `invoke`. Returns an empty object when the draft is valid.
*/
export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors {
const errors: ProfileErrors = {};
if (!isValidHttpUrl(c.endpoint)) {
errors.endpoint = "Endpoint must start with http:// or https://.";
}
if (c.model.trim().length === 0) {
errors.model = "Model is required.";
}
// The API key field holds the NAME of an environment variable, never a key.
if (c.apiKeyEnv !== undefined && c.apiKeyEnv.length > 0 && !isValidEnvVar(c.apiKeyEnv)) {
errors.apiKeyEnv = "Must be a valid env var name (not the key itself).";
}
if (!isValidPositiveInt(c.requestTimeoutMs, MAX_U32)) {
errors.requestTimeoutMs = "Must be a positive integer (ms).";
}
if (!isValidPositiveInt(c.connectTimeoutMs, MAX_U32)) {
errors.connectTimeoutMs = "Must be a positive integer (ms).";
}
if (!isValidPositiveInt(c.maxToolIterations, MAX_U16)) {
errors.maxToolIterations = "Must be a positive integer.";
}
return errors;
}
/**
* Validates a profile draft the way the backend would, so the wizard can surface
* errors before any `invoke`. Returns an empty object when the draft is valid.
@ -48,6 +114,17 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
} else if (ci.strategy === "env") {
if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier.";
}
// An OpenAI-compatible profile additionally carries an HTTP chat config; the
// backend refuses to persist it unless it is well-formed, so mirror that here.
if (p.structuredAdapter === "openAiCompatible") {
if (!p.chatHttp) {
errors.endpoint = "Endpoint must start with http:// or https://.";
errors.model = "Model is required.";
} else {
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
}
}
return errors;
}
@ -73,6 +150,21 @@ export function defaultInjection(strategy: InjectionStrategy): ContextInjection
}
}
/**
* A sensible default {@link HttpChatConfig} for a fresh OpenAI-compatible
* profile draft — pre-fills a local Ollama endpoint so the form starts valid.
* Mirrors the backend reference profile (`http://localhost:11434/v1`).
*/
export function defaultHttpChatConfig(): HttpChatConfig {
return {
endpoint: "http://localhost:11434/v1",
model: "qwen2.5-coder",
requestTimeoutMs: 120_000,
connectTimeoutMs: 5_000,
maxToolIterations: 16,
};
}
/** A fresh, empty custom-profile draft (id minted client-side). */
export function emptyCustomProfile(): AgentProfile {
return {