Files
fusion/packages/dashboard/app/components/CustomProviderForm.tsx
gsxdsm 1e49494bac feat(i18n): full-sweep string migration — 5,930 keys across 5 locales (#1352)
Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
  English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
  carry ~5,930 keys each across common/app/errors/cli namespaces;
  CLI bundles regenerated (6 locales incl. ko)

Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
  plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
  moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
  literal {{placeholders}}); dashboard vitest.setup boots a minimal en
  i18next instance for the same reason

Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:06:53 -07:00

292 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Search } from "lucide-react";
import type { CustomProviderConfig, CustomProviderModelInput } from "../api";
import { probeProviderModels } from "../api";
import "./CustomProviderForm.css";
// Reserved built-in IDs (including hidden/deprecated aliases) to prevent custom-provider collisions.
export const BUILT_IN_PROVIDER_IDS = new Set<string>([
"anthropic", "claude-cli", "pi-claude-cli", "openai", "openai-codex", "google", "gemini", "google-antigravity",
"antigravity", "google-vertex", "vertex", "google-cloud-code", "cloud-code", "google-gemini-cli", "google-generative-ai",
"ollama", "github", "github-copilot", "openrouter", "minimax", "minimax-cn", "zai", "kimi", "moonshot", "kimi-coding",
"bedrock", "amazon-bedrock", "xai", "grok", "opencode", "opencode-go", "qwen", "qwen-ai", "qwen-coder", "alibaba", "tongyi",
"lmstudio", "lm-studio", "huggingface", "hugging-face", "hf", "mistral", "mistral-ai", "azure", "azure-openai",
"azure-openai-responses", "fireworks", "fireworks-ai", "fireworksai", "cerebras", "groq", "vercel", "vercel-ai-gateway",
"hermes", "hermes-agent", "hermesagent", "openclaw", "open-claw", "paperclip", "paperclipai", "paperclip-ai",
]);
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
const API_TYPES: CustomProviderConfig["api"][] = [
"openai-completions",
"openai-responses",
"anthropic-messages",
"google-generative-ai",
];
type Props = {
initialConfig?: CustomProviderConfig;
onSave: (config: CustomProviderConfig) => void | Promise<void>;
onCancel?: () => void;
saving?: boolean;
error?: string;
};
function emptyModel(): CustomProviderModelInput {
return { id: "", name: "", reasoning: false };
}
export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = false, error }: Props) {
const { t } = useTranslation("app");
const editing = Boolean(initialConfig);
const [id, setId] = useState(initialConfig?.id ?? "");
const [name, setName] = useState(initialConfig?.name ?? "");
const [baseUrl, setBaseUrl] = useState(initialConfig?.baseUrl ?? "");
const [api, setApi] = useState<CustomProviderConfig["api"]>(initialConfig?.api ?? "openai-completions");
const [apiKey, setApiKey] = useState(initialConfig?.apiKey ?? "");
const [models, setModels] = useState<CustomProviderModelInput[]>(initialConfig?.models?.length ? initialConfig.models : [emptyModel()]);
const [validationError, setValidationError] = useState<string | null>(null);
const [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = useState<string | null>(null);
const canRemoveModel = models.length > 1;
const mergedError = useMemo(() => validationError ?? error ?? null, [validationError, error]);
function updateModel(index: number, patch: Partial<CustomProviderModelInput>) {
setModels((prev) => prev.map((model, i) => (i === index ? { ...model, ...patch } : model)));
}
function removeModel(index: number) {
setModels((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
}
// Detect Models is available for all API types that expose a /models endpoint:
// - openai-completions / openai-responses → openai-compatible
// - anthropic-messages → anthropic-compatible
// - google-generative-ai → google-generative-ai
const probeApiType = api === "anthropic-messages"
? "anthropic-compatible"
: api === "google-generative-ai"
? "google-generative-ai"
: "openai-compatible";
const handleDetectModels = useCallback(async () => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) {
setDetectError(t("providers.detectError.urlRequired", "Base URL is required to detect models."));
return;
}
setDetecting(true);
setDetectError(null);
try {
const result = await probeProviderModels({
baseUrl: trimmedBaseUrl,
apiKey: apiKey.trim() || undefined,
apiType: probeApiType,
});
if (result.models.length === 0) {
setDetectError(t("providers.detectError.noModels", "No models found. The provider may require an API key."));
return;
}
// Merge discovered models, avoiding duplicates by ID
const existingIds = new Set(models.map((m) => m.id.trim()));
const newModels = result.models
.filter((m) => !existingIds.has(m.id.trim()))
.map((m) => ({
id: m.id,
name: m.name || m.id,
reasoning: Boolean(m.reasoning),
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
}));
if (newModels.length > 0) {
// Replace empty default rows with discovered models
setModels((prev) => {
const nonEmpty = prev.filter((m) => m.id.trim().length > 0);
return [...nonEmpty, ...newModels];
});
} else {
setDetectError(t("providers.detectError.allDuplicate", "All discovered models are already in the list."));
}
} catch (err) {
setDetectError(
err instanceof Error ? err.message : t("providers.detectError.failed", "Failed to detect models"),
);
} finally {
setDetecting(false);
}
}, [baseUrl, apiKey, probeApiType, models, t]);
function validate(): string | null {
if (!id.trim()) return t("providers.validation.idRequired", "Provider ID is required.");
if (!PROVIDER_ID_PATTERN.test(id.trim())) return t("providers.validation.idKebabCase", "Provider ID must be kebab-case.");
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return t("providers.validation.idConflict", "Provider ID conflicts with a built-in provider.");
if (!baseUrl.trim()) return t("providers.validation.urlRequired", "Base URL is required.");
try {
const parsed = new URL(baseUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return t("providers.validation.urlProtocol", "Base URL must use http or https.");
}
} catch {
return t("providers.validation.urlValid", "Base URL must be a valid URL.");
}
if (!API_TYPES.includes(api)) return t("providers.validation.apiTypeRequired", "API type is required.");
if (models.length === 0) return t("providers.validation.modelRequired", "At least one model is required.");
if (models.some((model) => !model.id?.trim())) return t("providers.validation.modelId", "Each model must have a model ID.");
return null;
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const message = validate();
setValidationError(message);
if (message) return;
await onSave({
id: id.trim(),
name: name.trim() || undefined,
baseUrl: baseUrl.trim(),
api,
apiKey: apiKey.trim() || undefined,
models: models.map((model) => ({
id: model.id.trim(),
name: model.name?.trim() || undefined,
reasoning: Boolean(model.reasoning),
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
})),
});
}
return (
<form onSubmit={onSubmit} className="custom-provider-form" aria-label="custom-provider-form">
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-id">{t("providers.fields.id", "Provider ID")}</label>
<input id="custom-provider-id" className="input" value={id} onChange={(e) => setId(e.target.value)} disabled={editing || saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-name">{t("providers.fields.name", "Display Name")}</label>
<input id="custom-provider-name" className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-base-url">{t("providers.fields.baseUrl", "Base URL")}</label>
<input id="custom-provider-base-url" className="input" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-api">{t("providers.fields.apiType", "API Type")}</label>
<select id="custom-provider-api" className="select" value={api} onChange={(e) => setApi(e.target.value as CustomProviderConfig["api"])} disabled={saving}>
{API_TYPES.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-api-key">{t("providers.fields.apiKey", "API Key")}</label>
<input id="custom-provider-api-key" className="input" placeholder={t("providers.placeholders.apiKey", "sk-..., MY_API_KEY, or !command")} value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label>{t("providers.fields.models", "Models")}</label>
<div className="custom-provider-form__models">
{models.map((model, index) => (
<div key={`${index}-model`} className="custom-provider-form__model-row">
<input
className="input"
aria-label={`${t("providers.fields.modelId", "Model ID")} ${index + 1}`}
placeholder={t("providers.fields.modelId", "Model ID")}
value={model.id}
onChange={(e) => updateModel(index, { id: e.target.value })}
disabled={saving}
/>
<input
className="input"
aria-label={`${t("providers.fields.modelName", "Display name")} ${index + 1}`}
placeholder={t("providers.fields.modelName", "Display name")}
value={model.name ?? ""}
onChange={(e) => updateModel(index, { name: e.target.value })}
disabled={saving}
/>
<label className="checkbox-label custom-provider-form__toggle">
<input
type="checkbox"
checked={Boolean(model.reasoning)}
onChange={(e) => updateModel(index, { reasoning: e.target.checked })}
disabled={saving}
/>
{t("providers.fields.reasoning", "Reasoning")}
</label>
<input
className="input"
aria-label={`${t("providers.fields.contextWindow", "Context window")} ${index + 1}`}
placeholder={t("providers.fields.contextWindow", "Context window")}
type="number"
value={model.contextWindow ?? ""}
onChange={(e) => updateModel(index, { contextWindow: e.target.value ? Number(e.target.value) : undefined })}
disabled={saving}
/>
<input
className="input"
aria-label={`${t("providers.fields.maxTokens", "Max tokens")} ${index + 1}`}
placeholder={t("providers.fields.maxTokens", "Max tokens")}
type="number"
value={model.maxTokens ?? ""}
onChange={(e) => updateModel(index, { maxTokens: e.target.value ? Number(e.target.value) : undefined })}
disabled={saving}
/>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => removeModel(index)}
disabled={saving || !canRemoveModel}
aria-label={t("providers.actions.removeModel", "Remove model", { count: index + 1 })}
>
×
</button>
</div>
))}
</div>
<div className="custom-provider-form__model-actions" style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button type="button" className="btn btn-sm" onClick={() => setModels((prev) => [...prev, emptyModel()])} disabled={saving}>
{t("providers.actions.addModel", "+ Add model")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title={t("providers.actions.detectModelsTitle", "Call the provider's /models endpoint to discover available models")}
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> {t("providers.actions.detecting", "Detecting…")}
</>
) : (
<>
<Search size={14} /> {t("providers.actions.detectModels", "Detect Models")}
</>
)}
</button>
</div>
{detectError ? <div className="form-error" style={{ marginTop: "4px" }}>{detectError}</div> : null}
</div>
{mergedError ? <div className="form-error">{mergedError}</div> : null}
<div className="custom-provider-form__actions">
{onCancel ? <button type="button" className="btn" onClick={onCancel} disabled={saving}>{t("actions.cancel", "Cancel")}</button> : null}
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? t("providers.actions.saving", "Saving...") : t("providers.actions.save", "Save Provider")}</button>
</div>
</form>
);
}