- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens) - 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处 - 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA - contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写 - 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除 Co-Authored-By: Claude Opus 5.5 <[email protected]>
429 lines
15 KiB
TypeScript
429 lines
15 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,
|
|
useRefreshPiModels,
|
|
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 { maskedDisplay, secretPatch } from "~/lib/secret.ts";
|
|
import { formatDateTime } from "~/lib/utils.ts";
|
|
import { PiOAuthLogin } from "./pi-oauth.tsx";
|
|
import {
|
|
AUTH_LABEL,
|
|
ModelFetchBar,
|
|
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 refresh = useRefreshPiModels();
|
|
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 fetchBlocked =
|
|
upstream.authKind !== "none" && !custom && !upstream.hasCredential
|
|
? upstream.authKind === "oauth"
|
|
? "登录后可获取"
|
|
: "先配置 API Key"
|
|
: v.apiKey !== "" || v.baseUrl !== (upstream.baseUrl ?? "")
|
|
? "先保存 API Key / Base URL 再获取"
|
|
: null;
|
|
|
|
const save = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const env = Object.fromEntries(Object.entries(v.env).filter(([, x]) => x.trim()));
|
|
const apiKey = secretPatch(v.apiKey);
|
|
const parsed = PiUpstreamUpdate.safeParse({
|
|
name: v.name.trim(),
|
|
defaultModel: v.model.trim() || null,
|
|
...(showThinking ? { thinkingLevel: v.thinking } : {}),
|
|
...(showBaseUrl ? { baseUrl: v.baseUrl.trim() || null } : {}),
|
|
...(apiKey !== undefined ? { apiKey } : {}),
|
|
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 () => {
|
|
const model = v.model.trim();
|
|
if (!model) {
|
|
setErrors((e) => ({ ...e, defaultModel: "设为默认模型服务前先选默认模型" }));
|
|
return;
|
|
}
|
|
try {
|
|
await update.mutateAsync({
|
|
id: upstream.id,
|
|
body: { makeDefault: true, ...(model !== (upstream.defaultModel ?? "") ? { defaultModel: model } : {}) },
|
|
});
|
|
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">{maskedDisplay(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="可从列表选,也可手填"
|
|
className="sm:col-span-2"
|
|
>
|
|
<ModelFetchBar
|
|
data={models.data}
|
|
pending={refresh.isPending}
|
|
onFetch={() =>
|
|
refresh.mutate(upstream.id, { onError: (err) => toast.error(errorMessage(err, "获取失败")) })
|
|
}
|
|
disabledReason={fetchBlocked}
|
|
/>
|
|
<ModelInput
|
|
id="pu-model"
|
|
value={v.model}
|
|
onChange={(x) => {
|
|
setV((s) => ({ ...s, model: x }));
|
|
setErrors(({ defaultModel: _, ...rest }) => rest);
|
|
}}
|
|
models={models.data?.models ?? []}
|
|
disabled={pending}
|
|
invalid={!!errors.defaultModel}
|
|
/>
|
|
</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: "API Key",
|
|
kind: "secret",
|
|
secret: { set: () => upstream.hasCredential, mask: () => upstream.credentialMask, clearable: true },
|
|
wide: true,
|
|
}}
|
|
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>
|
|
);
|
|
}
|