Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

566 lines
20 KiB
TypeScript

"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<SubagentBackendOptions | null>(null);
const [config, setConfig] = useState<SubagentBackendConfig>({ ...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<string | null>(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<SubagentBackendConfig>) => {
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<SubagentBackendConfig> = { 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 (
<div>
<SettingsPageHeader
title={displayName}
description={tr({
zh: `DeepTutor 通过 consult_subagent 调用本机 ${displayName} 时使用的模型、推理强度与运行参数。设置后即覆盖 CLI 的默认值;留空表示沿用 CLI 默认。`,
en: `Model, reasoning effort, and run parameters DeepTutor drives the local ${displayName} with when consulting it. These override the CLI defaults; leave blank to keep the CLI's own default.`,
})}
/>
{loading && (
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" />
{tr({ zh: "加载中…", en: "Loading…" })}
</div>
)}
{!loading && error && (
<div className="mb-5 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-[13px] text-red-600 dark:text-red-300">
{error}
</div>
)}
{!loading && options && (
<>
{/* Availability + sync. The model/effort lists change over time, so
the user can re-pull them on demand. */}
<SettingSection
title={tr({ zh: "连接与同步", en: "Connection & sync" })}
description={tr({
zh: "供应商会不定期增删模型与推理档位——随时点同步即可重新拉取最新列表。",
en: "Vendors add and retire models and effort levels over time — sync any time to re-pull the latest lists.",
})}
>
<SettingRow
title={tr({ zh: "本机状态", en: "On this machine" })}
description={
options.available
? options.version
: options.detail ||
tr({
zh: "未在 PATH 上找到该 CLI。",
en: "CLI not found on PATH.",
})
}
control={
<span
className={`inline-flex items-center gap-1.5 text-[12px] ${
options.available
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"
}`}
>
{Glyph ? <Glyph size={15} /> : null}
{options.available ? (
<CheckCircle2 className="h-3.5 w-3.5" />
) : (
<XCircle className="h-3.5 w-3.5" />
)}
{options.available
? tr({ zh: "已安装", en: "Installed" })
: tr({ zh: "未检测到", en: "Not detected" })}
</span>
}
/>
<SettingRow
title={tr({ zh: "模型列表", en: "Model list" })}
description={
options.synced_at
? tr({
zh: `上次同步:${formatTs(options.synced_at, zh)}`,
en: `Last synced: ${formatTs(options.synced_at, zh)}`,
})
: tr({
zh: "该 CLI 无可枚举的模型接口,下方为常用别名,可自定义任意模型名。",
en: "This CLI has no model-list API; below are the common aliases, and any model name is accepted.",
})
}
control={
<button
type="button"
disabled={syncing}
onClick={() => 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"
>
<RefreshCw
className={`h-3.5 w-3.5 ${syncing ? "animate-spin" : ""}`}
/>
{tr({ zh: "同步", en: "Sync" })}
</button>
}
/>
</SettingSection>
<SettingSection
title={tr({ zh: "模型", en: "Model" })}
description={tr({
zh: "DeepTutor 调用该智能体时使用的模型与推理强度。",
en: "The model and reasoning effort DeepTutor consults this agent with.",
})}
>
<SettingRow
title={tr({ zh: "启用", en: "Enabled" })}
description={tr({
zh: "关闭后,DeepTutor 不会在对话中调用该智能体。",
en: "When off, DeepTutor won't consult this agent in chat.",
})}
control={
<Toggle
checked={config.enabled !== false}
disabled={busy}
onChange={(v) => void save({ enabled: v })}
/>
}
/>
<SettingRow
title={tr({ zh: "模型", en: "Model" })}
control={
<div className="flex w-[260px] flex-col items-end gap-2">
<select
className={selectClass}
disabled={busy}
value={showCustomModel ? CUSTOM : (config.model ?? "")}
onChange={(e) => onModelSelect(e.target.value)}
>
<option value="">
{tr({ zh: "CLI 默认", en: "CLI default" })}
</option>
{options.models.map((m) => (
<option key={m.slug} value={m.slug}>
{m.display_name}
</option>
))}
{options.allow_custom_model && (
<option value={CUSTOM}>
{tr({ zh: "自定义…", en: "Custom…" })}
</option>
)}
</select>
{showCustomModel && (
<input
className={inputClass}
disabled={busy}
placeholder={tr({
zh: "输入模型名",
en: "Enter a model name",
})}
value={config.model ?? ""}
onChange={(e) =>
setConfig((p) => ({ ...p, model: e.target.value }))
}
onBlur={(e) =>
void save({ model: e.target.value.trim() })
}
/>
)}
</div>
}
/>
<SettingRow
title={tr({ zh: "推理强度", en: "Reasoning effort" })}
control={
<select
className={`${selectClass} w-[260px]`}
disabled={busy || effortChoices.length === 0}
value={config.effort ?? ""}
onChange={(e) => void save({ effort: e.target.value })}
>
<option value="">
{tr({ zh: "CLI 默认", en: "CLI default" })}
</option>
{effortChoices.map((eff) => (
<option key={eff} value={eff}>
{eff}
</option>
))}
</select>
}
/>
</SettingSection>
{!isCodex && (
<SettingSection
title={tr({ zh: "系统提示", en: "System prompt" })}
description={tr({
zh: "追加到该智能体的系统提示(--append-system-prompt)。留空则使用 DeepTutor 的默认委派提示。",
en: "Appended to the agent's system prompt (--append-system-prompt). Blank uses DeepTutor's default delegate instruction.",
})}
>
<div className="py-4">
<textarea
className={`${inputClass} min-h-[96px] resize-y leading-relaxed`}
disabled={busy}
placeholder={tr({
zh: "(留空使用默认委派提示)",
en: "(blank uses the default delegate instruction)",
})}
value={config.system_prompt ?? ""}
onChange={(e) =>
setConfig((p) => ({ ...p, system_prompt: e.target.value }))
}
onBlur={(e) => void save({ system_prompt: e.target.value })}
/>
</div>
</SettingSection>
)}
<SettingSection
title={tr({ zh: "运行参数", en: "Run parameters" })}
description={tr({
zh: "DeepTutor 无人值守地驱动该智能体——默认值确保它不会卡在等待确认上。",
en: "DeepTutor drives the agent unattended — the defaults ensure it never stalls waiting for an approval prompt.",
})}
>
{!isCodex && (
<SettingRow
title={tr({ zh: "权限模式", en: "Permission mode" })}
description={tr({
zh: "非「绕过权限」的模式可能让无人值守的运行卡住等待确认。",
en: "Modes other than bypass may stall an unattended run waiting for a prompt.",
})}
control={
<select
className={`${selectClass} w-[260px]`}
disabled={busy}
value={config.permission_mode ?? DEFAULTS.permission_mode}
onChange={(e) =>
void save({ permission_mode: e.target.value })
}
>
{PERMISSION_MODES.map((o) => (
<option key={o.value} value={o.value}>
{tr(o.label)}
</option>
))}
</select>
}
/>
)}
{isCodex && (
<>
<SettingRow
title={tr({ zh: "沙箱", en: "Sandbox" })}
control={
<select
className={`${selectClass} w-[260px]`}
disabled={busy}
value={config.sandbox ?? DEFAULTS.sandbox}
onChange={(e) => void save({ sandbox: e.target.value })}
>
{SANDBOXES.map((o) => (
<option key={o.value} value={o.value}>
{tr(o.label)}
</option>
))}
</select>
}
/>
<SettingRow
title={tr({ zh: "审批策略", en: "Approval policy" })}
description={tr({
zh: "非「从不询问」可能让无人值守的运行卡住。",
en: "Anything but never may stall an unattended run.",
})}
control={
<select
className={`${selectClass} w-[260px]`}
disabled={busy}
value={config.approval ?? DEFAULTS.approval}
onChange={(e) => void save({ approval: e.target.value })}
>
{APPROVALS.map((o) => (
<option key={o.value} value={o.value}>
{tr(o.label)}
</option>
))}
</select>
}
/>
<SettingRow
title={tr({ zh: "命令联网", en: "Command network access" })}
description={tr({
zh: "允许模型运行的 shell 命令访问网络(工作目录可写模式默认离线)。内置 web search 不受影响。",
en: "Let the model's shell commands reach the network (workspace-write is offline by default). The built-in web search is unaffected.",
})}
control={
<Toggle
checked={Boolean(config.network_access)}
disabled={busy}
onChange={(v) => void save({ network_access: v })}
/>
}
/>
<SettingRow
title={tr({ zh: "临时会话", en: "Ephemeral session" })}
description={tr({
zh: "不在 ~/.codex/sessions 下持久化本次会话。",
en: "Don't persist the session under ~/.codex/sessions.",
})}
control={
<Toggle
checked={Boolean(config.ephemeral)}
disabled={busy}
onChange={(v) => void save({ ephemeral: v })}
/>
}
/>
</>
)}
<SettingRow
title={tr({ zh: "转发图片", en: "Forward images" })}
description={tr({
zh: "允许 DeepTutor 把本轮对话中的图片附件转发给该智能体。",
en: "Let DeepTutor forward image attachments from the chat turn to this agent.",
})}
control={
<Toggle
checked={Boolean(config.forward_images)}
disabled={busy}
onChange={(v) => void save({ forward_images: v })}
/>
}
/>
</SettingSection>
</>
)}
</div>
);
}
function formatTs(value: string, zh: boolean): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString(zh ? "zh-CN" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
});
}
export default SubagentSettingsEditor;