- 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]>
411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
import {
|
|
type PiAuthKind,
|
|
type PiProviderInfo,
|
|
type PiThinkingLevel,
|
|
type PiUpstream,
|
|
PiUpstreamCreate,
|
|
} from "@umt/contracts";
|
|
import { ChevronLeft, LoaderCircle, Search } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { useCreatePiUpstream, usePiProviders, usePiUpstreamModels, 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 { cn } from "~/lib/utils.ts";
|
|
import { PiOAuthLogin } from "./pi-oauth.tsx";
|
|
import { API_OPTIONS, AUTH_LABEL, GROUP_LABEL, GROUP_ORDER, ModelInput, THINKING_OPTIONS } from "./pi-shared.tsx";
|
|
import { PiTestPanel } from "./pi-test.tsx";
|
|
import { FieldControl } from "./resource-section.tsx";
|
|
|
|
// 新建 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 close = () => {
|
|
onClose();
|
|
// 关闭动画后再复位,避免闪回第一步
|
|
setTimeout(() => {
|
|
setStep("provider");
|
|
setProvider(null);
|
|
setCreated(null);
|
|
}, 200);
|
|
};
|
|
|
|
const title =
|
|
step === "provider"
|
|
? "新建 Agent 上游"
|
|
: step === "config"
|
|
? `新建上游 · ${provider?.name ?? ""}`
|
|
: (created?.name ?? "");
|
|
const description =
|
|
step === "provider"
|
|
? "第 1 步,共 3 步:选 provider"
|
|
: 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) => {
|
|
setCreated(u);
|
|
setStep("verify");
|
|
}}
|
|
/>
|
|
) : created ? (
|
|
<VerifyStep upstream={created} provider={provider} 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="筛选 provider"
|
|
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">没有匹配的 provider。</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConfigStep({
|
|
provider,
|
|
onBack,
|
|
onCreated,
|
|
}: {
|
|
provider: PiProviderInfo;
|
|
onBack: () => void;
|
|
onCreated: (u: PiUpstream) => void;
|
|
}) {
|
|
const full = usePiProviders(true);
|
|
const create = useCreatePiUpstream();
|
|
const models = useMemo(
|
|
() => full.data?.providers.find((p) => p.id === provider.id)?.models ?? [],
|
|
[full.data, provider.id],
|
|
);
|
|
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 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: Number(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 字以内";
|
|
if (fe["extra.contextWindow"]) fe.contextWindow = "填 1024 以上的整数";
|
|
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);
|
|
} 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 />
|
|
换一个 provider
|
|
</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: "password",
|
|
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: "上下文窗口(token)", kind: "number", placeholder: "128000" }}
|
|
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" : "留空用 provider 的默认模型"}
|
|
className="sm:col-span-2"
|
|
>
|
|
<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,
|
|
onDone,
|
|
}: {
|
|
upstream: PiUpstream;
|
|
provider: PiProviderInfo | null;
|
|
onDone: () => void;
|
|
}) {
|
|
const update = useUpdatePiUpstream();
|
|
const models = usePiUpstreamModels(upstream.id);
|
|
const [model, setModel] = useState(upstream.defaultModel ?? "");
|
|
const [makeDefault, setMakeDefault] = useState(upstream.isDefault);
|
|
const [loggedIn, setLoggedIn] = useState(upstream.authKind !== "oauth");
|
|
|
|
const finish = async () => {
|
|
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"
|
|
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
|
|
>
|
|
<ModelInput id="pv-model" value={model} onChange={setModel} models={models.data?.items ?? []} />
|
|
</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>
|
|
);
|
|
}
|