322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
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<string | null>(null);
|
||
|
||
if (agents.length === 0) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<PanelHeader count={0} />
|
||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<PanelHeader count={agents.length} />
|
||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||
{agents.map(a => (
|
||
<AgentModelRow
|
||
key={a.agent_name}
|
||
agentName={a.agent_name}
|
||
expanded={expanded === a.agent_name}
|
||
onToggle={() => setExpanded(expanded === a.agent_name ? null : a.agent_name)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PanelHeader({ count }: { count: number }) {
|
||
return (
|
||
<>
|
||
<div className="flex items-center gap-2">
|
||
<CpuIcon className="w-4 h-4 text-gray-500" />
|
||
<h3 className="text-sm font-semibold text-gray-900">Agent 模型范围</h3>
|
||
{count > 0 && <span className="text-xs text-gray-400">{count}</span>}
|
||
</div>
|
||
<p className="text-[11px] text-gray-500">
|
||
划定每个平台在邮件场景下可用的模型。勾选顺序即尝试顺序 —— 插件按序降级,
|
||
全部失败才回一封说明失败原因的邮件。
|
||
<br />
|
||
一个都不选 = 不限定,用平台自己的默认模型。清单由插件随心跳上报。
|
||
</p>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AgentModelRow({
|
||
agentName,
|
||
expanded,
|
||
onToggle
|
||
}: {
|
||
agentName: string;
|
||
expanded: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
const [catalog, setCatalog] = useState<CatalogModel[]>([]);
|
||
const [stale, setStale] = useState<ModelRoute[]>([]);
|
||
// picks 是有序的:数组下标就是 rank
|
||
const [picks, setPicks] = useState<string[]>([]);
|
||
const [saved, setSaved] = useState<string[]>([]);
|
||
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 (
|
||
<div>
|
||
<button
|
||
onClick={onToggle}
|
||
className="w-full px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap text-left hover:bg-gray-50"
|
||
>
|
||
<ChevronRightIcon
|
||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||
/>
|
||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">{agentName}</span>
|
||
<span className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||
{saved.length === 0 ? '不限定(用平台默认模型)' : `${saved.length} 个模型,按序尝试`}
|
||
</span>
|
||
{staleKeys.length > 0 && (
|
||
<span
|
||
className="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[9px]"
|
||
title="已选但平台当前没有上报这些模型"
|
||
>
|
||
{staleKeys.length} 个已失效
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{expanded && (
|
||
<div className="px-3 pb-3 space-y-3 bg-gray-50 border-t border-gray-100">
|
||
{loading && (
|
||
<div className="flex items-center gap-2 text-xs text-gray-400 pt-3">
|
||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||
加载中
|
||
</div>
|
||
)}
|
||
{err && <p className="text-xs text-red-600 pt-3">{err}</p>}
|
||
|
||
{!loading && catalog.length === 0 && staleKeys.length === 0 && (
|
||
<p className="text-[11px] text-gray-500 pt-3">
|
||
该平台还没有上报模型清单。插件会在心跳时上报(约 30 秒一次)——
|
||
若长时间为空,检查插件是否在运行、以及它能否读到平台的 provider 配置。
|
||
</p>
|
||
)}
|
||
|
||
{picks.length > 0 && (
|
||
<div className="pt-3">
|
||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||
尝试顺序(自上而下)
|
||
</p>
|
||
<div className="space-y-1">
|
||
{picks.map((key, i) => {
|
||
const meta = catalog.find(m => keyOf(m) === key);
|
||
const isStale = !meta && staleKeys.includes(key);
|
||
return (
|
||
<div
|
||
key={key}
|
||
className={`flex items-center gap-1.5 px-2 py-1 rounded border bg-white ${
|
||
isStale ? 'border-amber-200' : 'border-gray-200'
|
||
}`}
|
||
>
|
||
<span className="w-4 text-[10px] text-gray-400 shrink-0">{i + 1}</span>
|
||
<span className="text-xs font-mono text-gray-800 truncate">{key}</span>
|
||
{isStale && (
|
||
<span
|
||
className="shrink-0 text-[9px] text-amber-700"
|
||
title="平台当前没有上报这个模型,可能已下线"
|
||
>
|
||
已失效
|
||
</span>
|
||
)}
|
||
<div className="flex-1" />
|
||
<button
|
||
onClick={() => move(key, -1)}
|
||
disabled={i === 0}
|
||
title="上移"
|
||
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
onClick={() => move(key, 1)}
|
||
disabled={i === picks.length - 1}
|
||
title="下移"
|
||
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||
>
|
||
↓
|
||
</button>
|
||
<button
|
||
onClick={() => toggle(key)}
|
||
title="移除"
|
||
className="tap shrink-0 text-gray-400 hover:text-red-600 text-xs px-1"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{catalog.length > 0 && (
|
||
<div>
|
||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||
平台上报的模型({catalog.length})
|
||
</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{catalog.map(m => {
|
||
const key = keyOf(m);
|
||
const on = picks.includes(key);
|
||
return (
|
||
<button
|
||
key={key}
|
||
onClick={() => toggle(key)}
|
||
title={m.display_name || key}
|
||
className={`tap px-2 py-1 text-[11px] font-mono rounded border transition-colors ${
|
||
on
|
||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
{key}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(catalog.length > 0 || picks.length > 0) && (
|
||
<div className="flex items-center gap-2">
|
||
{picks.length === 0 && (
|
||
<span className="text-[10px] text-gray-400">
|
||
未选 = 不限定,用平台默认模型
|
||
</span>
|
||
)}
|
||
<div className="flex-1" />
|
||
{dirty && (
|
||
<button
|
||
onClick={() => setPicks(saved)}
|
||
className="tap text-[11px] text-gray-500 hover:text-gray-900"
|
||
>
|
||
撤销
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={save}
|
||
disabled={!dirty || saving}
|
||
className="tap inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||
>
|
||
{saving ? (
|
||
<SpinnerIcon className="w-3 h-3 animate-spin" />
|
||
) : (
|
||
<CheckIcon className="w-3 h-3" />
|
||
)}
|
||
保存
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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) };
|
||
}
|