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

@@ -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