"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SettingRow, SettingSection, SettingsPageHeader, selectClass, inputClass, } from "@/components/settings/shared"; import { Toggle } from "@/components/settings/Toggle"; import { agentGlyph } from "@/components/agents/agent-icons"; import { getBackendOptions, getSubagentSettings, syncBackendOptions, updateSubagentSettings, type SubagentBackendConfig, type SubagentBackendOptions, } from "@/lib/subagents-api"; type Lang = { zh: string; en: string }; /** The CLI default sentinel — empty model/effort means "let the CLI decide". */ const CUSTOM = "__custom__"; // Defaults mirror BackendConfig in deeptutor/services/subagent/config.py so the // form shows the same starting state the backend would synthesize. const DEFAULTS: Required< Pick< SubagentBackendConfig, | "enabled" | "model" | "effort" | "system_prompt" | "permission_mode" | "sandbox" | "approval" | "network_access" | "ephemeral" | "forward_images" > > = { enabled: true, model: "", effort: "", system_prompt: "", permission_mode: "bypassPermissions", sandbox: "workspace-write", approval: "never", network_access: false, ephemeral: false, forward_images: false, }; const PERMISSION_MODES: { value: string; label: Lang }[] = [ { value: "bypassPermissions", label: { zh: "绕过权限 · 全自主(推荐)", en: "Bypass permissions · autonomous (recommended)", }, }, { value: "acceptEdits", label: { zh: "自动接受编辑", en: "Accept edits automatically" }, }, { value: "default", label: { zh: "默认 · 可能等待确认", en: "Default · may wait for prompts" }, }, { value: "plan", label: { zh: "计划模式 · 只读", en: "Plan mode · read-only" }, }, ]; const SANDBOXES: { value: string; label: Lang }[] = [ { value: "read-only", label: { zh: "只读", en: "Read-only" } }, { value: "workspace-write", label: { zh: "工作目录可写(推荐)", en: "Workspace write (recommended)" }, }, { value: "danger-full-access", label: { zh: "完全访问", en: "Full access" } }, { value: "bypass", label: { zh: "绕过沙箱与审批", en: "Bypass sandbox & approvals" }, }, ]; const APPROVALS: { value: string; label: Lang }[] = [ { value: "never", label: { zh: "从不询问(推荐)", en: "Never ask (recommended)" }, }, { value: "on-failure", label: { zh: "失败时询问", en: "On failure" } }, { value: "on-request", label: { zh: "按需询问", en: "On request" } }, { value: "untrusted", label: { zh: "不可信命令时询问", en: "Untrusted commands" }, }, ]; export function SubagentSettingsEditor({ kind }: { kind: string }) { const { i18n } = useTranslation(); const zh = i18n.language?.toLowerCase().startsWith("zh"); const tr = useCallback((l: Lang) => (zh ? l.zh : l.en), [zh]); const [options, setOptions] = useState(null); const [config, setConfig] = useState({ ...DEFAULTS }); const [customModel, setCustomModel] = useState(false); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const Glyph = agentGlyph(kind); const fetchOptions = useCallback(async () => { const all = await getBackendOptions(); return all.find((o) => o.kind === kind) ?? null; }, [kind]); const load = useCallback(async () => { setError(null); try { const [opts, settings] = await Promise.all([ fetchOptions(), getSubagentSettings(), ]); setOptions(opts); const stored = settings.backends?.[kind] ?? {}; setConfig({ ...DEFAULTS, ...stored }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [fetchOptions, kind]); useEffect(() => { void load(); }, [load]); const sync = useCallback(async () => { setSyncing(true); setError(null); try { // Actively re-pull this backend's catalog (CC scrapes /model live). setOptions(await syncBackendOptions(kind)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSyncing(false); } }, [kind]); // Persist a patch for THIS backend only; the API merges per field, so we send // just what changed and never clobber the other backend or unsent fields. const save = useCallback( async (patch: Partial) => { setConfig((prev) => ({ ...prev, ...patch })); setBusy(true); setError(null); try { await updateSubagentSettings({ backends: { [kind]: patch } }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }, [kind], ); const isCodex = kind === "codex"; const knownSlugs = useMemo( () => new Set((options?.models ?? []).map((m) => m.slug)), [options], ); const showCustomModel = customModel || (config.model !== "" && !knownSlugs.has(config.model ?? "")); // Effort choices: per-model for Codex (its cache carries each model's levels), // global for Claude Code. Falls back to the backend's union when the chosen // model isn't a known slug (custom / default). const effortChoices = useMemo(() => { if (isCodex && config.model && knownSlugs.has(config.model)) { const m = options?.models.find((x) => x.slug === config.model); if (m?.efforts?.length) return m.efforts; } return options?.efforts ?? []; }, [isCodex, config.model, knownSlugs, options]); const onModelSelect = useCallback( (value: string) => { if (value === CUSTOM) { setCustomModel(true); return; } setCustomModel(false); // Reset an effort the newly chosen model doesn't support (Codex). const m = options?.models.find((x) => x.slug === value); const patch: Partial = { model: value }; if ( isCodex && config.effort && m?.efforts?.length && !m.efforts.includes(config.effort) ) { patch.effort = ""; } void save(patch); }, [options, isCodex, config.effort, save], ); const displayName = options?.display_name ?? (isCodex ? "Codex" : "Claude Code"); return (
{loading && (
{tr({ zh: "加载中…", en: "Loading…" })}
)} {!loading && error && (
{error}
)} {!loading && options && ( <> {/* Availability + sync. The model/effort lists change over time, so the user can re-pull them on demand. */} {Glyph ? : null} {options.available ? ( ) : ( )} {options.available ? tr({ zh: "已安装", en: "Installed" }) : tr({ zh: "未检测到", en: "Not detected" })} } /> void sync()} className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--foreground)] transition-colors hover:border-[var(--foreground)]/40 disabled:opacity-60" > {tr({ zh: "同步", en: "Sync" })} } /> void save({ enabled: v })} /> } /> {showCustomModel && ( setConfig((p) => ({ ...p, model: e.target.value })) } onBlur={(e) => void save({ model: e.target.value.trim() }) } /> )}
} /> void save({ effort: e.target.value })} > {effortChoices.map((eff) => ( ))} } /> {!isCodex && (