Files
UHD_Media_Tool/apps/web/app/components/settings/pi-shared.tsx
T
rootandClaude Opus 5.5 0765264a47 feat(pi): fetch and refresh upstream model lists, searchable model picker
- POST /pi/upstreams/{id}/models/refresh calls the provider's model list
  endpoint (OpenAI-shaped /models, Anthropic /v1/models, Google
  /v1beta/models, OpenRouter /api/v1/models; Radius via pi-ai refresh),
  normalizes {id, displayName, contextWindow, supportsVision,
  supportsTools, pricing} and stores it in pi_upstream.models (0044)
- GET .../models returns {source, fetchedAt, models[]}: remote merged
  with the static catalog (remote first); failures (401/network/
  unsupported) fall back to static with a one-line error
- POST /pi/models/probe for trying credentials before creating
- wizard and edit drawer: fetch button, searchable dropdown with
  vision/tool tags, manual id, source line; default upstream needs a model
- tests: mock /models, 401, network failure; smoke step; runbook

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 01:26:07 +08:00

274 lines
9.1 KiB
TypeScript

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 { cn } from "~/lib/utils.ts";
// Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游")
export const GROUP_LABEL: Record<PiProviderGroup, string> = {
api_key: "API Key",
subscription: "订阅登录",
cloud: "云凭证",
custom: "兼容端点",
test: "测试",
};
export const GROUP_ORDER: PiProviderGroup[] = ["api_key", "subscription", "cloud", "custom", "test"];
export const AUTH_LABEL: Record<PiAuthKind, string> = {
api_key: "API Key",
oauth: "订阅登录",
none: "不需要凭证",
};
export const THINKING_LABEL: Record<PiThinkingLevel, string> = {
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<PiUpstream, "status" | "authKind" | "enabled">): {
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<PiUpstream, "status" | "authKind" | "enabled"> }) {
const s = upstreamStatus(u);
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
}
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 : n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`;
/** 模型 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<string | null>(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<HTMLInputElement>) => {
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 (
<div className="relative">
<Input
id={id}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && filtered[active] ? `${listId}-${active}` : undefined}
value={value}
onChange={(e) => {
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 ? (
<div
id={listId}
role="listbox"
className="absolute inset-x-0 top-full z-30 mt-1 max-h-72 overflow-auto pg-float pg-float-solid rounded-2xl p-1"
>
{filtered.map((m, i) => (
<div
key={m.id}
id={`${listId}-${i}`}
role="option"
tabIndex={-1}
aria-selected={m.id === value}
// mousedown 先于 input blur,阻止默认才能选中
onMouseDown={(e) => {
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]",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono">{m.id}</span>
{m.displayName ? (
<span className="block truncate text-meta text-muted-foreground">{m.displayName}</span>
) : null}
</span>
{fmtContext(m.contextWindow) ? (
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{fmtContext(m.contextWindow)}
</span>
) : null}
{m.supportsVision ? <span className={TAG}>视觉</span> : null}
{m.supportsTools ? <span className={TAG}>工具</span> : null}
</div>
))}
</div>
) : null}
</div>
);
}
/** 「获取模型列表」按钮 + 来源行(来源:远程 / 静态 · 更新时间;失败一句话) */
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 (
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{unsupported ? null : (
<Button type="button" variant="outline" size="xs" onClick={onFetch} disabled={pending || !!disabledReason}>
{pending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{remote ? "更新模型列表" : "获取模型列表"}
</Button>
)}
<span className="text-meta text-muted-foreground">
{disabledReason && !unsupported
? disabledReason
: remote
? `来源:远程 · ${formatRelative(data?.fetchedAt)}更新`
: unsupported
? "来源:静态(这个 provider 没有模型列表接口)"
: data
? "来源:静态"
: null}
</span>
{failed ? (
<span role="alert" className="basis-full text-meta text-destructive">
获取失败:{failed}
</span>
) : null}
</div>
);
}