import type { PiAuthKind, PiListedModel, PiModelInfo, PiProviderGroup, PiProviderInfo, PiThinkingLevel, PiUpstream, PiUpstreamModels, } from "@umt/contracts"; import { LoaderCircle, RefreshCw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { formatRelative } from "~/lib/labels.ts"; import { formatTokens } from "~/lib/tokens.ts"; import { cn } from "~/lib/utils.ts"; // Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游") export const GROUP_LABEL: Record = { api_key: "API Key", subscription: "订阅登录", cloud: "云凭证", custom: "兼容端点", test: "测试", }; export const GROUP_ORDER: PiProviderGroup[] = ["api_key", "subscription", "cloud", "custom", "test"]; export const AUTH_LABEL: Record = { api_key: "API Key", oauth: "订阅登录", none: "不需要凭证", }; export const THINKING_LABEL: Record = { off: "关闭", minimal: "最低", low: "低", medium: "中", high: "高", xhigh: "很高", max: "最高", }; export const THINKING_OPTIONS = (Object.keys(THINKING_LABEL) as PiThinkingLevel[]).map((v) => ({ value: v, label: `${THINKING_LABEL[v]}(${v})`, })); export const API_OPTIONS = [ { value: "openai-completions", label: "OpenAI Chat Completions" }, { value: "openai-responses", label: "OpenAI Responses" }, { value: "anthropic-messages", label: "Anthropic Messages" }, { value: "google-generative-ai", label: "Google Generative AI" }, ]; export function upstreamStatus(u: Pick): { label: string; tone: StatusTone; } { if (!u.enabled) return { label: "已停用", tone: "neutral" }; switch (u.status) { case "ok": return { label: "可用", tone: "success" }; case "auth_required": return { label: u.authKind === "oauth" ? "待登录" : "缺 API Key", tone: "warning" }; case "expired": return { label: "登录已失效", tone: "destructive" }; case "error": return { label: "出错", tone: "destructive" }; } } export function UpstreamStatusBadge({ u }: { u: Pick }) { const s = upstreamStatus(u); return {s.label}; } export const providerName = (providers: PiProviderInfo[] | undefined, id: string) => providers?.find((p) => p.id === id)?.name ?? id; /** 静态目录(能力表的 PiModelInfo)转成列表项 */ export const listedFromInfo = (models: PiModelInfo[]): PiListedModel[] => models.map((m) => ({ id: m.id, displayName: m.name && m.name !== m.id ? m.name : null, contextWindow: m.contextWindow || null, supportsVision: m.input.includes("image"), supportsTools: null, pricing: null, origin: "static" as const, })); const TAG = "shrink-0 rounded-tight border border-border/60 px-1 text-meta text-muted-foreground"; const fmtContext = (n: number | null) => (!n ? null : formatTokens(n)); /** 模型 id:可搜索的下拉(显示 id 与视觉 / 工具标记),也可手填(目录滞后于上游时) */ export function ModelInput({ id, value, onChange, models, disabled, invalid, placeholder, }: { id: string; value: string; onChange: (v: string) => void; models: PiListedModel[]; disabled?: boolean; invalid?: boolean; placeholder?: string; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(null); const [active, setActive] = useState(0); const listId = `${id}-models`; // 打开时先列全部;输入后按输入筛选(id 与显示名) const q = (query ?? "").trim().toLowerCase(); const filtered = useMemo( () => (q ? models.filter((m) => m.id.toLowerCase().includes(q) || m.displayName?.toLowerCase().includes(q)) : models ).slice(0, 200), [models, q], ); const pick = (m: PiListedModel) => { onChange(m.id); setOpen(false); setQuery(null); }; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!open) setOpen(true); const n = filtered.length; if (n) setActive((a) => (e.key === "ArrowDown" ? (a + 1) % n : (a - 1 + n) % n)); } else if (e.key === "Enter" && open && filtered[active]) { e.preventDefault(); pick(filtered[active]); } }; // 下拉开着时 Esc 只收起下拉:抽屉 / 对话框在 document 捕获阶段监听 Esc,这里在更早的 window 捕获阶段拦下 useEffect(() => { if (!open) return; const onEsc = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.stopPropagation(); setOpen(false); }; window.addEventListener("keydown", onEsc, true); return () => window.removeEventListener("keydown", onEsc, true); }, [open]); return (
{ onChange(e.target.value); setQuery(e.target.value); setActive(0); setOpen(true); }} onFocus={() => setOpen(true)} onBlur={() => { setOpen(false); setQuery(null); }} onKeyDown={onKeyDown} disabled={disabled} aria-invalid={invalid} autoComplete="off" spellCheck={false} placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中搜索,或手填 id` : "模型 id")} /> {open && !disabled && filtered.length ? (
{filtered.map((m, i) => (
{ e.preventDefault(); pick(m); }} onMouseEnter={() => setActive(i)} className={cn( "flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-caption", i === active && "bg-foreground/[0.06]", )} > {m.id} {m.displayName ? ( {m.displayName} ) : null} {fmtContext(m.contextWindow) ? ( {fmtContext(m.contextWindow)} ) : null} {m.supportsVision ? 视觉 : null} {m.supportsTools ? 工具 : null}
))}
) : null}
); } /** 「获取模型列表」按钮 + 来源行(来源:远程 / 静态 · 更新时间;失败一句话) */ export function ModelFetchBar({ data, pending, onFetch, disabledReason, }: { data: PiUpstreamModels | undefined; pending: boolean; onFetch: () => void; /** 不能获取的原因(凭证没填等);有值时按钮禁用并显示它 */ disabledReason?: string | null; }) { const remote = data?.source === "remote"; const unsupported = data ? !data.remoteSupported : false; const failed = data?.error && data.errorKind !== "unsupported" ? data.error : null; return (
{unsupported ? null : ( )} {disabledReason && !unsupported ? disabledReason : remote ? `来源:远程 · ${formatRelative(data?.fetchedAt)}更新` : unsupported ? "来源:静态(这个提供方没有模型列表接口)" : data ? "来源:静态" : null} {failed ? ( 获取失败:{failed} ) : null}
); }