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:
1 parent
68f53390f6
commit
fa344a7184
39 files changed
+1175
-232
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||||
import { MASK_DOTS, maskTail, SECRET_CLEAR, secretHint, secretPatch, secretPlaceholder, secretView } from "./secret.ts";
|
||||
|
||||
const render = (props: Partial<Parameters<typeof SecretInput>[0]>) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(SecretInput, { id: "k", value: "", onChange: () => {}, isSet: false, label: "API Key", ...props }),
|
||||
);
|
||||
|
||||
describe("只写凭证的表单值约定", () => {
|
||||
it("表单值 → 请求值:空串不改、哨兵清除、其余去空白替换", () => {
|
||||
expect(secretPatch("")).toBeUndefined();
|
||||
expect(secretPatch(" ")).toBeUndefined();
|
||||
expect(secretPatch(undefined)).toBeUndefined();
|
||||
expect(secretPatch(true)).toBeUndefined();
|
||||
expect(secretPatch(SECRET_CLEAR)).toBeNull();
|
||||
expect(secretPatch(" sk-new ")).toBe("sk-new");
|
||||
});
|
||||
|
||||
it("掩码只取服务端 hint 的末 4 位,拼不出更多", () => {
|
||||
expect(maskTail("sk-l…1234")).toBe("1234");
|
||||
expect(maskTail("****")).toBeNull();
|
||||
expect(maskTail(null)).toBeNull();
|
||||
expect(secretPlaceholder("sk-l…1234")).toBe(`已设置 ${MASK_DOTS}1234`);
|
||||
expect(secretPlaceholder(null)).toBe(`已设置 ${MASK_DOTS}`);
|
||||
expect(secretPlaceholder("sk-l…1234")).not.toContain("sk-l");
|
||||
});
|
||||
|
||||
it("显示状态:空 / 已设置 / 输入中(替换)/ 待清除", () => {
|
||||
expect(secretView("", { isSet: false })).toEqual({ state: "empty" });
|
||||
expect(secretView("", { isSet: true, clearable: true })).toEqual({
|
||||
state: "set",
|
||||
placeholder: `已设置 ${MASK_DOTS}`,
|
||||
canClear: true,
|
||||
});
|
||||
expect(secretView("abc", { isSet: true })).toEqual({ state: "typing", replacing: true });
|
||||
expect(secretView("abc", { isSet: false })).toEqual({ state: "typing", replacing: false });
|
||||
expect(secretView(SECRET_CLEAR, { isSet: true })).toEqual({ state: "cleared" });
|
||||
});
|
||||
|
||||
it("说明文字随状态变", () => {
|
||||
expect(secretHint("在控制台生成", "", false)).toBe("在控制台生成");
|
||||
expect(secretHint("在控制台生成", "", true)).toBe("在控制台生成;留空不修改");
|
||||
expect(secretHint(undefined, "", true)).toBe("留空不修改");
|
||||
expect(secretHint("x", SECRET_CLEAR, true)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SecretInput 渲染", () => {
|
||||
it("未设置:普通密码框,用调用方占位,没有清除", () => {
|
||||
const html = render({ placeholder: "qbt_…" });
|
||||
expect(html).toContain('type="password"');
|
||||
expect(html).toContain('placeholder="qbt_…"');
|
||||
expect(html).not.toContain("清除");
|
||||
});
|
||||
|
||||
it("已设置:框里是掩码占位(不回填 value),可清除时有「清除」", () => {
|
||||
const html = render({ isSet: true, mask: "sk-l…1234", clearable: true });
|
||||
expect(html).toContain(`placeholder="已设置 ${MASK_DOTS}1234"`);
|
||||
expect(html).toContain('value=""');
|
||||
expect(html).toContain("清除");
|
||||
expect(html).not.toContain("sk-l");
|
||||
});
|
||||
|
||||
it("已设置但不可清除(必填凭证):没有「清除」", () => {
|
||||
expect(render({ isSet: true })).not.toContain("清除");
|
||||
});
|
||||
|
||||
it("输入了新值:可撤销回保持原值", () => {
|
||||
const html = render({ isSet: true, value: "new-key" });
|
||||
expect(html).toContain("撤销");
|
||||
expect(html).toContain('aria-label="不替换API Key"');
|
||||
});
|
||||
|
||||
it("待清除:只读框提示保存后清除,可撤销", () => {
|
||||
const html = render({ isSet: true, clearable: true, value: SECRET_CLEAR });
|
||||
expect(html).toContain('placeholder="保存后清除"');
|
||||
expect(html).toContain("撤销");
|
||||
expect(html).not.toContain(SECRET_CLEAR);
|
||||
});
|
||||
|
||||
it("多行(Cookie):textarea,不回填", () => {
|
||||
const html = render({ isSet: true, multiline: true });
|
||||
expect(html).toContain("<textarea");
|
||||
expect(html).toContain(`placeholder="已设置 ${MASK_DOTS}"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// 只写凭证字段(API Key、密码、Cookie、令牌)的表单值约定,前后端一致:
|
||||
// "" → 保持原值(请求里省略该字段)
|
||||
// SECRET_CLEAR → 清除(请求里传 null)
|
||||
// 其它字符串 → 替换为新值
|
||||
// 服务端从不回传明文;"已设置"只来自 secretRef / hasCredential 之类的布尔,掩码只用服务端给的 hint。
|
||||
|
||||
/** 表单里"清除"的哨兵值;不会是用户能输入的内容 */
|
||||
export const SECRET_CLEAR = "\u0000clear";
|
||||
|
||||
export const MASK_DOTS = "••••••••";
|
||||
|
||||
/** 表单值 → 请求值:undefined = 不改,null = 清除,字符串 = 替换(去掉首尾空白) */
|
||||
export function secretPatch(v: unknown): string | null | undefined {
|
||||
if (v === SECRET_CLEAR) return null;
|
||||
if (typeof v !== "string") return undefined;
|
||||
const s = v.trim();
|
||||
return s ? s : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端给的掩码(如 "sk-a…wxyz")只取末 4 位;没有或太短只显示圆点。
|
||||
* 不在前端拼任何可还原的内容。
|
||||
*/
|
||||
export function maskTail(mask: string | null | undefined): string | null {
|
||||
if (!mask) return null;
|
||||
const tail = mask.includes("…") ? mask.split("…").pop() : null;
|
||||
return tail && /^[^•*]{4}$/.test(tail) ? tail : null;
|
||||
}
|
||||
|
||||
/** 掩码显示:圆点 + 末 4 位(有的话) */
|
||||
export function maskedDisplay(mask?: string | null): string {
|
||||
return `${MASK_DOTS}${maskTail(mask) ?? ""}`;
|
||||
}
|
||||
|
||||
/** 已设置时输入框里的占位 */
|
||||
export function secretPlaceholder(mask?: string | null): string {
|
||||
return `已设置 ${maskedDisplay(mask)}`;
|
||||
}
|
||||
|
||||
export type SecretView =
|
||||
| { state: "empty" }
|
||||
| { state: "set"; placeholder: string; canClear: boolean }
|
||||
| { state: "typing"; replacing: boolean }
|
||||
| { state: "cleared" };
|
||||
|
||||
/** SecretInput 的显示状态(纯函数,便于测试) */
|
||||
export function secretView(
|
||||
value: string,
|
||||
opts: { isSet: boolean; mask?: string | null; clearable?: boolean },
|
||||
): SecretView {
|
||||
if (value === SECRET_CLEAR) return { state: "cleared" };
|
||||
if (value !== "") return { state: "typing", replacing: opts.isSet };
|
||||
if (opts.isSet) return { state: "set", placeholder: secretPlaceholder(opts.mask), canClear: !!opts.clearable };
|
||||
return { state: "empty" };
|
||||
}
|
||||
|
||||
/** 字段说明:已设置时补一句"留空不修改";待清除时框里已写明,说明保持原样 */
|
||||
export function secretHint(hint: string | undefined, value: unknown, isSet: boolean): string | undefined {
|
||||
if (value === SECRET_CLEAR) return hint;
|
||||
if (!isSet) return hint;
|
||||
return hint ? `${hint};留空不修改` : "留空不修改";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatTokens, kToTokens, tokenInputError, tokenRangeText, tokensToK } from "./tokens.ts";
|
||||
|
||||
describe("token 以 K 为单位的换算", () => {
|
||||
it("原始值 → 输入框 K 值:整千、小数、空", () => {
|
||||
expect(tokensToK(128_000)).toBe("128");
|
||||
expect(tokensToK(1_500)).toBe("1.5");
|
||||
expect(tokensToK(4_096)).toBe("4.096");
|
||||
expect(tokensToK(2_000_000)).toBe("2000");
|
||||
expect(tokensToK(null)).toBe("");
|
||||
expect(tokensToK(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("K 值 → 原始值:允许小数与尾随 K,空串 undefined,非法 NaN", () => {
|
||||
expect(kToTokens("128")).toBe(128_000);
|
||||
expect(kToTokens(" 1.5 ")).toBe(1_500);
|
||||
expect(kToTokens("200k")).toBe(200_000);
|
||||
expect(kToTokens(".5")).toBe(500);
|
||||
expect(kToTokens("0.0005")).toBe(1);
|
||||
expect(kToTokens("")).toBeUndefined();
|
||||
expect(kToTokens(" ")).toBeUndefined();
|
||||
expect(kToTokens("abc")).toBeNaN();
|
||||
expect(kToTokens("-1")).toBeNaN();
|
||||
expect(kToTokens("1e3")).toBeNaN();
|
||||
});
|
||||
|
||||
it("往返不丢精度", () => {
|
||||
for (const n of [4_000, 4_096, 128_000, 131_072, 1_000_000, 2_000_000, 1]) expect(kToTokens(tokensToK(n))).toBe(n);
|
||||
});
|
||||
|
||||
it("校验:范围、必填、非法输入,错误用 K 表述", () => {
|
||||
const r = { min: 4_000, max: 2_000_000, required: true };
|
||||
expect(tokenRangeText(r)).toBe("4K–2000K");
|
||||
expect(tokenInputError("128", r)).toBeNull();
|
||||
expect(tokenInputError("4", r)).toBeNull();
|
||||
expect(tokenInputError("3.9", r)).toBe("填 4K–2000K");
|
||||
expect(tokenInputError("2001", r)).toBe("填 4K–2000K");
|
||||
expect(tokenInputError("", r)).toBe("填一个数,单位 K");
|
||||
expect(tokenInputError("12x", r)).toMatch(/只填数字/);
|
||||
// 可选:空串通过;0 不行
|
||||
expect(tokenInputError("", { min: 1 })).toBeNull();
|
||||
expect(tokenInputError("0", { min: 1 })).toBe("填 0.001K 以上");
|
||||
expect(tokenInputError("0", {})).toBe("填大于 0 的数");
|
||||
});
|
||||
|
||||
it("列表 / 详情显示:K / M 简写", () => {
|
||||
expect(formatTokens(512)).toBe("512");
|
||||
expect(formatTokens(1_500)).toBe("1.5K");
|
||||
expect(formatTokens(12_345)).toBe("12.3K");
|
||||
expect(formatTokens(128_000)).toBe("128K");
|
||||
expect(formatTokens(200_000)).toBe("200K");
|
||||
expect(formatTokens(999_960)).toBe("1M");
|
||||
expect(formatTokens(1_000_000)).toBe("1M");
|
||||
expect(formatTokens(1_048_576)).toBe("1.05M");
|
||||
expect(formatTokens(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// token 数在界面上以 K(千)为单位输入与显示;存储与 API 仍是原始 token 数,只在前端换算。
|
||||
// 1K = 1000 token(模型目录的上下文窗口也是按 1000 计,如 128K = 128000)。
|
||||
|
||||
export const TOKENS_PER_K = 1000;
|
||||
|
||||
/** 去掉小数末尾的 0:1.50 → "1.5",2.00 → "2" */
|
||||
const trim = (n: number, digits: number) => String(Number(n.toFixed(digits)));
|
||||
|
||||
/** 列表 / 详情里的 token 数:<1000 原样,千级 "128K",百万级 "1.5M" */
|
||||
export function formatTokens(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return "—";
|
||||
const abs = Math.abs(n);
|
||||
if (abs < TOKENS_PER_K) return String(Math.round(n));
|
||||
if (abs < 1_000_000) {
|
||||
const k = n / TOKENS_PER_K;
|
||||
// 999.95K 以上四舍五入会变成 1000K,直接进位到 M
|
||||
if (Math.abs(Number(k.toFixed(1))) < 1000) return `${trim(k, Math.abs(k) < 10 ? 2 : 1)}K`;
|
||||
}
|
||||
return `${trim(n / 1_000_000, 2)}M`;
|
||||
}
|
||||
|
||||
/** 原始 token 数 → 输入框里的 K 值字符串(null → 空串);最多 3 位小数,正好换回原值 */
|
||||
export function tokensToK(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return "";
|
||||
return trim(n / TOKENS_PER_K, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框里的 K 值 → 原始 token 数。
|
||||
* 空串 → undefined;不是数字 → NaN(交给校验报错);其余四舍五入到整数 token。
|
||||
*/
|
||||
export function kToTokens(input: string): number | undefined {
|
||||
const s = input.trim().replace(/[kK]$/, "").trim();
|
||||
if (s === "") return undefined;
|
||||
if (!/^\d+(\.\d+)?$|^\.\d+$/.test(s)) return Number.NaN;
|
||||
return Math.round(Number(s) * TOKENS_PER_K);
|
||||
}
|
||||
|
||||
export type TokenRange = { min?: number; max?: number; required?: boolean };
|
||||
|
||||
/** 范围说明:{min:4000,max:2000000} → "4K–2000K" */
|
||||
export function tokenRangeText(r: TokenRange): string | null {
|
||||
if (r.min !== undefined && r.max !== undefined) return `${tokensToK(r.min)}K–${tokensToK(r.max)}K`;
|
||||
if (r.min !== undefined) return `${tokensToK(r.min)}K 以上`;
|
||||
if (r.max !== undefined) return `不超过 ${tokensToK(r.max)}K`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 校验 K 值输入;通过返回 null,否则返回一句错误 */
|
||||
export function tokenInputError(input: string, r: TokenRange = {}): string | null {
|
||||
const t = kToTokens(input);
|
||||
if (t === undefined) return r.required ? "填一个数,单位 K" : null;
|
||||
const range = tokenRangeText(r);
|
||||
if (Number.isNaN(t)) return "只填数字,单位 K,如 128 或 1.5";
|
||||
if (t <= 0 || (r.min !== undefined && t < r.min) || (r.max !== undefined && t > r.max))
|
||||
return range ? `填 ${range}` : "填大于 0 的数";
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user