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

@@ -19,6 +19,7 @@ import {
PathMappingCreate,
PathMappingUpdate,
type SecretPurpose,
secretInput,
} from "@umt/contracts";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
@@ -172,6 +173,14 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
return await ctx.secrets.create({ purpose, plaintext });
};
/** 审计里的字段名:凭证只记"设置 / 清除",不记值;空串(不变)不记 */
const auditFields = (body: Record<string, unknown>) =>
Object.keys(body).flatMap((k) => {
if (k !== "credential") return [k];
const c = secretInput(body.credential as string | null | undefined);
return c === undefined ? [] : [c === null ? "credential(cleared)" : "credential(set)"];
});
// ── library_root ──
const sentinel = async (localPath: string, file: string | null | undefined) => {
const name = file || DEFAULT_ROOT_SENTINEL;
@@ -254,7 +263,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
return { ...curSettings, ...(next.settings ?? {}), authKind };
};
endpoint(app, DL.create, async ({ body, actor }) => {
const { credential, authKind, ...rest } = body;
const { credential: rawCredential, authKind, ...rest } = body;
const credential = secretInput(rawCredential) ?? undefined;
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
const keep = authKind !== "none" && credential;
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
@@ -277,13 +287,18 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, DL.update, async ({ params, body, actor }) => {
const { credential, authKind, ...rest } = body;
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const { credential: rawCredential, authKind, ...rest } = body;
const credential = secretInput(rawCredential);
const cur = await getRow("downloader_instance", params.id);
const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind;
if (credential === null && (authKind ?? curAuth) === "api_key")
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
let dropRef: string | null = null;
if (authKind === "none") {
// 无认证:不再保留密码 / Key
if (authKind === "none" || credential === null) {
// 无认证或显式清除:不再保留密码 / Key
if (cur.secret_ref) {
values.secret_ref = null;
dropRef = cur.secret_ref;
@@ -299,7 +314,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
"downloader_instance.update",
{ type: "downloader_instance", id: row.id },
{
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
fields: auditFields(body),
},
);
return toApi(row);
@@ -354,7 +369,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
list("media_server_instance", "media-server-instances", MS.list);
get("media_server_instance", "media-server-instances", MS.get);
endpoint(app, MS.create, async ({ body, actor }) => {
const { credential, ...rest } = body;
const { credential: rawCredential, ...rest } = body;
const credential = secretInput(rawCredential) ?? undefined;
const ref = credential
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
: null;
@@ -374,20 +390,27 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, MS.update, async ({ params, body, actor }) => {
const { credential, ...rest } = body;
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const { credential: rawCredential, ...rest } = body;
const credential = secretInput(rawCredential);
const values = toDb(rest, JSON_COLS);
if (credential) {
let dropRef: string | null = null;
if (credential !== undefined) {
const cur = await getRow("media_server_instance", params.id);
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
if (credential === null) {
values.secret_ref = null;
dropRef = cur.secret_ref ?? null;
} else values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
}
const row = await update("media_server_instance", params.id, values);
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
await audit(
ctx,
actor,
"media_server_instance.update",
{ type: "media_server_instance", id: row.id },
{
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
fields: auditFields(body),
},
);
return toApi(row);
@@ -5,6 +5,7 @@ import {
mergeSettings,
type SecretPurpose,
type SettingsKey,
secretInput,
} from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
@@ -75,11 +76,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
// 审计里不留明文密钥
const logged: Record<string, unknown> = { ...patch };
const mark = (v: string | null | undefined) => {
const x = secretInput(v);
return x === undefined ? undefined : x === null ? "(cleared)" : "(set)";
};
if (patch.metadata && "tmdbApiKey" in patch.metadata)
logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" };
logged.metadata = { ...patch.metadata, tmdbApiKey: mark(patch.metadata.tmdbApiKey) };
const ws = patch.agent?.webSearch;
if (ws && "apiKey" in ws)
logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } };
if (ws && "apiKey" in ws) logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: mark(ws.apiKey) } };
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
return next;
});
@@ -96,7 +100,8 @@ interface SecretSlot {
/** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */
function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
const out: SecretSlot[] = [];
const tmdb = patch.metadata?.tmdbApiKey;
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const tmdb = secretInput(patch.metadata?.tmdbApiKey);
if (tmdb !== undefined)
out.push({
plaintext: tmdb,
@@ -107,7 +112,7 @@ function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
s.metadata.tmdbApiKeySecretRef = ref;
},
});
const ws = patch.agent?.webSearch?.apiKey;
const ws = secretInput(patch.agent?.webSearch?.apiKey);
if (ws !== undefined)
out.push({
plaintext: ws,