Files
UHD_Media_Tool/apps/web/app/components/settings/pi-upstreams.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

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

392 lines
14 KiB
TypeScript

import { type PiProviderInfo, type PiThinkingLevel, type PiUpstream, PiUpstreamUpdate } from "@umt/contracts";
import { LoaderCircle, LogOut, Plus, Star, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
useLogoutPiOAuth,
usePiProviders,
usePiUpstreamModels,
usePiUpstreams,
useRemovePiUpstream,
useUpdatePiUpstream,
} from "~/api/pi.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import {
AUTH_LABEL,
ModelInput,
providerName,
THINKING_LABEL,
THINKING_OPTIONS,
UpstreamStatusBadge,
} from "./pi-shared.tsx";
import { PiTestPanel } from "./pi-test.tsx";
import { PiUpstreamWizard } from "./pi-wizard.tsx";
import { FieldControl } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
// Agent 上游列表 + 编辑抽屉(登录、测试、设默认、删除)
export function PiUpstreamsBlock() {
const list = usePiUpstreams();
const providers = usePiProviders(false);
const [wizard, setWizard] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const open = list.data?.items.find((u) => u.id === openId) ?? null;
const pv = providers.data?.providers;
return (
<SettingsBlock
section="agent"
anchor="upstream"
title="上游"
description="没指定上游的运行用默认上游。密钥和登录凭证保存后不回显。"
plain
actions={
<Button size="sm" onClick={() => setWizard(true)}>
<Plus />
新建上游
</Button>
}
>
<DataTable
rows={list.data?.items}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setOpenId(r.id)}
empty={{ title: "还没有上游" }}
columns={[
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="flex items-center gap-1.5 font-medium">
<span className="truncate">{r.name}</span>
{r.isDefault ? <StatusBadge tone="success">默认</StatusBadge> : null}
</p>
<p className="truncate text-meta text-muted-foreground">{providerName(pv, r.provider)}</p>
</div>
),
},
{ key: "auth", header: "认证", hideBelow: "md", cell: (r) => AUTH_LABEL[r.authKind] },
{
key: "model",
header: "模型",
hideBelow: "sm",
cell: (r) => <code className="text-meta break-all">{r.defaultModel ?? "默认模型"}</code>,
},
{ key: "status", header: "状态", cell: (r) => <UpstreamStatusBadge u={r} /> },
]}
/>
<UpstreamDrawer
key={open?.id ?? "none"}
upstream={open}
provider={pv?.find((p) => p.id === open?.provider)}
onClose={() => setOpenId(null)}
/>
<PiUpstreamWizard open={wizard} onClose={() => setWizard(false)} />
</SettingsBlock>
);
}
function UpstreamDrawer({
upstream,
provider,
onClose,
}: {
upstream: PiUpstream | null;
provider: PiProviderInfo | undefined;
onClose: () => void;
}) {
const update = useUpdatePiUpstream();
const remove = useRemovePiUpstream();
const logout = useLogoutPiOAuth();
const models = usePiUpstreamModels(upstream?.id ?? null);
const [deleting, setDeleting] = useState(false);
const [v, setV] = useState(() => ({
name: upstream?.name ?? "",
model: upstream?.defaultModel ?? "",
thinking: (upstream?.thinkingLevel ?? "medium") as PiThinkingLevel,
baseUrl: upstream?.baseUrl ?? "",
apiKey: "",
enabled: upstream?.enabled ?? true,
env: { ...(upstream?.extra.env ?? {}) } as Record<string, string>,
}));
const [errors, setErrors] = useState<Record<string, string>>({});
if (!upstream) return null;
const custom = upstream.provider === "openai-compatible";
const showBaseUrl = (provider?.baseUrlOverridable ?? custom) && upstream.authKind !== "oauth";
const showThinking = provider?.supportsThinking || custom;
const envFields = provider?.envFields ?? [];
const dirty =
v.name !== upstream.name ||
v.model !== (upstream.defaultModel ?? "") ||
v.thinking !== upstream.thinkingLevel ||
v.baseUrl !== (upstream.baseUrl ?? "") ||
!!v.apiKey ||
v.enabled !== upstream.enabled ||
envFields.some((f) => (v.env[f.name] ?? "") !== (upstream.extra.env?.[f.name] ?? ""));
const save = async (e: React.FormEvent) => {
e.preventDefault();
const env = Object.fromEntries(Object.entries(v.env).filter(([, x]) => x.trim()));
const parsed = PiUpstreamUpdate.safeParse({
name: v.name.trim(),
defaultModel: v.model.trim() || null,
...(showThinking ? { thinkingLevel: v.thinking } : {}),
...(showBaseUrl ? { baseUrl: v.baseUrl.trim() || null } : {}),
...(v.apiKey.trim() ? { apiKey: v.apiKey.trim() } : {}),
enabled: v.enabled,
...(envFields.length ? { extra: { ...upstream.extra, env } } : {}),
});
if (!parsed.success) {
setErrors(zodFieldErrors(parsed.error.issues));
return;
}
setErrors({});
try {
await update.mutateAsync({ id: upstream.id, body: parsed.data });
setV((s) => ({ ...s, apiKey: "" }));
toast.success(`${v.name.trim()} 已保存`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const makeDefault = async () => {
try {
await update.mutateAsync({ id: upstream.id, body: { makeDefault: true } });
toast.success(`${upstream.name}已设为默认上游`);
} catch (err) {
toast.error(errorMessage(err, "设置失败"));
}
};
const pending = update.isPending;
return (
<DetailSheet
open
onOpenChange={(o) => !o && onClose()}
title={upstream.name}
description={
<span className="flex flex-wrap items-center gap-2">
<UpstreamStatusBadge u={upstream} />
{upstream.isDefault ? <StatusBadge tone="success">默认上游</StatusBadge> : null}
<span>
{provider?.name ?? upstream.provider} · {AUTH_LABEL[upstream.authKind]}
</span>
</span>
}
width="lg"
footer={
<>
<Button
variant="ghost"
className="mr-auto hover:text-destructive"
onClick={() => setDeleting(true)}
disabled={pending}
>
<Trash2 />
删除
</Button>
{upstream.isDefault ? null : (
<Button variant="outline" onClick={makeDefault} disabled={pending}>
<Star />
设为默认
</Button>
)}
<Button type="submit" form="pi-upstream-form" disabled={pending || !dirty}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-caption">
{upstream.credentialMask ? (
<>
<dt className="text-muted-foreground">API Key</dt>
<dd className="font-mono">{upstream.credentialMask}</dd>
</>
) : null}
{upstream.account ? (
<>
<dt className="text-muted-foreground">账号</dt>
<dd className="break-all">{upstream.account}</dd>
</>
) : null}
{upstream.oauthExpiresAt ? (
<>
<dt className="text-muted-foreground">令牌到期</dt>
<dd>{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)</dd>
</>
) : null}
<dt className="text-muted-foreground">最近验证</dt>
<dd>{upstream.lastVerifiedAt ? formatRelative(upstream.lastVerifiedAt) : "未验证"}</dd>
{upstream.lastError ? (
<>
<dt className="text-muted-foreground">最近错误</dt>
<dd className="break-words text-destructive">{upstream.lastError}</dd>
</>
) : null}
</dl>
{upstream.authKind === "oauth" ? (
<DetailSection
title="登录"
actions={
upstream.hasCredential ? (
<Button
variant="ghost"
size="xs"
disabled={logout.isPending}
onClick={async () => {
try {
await logout.mutateAsync(upstream.id);
toast.success("已退出登录");
} catch (err) {
toast.error(errorMessage(err, "退出失败"));
}
}}
>
<LogOut />
退出登录
</Button>
) : null
}
>
{upstream.hasCredential && upstream.status === "ok" ? (
<p className="text-caption text-muted-foreground">已登录。退出只删除本地令牌,不会撤销提供方侧的授权。</p>
) : (
<PiOAuthLogin upstream={upstream} provider={provider} />
)}
</DetailSection>
) : null}
<DetailSection title="配置">
<form id="pi-upstream-form" onSubmit={save} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<FieldControl
spec={{ name: "name", label: "名称", kind: "text", required: true }}
value={v.name}
error={errors.name}
onChange={(x) => setV((s) => ({ ...s, name: String(x) }))}
disabled={pending}
/>
<FieldControl
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后,使用它的运行会失败" }}
value={v.enabled}
onChange={(x) => setV((s) => ({ ...s, enabled: x === true }))}
disabled={pending}
/>
<FormField
label="默认模型"
htmlFor="pu-model"
error={errors.defaultModel}
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
className="sm:col-span-2"
>
<ModelInput
id="pu-model"
value={v.model}
onChange={(x) => setV((s) => ({ ...s, model: x }))}
models={models.data?.items ?? []}
disabled={pending}
/>
</FormField>
{showThinking ? (
<FieldControl
spec={{
name: "thinking",
label: "思考档位",
kind: "select",
options: THINKING_OPTIONS,
hint: `当前 ${THINKING_LABEL[upstream.thinkingLevel]};运行时按模型支持的档位取近`,
}}
value={v.thinking}
onChange={(x) => setV((s) => ({ ...s, thinking: x as PiThinkingLevel }))}
disabled={pending}
/>
) : null}
{showBaseUrl ? (
<FieldControl
spec={{
name: "baseUrl",
label: "Base URL",
kind: "url",
wide: true,
placeholder: provider?.defaultBaseUrl ?? undefined,
hint: custom ? undefined : "留空用官方地址",
}}
value={v.baseUrl}
error={errors.baseUrl}
onChange={(x) => setV((s) => ({ ...s, baseUrl: String(x) }))}
disabled={pending}
/>
) : null}
{upstream.authKind === "api_key" ? (
<FieldControl
spec={{
name: "apiKey",
label: upstream.hasCredential ? "替换 API Key" : "API Key",
kind: "password",
wide: true,
hint: upstream.hasCredential ? "留空不修改" : undefined,
}}
value={v.apiKey}
error={errors.apiKey}
onChange={(x) => setV((s) => ({ ...s, apiKey: String(x) }))}
disabled={pending}
/>
) : null}
{envFields.map((f) => (
<FieldControl
key={f.name}
spec={{ name: `env-${f.name}`, label: f.label, kind: "text", hint: f.hint ?? f.name }}
value={v.env[f.name] ?? ""}
onChange={(x) => setV((s) => ({ ...s, env: { ...s.env, [f.name]: String(x) } }))}
disabled={pending}
/>
))}
</form>
</DetailSection>
<DetailSection title="测试">
<PiTestPanel upstream={upstream} model={v.model.trim() || null} />
</DetailSection>
<ConfirmDialog
open={deleting}
onOpenChange={setDeleting}
title={`删除上游「${upstream.name}」?`}
description={
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
}
confirmLabel="删除上游"
destructive
onConfirm={async () => {
try {
await remove.mutateAsync(upstream.id);
toast.success(`${upstream.name}已删除`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</DetailSheet>
);
}