copy: second-round copy review across console, settings, agent and server messages

- Trim descriptions, empty states, confirm buttons and toasts to facts
- Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游
- Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk)
- Shorter, concrete goal templates for built-in Agent checks
- Human-readable nouns instead of table names in instance CRUD errors
- Audit labels for pi_upstream / schedule / automation_grant actions
- Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 13:29:42 +08:00
1 parent c67f6a9682
commit 31a54186c7
58 files changed
+200 -188

No files matched your search

+1 -4
View File
@@ -209,10 +209,7 @@ export function createAuthorizer(deps: {
}
return {
allowed: false,
reason:
reasons.length > 0
? `没有可用的授权:${reasons.join(";")}`
: `缺少 ${d.id} 的持续授权(automation_grant)或有效的单次审批(approval)`,
reason: reasons.length > 0 ? `没有可用的授权:${reasons.join(";")}` : `缺少 ${d.id} 的持续授权或有效的单次审批`,
};
};
}
+4 -3
View File
@@ -126,10 +126,11 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
}
function suggestionFor(d: DecisionRow, reason: string): string {
if (d.kind === "identity_ambiguous") return `${reason}。建议在待处理中人工选择身份,或为 identity.resolve 发放授权。`;
if (d.kind === "identity_ambiguous")
return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`;
return (
`${reason}。建议在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权` +
`(可绑定已发布的 automation 策略);换源、删除仍需人工决定。`
`${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
`换源、删除需人工决定。`
);
}
+1 -1
View File
@@ -421,7 +421,7 @@ export function registerAgentRoutes(
}
const rejected: { slotId: string | null; code: string; message: string }[] = [];
if (slotIds.length === 0)
rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标,先在订阅里新建" });
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" });
const invokeCtx = {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
+3 -3
View File
@@ -129,9 +129,9 @@ async function agentSettings(
const pi = s.pi;
let apiKey: string | undefined;
if (!fake && !upstream) {
if (!pi.apiKeySecretRef) throw new Error("未配置 Pi 上游(设置 → 模型上游)");
if (!pi.apiKeySecretRef) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)");
apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef);
if (!pi.model) throw new Error("未配置模型(设置 → pi.model)");
if (!pi.model) throw new Error("未配置模型(设置 → Agent → 上游)");
}
const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined;
const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined;
@@ -278,7 +278,7 @@ function systemPromptFor(
else if (run.trigger === "event")
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手",
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
goal: run.goal,
constraints,
});
+1 -1
View File
@@ -222,7 +222,7 @@ export async function finalize(
let summary: string;
if (res.status === "cancelled" || run.cancel_requested_at) {
status = "cancelled";
summary = `已取消(停止未来步骤,不撤销已完成动作)。${report.state === "no_actions" ? "" : report.summary}`;
summary = `已停止(后续步骤不再执行,已完成的动作不撤销)。${report.state === "no_actions" ? "" : report.summary}`;
} else if (res.reason === "budget") {
status = "budget_exceeded";
summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`;
+1 -1
View File
@@ -583,7 +583,7 @@ export function registerSubagentCapabilities(registry: CapabilityRegistry, ctx:
const parent = await parentOf(ctx, c);
const s = await ctx.settings.all();
const cfg = subagentSettings(s);
if (!cfg.enabled) throw new CapabilityError("precondition", "子代理未开启(设置 → Agent → 允许子代理)");
if (!cfg.enabled) throw new CapabilityError("precondition", "子会话未开启(设置 → Agent → 允许子会话)");
if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`;
@@ -64,6 +64,12 @@ const test = (path: string, summary: string, tag: string) => ({
});
type Table = "library_root" | "downloader_instance" | "media_server_instance" | "path_mapping";
const NOUN: Record<Table, string> = {
library_root: "库根",
downloader_instance: "下载器实例",
media_server_instance: "媒体服务器实例",
path_mapping: "路径映射",
};
const JSON_COLS = ["settings", "version_info", "health_detail", "verify_detail"] as const;
const errMessage = (e: unknown) => redactString(String((e as Error)?.message ?? e)).slice(0, 500);
@@ -111,7 +117,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
const getRow = async (table: Table, id: string) => {
// biome-ignore lint/suspicious/noExplicitAny: 同上
const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst();
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
if (!r) throw new ApiException("not_found", `${NOUN[table]}不存在`);
return r as Record<string, unknown> & { id: string; secret_ref?: string | null };
};
const get = (table: Table, tag: string, def: Def) =>
@@ -119,7 +125,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
toApi(await getRow(table, (params as { id: string }).id)),
);
const remove = (table: Table, tag: string, path: string, action: AuditAction) =>
endpoint(app, del(path, `删除 ${table}`, tag), async ({ params, actor }) => {
endpoint(app, del(path, `删除${NOUN[table]}`, tag), async ({ params, actor }) => {
const id = (params as { id: string }).id;
const row = await getRow(table, id);
// biome-ignore lint/suspicious/noExplicitAny: 同上
@@ -127,7 +133,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.deleteFrom(table)
.where("id", "=", id)
.execute()
.catch((e: unknown) => pgConflict(e, `${table} 仍被引用,不能删除`));
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}仍被引用,不能删除`));
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
await audit(ctx, actor, action, { type: table, id });
return { ok: true as const };
@@ -139,7 +145,10 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.values(values)
.returningAll()
.executeTakeFirstOrThrow()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record<string, unknown> & {
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`))) as Record<
string,
unknown
> & {
id: string;
};
const update = async (table: Table, id: string, values: Record<string, unknown>) => {
@@ -151,8 +160,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.where("id", "=", id)
.returningAll()
.executeTakeFirst()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`));
if (!r) throw new ApiException("not_found", `${table} ${id} 不存在`);
.catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`));
if (!r) throw new ApiException("not_found", `${NOUN[table]}不存在`);
return r as Record<string, unknown> & { id: string };
};
+14 -12
View File
@@ -60,12 +60,12 @@ export function readableLoginError(provider: string, err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
if (/EADDRINUSE|address already in use/i.test(raw)) {
const port = OAUTH_CALLBACK_PORTS[provider] ?? raw.match(/:(\d{2,5})\b/)?.[1];
return `服务端回调端口${port ? ` ${port}` : ""} 被占用(可能另有登录进行中,或容器内有 Claude Code / Codex CLI 占用该端口),请释放端口或稍后重试`;
return `服务端回调端口${port ? ` ${port}` : ""} 被占用。结束占用该端口的进程(如另一个登录流程、Claude Code / Codex CLI)后重试`;
}
if (/cancel|abort/i.test(raw)) return "登录已取消";
if (/state mismatch/i.test(raw)) return "粘贴的地址与本次登录不匹配(state 不一致),请用本次打开的授权链接重新登录";
if (/state mismatch/i.test(raw)) return "粘贴的地址不属于本次登录(state 不一致)。用本次打开的授权链接重新登录";
if (/\b404\b/.test(raw) && provider === "openai-codex")
return "该账号未开启设备码登录(Codex 返回 404),请改用浏览器登录";
return "这个账号未开启设备码登录(Codex 返回 404)。改用浏览器登录";
return classifyUpstreamError(err).message;
}
@@ -105,10 +105,7 @@ export class OAuthSessions {
async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> {
const info = providerInfo(row.provider);
if (row.auth_kind !== "oauth" || !info?.oauth)
throw new ApiException(
"precondition_failed",
"该上游不是订阅登录型(authKind 需为 oauth,且 provider 支持订阅登录)",
);
throw new ApiException("precondition_failed", "这个上游不是订阅登录类型");
if (!row.enabled) throw new ApiException("precondition_failed", "上游已停用");
const mode = req.mode ?? info.oauth.modes[0]!;
if (!info.oauth.modes.includes(mode))
@@ -122,7 +119,7 @@ export class OAuthSessions {
if (busy.upstreamId !== row.id)
throw new ApiException(
"conflict",
`${info.name} 已有登录进行中(同一 provider 的登录需串行),请先完成或等待其过期(${new Date(busy.expiresAt).toISOString()})`,
`${info.name} 已有登录进行中。完成它,或等到 ${new Date(busy.expiresAt).toISOString()} 过期后重试`,
);
this.cancel(busy, "cancelled", "已被新的登录请求取代");
await busy.done.catch(() => undefined);
@@ -147,7 +144,7 @@ export class OAuthSessions {
pasteWaiters: [],
firstEvent: Promise.withResolvers<void>(),
done: Promise.resolve(),
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,请重新开始"), ttl),
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,重新开始登录"), ttl),
};
(s.timer as { unref?: () => void }).unref?.();
this.sessions.set(s.id, s);
@@ -195,7 +192,7 @@ export class OAuthSessions {
for (const w of s.pasteWaiters.splice(0)) w();
});
}
throw new Error("该登录流程需要额外输入(secret),服务端登录不支持");
throw new Error("这个登录流程需要额外输入(secret),服务端不支持");
}
private notify(s: Session, e: UpstreamAuthEvent) {
@@ -238,9 +235,14 @@ export class OAuthSessions {
): Promise<{ sessionId: string; state: PiOAuthState; message: string | null; progress: string[] }> {
const s = this.sessions.get(req.sessionId);
if (!s || s.upstreamId !== upstreamId)
return { sessionId: req.sessionId, state: "expired", message: "登录会话不存在或已过期,请重新开始", progress: [] };
return {
sessionId: req.sessionId,
state: "expired",
message: "登录会话不存在或已过期,重新开始登录",
progress: [],
};
if (req.pastedUrlOrCode && s.state === "pending") {
if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成即可");
if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成");
if (!s.paste) {
// manual_code 提示可能还没发出:短暂等待
await Promise.race([
+3 -3
View File
@@ -24,13 +24,13 @@ const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
const title = `模型上游「${row.name}」登录已失效,需要重新登录`;
const title = `Agent 上游「${row.name}」登录已失效,需要重新登录`;
await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
values ('other', 'warning', ${title}, ${message}, 'pi_upstream', ${row.id}, ${DECISION_KEY(row.id)},
${JSON.stringify({ provider: row.provider, upstreamId: row.id, error: message })}::jsonb,
${JSON.stringify([{ key: "relogin", label: "去设置页重新登录", description: "设置 → 模型上游 → 登录" }])}::jsonb)
${JSON.stringify([{ key: "relogin", label: "重新登录", description: "设置 → Agent → 上游" }])}::jsonb)
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
updated_at = now()
@@ -102,7 +102,7 @@ export async function refreshPiTokens(ctx: ServerContext, opts: RefreshOptions =
if (r.ok) report.refreshed += 1;
else {
report.failed.push({ upstreamId: row.id, message: r.message });
ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message: r.message }, "Pi 上游 token 刷新失败");
ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message: r.message }, "Agent 上游 token 刷新失败");
}
}
return report;
+1 -1
View File
@@ -109,7 +109,7 @@ export function registerPiRoutes(
source: t.source,
model: t.model,
errorKind: null,
message: t.inCatalog ? null : `模型 ${t.model} 不在随包目录中,将按手填 id 调用`,
message: t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
};
await setStatus(ctx, row.id, { status: "ok", verified: true });
} catch (err) {
+3 -3
View File
@@ -213,10 +213,10 @@ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string
const id = upstreamId ?? (await defaultUpstreamId(ctx));
if (!id) return null;
const row = await getRow(ctx, id);
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,请在设置里重新选择");
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,在设置里重新选择");
if (!row.enabled) throw new Error(`上游「${row.name}」已停用`);
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,请在设置里重新登录`);
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,在设置里重新登录`);
if (row.status === "auth_required")
throw new Error(`上游「${row.name}」${row.auth_kind === "oauth" ? "尚未登录" : "未配置 API Key"}`);
throw new Error(`上游「${row.name}」${row.auth_kind === "oauth" ? "未登录" : "未配置 API Key"}`);
return { row, spec: { ...specOf(row), extra: parseExtra(row.extra) }, credentials: dbCredentialStore(ctx, row.id) };
}
+1 -1
View File
@@ -59,7 +59,7 @@ async function readSecret(ctx: Ctx, row: Pick<PiUpstreamRow, "auth_kind" | "secr
try {
return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref));
} catch (err) {
ctx.logger.warn({ secretRef: row.secret_ref, err: { message: (err as Error).message } }, "上游凭据读取失败");
ctx.logger.warn({ secretRef: row.secret_ref, err: { message: (err as Error).message } }, "上游凭证读取失败");
return undefined;
}
}
@@ -33,8 +33,8 @@ export const BUILTIN_AGENT_CHECKS: {
everyMs: 6 * HOUR,
config: {
goalTemplate:
"巡检待处理中心({{now}}):当前有 {{openDecisions}} 条未关闭事项。逐条查看证据,按紧急程度排序," +
"给出每条的处理建议与理由;只读,不执行任何变更。",
"巡检待处理({{now}}):有 {{openDecisions}} 条未关闭的待处理事项。逐条查看证据,按紧急程度排序," +
"给出处理建议和理由。只读,不做任何变更。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 20, maxDurationSec: 300 },
triggerCondition: "open_decisions",
@@ -47,7 +47,7 @@ export const BUILTIN_AGENT_CHECKS: {
everyMs: 2 * HOUR,
config: {
goalTemplate:
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载任务无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 24, maxDurationSec: 420 },
@@ -62,8 +62,8 @@ export const BUILTIN_AGENT_CHECKS: {
everyMs: 7 * 24 * HOUR,
config: {
goalTemplate:
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅与原因推测、" +
"健康异常的站点与 Feed,给出本周建议;只读,最后用一段不超过 300 字的中文汇总。",
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
"健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 30, maxDurationSec: 600 },
triggerCondition: "subscriptions_active",
@@ -124,7 +124,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
if (grants.length > 0) {
mode = "execute";
allowlist = [...new Set([...(allowlist.length ? allowlist : INVESTIGATE_CAPABILITIES), ...RECOVER_CAPABILITIES])];
} else note = "(没有覆盖恢复动作的有效授权,已降级只读)";
} else note = "(没有覆盖恢复动作的有效授权,改为只读)";
}
const goal = renderGoal(cfg.goalTemplate, c.now, f);
const dedupeKey = c.trigger === "manual" ? `schedule:${c.jobId}:manual:${c.runId}` : `schedule:${c.jobId}:${c.slot}`;
+2 -2
View File
@@ -201,7 +201,7 @@ export async function updateJob(
});
}
if (patch.enabled !== undefined) {
if (patch.enabled && !row.available) throw new ApiException("conflict", "该任务未配置,不能开启");
if (patch.enabled && !row.available) throw new ApiException("conflict", "任务未配置,不能启用");
set.enabled = patch.enabled;
set.customized = true;
if (patch.enabled && !row.enabled && !set.next_run_at) {
@@ -228,7 +228,7 @@ export async function updateJob(
/** 立即执行:置 run_requested_at(已在排队或运行中则合并,不重复) */
export async function requestRun(db: Kysely<DB>, id: string): Promise<{ row: ScheduledJobRow; queued: boolean }> {
const row = await getJob(db, id);
if (!row.available) throw new ApiException("conflict", "该任务未配置,不能执行");
if (!row.available) throw new ApiException("conflict", "任务未配置,不能执行");
const upd = await db
.updateTable("scheduled_job")
.set({ run_requested_at: sql`now()` })