feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -0,0 +1,410 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user