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