Files
MailUI4Agents/client/electron/src/components/ModelScopePanel.tsx

322 lines
12 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, 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));
// 用服务端返回的结果而不是本地 picksrepo 层会跳过重复与空字段,
// 直接信本地状态会让界面显示保存成功而实际存下来的不同
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) };
}