Ajoute l'UI de configuration des providers OpenCode cloud dans le wizard first-run (sélection, édition, sauvegarde, suppression), le domaine et les ports associés, ainsi que les adapters HTTP/mock correspondants. Build + 952 tests verts, comportements clés vérifiés en exécution réelle, y compris un test de couverture ajouté pour le parcours d'édition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
240 lines
8.0 KiB
TypeScript
240 lines
8.0 KiB
TypeScript
/**
|
|
* Pure, framework-free helpers for the first-run wizard (testable without React
|
|
* or Tauri, ARCHITECTURE §1.3). Validation of a custom/edited profile and small
|
|
* factories live here; the components only render.
|
|
*/
|
|
|
|
import type {
|
|
AgentProfile,
|
|
ContextInjection,
|
|
HttpChatConfig,
|
|
InjectionStrategy,
|
|
OpenCodeConfig,
|
|
} from "@/domain";
|
|
|
|
/** A field-keyed validation error map (empty ⇒ valid). */
|
|
export type ProfileErrors = Partial<
|
|
Record<
|
|
| "name"
|
|
| "command"
|
|
| "target"
|
|
| "flag"
|
|
| "var"
|
|
| "endpoint"
|
|
| "baseURL"
|
|
| "model"
|
|
| "apiKeyEnv"
|
|
| "requestTimeoutMs"
|
|
| "connectTimeoutMs"
|
|
| "maxToolIterations",
|
|
string
|
|
>
|
|
>;
|
|
|
|
/** Whether a path is a relative, traversal-free file name (mirror of backend). */
|
|
export function isRelativeSafe(path: string): boolean {
|
|
if (path.length === 0) return false;
|
|
if (path.startsWith("/") || path.startsWith("\\")) return false;
|
|
// Windows drive (C:) / UNC.
|
|
if (/^[a-zA-Z]:/.test(path)) return false;
|
|
return !path.split(/[/\\]/).includes("..");
|
|
}
|
|
|
|
/** Whether a string is a valid environment-variable identifier. */
|
|
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 an {@link OpenCodeConfig} draft the way the backend
|
|
* `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty;
|
|
* the API key is a free-form, optional secret). Returns an empty object when the
|
|
* draft is valid.
|
|
*/
|
|
export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors {
|
|
const errors: ProfileErrors = {};
|
|
if (!isValidHttpUrl(c.baseURL)) {
|
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
|
}
|
|
if (c.model.trim().length === 0) {
|
|
errors.model = "Model is required.";
|
|
}
|
|
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.
|
|
*/
|
|
export function validateProfile(p: AgentProfile): ProfileErrors {
|
|
const errors: ProfileErrors = {};
|
|
if (p.name.trim().length === 0) errors.name = "Name is required.";
|
|
if (p.command.trim().length === 0) errors.command = "Command is required.";
|
|
|
|
const ci = p.contextInjection;
|
|
if (ci.strategy === "conventionFile") {
|
|
if (!isRelativeSafe(ci.target)) {
|
|
errors.target = "Target must be a relative file name (no .. or absolute).";
|
|
}
|
|
} else if (ci.strategy === "flag") {
|
|
if (ci.flag.trim().length === 0) errors.flag = "Flag is required.";
|
|
} 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));
|
|
}
|
|
}
|
|
// An OpenCode profile carries its own llama.cpp endpoint config (local mode)
|
|
// or a cloud provider config (`opencodeProvider`, ticket #92) — never both,
|
|
// and the cloud sub-form owns its own submit-time validation (provider,
|
|
// model, API key), so only the local-mode shape is mirrored here.
|
|
if (p.structuredAdapter === "openCode" && !p.opencodeProvider) {
|
|
if (!p.opencode) {
|
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
|
errors.model = "Model is required.";
|
|
} else {
|
|
Object.assign(errors, validateOpenCodeConfig(p.opencode));
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/** Whether a profile draft is valid (no errors). */
|
|
export function isProfileValid(p: AgentProfile): boolean {
|
|
return Object.keys(validateProfile(p)).length === 0;
|
|
}
|
|
|
|
/**
|
|
* Builds a default {@link ContextInjection} for a strategy, so switching the
|
|
* strategy dropdown produces a sensible editable shape.
|
|
*/
|
|
export function defaultInjection(strategy: InjectionStrategy): ContextInjection {
|
|
switch (strategy) {
|
|
case "conventionFile":
|
|
return { strategy, target: "CONTEXT.md" };
|
|
case "flag":
|
|
return { strategy, flag: "--context-file {path}" };
|
|
case "env":
|
|
return { strategy, var: "AGENT_CONTEXT_FILE" };
|
|
case "stdin":
|
|
return { strategy };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft —
|
|
* pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the
|
|
* backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`).
|
|
*/
|
|
export function defaultOpenCodeConfig(): OpenCodeConfig {
|
|
return {
|
|
baseURL: "http://localhost:8080/v1",
|
|
apiKey: "sk-no-key",
|
|
model: "qwen3-coder-30b",
|
|
};
|
|
}
|
|
|
|
/** A fresh, empty custom-profile draft (id minted client-side). */
|
|
export function emptyCustomProfile(): AgentProfile {
|
|
return {
|
|
id: newProfileId(),
|
|
name: "",
|
|
command: "",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "CONTEXT.md" },
|
|
detect: null,
|
|
cwdTemplate: "{projectRoot}",
|
|
};
|
|
}
|
|
|
|
/** Generates a UUID for a client-created profile (crypto when available). */
|
|
export function newProfileId(): string {
|
|
const c = globalThis.crypto as Crypto | undefined;
|
|
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
// Fallback for non-secure contexts/tests.
|
|
return `profile-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
|
|
export function parseArgs(raw: string): string[] {
|
|
return raw
|
|
.split(/\s+/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
}
|