Files
UHD_Media_Tool/apps/web/app/components/settings/pi-wizard.tsx
T
rootandClaude Opus 5.5 c57411f9a5 docs(copy): 设置页账户与智能体分区文案按 human-copy 重写
- 改密码后的会话说法统一为「其他会话已退出」
- 模型服务向导里的 provider 统一写「提供方」
- 能力类别「提案」改「方案」;自动调查说明去掉「有界调查」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:45 +08:00

503 lines
18 KiB
TypeScript

import {
type PiAuthKind,
type PiProviderInfo,
type PiThinkingLevel,
type PiUpstream,
PiUpstreamCreate,
} from "@umt/contracts";
import { ChevronLeft, LoaderCircle, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
useCreatePiUpstream,
usePiProviders,
usePiUpstreamModels,
useProbePiModels,
useRefreshPiModels,
useUpdatePiUpstream,
} from "~/api/pi.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { kToTokens, type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import {
API_OPTIONS,
AUTH_LABEL,
GROUP_LABEL,
GROUP_ORDER,
listedFromInfo,
ModelFetchBar,
ModelInput,
THINKING_OPTIONS,
} from "./pi-shared.tsx";
import { PiTestPanel } from "./pi-test.tsx";
import { FieldControl } from "./resource-section.tsx";
/** 自定义模型的上下文窗口(与 PiUpstreamExtra.contextWindow 一致) */
const CONTEXT_WINDOW_RANGE: TokenRange = { min: 1000, max: 10_000_000 };
// 新建 Agent 上游:① 选 provider → ② 填凭证与模型并创建 → ③ 登录 / 测试,可设为默认
type Step = "provider" | "config" | "verify";
export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: () => void }) {
const [step, setStep] = useState<Step>("provider");
const [provider, setProvider] = useState<PiProviderInfo | null>(null);
const [created, setCreated] = useState<PiUpstream | null>(null);
const [fetched, setFetched] = useState(false);
const close = () => {
onClose();
// 关闭动画后再复位,避免闪回第一步
setTimeout(() => {
setStep("provider");
setProvider(null);
setCreated(null);
}, 200);
};
const title =
step === "provider"
? "新建模型服务"
: step === "config"
? `新建模型服务 · ${provider?.name ?? ""}`
: (created?.name ?? "");
const description =
step === "provider"
? "第 1 步,共 3 步:选提供方"
: step === "config"
? "第 2 步,共 3 步:凭证与模型"
: created?.authKind === "oauth"
? "第 3 步,共 3 步:登录并测试。模型服务已创建。"
: "第 3 步,共 3 步:测试。模型服务已创建。";
return (
<DetailSheet open={open} onOpenChange={(o) => !o && close()} title={title} description={description} width="lg">
{step === "provider" ? (
<ProviderPicker
onPick={(p) => {
setProvider(p);
setStep("config");
}}
/>
) : step === "config" && provider ? (
<ConfigStep
provider={provider}
onBack={() => setStep("provider")}
onCreated={(u, f) => {
setCreated(u);
setFetched(f);
setStep("verify");
}}
/>
) : created ? (
<VerifyStep upstream={created} provider={provider} autoFetch={fetched} onDone={close} />
) : null}
</DetailSheet>
);
}
function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) {
const q = usePiProviders(false);
const [filter, setFilter] = useState("");
if (q.isPending) return <LoadingState rows={6} />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
const f = filter.trim().toLowerCase();
const list = q.data.providers.filter((p) => !f || p.name.toLowerCase().includes(f) || p.id.includes(f));
return (
<div className="grid gap-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="筛选提供方"
placeholder="筛选,例如 openai"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9"
/>
</div>
{GROUP_ORDER.map((g) => {
const items = list.filter((p) => p.group === g);
if (!items.length) return null;
return (
<DetailSection key={g} title={GROUP_LABEL[g]}>
<ul className="grid gap-1.5 sm:grid-cols-2">
{items.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => onPick(p)}
className="flex w-full cursor-pointer flex-col items-start rounded-lg border border-border/60 px-3 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<span className="text-caption font-medium text-foreground">{p.name}</span>
<span className="text-meta text-muted-foreground">
{p.authKinds.map((k) => AUTH_LABEL[k]).join(" / ")}
{p.modelCount ? ` · ${p.modelCount} 个模型` : ""}
</span>
</button>
</li>
))}
</ul>
</DetailSection>
);
})}
{list.length === 0 ? <p className="text-caption text-muted-foreground">没有匹配的提供方。</p> : null}
</div>
);
}
function ConfigStep({
provider,
onBack,
onCreated,
}: {
provider: PiProviderInfo;
onBack: () => void;
/** fetched = 创建前已成功试拉过模型列表(下一步自动获取并存库) */
onCreated: (u: PiUpstream, fetched: boolean) => void;
}) {
const full = usePiProviders(true);
const create = useCreatePiUpstream();
const probe = useProbePiModels();
const staticModels = useMemo(
() => listedFromInfo(full.data?.providers.find((p) => p.id === provider.id)?.models ?? []),
[full.data, provider.id],
);
const models = probe.data?.models ?? staticModels;
const custom = provider.id === "openai-compatible";
const [authKind, setAuthKind] = useState<PiAuthKind>(provider.authKinds[0] ?? "none");
const [name, setName] = useState(provider.name);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [model, setModel] = useState("");
const [thinking, setThinking] = useState<PiThinkingLevel>("medium");
const [api, setApi] = useState("openai-completions");
const [contextWindow, setContextWindow] = useState("");
const [env, setEnv] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const showBaseUrl = provider.baseUrlOverridable && authKind !== "oauth";
// 凭证填好才能获取(订阅登录要先创建再登录,在下一步获取)
const fetchBlocked =
authKind === "oauth"
? "登录后在下一步获取"
: custom && !baseUrl.trim()
? "填好 Base URL 后可获取"
: authKind === "api_key" && !custom && !apiKey.trim()
? "填好 API Key 后可获取"
: null;
const fetchModels = async () => {
try {
const r = await probe.mutateAsync({
provider: provider.id,
authKind,
...(authKind === "api_key" && apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
baseUrl: showBaseUrl && baseUrl.trim() ? baseUrl.trim() : null,
...(custom ? { extra: { api: api as never } } : {}),
});
if (r.source === "remote") toast.success(`获取到 ${r.models.filter((m) => m.origin === "remote").length} 个模型`);
} catch (err) {
toast.error(errorMessage(err, "获取模型列表失败"));
}
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const envVals = Object.fromEntries(Object.entries(env).filter(([, v]) => v.trim()));
const extra = {
...(Object.keys(envVals).length ? { env: envVals } : {}),
...(custom ? { api: api as never } : {}),
...(custom && contextWindow.trim() ? { contextWindow: kToTokens(contextWindow) } : {}),
};
const parsed = PiUpstreamCreate.safeParse({
name: name.trim(),
provider: provider.id,
authKind,
...(authKind === "api_key" && apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
baseUrl: showBaseUrl && baseUrl.trim() ? baseUrl.trim() : null,
defaultModel: model.trim() || null,
...(provider.supportsThinking || custom ? { thinkingLevel: thinking } : {}),
extra,
});
const fe: Record<string, string> = parsed.success ? {} : zodFieldErrors(parsed.error.issues);
for (const f of provider.envFields) if (f.required && !env[f.name]?.trim()) fe[`env.${f.name}`] = `${f.label}必填`;
if (authKind === "api_key" && !apiKey.trim() && !custom) fe.apiKey = "填写 API Key";
if (fe.name) fe.name = "名称必填,64 字以内";
const cwError = custom ? tokenInputError(contextWindow, CONTEXT_WINDOW_RANGE) : null;
if (cwError) fe.contextWindow = cwError;
else if (fe["extra.contextWindow"]) fe.contextWindow = `填 ${tokenRangeText(CONTEXT_WINDOW_RANGE)}`;
if (!parsed.success || Object.keys(fe).length) {
setErrors(fe);
return;
}
setErrors({});
try {
const u = await create.mutateAsync(parsed.data);
toast.success(`${u.name}已创建`);
onCreated(u, probe.data?.source === "remote");
} catch (err) {
toast.error(errorMessage(err, "创建失败"));
}
};
const pending = create.isPending;
return (
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
<div className="sm:col-span-2">
<Button type="button" variant="ghost" size="xs" onClick={onBack} className="-ml-2 mb-2">
<ChevronLeft />
换一个提供方
</Button>
{provider.note && !custom ? <p className="mb-3 text-caption text-muted-foreground">{provider.note}</p> : null}
</div>
<FormField label="名称" htmlFor="pw-name" error={errors.name} required className="sm:col-span-2">
<Input id="pw-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
</FormField>
{provider.authKinds.length > 1 ? (
<FieldControl
spec={{
name: "authKind",
label: "认证方式",
kind: "select",
options: provider.authKinds.map((k) => ({ value: k, label: AUTH_LABEL[k] })),
wide: true,
}}
value={authKind}
onChange={(v) => setAuthKind(v as PiAuthKind)}
disabled={pending}
/>
) : null}
{authKind === "oauth" && provider.termsNote ? (
<p className="mb-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption sm:col-span-2">
{provider.termsNote}
</p>
) : null}
{authKind === "api_key" ? (
<FieldControl
spec={{
name: "apiKey",
label: "API Key",
kind: "secret",
secret: { set: () => false },
required: !custom,
wide: true,
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示末 4 位",
}}
value={apiKey}
error={errors.apiKey}
onChange={(v) => setApiKey(String(v))}
disabled={pending}
/>
) : null}
{authKind === "none" && provider.group === "cloud" ? (
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
不存密钥,运行时读服务端进程的环境凭证(AWS 凭证链 / Google ADC)。
</p>
) : null}
{showBaseUrl ? (
<FieldControl
spec={{
name: "baseUrl",
label: "Base URL",
kind: "url",
required: custom,
wide: true,
placeholder: provider.defaultBaseUrl ?? "https://example.com/v1",
hint: custom ? "OpenAI 兼容地址一般以 /v1 结尾" : "留空用官方地址",
}}
value={baseUrl}
error={errors.baseUrl}
onChange={(v) => setBaseUrl(String(v))}
disabled={pending}
/>
) : null}
{custom ? (
<>
<FieldControl
spec={{ name: "api", label: "接口形状", kind: "select", options: API_OPTIONS }}
value={api}
onChange={(v) => setApi(String(v))}
disabled={pending}
/>
<FieldControl
spec={{
name: "contextWindow",
label: "上下文窗口",
kind: "tokens",
tokens: CONTEXT_WINDOW_RANGE,
placeholder: "128",
hint: "单位 K,如 128 表示 128K;留空按 128K",
}}
value={contextWindow}
error={errors.contextWindow}
onChange={(v) => setContextWindow(String(v))}
disabled={pending}
/>
</>
) : null}
{provider.envFields.map((f) => (
<FieldControl
key={f.name}
spec={{ name: `env-${f.name}`, label: f.label, kind: "text", required: f.required, hint: f.hint ?? f.name }}
value={env[f.name] ?? ""}
error={errors[`env.${f.name}`]}
onChange={(v) => setEnv((s) => ({ ...s, [f.name]: String(v) }))}
disabled={pending}
/>
))}
<FormField
label="默认模型"
htmlFor="pw-model"
error={errors.defaultModel}
required={custom}
hint={custom ? "端点上的模型 id" : "留空用提供方的默认模型"}
className="sm:col-span-2"
>
<ModelFetchBar
data={probe.data}
pending={probe.isPending}
onFetch={fetchModels}
disabledReason={fetchBlocked}
/>
<ModelInput
id="pw-model"
value={model}
onChange={setModel}
models={models}
disabled={pending}
invalid={!!errors.defaultModel}
placeholder={full.isPending && !custom ? "模型列表加载中…" : undefined}
/>
</FormField>
{provider.supportsThinking || custom ? (
<FieldControl
spec={{
name: "thinking",
label: "思考档位",
kind: "select",
options: THINKING_OPTIONS,
hint: "运行时按模型支持的档位取近",
}}
value={thinking}
onChange={(v) => setThinking(v as PiThinkingLevel)}
disabled={pending}
/>
) : null}
<div className="mt-2 sm:col-span-2">
<Button type="submit" disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
{authKind === "oauth" ? "创建并去登录" : "创建并测试"}
</Button>
</div>
</form>
);
}
function VerifyStep({
upstream,
provider,
autoFetch,
onDone,
}: {
upstream: PiUpstream;
provider: PiProviderInfo | null;
autoFetch: boolean;
onDone: () => void;
}) {
const update = useUpdatePiUpstream();
const models = usePiUpstreamModels(upstream.id);
const refresh = useRefreshPiModels();
// 创建前试拉成功过:创建后用已存的凭证再拉一次并存库(只触发一次)
const autoFetched = useRef(false);
useEffect(() => {
if (!autoFetch || autoFetched.current) return;
autoFetched.current = true;
refresh.mutate(upstream.id);
}, [autoFetch, upstream.id, refresh.mutate]);
const [model, setModel] = useState(upstream.defaultModel ?? "");
const [modelError, setModelError] = useState<string | undefined>();
const [makeDefault, setMakeDefault] = useState(upstream.isDefault);
const [loggedIn, setLoggedIn] = useState(upstream.authKind !== "oauth");
const finish = async () => {
if (makeDefault && !model.trim()) {
setModelError("设为默认模型服务前先选默认模型");
return;
}
const body = {
...(model.trim() !== (upstream.defaultModel ?? "") ? { defaultModel: model.trim() || null } : {}),
...(makeDefault && !upstream.isDefault ? { makeDefault: true } : {}),
};
try {
if (Object.keys(body).length) await update.mutateAsync({ id: upstream.id, body });
if (makeDefault && !upstream.isDefault) toast.success(`${upstream.name}已设为默认模型服务`);
onDone();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<div className="grid gap-6">
{upstream.authKind === "oauth" ? (
<DetailSection title="登录">
<PiOAuthLogin upstream={upstream} provider={provider ?? undefined} onSucceeded={() => setLoggedIn(true)} />
</DetailSection>
) : null}
<DetailSection title="模型">
<FormField label="默认模型" htmlFor="pv-model" error={modelError} hint="可从列表选,也可手填">
<ModelFetchBar
data={models.data}
pending={refresh.isPending}
onFetch={() =>
refresh.mutate(upstream.id, { onError: (err) => toast.error(errorMessage(err, "获取失败")) })
}
disabledReason={loggedIn ? null : "登录后可获取"}
/>
<ModelInput
id="pv-model"
value={model}
onChange={(v) => {
setModel(v);
setModelError(undefined);
}}
models={models.data?.models ?? []}
invalid={!!modelError}
/>
</FormField>
</DetailSection>
<DetailSection title="测试">
{loggedIn ? (
<PiTestPanel upstream={upstream} model={model.trim() || null} />
) : (
<p className="text-caption text-muted-foreground">登录后再测试。</p>
)}
</DetailSection>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-4">
<label
htmlFor="pv-default"
className={cn("flex items-center gap-2 text-caption", upstream.isDefault && "opacity-60")}
>
<Switch
id="pv-default"
checked={makeDefault}
onCheckedChange={setMakeDefault}
disabled={upstream.isDefault}
/>
{upstream.isDefault ? "已是默认模型服务" : "设为默认模型服务"}
</label>
<Button onClick={finish} disabled={update.isPending}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
完成
</Button>
</div>
</div>
);
}