chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,321 @@
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) };
}