import { useCallback, useEffect, useState } from 'react'; import * as api from '../api/client'; import type { AgentStats, CatalogModel, ModelRoute } from '../api/client'; import { CheckIcon, SpinnerIcon, ChevronRightIcon, CpuIcon } from './icons'; /** * 每个 Agent 平台在**邮件场景**下可用的模型范围。 * * 为什么是勾选而不是手打模型名:模型清单是平台侧的事实(opencode 的 provider * 配置、DSH 的 llm 适配器注册),手打就会打错,而打错的后果要到真发邮件时 * 才暴露成一次失败。插件随心跳上报它当前看得见的目录,这里只做勾选。 * * 顺序即优先级:插件按这个顺序逐个尝试,全部失败才回一封说明失败原因的邮件。 * 一个都不选 = 不限定,回退到平台自己的默认模型 —— 与「一个都不许用」不同。 */ export default function ModelScopePanel({ agents }: { agents: AgentStats[] }) { const [expanded, setExpanded] = useState(null); if (agents.length === 0) { return (
暂无已注册的 Agent
); } return (
{agents.map(a => ( setExpanded(expanded === a.agent_name ? null : a.agent_name)} /> ))}
); } function PanelHeader({ count }: { count: number }) { return ( <>

Agent 模型范围

{count > 0 && {count}}

划定每个平台在邮件场景下可用的模型。勾选顺序即尝试顺序 —— 插件按序降级, 全部失败才回一封说明失败原因的邮件。
一个都不选 = 不限定,用平台自己的默认模型。清单由插件随心跳上报。

); } function AgentModelRow({ agentName, expanded, onToggle }: { agentName: string; expanded: boolean; onToggle: () => void; }) { const [catalog, setCatalog] = useState([]); const [stale, setStale] = useState([]); // picks 是有序的:数组下标就是 rank const [picks, setPicks] = useState([]); const [saved, setSaved] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [err, setErr] = useState(''); const load = useCallback(async () => { setLoading(true); setErr(''); try { const res = await api.adminGetAgentModels(agentName); setCatalog(res.catalog); setStale(res.stale || []); // 已选项按 rank 排出初始顺序 const chosen = res.catalog .filter(m => m.allowed) .sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0)) .map(keyOf); // 已选但已不在目录里的仍要保留:不显示会让人以为没选过, // 而保存时若把它们丢掉就等于静默改了配置 const staleKeys = (res.stale || []).map(keyOf); const all = [...chosen, ...staleKeys.filter(k => !chosen.includes(k))]; setPicks(all); setSaved(all); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } }, [agentName]); useEffect(() => { if (expanded) load(); }, [expanded, load]); const toggle = (key: string) => { setPicks(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])); }; const move = (key: string, delta: number) => { setPicks(prev => { const i = prev.indexOf(key); const j = i + delta; if (i < 0 || j < 0 || j >= prev.length) return prev; const next = [...prev]; [next[i], next[j]] = [next[j], next[i]]; return next; }); }; const save = async () => { setSaving(true); setErr(''); try { const res = await api.adminSetAgentModels(agentName, picks.map(parseKey)); // 用服务端返回的结果而不是本地 picks:repo 层会跳过重复与空字段, // 直接信本地状态会让界面显示保存成功而实际存下来的不同 const persisted = res.models.map(keyOf); setPicks(persisted); setSaved(persisted); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } }; const dirty = picks.join('|') !== saved.join('|'); const staleKeys = stale.map(keyOf); return (
{expanded && (
{loading && (
加载中
)} {err &&

{err}

} {!loading && catalog.length === 0 && staleKeys.length === 0 && (

该平台还没有上报模型清单。插件会在心跳时上报(约 30 秒一次)—— 若长时间为空,检查插件是否在运行、以及它能否读到平台的 provider 配置。

)} {picks.length > 0 && (

尝试顺序(自上而下)

{picks.map((key, i) => { const meta = catalog.find(m => keyOf(m) === key); const isStale = !meta && staleKeys.includes(key); return (
{i + 1} {key} {isStale && ( 已失效 )}
); })}
)} {catalog.length > 0 && (

平台上报的模型({catalog.length})

{catalog.map(m => { const key = keyOf(m); const on = picks.includes(key); return ( ); })}
)} {(catalog.length > 0 || picks.length > 0) && (
{picks.length === 0 && ( 未选 = 不限定,用平台默认模型 )}
{dirty && ( )}
)}
)}
); } /** provider/model 拼成一个稳定的键,用于勾选状态与顺序。 */ function keyOf(m: { provider: string; model: string }): string { return `${m.provider}/${m.model}`; } /** * 键拆回 provider 与 model。 * * 按**第一个** `/` 切:model id 里可能含 `/`(如 `org/model-name`), * 而 provider id 不含。按最后一个切会把 provider 切错。 */ function parseKey(key: string): ModelRoute { const i = key.indexOf('/'); if (i < 0) return { provider: key, model: '' }; return { provider: key.slice(0, i), model: key.slice(i + 1) }; }