Merge branch 'fix8'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
9ef2e8554c
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,
|
||||
|
||||
@@ -80,6 +80,9 @@ describe.skipIf(!up)("浏览器服务登记", () => {
|
||||
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
|
||||
expect(p2.status).toBe(200);
|
||||
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
|
||||
// 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
|
||||
const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
|
||||
expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
|
||||
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
|
||||
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
|
||||
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
DownloadArtifact,
|
||||
NavigationBudget,
|
||||
} from "@umt/contracts";
|
||||
import { secretInput } from "@umt/contracts";
|
||||
import {
|
||||
type BrowserControlLeaseRow,
|
||||
type BrowserProviderRow,
|
||||
@@ -354,8 +355,9 @@ export class BrowserService {
|
||||
.where("name", "=", body.name)
|
||||
.executeTakeFirst();
|
||||
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
|
||||
const secretRef = body.accessKey
|
||||
? await this.ctx.secrets.create({ purpose: "other", plaintext: body.accessKey, label: `browser:${body.name}` })
|
||||
const accessKey = secretInput(body.accessKey);
|
||||
const secretRef = accessKey
|
||||
? await this.ctx.secrets.create({ purpose: "other", plaintext: accessKey, label: `browser:${body.name}` })
|
||||
: null;
|
||||
const row = await this.ctx.db
|
||||
.insertInto("browser_provider")
|
||||
@@ -374,8 +376,10 @@ export class BrowserService {
|
||||
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
|
||||
async updateProvider(
|
||||
id: string,
|
||||
body: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
|
||||
input: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
|
||||
): Promise<BrowserProviderView> {
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const body = { ...input, accessKey: secretInput(input.accessKey) };
|
||||
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
|
||||
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type UpstreamCredentialStore,
|
||||
type UpstreamSpec,
|
||||
} from "@umt/agent";
|
||||
import type { Actor, PiUpstream, PiUpstreamCreate, PiUpstreamUpdate } from "@umt/contracts";
|
||||
import { type Actor, type PiUpstream, type PiUpstreamCreate, type PiUpstreamUpdate, secretInput } from "@umt/contracts";
|
||||
import type { PiUpstreamRow } from "@umt/db";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
@@ -91,8 +91,10 @@ export async function listUpstreams(
|
||||
export async function createUpstream(
|
||||
ctx: ServerContext,
|
||||
actor: Actor | undefined,
|
||||
body: PiUpstreamCreate,
|
||||
input: PiUpstreamCreate,
|
||||
): Promise<PiUpstream> {
|
||||
// 只写凭证统一语义:空串 = 不设置
|
||||
const body = { ...input, apiKey: secretInput(input.apiKey) ?? undefined };
|
||||
validateShape(body);
|
||||
const id = randomUUID();
|
||||
let secretRef: string | null = null;
|
||||
@@ -132,9 +134,13 @@ export async function updateUpstream(
|
||||
ctx: ServerContext,
|
||||
actor: Actor | undefined,
|
||||
row: PiUpstreamRow,
|
||||
body: PiUpstreamUpdate,
|
||||
input: PiUpstreamUpdate,
|
||||
onCredentialDropped: () => void,
|
||||
): Promise<PiUpstream> {
|
||||
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
|
||||
const { apiKey: rawKey, ...rest } = input;
|
||||
const apiKey = secretInput(rawKey);
|
||||
const body: PiUpstreamUpdate = apiKey === undefined ? rest : { ...rest, apiKey };
|
||||
const authKind = body.authKind ?? row.auth_kind;
|
||||
const baseUrl = body.baseUrl !== undefined ? body.baseUrl : authKind === "oauth" ? null : row.base_url;
|
||||
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
|
||||
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
SiteLoginResult,
|
||||
SiteUserDataPoint,
|
||||
} from "@umt/contracts";
|
||||
import { secretInput } from "@umt/contracts";
|
||||
import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { toApi } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||
@@ -92,24 +94,40 @@ const PURPOSE: Record<string, SecretPurpose> = {
|
||||
password: "site_password",
|
||||
};
|
||||
|
||||
const MISSING_SECRET: Record<string, string> = {
|
||||
password: "填写密码",
|
||||
cookie: "粘贴 Cookie",
|
||||
api_key: "填写 API Key",
|
||||
};
|
||||
|
||||
type NormalizedCredentials =
|
||||
| {
|
||||
kind: "password";
|
||||
label: string;
|
||||
username: string;
|
||||
password: string;
|
||||
totpSecret?: string;
|
||||
/** undefined = 保持已存密码 */
|
||||
password?: string | undefined;
|
||||
/** undefined = 保持;null = 清除 */
|
||||
totpSecret?: string | null | undefined;
|
||||
saveCookieOnly?: boolean;
|
||||
userAgent?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
| { kind: "cookie"; label: string; cookie: string; userAgent?: string; enabled?: boolean }
|
||||
| { kind: "api_key"; label: string; apiKey: string; enabled?: boolean }
|
||||
| { kind: "cookie"; label: string; cookie?: string | undefined; userAgent?: string; enabled?: boolean }
|
||||
| { kind: "api_key"; label: string; apiKey?: string | undefined; enabled?: boolean }
|
||||
| { kind: "passkey"; label: string; passkey: string; enabled?: boolean };
|
||||
|
||||
/** 旧格式 {authKind, credential, username?} → 新格式 */
|
||||
export function normalizeCredentials(body: SiteCredentialsPut): NormalizedCredentials {
|
||||
if ("kind" in body) return body as NormalizedCredentials;
|
||||
if ("kind" in body) {
|
||||
// 只写凭证统一语义:省略或空串 = 保持已存值;TOTP 的 null = 清除
|
||||
const b = body as NormalizedCredentials;
|
||||
if (b.kind === "password")
|
||||
return { ...b, password: secretInput(b.password) ?? undefined, totpSecret: secretInput(b.totpSecret) };
|
||||
if (b.kind === "cookie") return { ...b, cookie: secretInput(b.cookie) ?? undefined };
|
||||
if (b.kind === "api_key") return { ...b, apiKey: secretInput(b.apiKey) ?? undefined };
|
||||
return b;
|
||||
}
|
||||
const label = body.label;
|
||||
const enabled = body.enabled;
|
||||
const opt = enabled === undefined ? {} : { enabled };
|
||||
@@ -148,7 +166,7 @@ export async function setSiteCredentials(
|
||||
// 已有账号的同类凭证原地覆盖(replace),不新建 secret
|
||||
const pre = await db
|
||||
.selectFrom("site_account")
|
||||
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref"])
|
||||
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref", "session_secret_ref", "login_name"])
|
||||
.where("site_id", "=", site.id)
|
||||
.where("label", "=", label)
|
||||
.executeTakeFirst();
|
||||
@@ -213,11 +231,35 @@ export async function setSiteCredentials(
|
||||
});
|
||||
} else {
|
||||
const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey;
|
||||
const ref = await put(pre?.auth_kind === body.kind ? pre.secret_ref : null, PURPOSE[body.kind]!, plaintext);
|
||||
const sameKind = pre?.auth_kind === body.kind;
|
||||
// 留空 = 保持已存的主凭证:只在同类账号且已存时可以
|
||||
const keepMain = plaintext === undefined;
|
||||
if (keepMain && !(sameKind && pre?.secret_ref))
|
||||
throw new ApiException("validation_failed", MISSING_SECRET[body.kind] ?? "填写凭证");
|
||||
const ref =
|
||||
plaintext === undefined
|
||||
? pre!.secret_ref!
|
||||
: await put(sameKind ? pre?.secret_ref : null, PURPOSE[body.kind]!, plaintext);
|
||||
if (keepMain) reused.add(ref);
|
||||
// TOTP:新值替换;省略 = 同类账号保持;null 或换类型 = 清除
|
||||
const keepTotp = body.kind === "password" && body.totpSecret === undefined && sameKind && pre?.totp_secret_ref;
|
||||
const totpRef =
|
||||
body.kind === "password" && body.totpSecret
|
||||
? await put(pre?.totp_secret_ref, "site_password", body.totpSecret, ":totp")
|
||||
: keepTotp
|
||||
? pre!.totp_secret_ref
|
||||
: null;
|
||||
if (totpRef) reused.add(totpRef);
|
||||
// 只改 UA / 启用等、密码与登录名都没变:会话 Cookie 仍有效,不丢
|
||||
const keepSession =
|
||||
keepMain &&
|
||||
body.kind === "password" &&
|
||||
pre?.session_secret_ref &&
|
||||
pre.login_name === body.username &&
|
||||
totpRef === pre.totp_secret_ref
|
||||
? pre.session_secret_ref
|
||||
: null;
|
||||
if (keepSession) reused.add(keepSession);
|
||||
result = await db.transaction().execute(async (trx) => {
|
||||
const cur = await trx
|
||||
.selectFrom("site_account")
|
||||
@@ -236,7 +278,7 @@ export async function setSiteCredentials(
|
||||
secret_ref: ref,
|
||||
status: "unknown" as const,
|
||||
last_error: null,
|
||||
session_secret_ref: null,
|
||||
session_secret_ref: keepSession,
|
||||
totp_secret_ref: totpRef,
|
||||
login_name: body.kind === "password" ? body.username : null,
|
||||
user_agent: "userAgent" in body ? (body.userAgent ?? null) : null,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
// FIX-8 只写凭证统一语义:省略或空串 = 保持原值,null = 清除,非空 = 替换;明文不出 API。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("只写凭证:留空不变 / 显式清除(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let admin: ReturnType<typeof bearer>;
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness({ engine: false });
|
||||
admin = bearer(h.app, DEV_KEY);
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
const secretOf = (ref: string) => h.ctx.secrets.resolve(ref);
|
||||
|
||||
it("下载器(账密):空串保持;null 清除;新值原地替换", async () => {
|
||||
const c = (
|
||||
await json(
|
||||
await admin("POST", "/downloader-instances", {
|
||||
name: "qb-keep",
|
||||
baseUrl: "http://127.0.0.1:1",
|
||||
authKind: "password",
|
||||
username: "u",
|
||||
credential: "pw-original",
|
||||
}),
|
||||
)
|
||||
).data;
|
||||
const ref = c.secretRef as string;
|
||||
expect(ref).toBeTruthy();
|
||||
|
||||
const kept = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "", name: "qb-k2" })))
|
||||
.data;
|
||||
expect(kept.secretRef).toBe(ref);
|
||||
expect(await secretOf(ref)).toBe("pw-original");
|
||||
|
||||
const ws = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: " " }))).data;
|
||||
expect(ws.secretRef).toBe(ref);
|
||||
|
||||
const replaced = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "pw-new" }))).data;
|
||||
expect(replaced.secretRef).toBe(ref);
|
||||
expect(await secretOf(ref)).toBe("pw-new");
|
||||
|
||||
const cleared = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: null }))).data;
|
||||
expect(cleared.secretRef).toBeNull();
|
||||
await expect(secretOf(ref)).rejects.toThrow(/不存在/);
|
||||
expect(JSON.stringify(cleared)).not.toContain("pw-");
|
||||
});
|
||||
|
||||
it("下载器(API Key):不能清除 Key(422),留空保持", async () => {
|
||||
const c = (
|
||||
await json(
|
||||
await admin("POST", "/downloader-instances", {
|
||||
name: "qb-key",
|
||||
baseUrl: "http://127.0.0.1:1",
|
||||
authKind: "api_key",
|
||||
credential: "qbt_key_1",
|
||||
}),
|
||||
)
|
||||
).data;
|
||||
expect((await admin("PATCH", `/downloader-instances/${c.id}`, { credential: null })).status).toBe(422);
|
||||
const kept = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "" }))).data;
|
||||
expect(kept.secretRef).toBe(c.secretRef);
|
||||
// 新建时空串 = 没填:API Key 认证必须有 Key
|
||||
expect(
|
||||
(
|
||||
await admin("POST", "/downloader-instances", {
|
||||
name: "qb-empty",
|
||||
baseUrl: "http://127.0.0.1:1",
|
||||
authKind: "api_key",
|
||||
credential: "",
|
||||
})
|
||||
).status,
|
||||
).toBe(422);
|
||||
});
|
||||
|
||||
it("媒体服务器:空串保持;null 清除", async () => {
|
||||
const c = (
|
||||
await json(
|
||||
await admin("POST", "/media-server-instances", {
|
||||
name: "emby-keep",
|
||||
baseUrl: "http://127.0.0.1:1",
|
||||
credential: "emby-key-1",
|
||||
}),
|
||||
)
|
||||
).data;
|
||||
const ref = c.secretRef as string;
|
||||
const kept = (await json(await admin("PATCH", `/media-server-instances/${c.id}`, { credential: "" }))).data;
|
||||
expect(kept.secretRef).toBe(ref);
|
||||
expect(await secretOf(ref)).toBe("emby-key-1");
|
||||
const cleared = (await json(await admin("PATCH", `/media-server-instances/${c.id}`, { credential: null }))).data;
|
||||
expect(cleared.secretRef).toBeNull();
|
||||
await expect(secretOf(ref)).rejects.toThrow(/不存在/);
|
||||
// 空串新建 = 不存 key
|
||||
const n = (
|
||||
await json(
|
||||
await admin("POST", "/media-server-instances", {
|
||||
name: "emby-nokey",
|
||||
baseUrl: "http://127.0.0.1:1",
|
||||
credential: "",
|
||||
}),
|
||||
)
|
||||
).data;
|
||||
expect(n.secretRef).toBeNull();
|
||||
});
|
||||
|
||||
it("设置(TMDB / 网页搜索 Key):空串保持;null 清除;审计不留明文", async () => {
|
||||
const set = (await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: "tmdb-secret-1" } }))).data;
|
||||
const ref = set.metadata.tmdbApiKeySecretRef as string;
|
||||
expect(ref).toBeTruthy();
|
||||
const kept = (
|
||||
await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: "", tmdbBaseUrl: "https://t.local" } }))
|
||||
).data;
|
||||
expect(kept.metadata.tmdbApiKeySecretRef).toBe(ref);
|
||||
expect(kept.metadata.tmdbBaseUrl).toBe("https://t.local");
|
||||
expect(await secretOf(ref)).toBe("tmdb-secret-1");
|
||||
const cleared = (await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: null } }))).data;
|
||||
expect(cleared.metadata.tmdbApiKeySecretRef).toBeNull();
|
||||
|
||||
const ws = (
|
||||
await json(await admin("PATCH", "/settings", { agent: { webSearch: { provider: "brave", apiKey: "brave-1" } } }))
|
||||
).data;
|
||||
const wref = ws.agent.webSearch.apiKeySecretRef as string;
|
||||
const wkept = (await json(await admin("PATCH", "/settings", { agent: { webSearch: { apiKey: "" } } }))).data;
|
||||
expect(wkept.agent.webSearch.apiKeySecretRef).toBe(wref);
|
||||
const wcleared = (await json(await admin("PATCH", "/settings", { agent: { webSearch: { apiKey: null } } }))).data;
|
||||
expect(wcleared.agent.webSearch.apiKeySecretRef).toBeNull();
|
||||
|
||||
const audits = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select("payload")
|
||||
.where("action", "=", "settings.update")
|
||||
.execute();
|
||||
const text = JSON.stringify(audits);
|
||||
expect(text).not.toContain("tmdb-secret-1");
|
||||
expect(text).not.toContain("brave-1");
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,11 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
|
||||
(await req("POST", "/pi/upstreams", { name: "OpenAI 主号", provider: "openai", authKind: "api_key" })).status,
|
||||
).toBe(409);
|
||||
|
||||
// 空串 = 保持原 key(FIX-8 只写凭证统一语义);掩码只给前后 4 位
|
||||
const kept = (await req("PATCH", `/pi/upstreams/${a.id}`, { apiKey: "", name: "OpenAI 主号" })).body.data;
|
||||
expect(kept).toMatchObject({ hasCredential: true });
|
||||
expect(kept.credentialMask).toMatch(/^.{4}….{4}$/);
|
||||
expect(JSON.stringify(kept)).not.toMatch(/secret-\d/);
|
||||
// 清 key → auth_required;测试报 auth
|
||||
const cleared = (await req("PATCH", `/pi/upstreams/${a.id}`, { apiKey: null })).body.data;
|
||||
expect(cleared).toMatchObject({ status: "auth_required", hasCredential: false });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
// FIX-8 站点账号凭证:同类账号更新时密码 / Cookie / API Key 留空 = 保持,TOTP 省略保持、null 清除;
|
||||
// 新账号或换类型时必须填。明文不出 API。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 响应体断言
|
||||
const json = async (r: Response): Promise<any> => {
|
||||
const body = await r.json();
|
||||
if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`);
|
||||
return body.data;
|
||||
};
|
||||
|
||||
describe.skipIf(!up)("站点凭证留空不变 / 显式清除", () => {
|
||||
let h: Harness;
|
||||
let siteId: string;
|
||||
const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body });
|
||||
const account = () =>
|
||||
h.db
|
||||
.selectFrom("site_account")
|
||||
.selectAll()
|
||||
.where("site_id", "=", siteId)
|
||||
.where("label", "=", "default")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("credkeep");
|
||||
const site = await json(
|
||||
await h.req("/sites", {
|
||||
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "http://127.0.0.1:1/" },
|
||||
}),
|
||||
);
|
||||
siteId = site.id;
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("新账号不填密码 → 422", async () => {
|
||||
expect((await put({ kind: "password", username: "alice" })).status).toBe(422);
|
||||
expect((await put({ kind: "password", username: "alice", password: " " })).status).toBe(422);
|
||||
});
|
||||
|
||||
it("账密:改登录名不填密码 = 保持密码与 TOTP;null 清除 TOTP", async () => {
|
||||
const r = await json(await put({ kind: "password", username: "alice", password: "pw-1", totpSecret: "JBSWY3DP" }));
|
||||
expect(JSON.stringify(r)).not.toContain("pw-1");
|
||||
const a1 = await account();
|
||||
expect(h.secrets.plain.get(a1.secret_ref!)).toBe("pw-1");
|
||||
expect(a1.totp_secret_ref).toBeTruthy();
|
||||
|
||||
await json(await put({ kind: "password", username: "alice2", password: "", userAgent: "UA/1" }));
|
||||
const a2 = await account();
|
||||
expect(a2.secret_ref).toBe(a1.secret_ref);
|
||||
expect(h.secrets.plain.get(a2.secret_ref!)).toBe("pw-1");
|
||||
expect(a2.totp_secret_ref).toBe(a1.totp_secret_ref);
|
||||
expect(a2.login_name).toBe("alice2");
|
||||
expect(a2.user_agent).toBe("UA/1");
|
||||
|
||||
await json(await put({ kind: "password", username: "alice2", totpSecret: null }));
|
||||
const a3 = await account();
|
||||
expect(a3.totp_secret_ref).toBeNull();
|
||||
expect(h.secrets.plain.get(a3.secret_ref!)).toBe("pw-1");
|
||||
expect(h.secrets.plain.has(a1.totp_secret_ref!)).toBe(false);
|
||||
|
||||
await json(await put({ kind: "password", username: "alice2", password: "pw-2" }));
|
||||
expect(h.secrets.plain.get((await account()).secret_ref!)).toBe("pw-2");
|
||||
});
|
||||
|
||||
it("换类型必须填新凭证;同类 Cookie / API Key 留空保持", async () => {
|
||||
expect((await put({ kind: "cookie" })).status).toBe(422);
|
||||
await json(await put({ kind: "cookie", cookie: "a=1" }));
|
||||
const c1 = await account();
|
||||
await json(await put({ kind: "cookie", cookie: "", userAgent: "UA/2" }));
|
||||
const c2 = await account();
|
||||
expect(c2.secret_ref).toBe(c1.secret_ref);
|
||||
expect(h.secrets.plain.get(c2.secret_ref!)).toBe("a=1");
|
||||
|
||||
expect((await put({ kind: "api_key", apiKey: "" })).status).toBe(422);
|
||||
await json(await put({ kind: "api_key", apiKey: "k-1" }));
|
||||
const k1 = await account();
|
||||
await json(await put({ kind: "api_key" }));
|
||||
expect(h.secrets.plain.get((await account()).secret_ref!)).toBe("k-1");
|
||||
expect((await account()).secret_ref).toBe(k1.secret_ref);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import { formatUsd } from "~/components/schedules/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { humanizeReason } from "~/lib/reasons.ts";
|
||||
import { formatTokens } from "~/lib/tokens.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||
import { splitContextGoal } from "./agent-store.ts";
|
||||
@@ -691,7 +692,7 @@ export function ResultLine({
|
||||
value:
|
||||
u.totalTokens === undefined
|
||||
? null
|
||||
: `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
|
||||
: `${formatTokens(u.totalTokens)}${run.budget.maxTokens ? ` / ${formatTokens(run.budget.maxTokens)}` : ""}`,
|
||||
},
|
||||
{
|
||||
label: "工具调用",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { formatUsd } from "~/components/schedules/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||
import { formatTokens } from "~/lib/tokens.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import {
|
||||
AgentConversation,
|
||||
@@ -216,7 +217,7 @@ export function RunOverviewBar({
|
||||
? `约 ${formatUsd(u.costUsd)}${r.budget.maxCostUsd ? ` / 上限 ${formatUsd(r.budget.maxCostUsd)}` : ""}`
|
||||
: null,
|
||||
},
|
||||
{ label: "token", value: u.totalTokens !== undefined ? u.totalTokens.toLocaleString("zh-CN") : null },
|
||||
{ label: "token", value: u.totalTokens !== undefined ? formatTokens(u.totalTokens) : null },
|
||||
{
|
||||
label: "工具调用",
|
||||
value: `${r.toolCallCount}${r.budget.maxToolCalls ? ` / ${r.budget.maxToolCalls}` : ""}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
||||
import { formatTokens } from "~/lib/tokens.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { shortGoalTitle } from "./agent-store.ts";
|
||||
@@ -10,7 +11,7 @@ import { shortGoalTitle } from "./agent-store.ts";
|
||||
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
|
||||
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
|
||||
|
||||
const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
||||
const tokens = (n: number | undefined) => formatTokens(n);
|
||||
|
||||
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
|
||||
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Undo2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { SECRET_CLEAR, secretView } from "~/lib/secret.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 只写凭证输入框。已保存时框里显示掩码占位(「已设置 ••••••••」,服务端给了末 4 位就带上),
|
||||
* 聚焦后直接输入即替换;留空保存 = 保持原值;「清除」确认后把值设为 SECRET_CLEAR,保存时才生效。
|
||||
* 值约定见 ~/lib/secret.ts。明文只在用户输入时存在于这个表单里,从不由服务端回填。
|
||||
*/
|
||||
export function SecretInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
isSet,
|
||||
mask,
|
||||
clearable = false,
|
||||
label = "凭证",
|
||||
placeholder,
|
||||
multiline = false,
|
||||
disabled,
|
||||
invalid,
|
||||
className,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
/** 服务端已保存(secretRef / hasCredential 等) */
|
||||
isSet: boolean;
|
||||
/** 服务端给的掩码 hint(如 credentialMask),只取末 4 位显示 */
|
||||
mask?: string | null;
|
||||
/** 允许清除(必填凭证不给) */
|
||||
clearable?: boolean;
|
||||
/** 确认弹窗里的名字:"API Key"、"密码" */
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
/** Cookie 这类长串用多行 */
|
||||
multiline?: boolean;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const view = secretView(value, { isSet, mask, clearable });
|
||||
|
||||
if (view.state === "cleared") {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
||||
<Input
|
||||
id={id}
|
||||
readOnly
|
||||
value=""
|
||||
placeholder="保存后清除"
|
||||
aria-label={`${label}:保存后清除`}
|
||||
className="min-w-0 flex-1 border-dashed placeholder:text-destructive"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onChange("")} disabled={disabled}>
|
||||
<Undo2 />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const shownPlaceholder = view.state === "set" ? view.placeholder : placeholder;
|
||||
const common = {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
// 没有外部 <label htmlFor> 时用名字作无障碍标签
|
||||
"aria-label": id ? undefined : label,
|
||||
"aria-invalid": invalid || undefined,
|
||||
placeholder: shownPlaceholder,
|
||||
// 掩码占位要像"有值",不能像空框
|
||||
className: cn("min-w-0 flex-1", view.state === "set" && "placeholder:text-foreground"),
|
||||
};
|
||||
return (
|
||||
<div className={cn("flex min-w-0 items-start gap-2", className)}>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
rows={3}
|
||||
className={cn(common.className, "font-mono text-caption")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...common}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{view.state === "set" && view.canClear ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 hover:text-destructive"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X />
|
||||
清除
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
onOpenChange={setConfirming}
|
||||
title={`清除已保存的${label}?`}
|
||||
description="保存后生效,清除后无法找回,用到它的功能要重新填写。"
|
||||
confirmLabel="清除"
|
||||
destructive
|
||||
onConfirm={() => onChange(SECRET_CLEAR)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{view.state === "typing" && view.replacing ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onChange("")}
|
||||
disabled={disabled}
|
||||
aria-label={`不替换${label}`}
|
||||
>
|
||||
<Undo2 />
|
||||
撤销
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type * as React from "react";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 带固定单位后缀的数字输入(如 token 数以 K 为单位:输入 128 表示 128K)。
|
||||
* 值是字符串,允许小数;换算与校验由调用方做(token 见 ~/lib/tokens.ts)。
|
||||
*/
|
||||
export function UnitInput({
|
||||
unit,
|
||||
className,
|
||||
inputClassName,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<"input">, "type"> & { unit: string; inputClassName?: string }) {
|
||||
return (
|
||||
<div className={cn("relative min-w-0", className)}>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
{...props}
|
||||
className={cn("pr-9 tabular-nums", inputClassName)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-caption text-muted-foreground"
|
||||
>
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { agentRunLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { formatTokens } from "~/lib/tokens.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
||||
import { IntervalSelect } from "./interval-select.tsx";
|
||||
@@ -105,7 +106,7 @@ export function AgentCheckCard({
|
||||
const budgetParts = [
|
||||
budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null,
|
||||
budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null,
|
||||
budget.maxTokens ? `${budget.maxTokens.toLocaleString("zh-CN")} tokens` : null,
|
||||
budget.maxTokens ? `${formatTokens(budget.maxTokens)} token` : null,
|
||||
budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { SiteCredentialsBody } from "@umt/contracts";
|
||||
import type { SiteAccount, SiteCredentialsBody } from "@umt/contracts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { secretHint, secretPatch } from "~/lib/secret.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
|
||||
|
||||
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
|
||||
// 更新已有账号且类型不变时,已存的密码 / Cookie / API Key / TOTP 显示掩码占位,留空 = 保持原值(值约定见 ~/lib/secret.ts)。
|
||||
|
||||
/** 已存账号里与表单相关的非密信息 */
|
||||
export type SavedCredential = { kind: LoginKind; hasTotp: boolean };
|
||||
|
||||
/** 已有账号 → 表单初值:同类型时带上登录名与 UA(非密),凭证留空表示保持 */
|
||||
export function draftFromAccount(kind: LoginKind, acc: SiteAccount | null | undefined): CredentialDraft {
|
||||
const d = emptyCredential(kind);
|
||||
if (!acc || acc.authKind !== kind) return d;
|
||||
return { ...d, username: acc.loginName ?? "", userAgent: acc.userAgent ?? "" };
|
||||
}
|
||||
|
||||
export type CredentialDraft = {
|
||||
kind: LoginKind;
|
||||
@@ -34,20 +46,25 @@ export const emptyCredential = (kind: LoginKind): CredentialDraft => ({
|
||||
export function credentialBody(
|
||||
d: CredentialDraft,
|
||||
label: string,
|
||||
saved?: SavedCredential | null,
|
||||
): { body: SiteCredentialsBody; errors: null } | { body: null; errors: Record<string, string> } {
|
||||
const ua = d.userAgent.trim() || undefined;
|
||||
// 同类型已存:密码 / Cookie / API Key 可以留空保持
|
||||
const keep = saved?.kind === d.kind;
|
||||
if (d.kind === "password") {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!d.username.trim()) errors.username = "填写登录名";
|
||||
if (!d.password) errors.password = "填写密码";
|
||||
const password = secretPatch(d.password);
|
||||
if (!password && !keep) errors.password = "填写密码";
|
||||
if (Object.keys(errors).length) return { body: null, errors };
|
||||
const totp = secretPatch(d.totpSecret);
|
||||
return {
|
||||
body: {
|
||||
kind: "password",
|
||||
label,
|
||||
username: d.username.trim(),
|
||||
password: d.password,
|
||||
totpSecret: d.totpSecret.trim() || undefined,
|
||||
...(password ? { password } : {}),
|
||||
...(totp !== undefined ? { totpSecret: totp } : {}),
|
||||
saveCookieOnly: d.saveCookieOnly || undefined,
|
||||
userAgent: ua,
|
||||
},
|
||||
@@ -55,11 +72,13 @@ export function credentialBody(
|
||||
};
|
||||
}
|
||||
if (d.kind === "cookie") {
|
||||
if (!d.cookie.trim()) return { body: null, errors: { cookie: "粘贴 Cookie" } };
|
||||
return { body: { kind: "cookie", label, cookie: d.cookie.trim(), userAgent: ua }, errors: null };
|
||||
const cookie = secretPatch(d.cookie);
|
||||
if (!cookie && !keep) return { body: null, errors: { cookie: "粘贴 Cookie" } };
|
||||
return { body: { kind: "cookie", label, ...(cookie ? { cookie } : {}), userAgent: ua }, errors: null };
|
||||
}
|
||||
if (!d.apiKey.trim()) return { body: null, errors: { apiKey: "填写 API Key" } };
|
||||
return { body: { kind: "api_key", label, apiKey: d.apiKey.trim() }, errors: null };
|
||||
const apiKey = secretPatch(d.apiKey);
|
||||
if (!apiKey && !keep) return { body: null, errors: { apiKey: "填写 API Key" } };
|
||||
return { body: { kind: "api_key", label, ...(apiKey ? { apiKey } : {}) }, errors: null };
|
||||
}
|
||||
|
||||
export function CredentialFields({
|
||||
@@ -69,6 +88,7 @@ export function CredentialFields({
|
||||
errors,
|
||||
disabled,
|
||||
idPrefix = "cred",
|
||||
saved,
|
||||
}: {
|
||||
value: CredentialDraft;
|
||||
onChange: (v: CredentialDraft) => void;
|
||||
@@ -76,8 +96,12 @@ export function CredentialFields({
|
||||
errors: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
idPrefix?: string;
|
||||
/** 更新已有账号时传:同类型的已存凭证显示掩码,留空保持 */
|
||||
saved?: SavedCredential | null;
|
||||
}) {
|
||||
const set = <K extends keyof CredentialDraft>(k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v });
|
||||
const isSet = saved?.kind === value.kind;
|
||||
const keepHint = (h?: string) => (isSet ? (h ? `${h};留空不修改` : "留空不修改") : h);
|
||||
const id = (s: string) => `${idPrefix}-${s}`;
|
||||
return (
|
||||
<div className="grid gap-x-4 gap-y-1">
|
||||
@@ -121,14 +145,14 @@ export function CredentialFields({
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="密码" htmlFor={id("pass")} required error={errors.password}>
|
||||
<Input
|
||||
<FormField label="密码" htmlFor={id("pass")} required error={errors.password} hint={keepHint()}>
|
||||
<SecretInput
|
||||
id={id("pass")}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label="密码"
|
||||
value={value.password}
|
||||
aria-invalid={!!errors.password}
|
||||
onChange={(e) => set("password", e.target.value)}
|
||||
isSet={isSet}
|
||||
invalid={!!errors.password}
|
||||
onChange={(v) => set("password", v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -136,14 +160,19 @@ export function CredentialFields({
|
||||
<FormField
|
||||
label="TOTP 密钥"
|
||||
htmlFor={id("totp")}
|
||||
hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码"
|
||||
hint={secretHint(
|
||||
"站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码",
|
||||
value.totpSecret,
|
||||
isSet && !!saved?.hasTotp,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
<SecretInput
|
||||
id={id("totp")}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
label="TOTP 密钥"
|
||||
value={value.totpSecret}
|
||||
onChange={(e) => set("totpSecret", e.target.value)}
|
||||
isSet={isSet && !!saved?.hasTotp}
|
||||
clearable
|
||||
onChange={(v) => set("totpSecret", v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -168,27 +197,34 @@ export function CredentialFields({
|
||||
htmlFor={id("cookie")}
|
||||
required
|
||||
error={errors.cookie}
|
||||
hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie"
|
||||
hint={keepHint("浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie")}
|
||||
>
|
||||
<Textarea
|
||||
<SecretInput
|
||||
id={id("cookie")}
|
||||
rows={3}
|
||||
className="font-mono text-caption"
|
||||
label="Cookie"
|
||||
multiline
|
||||
value={value.cookie}
|
||||
aria-invalid={!!errors.cookie}
|
||||
onChange={(e) => set("cookie", e.target.value)}
|
||||
isSet={isSet}
|
||||
invalid={!!errors.cookie}
|
||||
onChange={(v) => set("cookie", v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
) : (
|
||||
<FormField label="API Key" htmlFor={id("apikey")} required error={errors.apiKey} hint="在站点控制台生成">
|
||||
<Input
|
||||
<FormField
|
||||
label="API Key"
|
||||
htmlFor={id("apikey")}
|
||||
required
|
||||
error={errors.apiKey}
|
||||
hint={keepHint("在站点控制台生成")}
|
||||
>
|
||||
<SecretInput
|
||||
id={id("apikey")}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
label="API Key"
|
||||
value={value.apiKey}
|
||||
aria-invalid={!!errors.apiKey}
|
||||
onChange={(e) => set("apiKey", e.target.value)}
|
||||
isSet={isSet}
|
||||
invalid={!!errors.apiKey}
|
||||
onChange={(v) => set("apiKey", v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -8,9 +8,9 @@ import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
@@ -226,7 +226,7 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!v.trim()) {
|
||||
setErr("填写 Passkey");
|
||||
setErr(has ? "填写新的 Passkey 再保存" : "填写 Passkey");
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
@@ -241,16 +241,17 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
|
||||
>
|
||||
<h2 className="text-lead font-semibold text-foreground">站点 Passkey(RSS 与下载链接用)</h2>
|
||||
<p className="mb-3 text-caption text-muted-foreground">
|
||||
用于 RSS 和下载链接,不参与登录。{has ? "已保存;再次保存会替换。" : "还没有保存。"}
|
||||
用于 RSS 和下载链接,不参与登录。{has ? "填写新值保存会替换。" : "还没有保存。"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1">
|
||||
<Input
|
||||
<SecretInput
|
||||
id="pk"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
label="Passkey"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
isSet={has}
|
||||
invalid={!!err}
|
||||
onChange={setV}
|
||||
disabled={put.isPending}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -28,7 +28,13 @@ 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 { CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
|
||||
import {
|
||||
CredentialFields,
|
||||
credentialBody,
|
||||
draftFromAccount,
|
||||
emptyCredential,
|
||||
type SavedCredential,
|
||||
} from "./credential-fields.tsx";
|
||||
import {
|
||||
AUTH_KIND_LABEL,
|
||||
bytes,
|
||||
@@ -229,7 +235,11 @@ export function CredentialDialog({
|
||||
(current && current.authKind !== "passkey" && kinds.includes(current.authKind as LoginKind)
|
||||
? (current.authKind as LoginKind)
|
||||
: (kinds[0] ?? "cookie"));
|
||||
const [draft, setDraft] = useState(() => emptyCredential(initialKind));
|
||||
const [draft, setDraft] = useState(() => draftFromAccount(initialKind, current));
|
||||
const saved: SavedCredential | null =
|
||||
current && current.authKind !== "passkey" && current.secretRef
|
||||
? { kind: current.authKind as LoginKind, hasTotp: current.hasTotp }
|
||||
: null;
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<SiteLoginResult | null>(null);
|
||||
const put = usePutCredentials();
|
||||
@@ -239,7 +249,7 @@ export function CredentialDialog({
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const b = credentialBody(draft, current?.label ?? site.key);
|
||||
const b = credentialBody(draft, current?.label ?? site.key, saved);
|
||||
if (!b.body) {
|
||||
setErrors(b.errors);
|
||||
return;
|
||||
@@ -283,6 +293,7 @@ export function CredentialDialog({
|
||||
errors={errors}
|
||||
disabled={busy}
|
||||
idPrefix="upd"
|
||||
saved={saved}
|
||||
/>
|
||||
</form>
|
||||
{result && !result.ok ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -264,7 +264,7 @@ const HttpUrl = z
|
||||
export const BrowserProviderCreate = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
baseUrl: HttpUrl,
|
||||
accessKey: z.string().min(1).max(512).optional(),
|
||||
accessKey: z.string().max(512).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type BrowserProviderCreate = z.infer<typeof BrowserProviderCreate>;
|
||||
@@ -272,7 +272,8 @@ export type BrowserProviderCreate = z.infer<typeof BrowserProviderCreate>;
|
||||
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据 */
|
||||
export const BrowserProviderUpdate = z.object({
|
||||
baseUrl: HttpUrl.optional(),
|
||||
accessKey: z.string().min(1).max(512).nullable().optional(),
|
||||
/** 省略或空串 = 不变 */
|
||||
accessKey: z.string().max(512).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
export type BrowserProviderUpdate = z.infer<typeof BrowserProviderUpdate>;
|
||||
|
||||
@@ -8,6 +8,21 @@ export type Id = z.infer<typeof Id>;
|
||||
export const Timestamp = z.iso.datetime({ offset: true });
|
||||
export type Timestamp = z.infer<typeof Timestamp>;
|
||||
|
||||
/**
|
||||
* 只写凭证输入(API Key、密码、Cookie、令牌)。全站统一语义:
|
||||
* 省略或空串(含纯空白)= 保持原值;null = 清除(只在允许清除的更新接口上);非空 = 替换。
|
||||
* 明文只在请求里出现,服务端加密为 secret 后丢弃,响应只给是否已设置(和个别接口的掩码)。
|
||||
*/
|
||||
export const WriteOnlySecret = (max = 4096) => z.string().max(max);
|
||||
|
||||
/** 归一只写凭证输入:空串 → undefined(不改);null 原样(清除);其余去首尾空白 */
|
||||
export function secretInput(v: string | null | undefined): string | null | undefined {
|
||||
if (v === null) return null;
|
||||
if (v === undefined) return undefined;
|
||||
const s = v.trim();
|
||||
return s === "" ? undefined : s;
|
||||
}
|
||||
|
||||
/** 游标分页请求:cursor 为不透明字符串,由服务端编码 */
|
||||
export const CursorQuery = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts";
|
||||
import { EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp, WriteOnlySecret } from "./common.ts";
|
||||
import { SiteCredentialsBody } from "./sources.ts";
|
||||
|
||||
// 实例域:媒体库根、下载器实例、媒体服务器实例、路径映射(REQ §7.1、§8.4、§12.5)
|
||||
@@ -44,8 +44,8 @@ export type LibraryRootCreate = z.infer<typeof LibraryRootCreate>;
|
||||
export const LibraryRootUpdate = LibraryRootCreate.partial();
|
||||
export type LibraryRootUpdate = z.infer<typeof LibraryRootUpdate>;
|
||||
|
||||
/** 只写凭证:明文仅在请求中出现,绝不回显 */
|
||||
const Credential = z.string().min(1).max(4096);
|
||||
/** 只写凭证:明文仅在请求中出现,绝不回显;空串 = 不设置 / 不修改 */
|
||||
const Credential = WriteOnlySecret(4096);
|
||||
|
||||
export const DownloaderKind = z.enum(["qbittorrent"]);
|
||||
export type DownloaderKind = z.infer<typeof DownloaderKind>;
|
||||
@@ -91,7 +91,10 @@ export const DownloaderInstanceCreate = DownloaderInstance.pick({
|
||||
authKind: DownloaderAuthKind.optional(),
|
||||
});
|
||||
export type DownloaderInstanceCreate = z.infer<typeof DownloaderInstanceCreate>;
|
||||
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial();
|
||||
/** 更新:credential 省略或空串 = 不变,null = 清除 */
|
||||
export const DownloaderInstanceUpdate = DownloaderInstanceCreate.partial().extend({
|
||||
credential: Credential.nullable().optional(),
|
||||
});
|
||||
export type DownloaderInstanceUpdate = z.infer<typeof DownloaderInstanceUpdate>;
|
||||
|
||||
export const MediaServerKind = z.enum(["emby", "jellyfin"]);
|
||||
@@ -121,7 +124,10 @@ export const MediaServerInstanceCreate = MediaServerInstance.pick({
|
||||
.partial({ kind: true, userId: true, enabled: true, settings: true })
|
||||
.extend({ credential: Credential.optional() });
|
||||
export type MediaServerInstanceCreate = z.infer<typeof MediaServerInstanceCreate>;
|
||||
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial();
|
||||
/** 更新:credential 省略或空串 = 不变,null = 清除 */
|
||||
export const MediaServerInstanceUpdate = MediaServerInstanceCreate.partial().extend({
|
||||
credential: Credential.nullable().optional(),
|
||||
});
|
||||
export type MediaServerInstanceUpdate = z.infer<typeof MediaServerInstanceUpdate>;
|
||||
|
||||
export const PathViewKind = z.enum(["downloader", "media_server"]);
|
||||
@@ -191,7 +197,7 @@ export const SiteCredentialsLegacyPut = z.object({
|
||||
label: z.string().min(1).max(80).default("default"),
|
||||
authKind: z.enum(["cookie", "passkey", "api_key", "password"]),
|
||||
/** 只写明文 */
|
||||
credential: Credential,
|
||||
credential: z.string().min(1).max(4096),
|
||||
/** authKind=password 时的登录名 */
|
||||
username: z.string().max(200).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -100,7 +100,7 @@ export const PiUpstreamExtra = z
|
||||
compat: z.record(z.string(), z.unknown()).optional(),
|
||||
headers: z.record(z.string().min(1).max(128), z.string().max(2048)).optional(),
|
||||
/** 自定义模型的上下文窗口(openai-compatible) */
|
||||
contextWindow: z.number().int().min(1024).max(10_000_000).optional(),
|
||||
contextWindow: z.number().int().min(1000).max(10_000_000).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -226,7 +226,8 @@ export const PiUpstreamList = z.object({
|
||||
});
|
||||
export type PiUpstreamList = z.infer<typeof PiUpstreamList>;
|
||||
|
||||
const ApiKeyInput = z.string().min(1).max(8192);
|
||||
/** 只写;空串 = 不设置 / 不修改 */
|
||||
const ApiKeyInput = z.string().max(8192);
|
||||
|
||||
/** 新建上游。apiKey 只写(存 secret 表);oauth 型建好后走 /oauth/start 登录 */
|
||||
export const PiUpstreamCreate = z
|
||||
@@ -244,7 +245,7 @@ export const PiUpstreamCreate = z
|
||||
makeDefault: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.authKind !== "api_key" && v.apiKey !== undefined)
|
||||
if (v.authKind !== "api_key" && v.apiKey?.trim())
|
||||
ctx.addIssue({ code: "custom", path: ["apiKey"], message: "只有 api_key 型模型服务可以填 apiKey" });
|
||||
if (v.authKind === "oauth" && v.baseUrl)
|
||||
ctx.addIssue({ code: "custom", path: ["baseUrl"], message: "订阅登录型模型服务不能覆盖 baseUrl" });
|
||||
|
||||
@@ -86,7 +86,7 @@ export const PiSettings = z.object({
|
||||
/** @deprecated 上游级 thinkingLevel 见 pi_upstream */
|
||||
thinkingLevel: ThinkingLevel,
|
||||
/** 上下文预算(token) */
|
||||
contextBudget: z.number().int().min(4096).max(2_000_000),
|
||||
contextBudget: z.number().int().min(4000).max(2_000_000),
|
||||
compaction: z.object({
|
||||
enabled: z.boolean(),
|
||||
/** 达到上下文预算的该比例时触发压缩 */
|
||||
@@ -675,8 +675,8 @@ export const GlobalSettingsUpdate = z.object({
|
||||
webSearch: WebSearchSettings.omit({ apiKeySecretRef: true })
|
||||
.partial()
|
||||
.extend({
|
||||
/** 只写:搜索服务 API Key,服务端存为 secret 并回填 apiKeySecretRef;null 清除 */
|
||||
apiKey: z.string().min(1).max(4096).nullable().optional(),
|
||||
/** 只写:搜索服务 API Key,服务端存为 secret 并回填 apiKeySecretRef;空串不变,null 清除 */
|
||||
apiKey: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -684,8 +684,8 @@ export const GlobalSettingsUpdate = z.object({
|
||||
metadata: MetadataSettings.omit({ tmdbApiKeySecretRef: true })
|
||||
.partial()
|
||||
.extend({
|
||||
/** 只写:TMDB API Key(v3 key 或 v4 read token),服务端存为 secret 并回填 tmdbApiKeySecretRef;null 清除 */
|
||||
tmdbApiKey: z.string().min(1).max(4096).nullable().optional(),
|
||||
/** 只写:TMDB API Key(v3 key 或 v4 read token),服务端存为 secret 并回填 tmdbApiKeySecretRef;空串不变,null 清除 */
|
||||
tmdbApiKey: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
sites: SitesSettings.partial().optional(),
|
||||
|
||||
@@ -186,19 +186,22 @@ export type SiteHealthResult = z.infer<typeof SiteHealthResult>;
|
||||
|
||||
const CredLabel = z.string().min(1).max(80).default("default");
|
||||
const CredSecret = z.string().min(1).max(16384);
|
||||
/** 已有同类账号时可省略或留空 = 保持已存的值(只写凭证统一语义);新账号或换类型时必填 */
|
||||
const CredSecretKeep = z.string().max(16384).optional();
|
||||
const CredUa = z.string().min(1).max(500);
|
||||
/**
|
||||
* PUT /sites/{id}/credentials 新格式(按 kind 判别)。按 (siteId, label) 建立或更新账号;明文加密为 secret,不回显。
|
||||
* 更新凭证:账号回到 unknown,关闭该站 site-auth / site-login 待处理。
|
||||
* 同类账号更新时 password / cookie / apiKey 省略或空串 = 保持原值;totpSecret 省略或空串 = 保持,null = 清除。
|
||||
*/
|
||||
export const SiteCredentialsBody = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("password"),
|
||||
label: CredLabel,
|
||||
username: z.string().min(1).max(200),
|
||||
password: CredSecret,
|
||||
password: CredSecretKeep,
|
||||
/** TOTP 密钥(base32);表单有二次验证字段时自动填当前 6 位码 */
|
||||
totpSecret: z.string().min(1).max(200).optional(),
|
||||
totpSecret: z.string().max(200).nullable().optional(),
|
||||
/** 只换 Cookie:登录成功后凭证类型落为 cookie,密码与 TOTP 丢弃 */
|
||||
saveCookieOnly: z.boolean().optional(),
|
||||
userAgent: CredUa.optional(),
|
||||
@@ -207,11 +210,11 @@ export const SiteCredentialsBody = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("cookie"),
|
||||
label: CredLabel,
|
||||
cookie: CredSecret,
|
||||
cookie: CredSecretKeep,
|
||||
userAgent: CredUa.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}),
|
||||
z.object({ kind: z.literal("api_key"), label: CredLabel, apiKey: CredSecret, enabled: z.boolean().optional() }),
|
||||
z.object({ kind: z.literal("api_key"), label: CredLabel, apiKey: CredSecretKeep, enabled: z.boolean().optional() }),
|
||||
/** passkey 附加到同账号(RSS / 下载用,不参与登录);账号不存在时新建 passkey 型账号 */
|
||||
z.object({ kind: z.literal("passkey"), label: CredLabel, passkey: CredSecret, enabled: z.boolean().optional() }),
|
||||
]);
|
||||
|
||||
Reference in new issue
Block a user