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

+5 -5
View File
@@ -177,7 +177,7 @@ export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
if (!isPublic && WRITE_METHODS.has(c.req.method)) { if (!isPublic && WRITE_METHODS.has(c.req.method)) {
const provided = c.req.header(CSRF_HEADER); const provided = c.req.header(CSRF_HEADER);
if (!provided || !safeEqual(provided, deriveCsrfToken(token))) { if (!provided || !safeEqual(provided, deriveCsrfToken(token))) {
throw new ApiException("forbidden", "CSRF 校验失败,请刷新页面后重试"); throw new ApiException("forbidden", "CSRF 校验失败,刷新页面后重试");
} }
} }
setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", { setActor(c, { kind: "admin_session", id: s.adminUserId, name: s.username, scopes: ["admin"] }, "admin", {
@@ -191,7 +191,7 @@ export function apiKeyAuth(opts: AuthOptions): MiddlewareHandler<AuthEnv> {
} }
} }
if (isPublic) return next(); if (isPublic) return next();
throw new ApiException("unauthorized", token ? "会话已过期或已注销,请重新登录" : "需要登录或 Bearer API Key"); throw new ApiException("unauthorized", token ? "会话已过期或已注销,重新登录" : "需要登录或 Bearer API Key");
}; };
} }
@@ -207,7 +207,7 @@ export function hasScope(granted: readonly string[], required: ApiScope): boolea
export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> { export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
return async (c, next) => { return async (c, next) => {
const p = c.get("principal"); const p = c.get("principal");
if (!p) throw new ApiException("unauthorized", "未鉴权"); if (!p) throw new ApiException("unauthorized", "未登录");
if (!anyOf.some((s) => hasScope(p.scopes, s))) { if (!anyOf.some((s) => hasScope(p.scopes, s))) {
throw new ApiException("forbidden", `需要 scope: ${anyOf.join(" | ")}`); throw new ApiException("forbidden", `需要 scope: ${anyOf.join(" | ")}`);
} }
@@ -222,8 +222,8 @@ export function requireScope(...anyOf: ApiScope[]): MiddlewareHandler<AuthEnv> {
export function requireAdminSession(): MiddlewareHandler<AuthEnv> { export function requireAdminSession(): MiddlewareHandler<AuthEnv> {
return async (c, next) => { return async (c, next) => {
const p = c.get("principal"); const p = c.get("principal");
if (!p) throw new ApiException("unauthorized", "未鉴权"); if (!p) throw new ApiException("unauthorized", "未登录");
if (p.kind !== "admin_session") throw new ApiException("forbidden", "该操作只能在控制台登录后进行"); if (p.kind !== "admin_session") throw new ApiException("forbidden", "这个操作需要在控制台登录后进行");
return next(); return next();
}; };
} }
+1 -4
View File
@@ -209,10 +209,7 @@ export function createAuthorizer(deps: {
} }
return { return {
allowed: false, allowed: false,
reason: reason: reasons.length > 0 ? `没有可用的授权:${reasons.join(";")}` : `缺少 ${d.id} 的持续授权或有效的单次审批`,
reasons.length > 0
? `没有可用的授权:${reasons.join(";")}`
: `缺少 ${d.id} 的持续授权(automation_grant)或有效的单次审批(approval)`,
}; };
}; };
} }
+4 -3
View File
@@ -126,10 +126,11 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
} }
function suggestionFor(d: DecisionRow, reason: string): string { 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 ( return (
`${reason}。建议在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权` + `${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
`(可绑定已发布的 automation 策略);换源、删除仍需人工决定。` `换源、删除需人工决定。`
); );
} }
+1 -1
View File
@@ -421,7 +421,7 @@ export function registerAgentRoutes(
} }
const rejected: { slotId: string | null; code: string; message: string }[] = []; const rejected: { slotId: string | null; code: string; message: string }[] = [];
if (slotIds.length === 0) if (slotIds.length === 0)
rejected.push({ slotId: null, code: "no_slot", message: "该作品没有版本目标,先在订阅里新建" }); rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有版本目标,先在订阅里新建" });
const invokeCtx = { const invokeCtx = {
actorId, actorId,
taskId: `http:${c.get("requestId") ?? "req"}`, taskId: `http:${c.get("requestId") ?? "req"}`,
+3 -3
View File
@@ -129,9 +129,9 @@ async function agentSettings(
const pi = s.pi; const pi = s.pi;
let apiKey: string | undefined; let apiKey: string | undefined;
if (!fake && !upstream) { 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); 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 maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined;
const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined; const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined;
@@ -278,7 +278,7 @@ function systemPromptFor(
else if (run.trigger === "event") else if (run.trigger === "event")
constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。"); constraints.push("本任务由后台事件触发,只做只读调查:收集证据,给出结论与建议,不要反复重试同一操作。");
return buildSystemPrompt({ return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手", role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化 Agent",
goal: run.goal, goal: run.goal,
constraints, constraints,
}); });
+1 -1
View File
@@ -222,7 +222,7 @@ export async function finalize(
let summary: string; let summary: string;
if (res.status === "cancelled" || run.cancel_requested_at) { if (res.status === "cancelled" || run.cancel_requested_at) {
status = "cancelled"; status = "cancelled";
summary = `已取消(停止未来步骤,不撤销已完成动作)。${report.state === "no_actions" ? "" : report.summary}`; summary = `已停止(后续步骤不再执行,已完成的动作不撤销)。${report.state === "no_actions" ? "" : report.summary}`;
} else if (res.reason === "budget") { } else if (res.reason === "budget") {
status = "budget_exceeded"; status = "budget_exceeded";
summary = `超出预算:${res.message ?? ""}。${report.state === "no_actions" ? "" : report.summary}`; 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 parent = await parentOf(ctx, c);
const s = await ctx.settings.all(); const s = await ctx.settings.all();
const cfg = subagentSettings(s); 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)) if (parent.cancel_requested_at || TERMINAL.includes(parent.status))
throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话"); throw new CapabilityError("precondition", "本会话已取消或已结束,不能派生子会话");
const dedupeKey = `subagent:${c.idempotencyKey ?? `${parent.id}:${Date.now()}`}`; 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"; 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 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); 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) => { const getRow = async (table: Table, id: string) => {
// biome-ignore lint/suspicious/noExplicitAny: 同上 // biome-ignore lint/suspicious/noExplicitAny: 同上
const r = await (db() as any).selectFrom(table).selectAll().where("id", "=", id).executeTakeFirst(); 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 }; return r as Record<string, unknown> & { id: string; secret_ref?: string | null };
}; };
const get = (table: Table, tag: string, def: Def) => 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)), toApi(await getRow(table, (params as { id: string }).id)),
); );
const remove = (table: Table, tag: string, path: string, action: AuditAction) => 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 id = (params as { id: string }).id;
const row = await getRow(table, id); const row = await getRow(table, id);
// biome-ignore lint/suspicious/noExplicitAny: 同上 // biome-ignore lint/suspicious/noExplicitAny: 同上
@@ -127,7 +133,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.deleteFrom(table) .deleteFrom(table)
.where("id", "=", id) .where("id", "=", id)
.execute() .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(() => {}); if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
await audit(ctx, actor, action, { type: table, id }); await audit(ctx, actor, action, { type: table, id });
return { ok: true as const }; return { ok: true as const };
@@ -139,7 +145,10 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
.values(values) .values(values)
.returningAll() .returningAll()
.executeTakeFirstOrThrow() .executeTakeFirstOrThrow()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`))) as Record<string, unknown> & { .catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`))) as Record<
string,
unknown
> & {
id: string; id: string;
}; };
const update = async (table: Table, id: string, values: Record<string, unknown>) => { 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) .where("id", "=", id)
.returningAll() .returningAll()
.executeTakeFirst() .executeTakeFirst()
.catch((e: unknown) => pgConflict(e, `${table} 名称/路径重复或引用不存在`)); .catch((e: unknown) => pgConflict(e, `${NOUN[table]}的名称或路径重复,或关联的对象不存在`));
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 }; 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); const raw = err instanceof Error ? err.message : String(err);
if (/EADDRINUSE|address already in use/i.test(raw)) { if (/EADDRINUSE|address already in use/i.test(raw)) {
const port = OAUTH_CALLBACK_PORTS[provider] ?? raw.match(/:(\d{2,5})\b/)?.[1]; 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 (/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") if (/\b404\b/.test(raw) && provider === "openai-codex")
return "该账号未开启设备码登录(Codex 返回 404),请改用浏览器登录"; return "这个账号未开启设备码登录(Codex 返回 404)。改用浏览器登录";
return classifyUpstreamError(err).message; return classifyUpstreamError(err).message;
} }
@@ -105,10 +105,7 @@ export class OAuthSessions {
async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> { async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> {
const info = providerInfo(row.provider); const info = providerInfo(row.provider);
if (row.auth_kind !== "oauth" || !info?.oauth) if (row.auth_kind !== "oauth" || !info?.oauth)
throw new ApiException( throw new ApiException("precondition_failed", "这个上游不是订阅登录类型");
"precondition_failed",
"该上游不是订阅登录型(authKind 需为 oauth,且 provider 支持订阅登录)",
);
if (!row.enabled) throw new ApiException("precondition_failed", "上游已停用"); if (!row.enabled) throw new ApiException("precondition_failed", "上游已停用");
const mode = req.mode ?? info.oauth.modes[0]!; const mode = req.mode ?? info.oauth.modes[0]!;
if (!info.oauth.modes.includes(mode)) if (!info.oauth.modes.includes(mode))
@@ -122,7 +119,7 @@ export class OAuthSessions {
if (busy.upstreamId !== row.id) if (busy.upstreamId !== row.id)
throw new ApiException( throw new ApiException(
"conflict", "conflict",
`${info.name} 已有登录进行中(同一 provider 的登录需串行),请先完成或等待其过期(${new Date(busy.expiresAt).toISOString()})`, `${info.name} 已有登录进行中。完成它,或等到 ${new Date(busy.expiresAt).toISOString()} 过期后重试`,
); );
this.cancel(busy, "cancelled", "已被新的登录请求取代"); this.cancel(busy, "cancelled", "已被新的登录请求取代");
await busy.done.catch(() => undefined); await busy.done.catch(() => undefined);
@@ -147,7 +144,7 @@ export class OAuthSessions {
pasteWaiters: [], pasteWaiters: [],
firstEvent: Promise.withResolvers<void>(), firstEvent: Promise.withResolvers<void>(),
done: Promise.resolve(), done: Promise.resolve(),
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,请重新开始"), ttl), timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,重新开始登录"), ttl),
}; };
(s.timer as { unref?: () => void }).unref?.(); (s.timer as { unref?: () => void }).unref?.();
this.sessions.set(s.id, s); this.sessions.set(s.id, s);
@@ -195,7 +192,7 @@ export class OAuthSessions {
for (const w of s.pasteWaiters.splice(0)) w(); for (const w of s.pasteWaiters.splice(0)) w();
}); });
} }
throw new Error("该登录流程需要额外输入(secret),服务端登录不支持"); throw new Error("这个登录流程需要额外输入(secret),服务端不支持");
} }
private notify(s: Session, e: UpstreamAuthEvent) { private notify(s: Session, e: UpstreamAuthEvent) {
@@ -238,9 +235,14 @@ export class OAuthSessions {
): Promise<{ sessionId: string; state: PiOAuthState; message: string | null; progress: string[] }> { ): Promise<{ sessionId: string; state: PiOAuthState; message: string | null; progress: string[] }> {
const s = this.sessions.get(req.sessionId); const s = this.sessions.get(req.sessionId);
if (!s || s.upstreamId !== upstreamId) 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 (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) { if (!s.paste) {
// manual_code 提示可能还没发出:短暂等待 // manual_code 提示可能还没发出:短暂等待
await Promise.race([ await Promise.race([
+3 -3
View File
@@ -24,13 +24,13 @@ const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */ /** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) { 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) => { await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>` const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options) 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)}, 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({ 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') on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary, do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
updated_at = now() updated_at = now()
@@ -102,7 +102,7 @@ export async function refreshPiTokens(ctx: ServerContext, opts: RefreshOptions =
if (r.ok) report.refreshed += 1; if (r.ok) report.refreshed += 1;
else { else {
report.failed.push({ upstreamId: row.id, message: r.message }); 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; return report;
+1 -1
View File
@@ -109,7 +109,7 @@ export function registerPiRoutes(
source: t.source, source: t.source,
model: t.model, model: t.model,
errorKind: null, 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 }); await setStatus(ctx, row.id, { status: "ok", verified: true });
} catch (err) { } catch (err) {
+3 -3
View File
@@ -213,10 +213,10 @@ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string
const id = upstreamId ?? (await defaultUpstreamId(ctx)); const id = upstreamId ?? (await defaultUpstreamId(ctx));
if (!id) return null; if (!id) return null;
const row = await getRow(ctx, id); 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.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") 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) }; 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 { try {
return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref)); return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref));
} catch (err) { } 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; return undefined;
} }
} }
@@ -33,8 +33,8 @@ export const BUILTIN_AGENT_CHECKS: {
everyMs: 6 * HOUR, everyMs: 6 * HOUR,
config: { config: {
goalTemplate: goalTemplate:
"巡检待处理中心({{now}}):当前有 {{openDecisions}} 条未关闭事项。逐条查看证据,按紧急程度排序," + "巡检待处理({{now}}):有 {{openDecisions}} 条未关闭的待处理事项。逐条查看证据,按紧急程度排序," +
"给出每条的处理建议与理由;只读,不执行任何变更。", "给出处理建议和理由。只读,不做任何变更。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES], capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 20, maxDurationSec: 300 }, budget: { maxToolCalls: 20, maxDurationSec: 300 },
triggerCondition: "open_decisions", triggerCondition: "open_decisions",
@@ -47,7 +47,7 @@ export const BUILTIN_AGENT_CHECKS: {
everyMs: 2 * HOUR, everyMs: 2 * HOUR,
config: { config: {
goalTemplate: goalTemplate:
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," + "巡检下载({{now}}):有 {{stalledDownloads}} 个下载任务无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。", "给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES], capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 24, maxDurationSec: 420 }, budget: { maxToolCalls: 24, maxDurationSec: 420 },
@@ -62,8 +62,8 @@ export const BUILTIN_AGENT_CHECKS: {
everyMs: 7 * 24 * HOUR, everyMs: 7 * 24 * HOUR,
config: { config: {
goalTemplate: goalTemplate:
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅与原因推测、" + "生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅及可能原因、" +
"健康异常的站点与 Feed,给出本周建议;只读,最后用一段不超过 300 字的中文汇总。", "健康异常的站点与 Feed,给出本周建议。只读。最后用不超过 300 字的中文汇总。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES], capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 30, maxDurationSec: 600 }, budget: { maxToolCalls: 30, maxDurationSec: 600 },
triggerCondition: "subscriptions_active", triggerCondition: "subscriptions_active",
@@ -124,7 +124,7 @@ export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promi
if (grants.length > 0) { if (grants.length > 0) {
mode = "execute"; mode = "execute";
allowlist = [...new Set([...(allowlist.length ? allowlist : INVESTIGATE_CAPABILITIES), ...RECOVER_CAPABILITIES])]; allowlist = [...new Set([...(allowlist.length ? allowlist : INVESTIGATE_CAPABILITIES), ...RECOVER_CAPABILITIES])];
} else note = "(没有覆盖恢复动作的有效授权,已降级只读)"; } else note = "(没有覆盖恢复动作的有效授权,改为只读)";
} }
const goal = renderGoal(cfg.goalTemplate, c.now, f); const goal = renderGoal(cfg.goalTemplate, c.now, f);
const dedupeKey = c.trigger === "manual" ? `schedule:${c.jobId}:manual:${c.runId}` : `schedule:${c.jobId}:${c.slot}`; 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 !== undefined) {
if (patch.enabled && !row.available) throw new ApiException("conflict", "该任务未配置,不能开启"); if (patch.enabled && !row.available) throw new ApiException("conflict", "任务未配置,不能启用");
set.enabled = patch.enabled; set.enabled = patch.enabled;
set.customized = true; set.customized = true;
if (patch.enabled && !row.enabled && !set.next_run_at) { if (patch.enabled && !row.enabled && !set.next_run_at) {
@@ -228,7 +228,7 @@ export async function updateJob(
/** 立即执行:置 run_requested_at(已在排队或运行中则合并,不重复) */ /** 立即执行:置 run_requested_at(已在排队或运行中则合并,不重复) */
export async function requestRun(db: Kysely<DB>, id: string): Promise<{ row: ScheduledJobRow; queued: boolean }> { export async function requestRun(db: Kysely<DB>, id: string): Promise<{ row: ScheduledJobRow; queued: boolean }> {
const row = await getJob(db, id); const row = await getJob(db, id);
if (!row.available) throw new ApiException("conflict", "该任务未配置,不能执行"); if (!row.available) throw new ApiException("conflict", "任务未配置,不能执行");
const upd = await db const upd = await db
.updateTable("scheduled_job") .updateTable("scheduled_job")
.set({ run_requested_at: sql`now()` }) .set({ run_requested_at: sql`now()` })
+1 -1
View File
@@ -275,7 +275,7 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
await e.settings({ pi: { subagents: { enabled: false } } }); await e.settings({ pi: { subagents: { enabled: false } } });
expect(await spawn(parent.id, { goal: "关闭后" })).toMatchObject({ expect(await spawn(parent.id, { goal: "关闭后" })).toMatchObject({
code: "precondition", code: "precondition",
message: expect.stringContaining("子代理未开启"), message: expect.stringContaining("子会话未开启"),
}); });
await e.settings({ pi: { subagents: { enabled: true } } }); await e.settings({ pi: { subagents: { enabled: true } } });
// 以上都没有建出子会话 // 以上都没有建出子会话
+3 -3
View File
@@ -209,8 +209,8 @@ export function useAbortAgentRun() {
return useMutation({ return useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
if (agentMockEnabled()) return mockDetail(id, { abort: true }).run; if (agentMockEnabled()) return mockDetail(id, { abort: true }).run;
return apiMutate(`${AGENT_BASE}/:id/abort`, { pathParams: { id }, body: { reason: "用户在控制台中止" } }).then( return apiMutate(`${AGENT_BASE}/:id/abort`, { pathParams: { id }, body: { reason: "在控制台停止" } }).then((d) =>
(d) => AgentRun.parse(d), AgentRun.parse(d),
); );
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }), onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
@@ -236,7 +236,7 @@ export const RUN_STATUS_LABEL: Record<AgentRunStatus, string> = {
waiting: "等待中", waiting: "等待中",
succeeded: "已完成", succeeded: "已完成",
failed: "失败", failed: "失败",
cancelled: "已中止", cancelled: "已停止",
budget_exceeded: "超出预算", budget_exceeded: "超出预算",
}; };
@@ -174,10 +174,10 @@ export function ToolCallCard({
reason={authReason ?? call.reason ?? "未授权"} reason={authReason ?? call.reason ?? "未授权"}
explanation={ explanation={
authReason authReason
? '有副作用的能力必须命中有效的持续授权(automation_grant)或单次审批(approval),由服务端在执行前核对;模型声称"用户已同意"不会被采信。需要放行时到「设置 → 持续授权」发放,或对计划申请单次审批。' ? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。发布收录策略会生成持续授权,也可以为计划申请单次审批。'
: mode === "plan_only" && writeKind : mode === "plan_only" && writeKind
? "本次是「只要方案」:白名单只放行只读、预览、提案类能力,执行类调用一律拒绝。要真正执行,请按方案以执行模式重新发起。" ? "本次是「只要方案」,只放行只读、预览、提案类能力。要执行,点「按方案执行」。"
: "这个能力不在本次运行的能力白名单内(或已超出调用预算),调用在到达处理器前就被拦下。Agent 无法绕过。" : "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
} }
parameters={[ parameters={[
{ id: "call", label: "调用 ID", value: call.toolCallId }, { id: "call", label: "调用 ID", value: call.toolCallId },
@@ -219,9 +219,7 @@ export function ToolCallCard({
<dt className="text-muted-foreground">幂等键</dt> <dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80"> <dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ?? {audit?.idempotencyKey ??
(meta && meta.kind !== "read" (meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
? "由服务端生成(能力:版本:运行:输入哈希),审计未取到"
: "只读能力不记操作账本")}
</dd> </dd>
{audit?.grantId || audit?.approvalId ? ( {audit?.grantId || audit?.approvalId ? (
<> <>
@@ -298,13 +296,13 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
{budget {budget
? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止` ? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止`
: aborted : aborted
? "已中止" ? "已停止"
: "运行失败"} : "运行失败"}
</p> </p>
{item.message ? <p className="mt-0.5 text-caption break-words">{item.message}</p> : null} {item.message ? <p className="mt-0.5 text-caption break-words">{item.message}</p> : null}
{budget ? ( {budget ? (
<p className="mt-1 text-meta text-muted-foreground"> <p className="mt-1 text-meta text-muted-foreground">
已完成的外部动作不会撤销;可调大预算后追加消息续跑。 已完成的外部动作不会撤销。调大预算后追加消息可继续运行。
</p> </p>
) : null} ) : null}
</MessageBubbleContent> </MessageBubbleContent>
@@ -636,7 +634,7 @@ export function ApprovalItem({ approval: a }: { approval: Approval }) {
<ApprovalCard <ApprovalCard
status={status} status={status}
title={`批准执行这份${PLAN_LABEL[a.planType] ?? a.planType}?`} title={`批准执行这份${PLAN_LABEL[a.planType] ?? a.planType}?`}
description={a.reason ?? "审批绑定计划 ID、payload hash 与版本号;计划变化后旧审批自动失效。"} description={a.reason ?? "计划变化后,这份审批自动失效。"}
badges={ badges={
<> <>
{risk ? <RiskBadge level={risk} /> : null} {risk ? <RiskBadge level={risk} /> : null}
@@ -645,7 +643,7 @@ export function ApprovalItem({ approval: a }: { approval: Approval }) {
} }
onApprove={() => act("approved")} onApprove={() => act("approved")}
onReject={() => act("rejected")} onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"} disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={ result={
a.decidedAt a.decidedAt
? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}` ? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}`
@@ -736,7 +734,7 @@ export function FinalReport({
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto"> <StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
{said || "(没有文字汇报)"} {said || "(没有文字汇报)"}
</StreamingResponse> </StreamingResponse>
<p className="mt-2 text-meta text-muted-foreground">模型的自述,仅供参考,不作为完成依据。</p> <p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div> </div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3"> <div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase"> <p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
@@ -798,12 +796,12 @@ export function FinalReport({
/> />
) : null} ) : null}
<p className="mt-2 text-meta text-muted-foreground"> <p className="mt-2 text-meta text-muted-foreground">
汇总自下载计划、下载任务与整理记录(生成于 {formatDateTime(report.generatedAt)} 取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
);"已提交"只代表进入下载器,整理成功才算入库。 )。"已提交"指已进入下载器,整理成功才算入库。
</p> </p>
</> </>
) : ( ) : (
<p className="mt-2 text-meta text-muted-foreground">服务端汇报尚未生成;以任务页、整理记录的实际状态为准。</p> <p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
)} )}
{run.error ? ( {run.error ? (
<p className="mt-2 text-caption break-words text-destructive"> <p className="mt-2 text-caption break-words text-destructive">
@@ -833,7 +831,7 @@ export function RunAnnouncer({ status, finished }: { status: AgentRunStatus; fin
prev.current = status; prev.current = status;
}, [status]); }, [status]);
useEffect(() => { useEffect(() => {
if (finished) setMsg("回复完成,最终汇报已生成"); if (finished) setMsg("最终汇报已生成");
}, [finished]); }, [finished]);
return ( return (
<span className="sr-only" role="status" aria-live="polite"> <span className="sr-only" role="status" aria-live="polite">
@@ -93,7 +93,7 @@ export function AgentDrawer() {
Agent Agent
</SheetTitle> </SheetTitle>
<SheetDescription className="break-words"> <SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"} {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline"> <span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭) ({agentShortcutLabel()} 开关,Esc 关闭)
</span> </span>
@@ -37,16 +37,16 @@ import {
import { SubagentTree } from "./agent-subagent-tree.tsx"; import { SubagentTree } from "./agent-subagent-tree.tsx";
import { useAgentStream } from "./use-agent-stream.ts"; import { useAgentStream } from "./use-agent-stream.ts";
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const; const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
export const MODE_HELP: Record<AgentMode, { label: string; help: string }> = { export const MODE_HELP: Record<AgentMode, { label: string; help: string }> = {
execute: { execute: {
label: "授权内执行", label: "授权内执行",
help: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。", help: "未授权的动作会被服务端拒绝并在对话里标出。",
}, },
plan_only: { plan_only: {
label: "只要方案", label: "只要方案",
help: "只调查、出方案,不执行任何写操作;满意后可一键按方案执行。", help: "只调查、出方案,不执行写操作。之后可按方案执行。",
}, },
}; };
@@ -134,7 +134,7 @@ export function GoalComposer({
<div className="pt-2"> <div className="pt-2">
<p className="mb-1.5 flex items-center gap-1.5 px-1 text-meta text-muted-foreground"> <p className="mb-1.5 flex items-center gap-1.5 px-1 text-meta text-muted-foreground">
<Sparkles aria-hidden className="size-3.5" /> <Sparkles aria-hidden className="size-3.5" />
试试这些 常用目标
</p> </p>
<ul aria-label="快捷目标" className="flex flex-col gap-1.5"> <ul aria-label="快捷目标" className="flex flex-col gap-1.5">
{suggestions.map((s) => ( {suggestions.map((s) => (
@@ -267,9 +267,7 @@ export function AgentRunPanel({
className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning" className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
> >
<WifiOff aria-hidden className="size-4 shrink-0" /> <WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
实时连接中断,正在重连;恢复后按 after= 补读漏掉的事件,期间每 2.5 秒轮询兜底。
</span>
</div> </div>
) : null} ) : null}
{live.error ? ( {live.error ? (
@@ -320,7 +318,7 @@ export function AgentRunPanel({
maxRows={6} maxRows={6}
maxLength={8000} maxLength={8000}
aria-label="追加消息" aria-label="追加消息"
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息,继续这次会话"} placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
toolbar={ toolbar={
mode === "plan_only" && finished ? ( mode === "plan_only" && finished ? (
<Button type="button" size="sm" variant="outline" onClick={executePlan} disabled={create.isPending}> <Button type="button" size="sm" variant="outline" onClick={executePlan} disabled={create.isPending}>
@@ -334,7 +332,7 @@ export function AgentRunPanel({
/> />
<p className="px-1 text-meta text-muted-foreground"> <p className="px-1 text-meta text-muted-foreground">
{mode === "plan_only" && finished {mode === "plan_only" && finished
? "「按方案执行」会以授权内执行模式新开一次运行,仍受服务端授权校验。" ? "「按方案执行」会新开一次授权内执行的运行,仍受服务端授权校验。"
: MODE_HELP[mode].help}{" "} : MODE_HELP[mode].help}{" "}
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 中止本次运行" : ""}。 Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 中止本次运行" : ""}。
</p> </p>
@@ -344,12 +342,12 @@ export function AgentRunPanel({
open={confirmStop} open={confirmStop}
onOpenChange={setConfirmStop} onOpenChange={setConfirmStop}
title="停止这次运行?" title="停止这次运行?"
description="停止后续步骤;已经完成的外部动作(如已提交的下载)不会被撤销。" description="后续步骤不再执行,已完成的外部动作(如已提交的下载)不会撤销。"
confirmLabel="停止" confirmLabel="停止运行"
destructive destructive
onConfirm={() => onConfirm={() =>
abort.mutateAsync(r.id).then( abort.mutateAsync(r.id).then(
() => toast.success("已请求中止"), () => toast.success("已请求停止"),
(e) => { (e) => {
toast.error(errorMessage(e)); toast.error(errorMessage(e));
throw e; throw e;
@@ -75,7 +75,7 @@ export function AgentSessionList({
plain plain
icon={Bot} icon={Bot}
title={status ? "没有这个状态的会话" : "还没有会话"} title={status ? "没有这个状态的会话" : "还没有会话"}
description={status ? "换个筛选看看。" : "交代一个目标开始;也可以在任意页面右上角打开 Agent 侧栏。"} description={status ? undefined : "交代一个目标开始。任意页面都能从右下角打开 Agent。"}
/> />
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">
@@ -26,7 +26,7 @@ function budgetText(b: Record<string, number | undefined>) {
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`); if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`); if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`);
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`); if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
return parts.length ? parts.join(" · ") : "按设置缺省"; return parts.length ? parts.join(" · ") : "沿用设置";
} }
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) { function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
+2 -2
View File
@@ -42,7 +42,7 @@ const RULES: Rule[] = [
}, },
{ {
match: /^\/console\/identity/, match: /^\/console\/identity/,
goals: ["帮我识别待识别池里的文件", "找出外部身份映射可能错误的作品", "补全缺少元数据的作品"], goals: ["识别待识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
}, },
{ {
match: /^\/console\/subscriptions/, match: /^\/console\/subscriptions/,
@@ -50,7 +50,7 @@ const RULES: Rule[] = [
}, },
{ {
match: /^\/console\/sources/, match: /^\/console\/sources/,
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"], goals: ["哪些来源不新鲜了,原因是什么", "检查所有 Feed 的抓取状态", "最近一天各来源抓到了什么"],
}, },
{ {
match: /^\/console\/engine/, match: /^\/console\/engine/,
@@ -104,7 +104,7 @@ export function StreamingResponse({
{actions ? ( {actions ? (
<div className="mt-1 flex items-center gap-0.5 animate-in fade-in-0 duration-base motion-reduce:animate-none"> <div className="mt-1 flex items-center gap-0.5 animate-in fade-in-0 duration-base motion-reduce:animate-none">
{copyText ? ( {copyText ? (
<ActionButton label={copied ? "已复制" : "复制这段回复"} onClick={copy}> <ActionButton label={copied ? "已复制" : "复制回复"} onClick={copy}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</ActionButton> </ActionButton>
) : null} ) : null}
@@ -154,7 +154,7 @@ export function AgentLauncher() {
</kbd> </kbd>
</span> </span>
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground"> <span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
{open ? "Esc 也可关闭" : (summary ?? "交代目标,边做边汇报")} {open ? "Esc 关闭" : (summary ?? "交代目标,边做边汇报")}
</span> </span>
</span> </span>
</button>, </button>,
@@ -207,7 +207,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
{count ? <PulseDot /> : null} {count ? <PulseDot /> : null}
</span> </span>
<span className="truncate text-meta text-muted-foreground"> <span className="truncate text-meta text-muted-foreground">
{count ? `${count} 个会话进行中` : "交代目标,随时唤出"} {count ? `${count} 个会话进行中` : "交代目标,边做边汇报"}
</span> </span>
</span> </span>
{count ? ( {count ? (
@@ -225,8 +225,8 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
export function AgentShortcutsHelp() { export function AgentShortcutsHelp() {
const shortcut = agentShortcutLabel(); const shortcut = agentShortcutLabel();
const rows: [string, string][] = [ const rows: [string, string][] = [
[shortcut, "任意页面打开 / 收起 Agent 抽屉(带当前页面上下文)"], [shortcut, "打开 / 收起 Agent,附带当前页面"],
["Esc", "关闭 Agent 抽屉"], ["Esc", "关闭 Agent"],
["Enter", "发送目标;Shift+Enter 换行"], ["Enter", "发送目标;Shift+Enter 换行"],
]; ];
return ( return (
+1 -1
View File
@@ -174,7 +174,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/schedules", to: "/console/schedules",
label: "定时任务", label: "定时任务",
icon: Timer, icon: Timer,
description: "周期任务与 Agent 巡检的开关、间隔、立即执行与历史", description: "周期任务与 Agent 巡检的开关、间隔和运行记录",
}, },
{ {
to: "/console/engine", to: "/console/engine",
@@ -142,7 +142,7 @@ export function AgentCheckDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle> <DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle>
<DialogDescription>到点按目标入队一次 Agent 会话,只在白名单与预算内行动。</DialogDescription> <DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
className="grid gap-1" className="grid gap-1"
@@ -171,7 +171,7 @@ export function AgentCheckDialog({
onChange={(e) => set("goal", e.target.value)} onChange={(e) => set("goal", e.target.value)}
/> />
</FormField> </FormField>
<FormField label="能力白名单" hint="不选即只读类全部"> <FormField label="能力白名单" hint="不选则放行全部只读能力">
<div className="grid max-h-48 gap-1 overflow-y-auto rounded-tight border border-border/70 px-2.5 py-2 sm:grid-cols-2"> <div className="grid max-h-48 gap-1 overflow-y-auto rounded-tight border border-border/70 px-2.5 py-2 sm:grid-cols-2">
{READONLY_CAPS.map((c) => { {READONLY_CAPS.map((c) => {
const on = d.caps.includes(c.id); const on = d.caps.includes(c.id);
@@ -235,7 +235,7 @@ export function AgentCheckDialog({
label="间隔" label="间隔"
htmlFor="ac-every" htmlFor="ac-every"
error={errors.every} error={errors.every}
hint={cronJob ? "cron 调度请在卡片上修改" : undefined} hint={cronJob ? "cron 表达式在卡片上修改" : undefined}
> >
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
+1 -1
View File
@@ -33,7 +33,7 @@ export const TRIGGER_LABEL: Record<ScheduleTrigger, string> = {
}; };
export const CHECK_TRIGGER_LABEL: Record<AgentCheckTrigger, string> = { export const CHECK_TRIGGER_LABEL: Record<AgentCheckTrigger, string> = {
always: "每次都跑", always: "每次运行",
open_decisions: "有待处理时", open_decisions: "有待处理时",
stalled_downloads: "有下载无进展时", stalled_downloads: "有下载无进展时",
subscriptions_active: "有追踪订阅时", subscriptions_active: "有追踪订阅时",
@@ -17,7 +17,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
<DetailSheet <DetailSheet
open={!!job} open={!!job}
onOpenChange={(o) => !o && onClose()} onOpenChange={(o) => !o && onClose()}
title={job ? `${job.name} · 历史` : "历史"} title={job ? `${job.name} · 运行记录` : "运行记录"}
description={ description={
job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined job ? `${describeSchedule(job.schedule)} · 累计 ${job.runCount} 次,失败 ${job.failCount} 次` : undefined
} }
@@ -41,7 +41,7 @@ function AgentCheckSummary({ config }: { config: Partial<AgentCheckConfig> }) {
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"} 预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
{budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""} {budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""}
</span> </span>
<span>能力:{allow.length ? `${allow.length} 项` : "只读全部"}</span> <span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
{config.allowRecover ? <span>允许授权内恢复</span> : null} {config.allowRecover ? <span>允许授权内恢复</span> : null}
{config.reportAsDecision ? <span>写入待处理</span> : null} {config.reportAsDecision ? <span>写入待处理</span> : null}
</div> </div>
@@ -156,7 +156,7 @@ export function ScheduleCard({
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => onHistory(job)}> <Button size="sm" variant="ghost" onClick={() => onHistory(job)}>
<History /> <History />
查看历史 运行记录
</Button> </Button>
<span className="ml-auto flex items-center gap-1"> <span className="ml-auto flex items-center gap-1">
{job.customized ? ( {job.customized ? (
@@ -184,8 +184,8 @@ export function ScheduleCard({
</Button> </Button>
} }
title={`删除「${job.name}」?`} title={`删除「${job.name}」?`}
description="运行历史一并删除,不影响已产生的 Agent 会话。" description="运行记录一并删除,已产生的 Agent 会话保留。"
confirmLabel="删除" confirmLabel="删除巡检"
destructive destructive
onConfirm={() => onConfirm={() =>
del.mutateAsync({ id: job.id }).then( del.mutateAsync({ id: job.id }).then(
+3 -3
View File
@@ -10,7 +10,7 @@ const nullNum = (v: FieldValue | undefined) => (str(v) === "" ? null : Number(st
const RUNTIME_CARD: CardSpec = { const RUNTIME_CARD: CardSpec = {
anchor: "runtime", anchor: "runtime",
title: "上下文与子代理", title: "上下文与子会话",
fields: [ fields: [
{ {
name: "pi.contextBudget", name: "pi.contextBudget",
@@ -18,7 +18,7 @@ const RUNTIME_CARD: CardSpec = {
kind: "number", kind: "number",
hint: "4096–2000000,超过模型窗口时按窗口算", hint: "4096–2000000,超过模型窗口时按窗口算",
}, },
{ name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" }, { name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" }, { name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
{ {
name: "pi.compaction.thresholdRatio", name: "pi.compaction.thresholdRatio",
@@ -49,7 +49,7 @@ const RUNTIME_CARD: CardSpec = {
const BUDGET_CARD: CardSpec = { const BUDGET_CARD: CardSpec = {
anchor: "budget", anchor: "budget",
title: "预算", title: "预算",
description: "超出每日预算的运行停在「超预算」,等你放行。", description: "超出每日预算的运行停在「超出预算」,等你放行。",
fields: [ fields: [
{ name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" }, { name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" },
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" }, { name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" },
@@ -105,7 +105,7 @@ export function ApiKeysSection() {
section="api-keys" section="api-keys"
anchor="keys" anchor="keys"
title="API Key" title="API Key"
description="签发给外部程序(媒体服务器插件、脚本)的 Key,按权限范围授权。控制台走登录会话,不用 Key;浏览器本机 Token 在「账户与安全」。" description="签发给外部程序(媒体服务器插件、脚本)的 Key,按权限范围授权。本机 Token 在「账户与安全」。"
plain plain
actions={ actions={
<Button size="sm" onClick={() => setOpen(true)}> <Button size="sm" onClick={() => setOpen(true)}>
@@ -227,7 +227,7 @@ export function ApiKeysSection() {
onOpenChange={(o) => !o && setRevoking(null)} onOpenChange={(o) => !o && setRevoking(null)}
title={`撤销「${revoking?.name ?? ""}」?`} title={`撤销「${revoking?.name ?? ""}」?`}
description="使用这个 Key 的程序立即失去访问权限,无法恢复。" description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
confirmLabel="撤销" confirmLabel="撤销 Key"
destructive destructive
onConfirm={async () => { onConfirm={async () => {
if (!revoking) return; if (!revoking) return;
@@ -162,7 +162,7 @@ function Switches() {
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。" description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
checked={s.automation.readOnlyReconcile} checked={s.automation.readOnlyReconcile}
disabled={update.isPending} disabled={update.isPending}
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账" : "已退出只读对账")} onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
/> />
</div> </div>
</SettingsBlock> </SettingsBlock>
@@ -87,7 +87,7 @@ function DownloadersTable() {
config={{ config={{
noun: "下载器实例", noun: "下载器实例",
description: description:
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和它的路径映射。", "只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。", emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />, drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
columns: [ columns: [
@@ -181,7 +181,7 @@ function MediaServersTable() {
hooks={{ list, create, update, remove } as never} hooks={{ list, create, update, remove } as never}
config={{ config={{
noun: "媒体服务器", noun: "媒体服务器",
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和它的路径映射。", description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。",
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />, drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
columns: [ columns: [
{ {
@@ -11,7 +11,7 @@ import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
const TMDB_CARD: CardSpec = { const TMDB_CARD: CardSpec = {
anchor: "tmdb", anchor: "tmdb",
title: "TMDB", title: "TMDB",
description: "这里没填时使用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。", description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
fields: [ fields: [
{ {
name: "metadata.tmdbApiKey", name: "metadata.tmdbApiKey",
@@ -211,7 +211,7 @@ export function PathMappingsPanel({
onOpenChange={(o) => !o && setDeleting(null)} onOpenChange={(o) => !o && setDeleting(null)}
title="删除路径映射?" title="删除路径映射?"
description="依赖这条映射的路径换算会失效,磁盘文件不动。" description="依赖这条映射的路径换算会失效,磁盘文件不动。"
confirmLabel="删除" confirmLabel="删除映射"
destructive destructive
onConfirm={async () => { onConfirm={async () => {
if (!deleting) return; if (!deleting) return;
@@ -102,7 +102,7 @@ export function PiOAuthLogin({
abortRef.current = ac; abortRef.current = ac;
while (!ac.signal.aborted) { while (!ac.signal.aborted) {
if (Date.now() > new Date(s.expiresAt).getTime()) { if (Date.now() > new Date(s.expiresAt).getTime()) {
finish("expired", "超过有效期仍未完成授权。"); finish("expired", "授权已超时。");
return; return;
} }
try { try {
@@ -256,7 +256,7 @@ export function PiOAuthLogin({
) : ( ) : (
<form onSubmit={submitPasted} noValidate className="grid gap-2 rounded-lg border border-border/60 p-3"> <form onSubmit={submitPasted} noValidate className="grid gap-2 rounded-lg border border-border/60 p-3">
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
在自己的浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开是正常的,把地址栏里的完整 URL 粘到下面。 在浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开属正常,把地址栏里的完整 URL 粘贴到下面。
</p> </p>
{session.authorizeUrl ? ( {session.authorizeUrl ? (
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@@ -7,7 +7,7 @@ import { Input } from "~/components/ui/input.tsx";
export const GROUP_LABEL: Record<PiProviderGroup, string> = { export const GROUP_LABEL: Record<PiProviderGroup, string> = {
api_key: "API Key", api_key: "API Key",
subscription: "订阅登录", subscription: "订阅登录",
cloud: "云凭据", cloud: "云凭证",
custom: "兼容端点", custom: "兼容端点",
test: "测试", test: "测试",
}; };
@@ -16,7 +16,7 @@ export const GROUP_ORDER: PiProviderGroup[] = ["api_key", "subscription", "cloud
export const AUTH_LABEL: Record<PiAuthKind, string> = { export const AUTH_LABEL: Record<PiAuthKind, string> = {
api_key: "API Key", api_key: "API Key",
oauth: "订阅登录", oauth: "订阅登录",
none: "不需要凭据", none: "不需要凭证",
}; };
export const THINKING_LABEL: Record<PiThinkingLevel, string> = { export const THINKING_LABEL: Record<PiThinkingLevel, string> = {
+3 -3
View File
@@ -10,7 +10,7 @@ import { errorMessage } from "~/lib/api.ts";
// 上游测试:默认只验凭据与模型(不产生费用);"发一次最小补全"真实调用一次模型 // 上游测试:默认只验凭据与模型(不产生费用);"发一次最小补全"真实调用一次模型
const ERROR_KIND: Record<string, string> = { const ERROR_KIND: Record<string, string> = {
auth: "凭据被拒", auth: "凭证被拒",
oauth: "登录失效", oauth: "登录失效",
model: "模型不可用", model: "模型不可用",
network: "网络错误", network: "网络错误",
@@ -28,7 +28,7 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
try { try {
const r = await test.mutateAsync({ id: upstream.id, body: { mode, ...(model ? { model } : {}) } }); const r = await test.mutateAsync({ id: upstream.id, body: { mode, ...(model ? { model } : {}) } });
setResult(r); setResult(r);
if (r.ok) toast.success(mode === "auth" ? "凭据可用" : "补全成功"); if (r.ok) toast.success(mode === "auth" ? "凭证可用" : "补全成功");
else toast.error(r.message ?? "测试失败"); else toast.error(r.message ?? "测试失败");
} catch (err) { } catch (err) {
setResult(null); setResult(null);
@@ -51,7 +51,7 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
</Button> </Button>
</div> </div>
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
「测试」只检查凭据和模型,不产生费用;最小补全会真实调用一次模型(最多 16 个 token)。 「测试」只检查凭证和模型,不产生费用。最小补全会调用一次模型(最多 16 个 token)。
</p> </p>
{result ? ( {result ? (
<div className="flex flex-wrap items-center gap-2 text-caption"> <div className="flex flex-wrap items-center gap-2 text-caption">
@@ -51,7 +51,7 @@ export function PiUpstreamsBlock() {
section="agent" section="agent"
anchor="upstream" anchor="upstream"
title="上游" title="上游"
description="Agent 调用的模型上游。没指定上游的运行用默认上游;密钥和登录凭据只写入,不回显。" description="没指定上游的运行用默认上游。密钥和登录凭证保存后不回显。"
plain plain
actions={ actions={
<Button size="sm" onClick={() => setWizard(true)}> <Button size="sm" onClick={() => setWizard(true)}>
@@ -291,7 +291,7 @@ function UpstreamDrawer({
disabled={pending} disabled={pending}
/> />
<FieldControl <FieldControl
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后用它的运行直接失败" }} spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后,使用它的运行会失败" }}
value={v.enabled} value={v.enabled}
onChange={(x) => setV((s) => ({ ...s, enabled: x === true }))} onChange={(x) => setV((s) => ({ ...s, enabled: x === true }))}
disabled={pending} disabled={pending}
@@ -377,7 +377,7 @@ function UpstreamDrawer({
onOpenChange={setDeleting} onOpenChange={setDeleting}
title={`删除上游「${upstream.name}」?`} title={`删除上游「${upstream.name}」?`}
description={ description={
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭据一并删除,无法恢复。" : "凭据一并删除,无法恢复。" upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
} }
confirmLabel="删除上游" confirmLabel="删除上游"
destructive destructive
@@ -22,7 +22,7 @@ import { API_OPTIONS, AUTH_LABEL, GROUP_LABEL, GROUP_ORDER, ModelInput, THINKING
import { PiTestPanel } from "./pi-test.tsx"; import { PiTestPanel } from "./pi-test.tsx";
import { FieldControl } from "./resource-section.tsx"; import { FieldControl } from "./resource-section.tsx";
// 新建 Agent 上游:① 选 provider → ② 填凭据与模型并创建 → ③ 登录 / 测试,可设为默认 // 新建 Agent 上游:① 选 provider → ② 填凭证与模型并创建 → ③ 登录 / 测试,可设为默认
type Step = "provider" | "config" | "verify"; type Step = "provider" | "config" | "verify";
@@ -51,7 +51,7 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
step === "provider" step === "provider"
? "第 1 步,共 3 步:选 provider" ? "第 1 步,共 3 步:选 provider"
: step === "config" : step === "config"
? "第 2 步,共 3 步:凭据与模型" ? "第 2 步,共 3 步:凭证与模型"
: created?.authKind === "oauth" : created?.authKind === "oauth"
? "第 3 步,共 3 步:登录并测试。上游已创建。" ? "第 3 步,共 3 步:登录并测试。上游已创建。"
: "第 3 步,共 3 步:测试。上游已创建。"; : "第 3 步,共 3 步:测试。上游已创建。";
@@ -246,7 +246,7 @@ function ConfigStep({
) : null} ) : null}
{authKind === "none" && provider.group === "cloud" ? ( {authKind === "none" && provider.group === "cloud" ? (
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2"> <p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
不存密钥,运行时读服务端进程的环境凭据(AWS 凭据链 / Google ADC)。 不存密钥,运行时读服务端进程的环境凭证(AWS 凭证链 / Google ADC)。
</p> </p>
) : null} ) : null}
{showBaseUrl ? ( {showBaseUrl ? (
@@ -145,7 +145,7 @@ export function ResourceSection<Row extends { id: string }>({
onOpenChange={(o) => !o && setDeleting(null)} onOpenChange={(o) => !o && setDeleting(null)}
title={`删除${config.noun}?`} title={`删除${config.noun}?`}
description={config.deleteHint ?? "删除后无法恢复。"} description={config.deleteHint ?? "删除后无法恢复。"}
confirmLabel="删除" confirmLabel={`删除${config.noun}`}
destructive destructive
onConfirm={async () => { onConfirm={async () => {
if (!deleting) return; if (!deleting) return;
+1 -1
View File
@@ -598,7 +598,7 @@ function AccountsBlock() {
onOpenChange={(o) => !o && setDeleting(null)} onOpenChange={(o) => !o && setDeleting(null)}
title={`删除账号「${deleting?.label ?? ""}」?`} title={`删除账号「${deleting?.label ?? ""}」?`}
description="账号和凭证一并删除,无法恢复。" description="账号和凭证一并删除,无法恢复。"
confirmLabel="删除" confirmLabel="删除账号"
destructive destructive
onConfirm={async () => { onConfirm={async () => {
if (!deleting) return; if (!deleting) return;
+6 -6
View File
@@ -72,13 +72,13 @@ const ORG_ACTION: Record<string, string> = {
export const organizationActionLabel = (v: string) => ORG_ACTION[v] ?? v; export const organizationActionLabel = (v: string) => ORG_ACTION[v] ?? v;
const AGENT_RUN: Record<string, Labeled> = { const AGENT_RUN: Record<string, Labeled> = {
queued: { label: "排队", tone: "neutral" }, queued: { label: "排队中", tone: "neutral" },
running: { label: "执行中", tone: "neutral" }, running: { label: "运行中", tone: "neutral" },
waiting: { label: "等待", tone: "warning" }, waiting: { label: "等待中", tone: "warning" },
succeeded: { label: "完成", tone: "success" }, succeeded: { label: "已完成", tone: "success" },
failed: { label: "失败", tone: "destructive" }, failed: { label: "失败", tone: "destructive" },
cancelled: { label: "已取消", tone: "neutral" }, cancelled: { label: "已停止", tone: "neutral" },
budget_exceeded: { label: "超预算", tone: "warning" }, budget_exceeded: { label: "超出预算", tone: "warning" },
}; };
export const agentRunLabel = (v: string | null | undefined) => pick(AGENT_RUN, v); export const agentRunLabel = (v: string | null | undefined) => pick(AGENT_RUN, v);
+4 -7
View File
@@ -18,14 +18,11 @@ export default function AgentPage() {
return ( return (
<> <>
<PageHeader <PageHeader title="Agent 会话" description="交代目标,Agent 在授权范围内执行并汇报。只要方案时只调查,不执行。" />
title="Agent 会话"
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
/>
{mock ? ( {mock ? (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning"> <div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
<FlaskConical className="h-4 w-4 shrink-0" /> <FlaskConical className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">演示模式:会话是本地假数据,不连后端,不会产生任何动作。</span> <span className="min-w-0 flex-1">演示模式:会话是本地假数据,不连接后端,不执行任何操作。</span>
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=0")}> <Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=0")}>
退出演示 退出演示
</Button> </Button>
@@ -54,8 +51,8 @@ export default function AgentPage() {
<div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground"> <div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" /> <ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
<div className="space-y-1"> <div className="space-y-1">
<p>每一次工具调用都经服务端授权校验:不在白名单或没有授权覆盖的调用会被拒绝,并在对话里醒目标出。</p> <p>每次工具调用都经服务端授权校验,不在白名单或没有授权的调用会被拒绝并在对话里标出。</p>
<p>运行结束后,汇报分两栏:模型的自述只作参考,"系统核实的结果"取自下载计划、下载任务与整理记录。</p> <p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
</div> </div>
</div> </div>
</div> </div>
+9
View File
@@ -37,6 +37,12 @@ const ACTION_LABEL: Record<string, string> = {
"secret.delete": "删除凭证", "secret.delete": "删除凭证",
"site_account.credentials_set": "录入站点凭证", "site_account.credentials_set": "录入站点凭证",
"engine.run_cancel": "取消引擎运行", "engine.run_cancel": "取消引擎运行",
"pi_upstream.oauth_start": "开始订阅登录",
"pi_upstream.oauth_login": "完成订阅登录",
"pi_upstream.oauth_logout": "退出订阅登录",
"schedule.run_now": "立即执行定时任务",
"schedule.skip": "跳过定时任务",
"acquisition_plan.manual_submit": "手动提交下载计划",
}; };
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" }; const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
const NOUN: Record<string, string> = { const NOUN: Record<string, string> = {
@@ -44,6 +50,9 @@ const NOUN: Record<string, string> = {
downloader_instance: "下载器", downloader_instance: "下载器",
media_server_instance: "媒体服务器", media_server_instance: "媒体服务器",
path_mapping: "路径映射", path_mapping: "路径映射",
pi_upstream: "Agent 上游",
schedule: "定时任务",
automation_grant: "持续授权",
}; };
function actionLabel(a: string): string { function actionLabel(a: string): string {
if (ACTION_LABEL[a]) return ACTION_LABEL[a]; if (ACTION_LABEL[a]) return ACTION_LABEL[a];
+1 -1
View File
@@ -143,7 +143,7 @@ export default function DownloadsPage() {
footer={ footer={
open ? ( open ? (
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link to={consolePath("downloads", open.id)}>在独立页面打开</Link> <Link to={consolePath("downloads", open.id)}>打开详情页</Link>
</Button> </Button>
) : null ) : null
} }
+14 -20
View File
@@ -155,10 +155,10 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
</div> </div>
<p className="mt-0.5 text-caption text-muted-foreground"> <p className="mt-0.5 text-caption text-muted-foreground">
{paused {paused
? "自动收录、下载、整理都已停下;查询与手动操作仍可用。" ? "自动收录、下载、整理已停止,手动操作仍可用。"
: readOnly : readOnly
? "恢复后的只读对账:只核对不写入,确认无误后再关掉。" ? "只核对,不写入。确认无误后关闭。"
: `${a.activeSubscriptions} 个订阅在追踪,按规则自动收录、下载与入库。`} : `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
</p> </p>
</div> </div>
</div> </div>
@@ -170,7 +170,7 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
checked={readOnly} checked={readOnly}
disabled={update.isPending} disabled={update.isPending}
onCheckedChange={(v) => onCheckedChange={(v) =>
setAutomation({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式").catch(() => {}) setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
} }
aria-label="只读对账模式" aria-label="只读对账模式"
/> />
@@ -193,8 +193,8 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
</Button> </Button>
} }
title="暂停全部自动化?" title="暂停全部自动化?"
description="自动收录、下载、整理会停下,已提交的下载任务不受影响。随时可以恢复。" description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
confirmLabel="暂停" confirmLabel="暂停自动化"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")} onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
/> />
)} )}
@@ -278,9 +278,9 @@ function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) { function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
return ( return (
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}> <Section title="待处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? ( {n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待你决定的事" className="py-6" /> <EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
) : ( ) : (
<> <>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums"> <p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
@@ -308,8 +308,8 @@ function CacheSection({ c }: { c: Dashboard["cache"] }) {
<EmptyState <EmptyState
plain plain
icon={HardDrive} icon={HardDrive}
title="尚未登记下载器" title="还没有下载器"
description="添加下载器实例后显示缓存卷水位。" description="添加下载器后显示缓存水位。"
className="py-6" className="py-6"
/> />
) : ( ) : (
@@ -401,13 +401,7 @@ function SourcesSection({ s }: { s: Dashboard["sources"] }) {
return ( return (
<Section title="来源健康" icon={Radio} to={consolePath("sources")}> <Section title="来源健康" icon={Radio} to={consolePath("sources")}>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState <EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
plain
icon={Radio}
title="还没有来源"
description="在站点工作台登记站点,在来源健康新建 Feed。"
className="py-6"
/>
) : ( ) : (
<ul className="divide-y divide-border/60"> <ul className="divide-y divide-border/60">
{rows.slice(0, 8).map((r) => { {rows.slice(0, 8).map((r) => {
@@ -438,7 +432,7 @@ function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
return ( return (
<Section title="最近产出" icon={ListTodo} to={consolePath("organize")}> <Section title="最近产出" icon={ListTodo} to={consolePath("organize")}>
{items.length === 0 ? ( {items.length === 0 ? (
<EmptyState plain icon={ListTodo} title="还没有入库记录" className="py-6" /> <EmptyState plain icon={ListTodo} title="还没有整理记录" className="py-6" />
) : ( ) : (
<ul className="divide-y divide-border/60"> <ul className="divide-y divide-border/60">
{items.slice(0, 6).map((r) => { {items.slice(0, 6).map((r) => {
@@ -478,7 +472,7 @@ export default function Home() {
<> <>
<PageHeader <PageHeader
title="首页" title="首页"
description="系统是否在推进目标、完成了什么、还有什么缺口、是否需要你。" description="目标进度、今日产出、缺口和待处理事项。"
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LiveIndicator /> <LiveIndicator />
@@ -499,7 +493,7 @@ export default function Home() {
))} ))}
</div> </div>
) : q.isError ? ( ) : q.isError ? (
<ErrorState title="首页数据加载失败" error={q.error} onRetry={() => q.refetch()} /> <ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
) : ( ) : (
<> <>
<AutomationBanner a={q.data.automation} /> <AutomationBanner a={q.data.automation} />
+1 -1
View File
@@ -280,7 +280,7 @@ export default function IdentityPage() {
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link to={INBOX_IDENTITY}> <Link to={INBOX_IDENTITY}>
<Inbox /> <Inbox />
待确认身份 确认身份
</Link> </Link>
</Button> </Button>
} }
+2 -2
View File
@@ -110,7 +110,7 @@ export default function SchedulesPage() {
) : board.isError ? ( ) : board.isError ? (
<ErrorState error={board.error} onRetry={() => board.refetch()} /> <ErrorState error={board.error} onRetry={() => board.refetch()} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<EmptyState icon={CalendarClock} title="还没有登记的定时任务" description="worker 启动后会自动登记。" /> <EmptyState icon={CalendarClock} title="还没有定时任务" description="worker 启动后自动登记。" />
) : ( ) : (
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"> <div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{GROUP_ORDER.map((g) => { {GROUP_ORDER.map((g) => {
@@ -123,7 +123,7 @@ export default function SchedulesPage() {
</h2> </h2>
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<p className="rounded-xl border border-dashed border-border/70 px-4 py-6 text-center text-meta text-muted-foreground"> <p className="rounded-xl border border-dashed border-border/70 px-4 py-6 text-center text-meta text-muted-foreground">
{g === "agent" ? "暂无巡检,点「新建巡检」添加。" : "暂无任务。"} {g === "agent" ? "还没有巡检。" : "还没有定时任务。"}
</p> </p>
) : ( ) : (
jobs.map((j) => ( jobs.map((j) => (
+1 -1
View File
@@ -43,7 +43,7 @@ export default function SettingsPage() {
return ( return (
<> <>
<PageHeader title="设置" description="全局参数、接入的实例与凭证。凭证只写入,保存后不回显。" /> <PageHeader title="设置" description="全局参数、实例与凭证。凭证保存后不回显。" />
<div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8"> <div className="grid gap-6 lg:grid-cols-[13.5rem_minmax(0,1fr)] lg:gap-8">
{/* 窄屏:顶部下拉 */} {/* 窄屏:顶部下拉 */}
<div className="lg:hidden"> <div className="lg:hidden">
@@ -40,7 +40,7 @@ const PROMOS = [
]; ];
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [ const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" }, { value: "resolved", label: "已识别" },
{ value: "unresolved", label: "未识别" }, { value: "unresolved", label: "待识别" },
{ value: "ambiguous", label: "有歧义" }, { value: "ambiguous", label: "有歧义" },
{ value: "unidentifiable", label: "无法识别" }, { value: "unidentifiable", label: "无法识别" },
]; ];
@@ -358,11 +358,7 @@ export default function SiteWorkbenchPage() {
</TabsContent> </TabsContent>
<TabsContent value="origin"> <TabsContent value="origin">
<EmptyState <EmptyState icon={Globe} title="原站视图暂未提供" description={<>资源详情里有站点 ID,可据此到原站查看。</>} />
icon={Globe}
title="原站视图尚未提供"
description={<>计划在 M2 提供。资源详情里有站点 ID 和来源时间,可据此到原站查看。</>}
/>
</TabsContent> </TabsContent>
<TabsContent value="snapshots"> <TabsContent value="snapshots">
+1 -1
View File
@@ -146,7 +146,7 @@ export default function SitesPage() {
<EmptyState <EmptyState
icon={Globe} icon={Globe}
title="还没有登记站点" title="还没有登记站点"
description="登记站点并填写账号凭证后,可在这里浏览和搜索资源。" description="登记站点并填写账号后,在这里浏览和搜索资源。"
action={ action={
<Button size="sm" onClick={() => setRegistering(true)}> <Button size="sm" onClick={() => setRegistering(true)}>
<Plus /> <Plus />
+1 -1
View File
@@ -84,7 +84,7 @@ export default function SourcesPage() {
<> <>
<PageHeader <PageHeader
title="来源健康" title="来源健康"
description="单个站点的快照、Feed 与账号在站点工作台。" description="单个站点的快照、Feed 和账号在站点工作台管理。"
actions={ actions={
<Button size="sm" onClick={() => setCreating(true)}> <Button size="sm" onClick={() => setCreating(true)}>
<Plus /> <Plus />
+1 -1
View File
@@ -7,7 +7,7 @@ export default function NotFound() {
<div className="grid min-h-screen place-items-center bg-background px-4"> <div className="grid min-h-screen place-items-center bg-background px-4">
<EmptyState <EmptyState
title="页面不存在" title="页面不存在"
description="检查地址是否正确,或回到首页。" description="检查地址,或回到首页。"
action={ action={
<Link to="/console" className={buttonVariants({ variant: "outline" })}> <Link to="/console" className={buttonVariants({ variant: "outline" })}>
回到首页 回到首页
+12 -1
View File
@@ -27,7 +27,16 @@
| ExternalIdentity | 外部身份(TMDB/TVDB/IMDb ID) | 外部 ID 映射、外部标识 | | ExternalIdentity | 外部身份(TMDB/TVDB/IMDb ID) | 外部 ID 映射、外部标识 |
| 未确认身份的文件/资源 | 待识别 | 未识别(IA 重构时若改名,全站一起改) | | 未确认身份的文件/资源 | 待识别 | 未识别(IA 重构时若改名,全站一起改) |
| Trash | 回收站 | 垃圾桶 | | Trash | 回收站 | 垃圾桶 |
| Agent | Agent;上游模型配置叫"Agent 上游" | 助手、智能体、AI、Pi 上游 | | Agent | Agent;上游模型配置叫"Agent 上游" | 助手、智能体、AI、Pi 上游、模型上游 |
| PiUpstream | Agent 上游;在 Agent 设置分区和上游页内简称"上游" | Pi 上游、模型上游、Provider 配置 |
| OAuth 型上游的登录 | 订阅登录 | OAuth 登录、账号授权 |
| 密钥、Cookie、密码、令牌的统称 | 凭证 | 凭据、认证信息 |
| ScheduledJob | 定时任务 | 计划任务、周期作业、cron 任务 |
| kind=agent_check 的定时任务 | 巡检 | 巡查、Agent 定时任务 |
| 定时任务每次执行的记录 | 运行记录 | 历史、执行历史 |
| AgentRun(parent_run_id 非空) | 子会话;派生它的叫"父会话" | 子代理、子 Agent、subagent |
| AgentRun 被中止 | 停止;状态"已停止" | 中止、取消 |
| AutomationGrant | 持续授权 | 自动化授权、automation_grant |
| 工作流引擎的一次执行 | 运行 | run | | 工作流引擎的一次执行 | 运行 | run |
## 文案规则 ## 文案规则
@@ -42,3 +51,5 @@
8. 数字和状态来自数据,不写产品没有的承诺。 8. 数字和状态来自数据,不写产品没有的承诺。
9. 技术词保留原文:API Key、Cookie、TMDB、infohash、hash。 9. 技术词保留原文:API Key、Cookie、TMDB、infohash、hash。
10. 标题、按钮、标签、Toast 末尾不加句号。 10. 标题、按钮、标签、Toast 末尾不加句号。
11. 订阅登录的条款提示只陈述事实:借用哪个官方客户端身份、可能违反条款、账号可能被限流或封禁;不劝阻,不美化。
12. 给模型看的文字(巡检目标、调查提示)同样适用:具体、短,用界面同一套术语。
+11 -11
View File
@@ -49,15 +49,15 @@ export const OAUTH_CALLBACK_PORTS: Record<string, number> = {
radius: 1456, radius: 1456,
}; };
const TERMS_TAIL = "用于自动化后台服务可能违反提供方的用户条款,账号有被限流或封禁的风险。"; const TERMS_TAIL = "可能违反提供方的用户条款,账号可能被限流或封禁。";
/** 条款提示(研究文档 §5):订阅登录借用官方客户端身份 */ /** 条款提示(研究文档 §5):订阅登录借用官方客户端身份 */
const TERMS_NOTES: Record<string, string> = { const TERMS_NOTES: Record<string, string> = {
anthropic: `该登录借用 Claude Code 的客户端身份使用你的 Claude 订阅(请求会伪装成 Claude Code),${TERMS_TAIL}`, anthropic: `借用 Claude Code 的客户端身份使用 Claude 订阅,请求头模拟 Claude Code。${TERMS_TAIL}`,
"openai-codex": `该登录借用 Codex CLI 的客户端身份使用你的 ChatGPT 订阅,${TERMS_TAIL}`, "openai-codex": `借用 Codex CLI 的客户端身份使用 ChatGPT 订阅。${TERMS_TAIL}`,
"github-copilot": `该登录借用 VS Code Copilot Chat 的客户端身份(请求带其伪装头)使用你的 Copilot 订阅,${TERMS_TAIL}`, "github-copilot": `借用 VS Code Copilot Chat 的客户端身份使用 Copilot 订阅,请求头模拟该客户端。${TERMS_TAIL}`,
xai: `该登录借用官方客户端身份使用你的 SuperGrok / X Premium 订阅,${TERMS_TAIL}`, xai: `借用官方客户端身份使用 SuperGrok / X Premium 订阅。${TERMS_TAIL}`,
"kimi-coding": `该登录借用 Kimi Code 官方客户端身份使用你的 Kimi 订阅,${TERMS_TAIL}`, "kimi-coding": `借用 Kimi Code 的客户端身份使用 Kimi 订阅。${TERMS_TAIL}`,
meta: `该登录借用官方客户端身份使用你的 Meta Muse 订阅,${TERMS_TAIL}`, meta: `借用官方客户端身份使用 Meta Muse 订阅。${TERMS_TAIL}`,
}; };
const env = (name: string, label: string, required: boolean, hint: string | null = null): PiEnvField => ({ const env = (name: string, label: string, required: boolean, hint: string | null = null): PiEnvField => ({
@@ -115,10 +115,10 @@ const NO_BASE_OVERRIDE = new Set([
const NOTES: Record<string, string> = { const NOTES: Record<string, string> = {
opencode: "OpenCode Zen:没有订阅登录,用 opencode.ai 控制台生成的 key(订阅体现在 key 上)", opencode: "OpenCode Zen:没有订阅登录,用 opencode.ai 控制台生成的 key(订阅体现在 key 上)",
"opencode-go": "OpenCode Go:没有订阅登录,用 opencode.ai 控制台生成的 key(与 Zen 同一个 OPENCODE_API_KEY)", "opencode-go": "OpenCode Go:没有订阅登录,用 opencode.ai 控制台生成的 key(与 Zen 同一个 OPENCODE_API_KEY)",
openrouter: "OAuth 登录换得的是永久 API Key,不需要刷新", openrouter: "登录换得永久 API Key,不需要刷新",
radius: "pi 自家网关,模型表可动态刷新", radius: "Pi 官方网关,模型列表动态刷新",
"amazon-bedrock": "API Key 填 Bedrock bearer token;或选“无”使用服务端 AWS 凭据链", "amazon-bedrock": "API Key 填 Bedrock bearer token;或选“不需要凭证”使用服务端 AWS 凭证链",
"google-vertex": "API Key 填 Google Cloud API key;或选“无”使用服务端 ADC / 服务账号", "google-vertex": "API Key 填 Google Cloud API key;或选“不需要凭证”使用服务端 ADC / 服务账号",
}; };
export function modelInfo(m: Model<Api>, source: PiModelInfo["source"] = "static"): PiModelInfo { export function modelInfo(m: Model<Api>, source: PiModelInfo["source"] = "static"): PiModelInfo {
+1 -1
View File
@@ -406,7 +406,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
legacyTabs: [{ tab: "pi" }], legacyTabs: [{ tab: "pi" }],
anchors: [ anchors: [
{ id: "upstream", label: "上游" }, { id: "upstream", label: "上游" },
{ id: "runtime", label: "上下文与子代理" }, { id: "runtime", label: "上下文与子会话" },
{ id: "budget", label: "预算" }, { id: "budget", label: "预算" },
{ id: "investigate", label: "自动调查" }, { id: "investigate", label: "自动调查" },
{ id: "advanced", label: "高级" }, { id: "advanced", label: "高级" },