feat(web): token 数以 K 输入显示,已存凭证显示掩码占位(FIX-8)

- 新增 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 00:10:46 +08:00
1 parent 68f53390f6
commit fa344a7184
39 files changed
+1175 -232

No files matched your search

+6 -6
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/api.ts";
@@ -138,13 +139,12 @@ function SessionBlock() {
只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"}
</p>
<div className="flex max-w-md flex-wrap items-center gap-2">
<Input
type="password"
autoComplete="off"
aria-label="本机 Token"
placeholder={hasToken ? "输入新值替换" : "粘贴 API Key"}
<SecretInput
label="本机 Token"
isSet={hasToken}
placeholder="粘贴 API Key"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
onChange={setTokenValue}
className="min-w-0 flex-1"
/>
<Button
@@ -1,10 +1,10 @@
import type { GlobalSettings } from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { toast } from "sonner";
import { useTestWebSearch, useUpdateSettings } from "~/api/settings.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useTestWebSearch } from "~/api/settings.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { secretPatch } from "~/lib/secret.ts";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n } from "./settings-card.tsx";
@@ -28,8 +28,9 @@ export const WEBSEARCH_CARD: CardSpec = {
{
name: "agent.webSearch.apiKey",
label: "API Key",
kind: "password",
hint: "Brave Search API 的 Subscription Token;留空不修改",
kind: "secret",
secret: { set: (v) => v["agent.webSearch.apiKey:set"] === true, clearable: true },
hint: "Brave Search API 的 Subscription Token",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
@@ -79,6 +80,7 @@ export const WEBSEARCH_CARD: CardSpec = {
return {
"agent.webSearch.provider": w.provider,
"agent.webSearch.apiKey": "",
"agent.webSearch.apiKey:set": !!w.apiKeySecretRef,
"agent.webSearch.country": w.country,
"agent.webSearch.searchLang": w.searchLang ?? "",
"agent.webSearch.safesearch": w.safesearch,
@@ -87,7 +89,7 @@ export const WEBSEARCH_CARD: CardSpec = {
};
},
toPatch: (v) => {
const key = str(v["agent.webSearch.apiKey"]);
const key = secretPatch(v["agent.webSearch.apiKey"]);
const safe = str(v["agent.webSearch.safesearch"]);
return {
agent: {
@@ -98,7 +100,7 @@ export const WEBSEARCH_CARD: CardSpec = {
safesearch: safe === "off" || safe === "strict" ? safe : "moderate",
maxResults: num(v["agent.webSearch.maxResults"]),
requestsPerMinute: num(v["agent.webSearch.requestsPerMinute"]),
...(key ? { apiKey: key } : {}),
...(key !== undefined ? { apiKey: key } : {}),
},
},
};
@@ -106,54 +108,31 @@ export const WEBSEARCH_CARD: CardSpec = {
extra: (s) => <WebSearchKeyStatus settings={s} />,
};
/** 已保存 Key 时可以测试;Key 的设置与清除在上面的输入框里 */
function WebSearchKeyStatus({ settings }: { settings: GlobalSettings }) {
const update = useUpdateSettings();
const test = useTestWebSearch();
const w = settings.agent.webSearch;
if (w.provider === "none") return null;
const saved = !!w.apiKeySecretRef;
if (w.provider === "none" || !w.apiKeySecretRef) return null;
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
{saved ? (
<>
<Button
type="button"
variant="outline"
size="xs"
disabled={test.isPending}
onClick={async () => {
try {
const r = await test.mutateAsync({});
if (r.ok) toast.success(`搜索可用:${r.count} 条结果,用时 ${r.durationMs} ms`);
else toast.error(r.error?.message ?? "搜索失败");
} catch (err) {
toast.error(errorMessage(err, "测试失败"));
}
}}
>
{test.isPending ? <LoaderCircle className="animate-spin" /> : null}
测试搜索
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ agent: { webSearch: { apiKey: null } } });
toast.success("搜索服务 API Key 已清除");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除
</Button>
</>
) : null}
<Button
type="button"
variant="outline"
size="xs"
disabled={test.isPending}
onClick={async () => {
try {
const r = await test.mutateAsync({});
if (r.ok) toast.success(`搜索可用:${r.count} 条结果,用时 ${r.durationMs} ms`);
else toast.error(r.error?.message ?? "搜索失败");
} catch (err) {
toast.error(errorMessage(err, "测试失败"));
}
}}
>
{test.isPending ? <LoaderCircle className="animate-spin" /> : null}
测试搜索
</Button>
{test.data?.ok ? (
<span className="text-meta text-muted-foreground tabular-nums">
「{test.data.query}」{test.data.count} 条 · {test.data.durationMs} ms
+16 -8
View File
@@ -1,3 +1,4 @@
import { kToTokens, tokensToK } from "~/lib/tokens.ts";
import { AgentToolsBlock } from "./agent-tools.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
@@ -16,9 +17,10 @@ const RUNTIME_CARD: CardSpec = {
fields: [
{
name: "pi.contextBudget",
label: "上下文预算(token)",
kind: "number",
hint: "4096–2000000,超过模型窗口时按窗口算",
label: "上下文预算",
kind: "tokens",
tokens: { min: 4000, max: 2_000_000, required: true },
hint: "4K–2000K,超过模型窗口时按窗口算",
},
{ name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
@@ -31,14 +33,14 @@ const RUNTIME_CARD: CardSpec = {
},
],
fromSettings: (s) => ({
"pi.contextBudget": n(s.pi.contextBudget),
"pi.contextBudget": tokensToK(s.pi.contextBudget),
"pi.subagentsEnabled": s.pi.subagentsEnabled,
"pi.compaction.enabled": s.pi.compaction.enabled,
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
}),
toPatch: (v) => ({
pi: {
contextBudget: num(v["pi.contextBudget"]),
contextBudget: kToTokens(str(v["pi.contextBudget"])),
subagentsEnabled: v["pi.subagentsEnabled"] === true,
compaction: {
enabled: v["pi.compaction.enabled"] === true,
@@ -57,7 +59,13 @@ const BUDGET_CARD: CardSpec = {
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" },
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{ name: "agent.maxTokensPerRun", label: "单次 token 上限", kind: "number", hint: "留空不限" },
{
name: "agent.maxTokensPerRun",
label: "单次 token 上限",
kind: "tokens",
tokens: { min: 1 },
hint: "单位 K,留空不限",
},
{ name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" },
],
fromSettings: (s) => ({
@@ -65,7 +73,7 @@ const BUDGET_CARD: CardSpec = {
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
"agent.maxToolCalls": n(s.agent.maxToolCalls),
"agent.maxDurationSec": n(s.agent.maxDurationSec),
"agent.maxTokensPerRun": n(s.agent.maxTokensPerRun),
"agent.maxTokensPerRun": tokensToK(s.agent.maxTokensPerRun),
"agent.maxCostUsdPerRun": n(s.agent.maxCostUsdPerRun),
}),
toPatch: (v) => ({
@@ -76,7 +84,7 @@ const BUDGET_CARD: CardSpec = {
agent: {
maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]),
maxTokensPerRun: nullNum(v["agent.maxTokensPerRun"]),
maxTokensPerRun: kToTokens(str(v["agent.maxTokensPerRun"])) ?? null,
maxCostUsdPerRun: nullNum(v["agent.maxCostUsdPerRun"]),
},
}),
@@ -13,6 +13,7 @@ import {
useUpdateBrowserProvider,
} from "~/api/browser.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { secretPatch } from "~/lib/secret.ts";
import { Health, TestButton } from "./instances.tsx";
import { type FormValues, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
@@ -174,14 +175,20 @@ export function BrowserProvidersBlock() {
{
name: "accessKey",
label: "访问凭据",
kind: "password",
kind: "secret",
secret: { set: (v) => v["accessKey:set"] === true, clearable: true },
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
editHint: "留空不修改",
},
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", baseUrl: "", accessKey: "", enabled: true },
fromRow: (r) => ({ name: r.name, baseUrl: r.baseUrl ?? "", accessKey: "", enabled: r.enabled }),
fromRow: (r) => ({
name: r.name,
baseUrl: r.baseUrl ?? "",
accessKey: "",
"accessKey:set": r.hasAccessKey,
enabled: r.enabled,
}),
toBody: (v: FormValues, mode) =>
mode === "create"
? {
@@ -190,7 +197,7 @@ export function BrowserProvidersBlock() {
accessKey: optStr(v.accessKey),
enabled: v.enabled === true,
}
: { baseUrl: str(v.baseUrl), accessKey: optStr(v.accessKey), enabled: v.enabled === true },
: { baseUrl: str(v.baseUrl), accessKey: secretPatch(v.accessKey), enabled: v.enabled === true },
createSchema: BrowserProviderCreate,
updateSchema: BrowserProviderUpdate,
}}
+22 -8
View File
@@ -16,6 +16,7 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative, healthLabel } from "~/lib/labels.ts";
import { secretPatch } from "~/lib/secret.ts";
import { PathMappingsPanel } from "./path-mappings.tsx";
import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
@@ -65,7 +66,8 @@ export function TestButton({
);
}
const credentialHint = { editHint: "留空不修改" };
/** 下载器:已存凭证只对保存时的认证方式有效(改了认证方式就要重新填) */
const dlSecretSet = (v: FormValues) => v["secret:set"] === true && v["secret:authKind"] === v.authKind;
const AUTH_KINDS = [
{ value: "password", label: "用户名和密码" },
@@ -145,17 +147,17 @@ function DownloadersTable() {
{
name: "password",
label: "密码",
kind: "password",
kind: "secret",
secret: { set: dlSecretSet, clearable: true },
visible: (v) => v.authKind === "password",
...credentialHint,
},
{
name: "apiKey",
label: "API Key",
kind: "password",
kind: "secret",
secret: { set: dlSecretSet },
placeholder: "qbt_…",
visible: (v) => v.authKind === "api_key",
...credentialHint,
},
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
@@ -179,6 +181,8 @@ function DownloadersTable() {
username: r.username ?? "",
password: "",
apiKey: "",
"secret:set": !!r.secretRef,
"secret:authKind": authKindOf(r),
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
enabled: r.enabled,
@@ -190,7 +194,11 @@ function DownloadersTable() {
authKind: str(v.authKind),
username: v.authKind === "password" ? nullStr(v.username) : null,
credential:
v.authKind === "password" ? optStr(v.password) : v.authKind === "api_key" ? optStr(v.apiKey) : undefined,
v.authKind === "password"
? secretPatch(v.password)
: v.authKind === "api_key"
? secretPatch(v.apiKey)
: undefined,
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
enabled: v.enabled === true,
@@ -252,7 +260,12 @@ function MediaServersTable() {
},
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
{
name: "credential",
label: "API Key",
kind: "secret",
secret: { set: (v) => v["secret:set"] === true, clearable: true },
},
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true },
@@ -262,6 +275,7 @@ function MediaServersTable() {
baseUrl: r.baseUrl,
userId: r.userId ?? "",
credential: "",
"secret:set": !!r.secretRef,
enabled: r.enabled,
}),
toBody: (v) => ({
@@ -269,7 +283,7 @@ function MediaServersTable() {
kind: str(v.kind),
baseUrl: str(v.baseUrl),
userId: nullStr(v.userId),
credential: optStr(v.credential),
credential: secretPatch(v.credential),
enabled: v.enabled === true,
}),
createSchema: MediaServerInstanceCreate,
+16 -41
View File
@@ -1,10 +1,9 @@
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { toast } from "sonner";
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
import { useUpdateSettings } from "~/api/settings.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { secretPatch } from "~/lib/secret.ts";
import { str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
@@ -34,8 +33,9 @@ const TMDB_CARD: CardSpec = {
{
name: "metadata.tmdbApiKey",
label: "API Key",
kind: "password",
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
kind: "secret",
secret: { set: (v) => v["metadata.tmdbApiKey:set"] === true, clearable: true },
hint: "v3 API Key 或 v4 Read Access Token",
wide: true,
},
{
@@ -64,49 +64,24 @@ const TMDB_CARD: CardSpec = {
],
fromSettings: (s) => ({
"metadata.tmdbApiKey": "",
"metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
}),
toPatch: (v) => ({
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
includeSpecials: v["metadata.includeSpecials"] === true,
...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}),
},
}),
extra: (s) => <TmdbKeyStatus saved={!!s.metadata.tmdbApiKeySecretRef} />,
toPatch: (v) => {
const key = secretPatch(v["metadata.tmdbApiKey"]);
return {
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
includeSpecials: v["metadata.includeSpecials"] === true,
...(key !== undefined ? { tmdbApiKey: key } : {}),
},
};
},
};
function TmdbKeyStatus({ saved }: { saved: boolean }) {
const update = useUpdateSettings();
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
{saved ? (
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ metadata: { tmdbApiKey: null } });
toast.success("TMDB API Key 已清除");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除
</Button>
) : null}
</div>
);
}
// TMDB 缓存(对照 MP:META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL)
const TMDB_CACHE_CARD: CardSpec = {
anchor: "tmdb-cache",
@@ -14,6 +14,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
// Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游")
@@ -93,8 +94,7 @@ export const listedFromInfo = (models: PiModelInfo[]): PiListedModel[] =>
const TAG = "shrink-0 rounded-tight border border-border/60 px-1 text-meta text-muted-foreground";
const fmtContext = (n: number | null) =>
!n ? null : n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`;
const fmtContext = (n: number | null) => (!n ? null : formatTokens(n));
/** 模型 id:可搜索的下拉(显示 id 与视觉 / 工具标记),也可手填(目录滞后于上游时) */
export function ModelInput({
@@ -19,6 +19,7 @@ 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 {
@@ -138,7 +139,7 @@ function UpstreamDrawer({
v.model !== (upstream.defaultModel ?? "") ||
v.thinking !== upstream.thinkingLevel ||
v.baseUrl !== (upstream.baseUrl ?? "") ||
!!v.apiKey ||
v.apiKey !== "" ||
v.enabled !== upstream.enabled ||
envFields.some((f) => (v.env[f.name] ?? "") !== (upstream.extra.env?.[f.name] ?? ""));
@@ -148,19 +149,20 @@ function UpstreamDrawer({
? upstream.authKind === "oauth"
? "登录后可获取"
: "先配置 API Key"
: v.apiKey.trim() || v.baseUrl !== (upstream.baseUrl ?? "")
: 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 } : {}),
...(v.apiKey.trim() ? { apiKey: v.apiKey.trim() } : {}),
...(apiKey !== undefined ? { apiKey } : {}),
enabled: v.enabled,
...(envFields.length ? { extra: { ...upstream.extra, env } } : {}),
});
@@ -239,7 +241,7 @@ function UpstreamDrawer({
{upstream.credentialMask ? (
<>
<dt className="text-muted-foreground">API Key</dt>
<dd className="font-mono">{upstream.credentialMask}</dd>
<dd className="font-mono">{maskedDisplay(upstream.credentialMask)}</dd>
</>
) : null}
{upstream.account ? (
@@ -372,10 +374,10 @@ function UpstreamDrawer({
<FieldControl
spec={{
name: "apiKey",
label: upstream.hasCredential ? "替换 API Key" : "API Key",
kind: "password",
label: "API Key",
kind: "secret",
secret: { set: () => upstream.hasCredential, mask: () => upstream.credentialMask, clearable: true },
wide: true,
hint: upstream.hasCredential ? "留空不修改" : undefined,
}}
value={v.apiKey}
error={errors.apiKey}
+19 -5
View File
@@ -23,6 +23,7 @@ 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 {
@@ -38,6 +39,9 @@ import {
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";
@@ -209,7 +213,7 @@ function ConfigStep({
const extra = {
...(Object.keys(envVals).length ? { env: envVals } : {}),
...(custom ? { api: api as never } : {}),
...(custom && contextWindow.trim() ? { contextWindow: Number(contextWindow) } : {}),
...(custom && contextWindow.trim() ? { contextWindow: kToTokens(contextWindow) } : {}),
};
const parsed = PiUpstreamCreate.safeParse({
name: name.trim(),
@@ -225,7 +229,9 @@ function ConfigStep({
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 以上的整数";
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;
@@ -277,10 +283,11 @@ function ConfigStep({
spec={{
name: "apiKey",
label: "API Key",
kind: "password",
kind: "secret",
secret: { set: () => false },
required: !custom,
wide: true,
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示末 4 位",
}}
value={apiKey}
error={errors.apiKey}
@@ -319,7 +326,14 @@ function ConfigStep({
disabled={pending}
/>
<FieldControl
spec={{ name: "contextWindow", label: "上下文窗口(token)", kind: "number", placeholder: "128000" }}
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))}
@@ -8,6 +8,8 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { UnitInput } from "~/components/console/unit-input.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
@@ -15,10 +17,13 @@ import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { secretHint } from "~/lib/secret.ts";
import { type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写(kind=secret,值约定见 ~/lib/secret.ts):
// 留空不变、SECRET_CLEAR 清除;token 数字段(kind=tokens)以 K 输入,toBody 里用 kToTokens 换回原始值。
export type FieldValue = string | boolean;
export type FormValues = Record<string, FieldValue>;
@@ -26,7 +31,7 @@ export type FormValues = Record<string, FieldValue>;
export type FieldSpec = {
name: string;
label: string;
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea" | "tokens" | "secret";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
@@ -38,8 +43,37 @@ export type FieldSpec = {
/** 条件显示 */
visible?: (v: FormValues) => boolean;
wide?: boolean;
/** kind=tokens:以 K 为单位输入(表单值是 K 字符串),范围按原始 token 数 */
tokens?: TokenRange;
/** kind=secret:只写凭证。set/mask 从表单值(fromRow / fromSettings 放进去的隐藏键)或闭包取 */
secret?: {
set: (v: FormValues) => boolean;
mask?: (v: FormValues) => string | null | undefined;
clearable?: boolean;
multiline?: boolean;
};
};
/** kind=tokens 字段先在本地校验(错误用 K 表述),通过后再走 zod */
export function localFieldErrors(fields: FieldSpec[], values: FormValues): Record<string, string> {
const out: Record<string, string> = {};
for (const f of fields) {
if (f.visible && !f.visible(values)) continue;
if (f.kind === "tokens") {
const e = tokenInputError(str(values[f.name]), { ...f.tokens, required: f.tokens?.required ?? f.required });
if (e) out[f.name] = e;
}
}
return out;
}
/** 字段说明:凭证字段已设置时补"留空不修改",token 字段缺省给范围 */
export function fieldHint(spec: FieldSpec, value: FieldValue | undefined, values: FormValues, hint?: string) {
if (spec.kind === "secret") return secretHint(hint, value, spec.secret?.set(values) ?? false);
if (spec.kind === "tokens" && hint === undefined && spec.tokens) return tokenRangeText(spec.tokens) ?? undefined;
return hint;
}
export type ResourceConfig<Row extends { id: string }> = {
/** 单数名,用于标题与提示:"下载器实例" */
noun: string;
@@ -186,6 +220,12 @@ function ResourceFormDrawer<Row extends { id: string }>({
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const local = localFieldErrors(config.fields, values);
if (Object.keys(local).length) {
setErrors(local);
revealFirstError();
return;
}
const body = config.toBody(values, mode);
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
if (!parsed.success) {
@@ -240,6 +280,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
values={values}
/>
))}
</form>
@@ -255,6 +296,7 @@ export function FieldControl({
onChange,
disabled,
hint,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
@@ -262,8 +304,11 @@ export function FieldControl({
onChange: (v: FieldValue) => void;
disabled?: boolean;
hint?: string;
/** 整个表单的值(凭证字段据此判断是否已设置);单独使用时可省略 */
values?: FormValues;
}) {
const id = `f-${spec.name}`;
const all = values ?? {};
const wide = spec.wide || spec.kind === "textarea";
if (spec.kind === "switch") {
return (
@@ -281,11 +326,19 @@ export function FieldControl({
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
hint={fieldHint(spec, value, all, hint)}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
<FieldInput id={id} spec={spec} value={value} error={error} onChange={onChange} disabled={disabled} />
<FieldInput
id={id}
spec={spec}
value={value}
values={all}
error={error}
onChange={onChange}
disabled={disabled}
/>
</FormField>
);
}
@@ -299,6 +352,7 @@ export function FieldInput({
onChange,
disabled,
className,
values,
}: {
id: string;
spec: FieldSpec;
@@ -307,7 +361,41 @@ export function FieldInput({
onChange: (v: FieldValue) => void;
disabled?: boolean;
className?: string;
values?: FormValues;
}) {
if (spec.kind === "secret") {
const all = values ?? {};
return (
<SecretInput
id={id}
value={typeof value === "string" ? value : ""}
onChange={onChange}
isSet={spec.secret?.set(all) ?? false}
mask={spec.secret?.mask?.(all)}
clearable={spec.secret?.clearable}
multiline={spec.secret?.multiline}
label={spec.label}
placeholder={spec.placeholder}
disabled={disabled}
invalid={!!error}
className={className}
/>
);
}
if (spec.kind === "tokens") {
return (
<UnitInput
id={id}
unit="K"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
className={className}
/>
);
}
if (spec.kind === "switch") {
return (
<Switch
@@ -15,7 +15,14 @@ import { Label } from "~/components/ui/label.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { cn } from "~/lib/utils.ts";
import { FieldInput, type FieldSpec, type FieldValue, type FormValues } from "./resource-section.tsx";
import {
FieldInput,
type FieldSpec,
type FieldValue,
type FormValues,
fieldHint,
localFieldErrors,
} from "./resource-section.tsx";
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
@@ -49,6 +56,12 @@ export function SettingsCard({
const save = async (e: React.FormEvent) => {
e.preventDefault();
const local = localFieldErrors(spec.fields, values);
if (Object.keys(local).length) {
setErrors(local);
revealFirstError();
return;
}
const patch = spec.toPatch(values, settings);
const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch));
if (!parsed.success) {
@@ -89,6 +102,7 @@ export function SettingsCard({
key={f.name}
spec={f}
value={values[f.name]}
values={values}
error={errors[f.name]}
disabled={update.isPending}
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
@@ -124,7 +138,7 @@ export function SettingsCard({
function controlWidth(spec: FieldSpec): string {
if (spec.kind === "switch") return "";
if (spec.wide || spec.kind === "textarea") return "w-full";
if (spec.kind === "number") return "w-full sm:w-40";
if (spec.kind === "number" || spec.kind === "tokens") return "w-full sm:w-40";
if (spec.kind === "select") return "w-full sm:max-w-xs";
return "w-full sm:max-w-md";
}
@@ -139,15 +153,19 @@ export function SettingsField({
error,
onChange,
disabled,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
/** 整个表单的值(凭证字段据此判断是否已设置) */
values?: FormValues;
}) {
const id = `f-${spec.name}`;
const isSwitch = spec.kind === "switch";
const hint = fieldHint(spec, value, values ?? {}, spec.hint);
return (
<div
className={cn(
@@ -160,13 +178,14 @@ export function SettingsField({
{spec.label}
{spec.required ? <span className="text-destructive">*</span> : null}
</Label>
{spec.hint ? <p className="mt-1 text-meta text-muted-foreground">{spec.hint}</p> : null}
{hint ? <p className="mt-1 text-meta text-muted-foreground">{hint}</p> : null}
</div>
<div className={cn("min-w-0", isSwitch && "pt-0.5 md:justify-self-start")}>
<FieldInput
id={id}
spec={spec}
value={value}
values={values}
error={error}
onChange={onChange}
disabled={disabled}