diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts index 905cfb52..c9a7b374 100644 --- a/apps/server/src/modules/agent/authorize.ts +++ b/apps/server/src/modules/agent/authorize.ts @@ -3,11 +3,12 @@ import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent"; import type { CapabilitySpec } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import type { SettingsReader } from "../types.ts"; -import { inlineApproval, proposeFirstBlock } from "./interaction.ts"; +import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts"; import { exceedsAncestors } from "./subagents.ts"; // 服务端授权(REQ §10.2、§10.3):propose/approve/execute 类能力必须命中 // - 单次审批 approval:status=approved、绑定 plan 类型 + plan id + payload hash + revision、未过期(用后 consumed);或 +// - 选定即批准:管理员会话选定的方案绑定同一计划(AGUI-3,见 interaction.ts optionApproval);或 // - 持续授权 automation_grant:action 覆盖本能力、active 未过期、绑定的策略版本仍是 published、 // 对象范围(mediaEntityIds / mediaKinds)包含目标、预算(每日次数 / 字节)未用尽。 // 模型参数里的"用户已同意"不会被读取;输入里的 approvalId 也只是指针,必须在库里核对。 @@ -33,7 +34,7 @@ export const GRANT_ACTIONS: Record = { const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]); /** 单次审批绑定的计划类型与输入里的计划 ID 字段 */ -const APPROVAL_PLAN: Record = { +export const APPROVAL_PLAN: Record = { "download.submit": { planType: "acquisition_plan", field: "planId" }, "organize.execute": { planType: "organize_plan", field: "planId" }, "policy.apply": { planType: "policy_revision", field: "policyRevisionId" }, @@ -253,7 +254,13 @@ export function createAuthorizer(deps: { const covered = await coveringGrant(db, d.id, target, now()); if ("grantId" in covered) return { allowed: true, grantId: covered.grantId }; const reasons = covered.reasons; - // 3) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话 + // 3) 选定即批准(AGUI-3):管理员会话选定的方案绑定了同一份计划(类型 + ID + payload hash + revision) + if (ap && target && !approvalId && runId && (ctx.source === "agent" || ctx.source === "task")) { + const planId = inp[ap.field] as string; + const chosen = await optionApproval(db, runId, d.id, { planType: ap.planType, planId, binding: target }, now()); + if (chosen) return { allowed: true, approvalId: chosen.approvalId }; + } + // 4) 会话内审批:控制台管理员会话里,没有持续授权的计划类动作在步骤处发起审批并挂起会话 if (ap && target && !approvalId) { const planId = inp[ap.field] as string; const inline = await inlineApproval(db, d, { planType: ap.planType, planId, binding: target }, ctx, now()); diff --git a/apps/server/src/modules/agent/challenge.ts b/apps/server/src/modules/agent/challenge.ts index 595850a8..48a23081 100644 --- a/apps/server/src/modules/agent/challenge.ts +++ b/apps/server/src/modules/agent/challenge.ts @@ -292,6 +292,7 @@ async function runChild( const systemPrompt = buildSystemPrompt({ role: "网页挑战处理员", goal, + audience: "agent", constraints: [ `只能用这些工具:${VISION_CAPABILITIES.join(", ")};每个工具都要传 sessionId = ${input.sessionId}。`, "先 browser.screenshot 看页面;坐标一律以最近一张截图的像素为准,点击 / 输入 / 滚动之后会返回新截图,按新截图继续。", diff --git a/apps/server/src/modules/agent/interaction.ts b/apps/server/src/modules/agent/interaction.ts index 27724a38..05513ac3 100644 --- a/apps/server/src/modules/agent/interaction.ts +++ b/apps/server/src/modules/agent/interaction.ts @@ -7,8 +7,10 @@ import { redactString, } from "@umt/agent"; import { + type AgentOptionAction, type AgentOptionItem, type AgentOptionsTimeoutAction, + type AgentOptionView, type AgentPlanStep, type AgentProposeOptionsOutput, CAPABILITY_CATALOG, @@ -20,7 +22,7 @@ import { } from "@umt/contracts"; import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db"; import type { ServerContext } from "../types.ts"; -import type { PlanBinding } from "./authorize.ts"; +import { APPROVAL_PLAN, type PlanBinding, planBinding } from "./authorize.ts"; import { AwaitSuspendedError, type AwaitSuspension, @@ -189,15 +191,28 @@ async function closeDecision( // ── 方案选择 ── +/** 方案绑定的计划(提出方案时按计划当前内容固定;选定即批准时照它建审批) */ +export interface OptionBinding { + capabilityId: AgentOptionAction["capabilityId"]; + planType: string; + planId: string; + payloadHash: string; + revision: number; + /** false = 删除 / 覆盖类动作,选定不代表批准,执行时仍单独确认 */ + approves: boolean; +} + interface OptionsEvidence { source: "agent.propose_options"; runId: string; invocationId: string; question: string; - options: AgentOptionItem[]; + options: AgentOptionView[]; recommendedId: string | null; expiresAt: string; timeoutAction: AgentOptionsTimeoutAction; + /** 方案 id → 绑定的计划 */ + bindings?: Record; } interface OptionsResolution { optionKey?: string | null; @@ -206,6 +221,8 @@ interface OptionsResolution { note?: string | null; by?: string; timeout?: boolean; + /** 选定即批准时建的审批 */ + approvalId?: string | null; } function optionsResult( @@ -217,15 +234,181 @@ function optionsResult( const choice = res.optionKey ?? null; const option = choice ? (ev.options.find((o) => o.id === choice) ?? null) : null; const outcome = res.outcome ?? (status === "resolved" && option ? "chosen" : "rejected"); + const chosen = !!option && outcome === "chosen"; + const binding = chosen && choice ? ev.bindings?.[choice] : undefined; return { - outcome: option && outcome === "chosen" ? "chosen" : outcome === "stop" ? "stop" : "rejected", + outcome: chosen ? "chosen" : outcome === "stop" ? "stop" : "rejected", choice: option ? choice : null, - option, + option: option + ? { + ...option, + action: option.action ? { capabilityId: option.action.capabilityId, planId: option.action.planId } : null, + } + : null, reason: safe(res.reason ?? res.note ?? null, 1000) || null, by, + approvedAction: binding && res.approvalId ? { capabilityId: binding.capabilityId, planId: binding.planId } : null, }; } +// ── 选定即批准(AGUI-3) ── + +/** 动作要点:对象 + 大小 / 数量(从计划本身取,不用模型的说法) */ +async function actionDetail(db: Kysely, planType: string, planId: string): Promise { + if (planType === "acquisition_plan") { + const r = await db + .selectFrom("acquisition_plan as p") + .innerJoin("acquisition_slot as s", "s.id", "p.slot_id") + .innerJoin("media_entity as e", "e.id", "s.media_entity_id") + .select(["e.title as entity", "p.reserved_bytes"]) + .where("p.id", "=", planId) + .executeTakeFirst(); + if (!r) return null; + const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3; + return safe(`《${r.entity}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200); + } + if (planType === "organize_plan") { + const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst(); + const c = (r?.content ?? {}) as { + request?: { action?: string }; + plan?: { organize?: { files?: { action?: string }[] } }; + }; + const n = (c.plan?.organize?.files ?? []).filter((f) => f.action === "transfer" || f.action === "replace").length; + return `${n} 个文件 · ${c.request?.action === "move" ? "移动" : "复制"}`; + } + if (planType === "policy_revision") { + const r = await db.selectFrom("policy_revision").select("revision").where("id", "=", planId).executeTakeFirst(); + return r ? `策略版本 r${r.revision}` : null; + } + return null; +} + +/** + * 选定能不能当作批准:只有提交下载、只复制且不替换已有文件的整理。 + * 移动(删源文件)、替换已有文件、发布策略(覆盖当前策略)都要单独确认。 + */ +async function actionApprovable(db: Kysely, capabilityId: string, planId: string): Promise { + if (capabilityId === "download.submit") return true; + if (capabilityId === "organize.execute") { + const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst(); + const c = (r?.content ?? {}) as { + request?: { action?: string }; + plan?: { organize?: { files?: { action?: string }[] } }; + }; + return c.request?.action === "copy" && !(c.plan?.organize?.files ?? []).some((f) => f.action === "replace"); + } + return false; +} + +/** 提出方案时解析各方案的动作:计划必须存在;绑定计划当前的 payload hash + revision */ +export async function resolveOptionActions( + db: Kysely, + options: AgentOptionItem[], +): Promise<{ views: AgentOptionView[]; bindings: Record }> { + const views: AgentOptionView[] = []; + const bindings: Record = {}; + for (const o of options) { + if (!o.action) { + views.push({ ...o, action: null }); + continue; + } + const ap = APPROVAL_PLAN[o.action.capabilityId]!; + const b = await planBinding(db, ap.planType, o.action.planId); + if (!b) + throw new CapabilityError( + "invalid_input", + `方案 ${o.id} 的 action 指向的计划不存在:先生成计划,再把计划 ID 填进 action.planId`, + ); + const approves = await actionApprovable(db, o.action.capabilityId, o.action.planId); + bindings[o.id] = { + capabilityId: o.action.capabilityId, + planType: ap.planType, + planId: o.action.planId, + payloadHash: b.payloadHash, + revision: b.revision, + approves, + }; + views.push({ + ...o, + action: { + ...o.action, + label: CAPABILITY_CATALOG[o.action.capabilityId].title, + detail: await actionDetail(db, ap.planType, o.action.planId), + approves, + }, + }); + } + return { views, bindings }; +} + +/** + * 管理员会话选定绑定了计划的方案:按提出方案时固定的绑定建一份已批准的单次审批。 + * 计划在这期间变了(hash / revision 不同)就不建,执行时照常出审批卡。 + */ +async function approveChosenAction( + ctx: ServerContext, + runId: string, + decisionId: string, + optionId: string, + b: OptionBinding, + by: string, +): Promise { + if (!b.approves) return null; + const cur = await planBinding(ctx.db, b.planType, b.planId); + if (!cur || cur.payloadHash !== b.payloadHash || cur.revision !== b.revision) return null; + const s = await ctx.settings.get("agent"); + const scope = { agentRunId: runId, via: "option", decisionId, optionId, capabilityId: b.capabilityId }; + const a = await ctx.db + .insertInto("approval") + .values({ + plan_type: b.planType, + plan_id: b.planId, + payload_hash: b.payloadHash, + expected_revision: b.revision, + scope: JSON.stringify(scope), + requested_by: `agent:${runId}`, + status: "approved", + decided_by: by, + decided_at: new Date(), + expires_at: new Date(Date.now() + s.approvalTimeoutSec * 1000), + reason: "选定方案时一并批准", + }) + .returning("id") + .executeTakeFirstOrThrow(); + return a.id; +} + +/** + * 执行需审批的动作时:本会话(或祖先会话)里有随选定方案批准、绑定同一计划且未过期的审批就放行。 + * 核对能力、计划类型 + ID、payload hash、revision;不看模型参数里的任何"已同意"。 + */ +export async function optionApproval( + db: Kysely, + runId: string, + capabilityId: string, + plan: { planType: string; planId: string; binding: PlanBinding }, + now: Date, +): Promise<{ approvalId: string } | null> { + const runIds = [runId, ...(await ancestors(db, runId)).map((r) => r.id)]; + const rows = await db + .selectFrom("approval") + .selectAll() + .where("status", "=", "approved") + .where("plan_type", "=", plan.planType) + .where("plan_id", "=", plan.planId) + .where(sql`scope->>'via' = 'option'`) + .where(sql`scope->>'capabilityId' = ${capabilityId}`) + .where(sql`scope->>'agentRunId' in (${sql.join(runIds)})`) + .orderBy("created_at", "desc") + .execute(); + for (const a of rows) { + if (a.payload_hash !== plan.binding.payloadHash || a.expected_revision !== plan.binding.revision) continue; + if (a.expires_at && a.expires_at.getTime() <= now.getTime()) continue; + return { approvalId: a.id }; + } + return null; +} + /** * 用户回答方案选择(会话页或 API):选一个方案或都不要。写待处理事项后唤醒挂起的会话。 * 返回 false = 事项不属于这个会话 / 已处理。 @@ -235,15 +418,21 @@ export async function answerOptions( runId: string, input: { decisionId: string; optionId: string | null; reason?: string | undefined }, by: string, -): Promise<"ok" | "not_found" | "closed" | "bad_option"> { + /** 控制台管理员会话作答:选定绑定了计划的方案即批准这份计划(API Key 作答只选方案,不批准) */ + opts: { approver?: boolean } = {}, +): Promise<{ status: "ok"; approvalId: string | null } | { status: "not_found" | "closed" | "bad_option" }> { const d = await ctx.db .selectFrom("decision") .select(["id", "status", "evidence", "agent_run_id", "dedupe_key"]) .where("id", "=", input.decisionId) .executeTakeFirst(); - if (!d || d.agent_run_id !== runId || !d.dedupe_key.startsWith(`${OPTIONS_KEY}:`)) return "not_found"; + if (!d || d.agent_run_id !== runId || !d.dedupe_key.startsWith(`${OPTIONS_KEY}:`)) return { status: "not_found" }; const ev = d.evidence as unknown as OptionsEvidence; - if (input.optionId !== null && !ev.options?.some((o) => o.id === input.optionId)) return "bad_option"; + if (input.optionId !== null && !ev.options?.some((o) => o.id === input.optionId)) return { status: "bad_option" }; + if (!["open", "investigating", "awaiting_user"].includes(d.status)) return { status: "closed" }; + const binding = input.optionId ? ev.bindings?.[input.optionId] : undefined; + const approvalId = + binding && opts.approver ? await approveChosenAction(ctx, runId, d.id, input.optionId!, binding, by) : null; const closed = await closeDecision( ctx, d.id, @@ -253,12 +442,18 @@ export async function answerOptions( outcome: input.optionId ? "chosen" : "rejected", reason: input.reason ?? null, by, + approvalId, }, by, ); - if (!closed) return "closed"; + if (!closed) { + // 并发作答:另一方先关了事项,撤回这份没有生效的审批 + if (approvalId) + await ctx.db.updateTable("approval").set({ status: "revoked" }).where("id", "=", approvalId).execute(); + return { status: "closed" }; + } await wakeRun(ctx, runId, { decisionId: d.id }); - return "ok"; + return { status: "ok", approvalId }; } // ── 审批 ── @@ -508,6 +703,13 @@ export async function dismissPendingInteractions(ctx: ServerContext, runId: stri .where(sql`scope->>'agentRunId' = ${runId}`) .where("status", "=", "pending") .execute(); + // 选定即批准的审批还没用掉:会话停了就撤销(只绑本会话,不留给别处) + await ctx.db + .updateTable("approval") + .set({ status: "revoked", reason: "会话已停止" }) + .where(sql`scope->>'agentRunId' = ${runId} and scope->>'via' = 'option'`) + .where("status", "=", "approved") + .execute(); } /** 先看方案再执行:用户选定方案前拒绝执行类能力。返回拒绝原因或 null */ @@ -603,16 +805,6 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct const recommendedId = input.recommendedId && ids.includes(input.recommendedId) ? input.recommendedId : null; const s = await ctx.settings.get("agent"); const now = Date.now(); - const evidence: OptionsEvidence = { - source: "agent.propose_options", - runId: run.id, - invocationId: inv, - question: input.question, - options: input.options, - recommendedId, - expiresAt: new Date(now + s.optionsTimeoutSec * 1000).toISOString(), - timeoutAction: s.optionsTimeoutAction, - }; const dedupeKey = `${OPTIONS_KEY}:${run.id}:${inv}`; let d = await ctx.db .selectFrom("decision") @@ -622,6 +814,18 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct .executeTakeFirst(); if (!d) { if (inv.startsWith("reconcile:")) throw new AwaitSuspendedError(); + const { views, bindings } = await resolveOptionActions(ctx.db, input.options); + const evidence: OptionsEvidence = { + source: "agent.propose_options", + runId: run.id, + invocationId: inv, + question: input.question, + options: views, + recommendedId, + expiresAt: new Date(now + s.optionsTimeoutSec * 1000).toISOString(), + timeoutAction: s.optionsTimeoutAction, + bindings, + }; const stepId = await currentStepId(ctx, live, run.id); d = await insertDecision(ctx, { kind: "other", @@ -637,7 +841,7 @@ export function registerInteractionCapabilities(registry: CapabilityRegistry, ct invocationId: inv, decisionId: d.id, question: safe(input.question, 500), - options: input.options.map((o) => ({ + options: views.map((o) => ({ ...o, title: safe(o.title, 200), summary: safe(o.summary, 1000), diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 1e54f450..f1962864 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -220,15 +220,25 @@ export function registerAgentRoutes( runToApi(await appendMessage(ctx, params.id, body.text, actorId).catch(mapErr)), ); - route(app, Api.agentRuns.choose, async ({ params, body, actorId }) => { + route(app, Api.agentRuns.choose, async ({ params, body, actorId, c }) => { const run = await db().selectFrom("agent_run").selectAll().where("id", "=", params.id).executeTakeFirst(); if (!run) throw new ApiException("not_found", "智能体运行不存在"); if (body.optionId === null && !body.reason?.trim()) throw new ApiException("validation_failed", "都不要时写一句原因,智能体按它调整方案"); - const r = await answerOptions(ctx, run.id, body, actorId); - if (r === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择"); - if (r === "bad_option") throw new ApiException("validation_failed", "没有这个方案"); - if (r === "closed") throw new ApiException("conflict", "这项方案选择已处理"); + // 选定即批准只认控制台管理员会话(与 PATCH /approvals 同一口径);API Key 作答只选方案 + const r = await answerOptions(ctx, run.id, body, actorId, { + approver: c.get("principal")?.kind === "admin_session", + }); + if (r.status === "not_found") throw new ApiException("not_found", "这个会话没有这项方案选择"); + if (r.status === "bad_option") throw new ApiException("validation_failed", "没有这个方案"); + if (r.status === "closed") throw new ApiException("conflict", "这项方案选择已处理"); + if (r.status === "ok" && r.approvalId) + await audit(ctx, actorId, "approval.approved", r.approvalId, { + via: "option", + runId: run.id, + decisionId: body.decisionId, + optionId: body.optionId, + }); const fresh = await db().selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow(); return runToApi(fresh); }); diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index 19ee3d1a..d0ab3663 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -291,6 +291,8 @@ function systemPromptFor( role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化智能体", goal: run.goal, constraints, + // 子会话汇报给父会话汇总(可带对象 ID);其余会话按「汇报格式」写给用户 + audience: run.parent_run_id ? "agent" : "user", // 自动化优先:用户会话默认执行,先出步骤清单;有取舍时给方案;"先看方案再执行"是会话选项 ...(canAskUser(run) ? { diff --git a/apps/server/src/modules/agent/service.ts b/apps/server/src/modules/agent/service.ts index 43a5bf76..76ac9a08 100644 --- a/apps/server/src/modules/agent/service.ts +++ b/apps/server/src/modules/agent/service.ts @@ -315,18 +315,20 @@ export async function finalize( content_hash: sha(JSON.stringify({ runId, report })), }) .execute(); - await ctx.db - .updateTable("agent_run") - .set({ - status, - summary: summary.slice(0, 4000), - ...(status === "waiting" ? {} : { finished_at: new Date() }), - ...(res.status === "failed" - ? { error: JSON.stringify({ reason: res.reason, budget: res.budget, message: res.message }) } - : {}), - }) - .where("id", "=", runId) - .execute(); + if (status === "budget_exceeded") { + // 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建。 + // 先写事项再写终态:观察者看到 budget_exceeded 时,事项已指向这次运行(之前顺序相反, + // 负载下读到终态时事项还指向上一次运行,e15 偶发失败) + await writeDecision(ctx, { + kind: "other", + title: "智能体运行超出预算", + summary: res.message ?? "超出预算", + subjectId: runId, + dedupeKey: await budgetDedupeKey(ctx, run), + evidence: { runId, runIds: [runId], budget: res.budget, message: res.message, goal: run.goal }, + }); + } + // 任务行同样先于会话终态写:看到会话终态时任务页已是同一结论 if (run.task_id) { const taskStatus = status === "cancelled" @@ -349,17 +351,18 @@ export async function finalize( .where("id", "=", run.task_id) .execute(); } - if (status === "budget_exceeded") { - // 同根因(同一待处理事项 / 同一定时巡检 / 同一目标模板)超预算合并到已有事项,不重复新建 - await writeDecision(ctx, { - kind: "other", - title: "智能体运行超出预算", - summary: res.message ?? "超出预算", - subjectId: runId, - dedupeKey: await budgetDedupeKey(ctx, run), - evidence: { runId, runIds: [runId], budget: res.budget, message: res.message, goal: run.goal }, - }); - } + await ctx.db + .updateTable("agent_run") + .set({ + status, + summary: summary.slice(0, 4000), + ...(status === "waiting" ? {} : { finished_at: new Date() }), + ...(res.status === "failed" + ? { error: JSON.stringify({ reason: res.reason, budget: res.budget, message: res.message }) } + : {}), + }) + .where("id", "=", runId) + .execute(); // 后台调查:结论回写待处理事项,交还人工(仍可被新证据重新唤醒) if (run.dedupe_key?.startsWith("investigate:")) { const decisionId = run.dedupe_key.slice("investigate:".length); diff --git a/apps/server/test/e2e/e12-interaction.test.ts b/apps/server/test/e2e/e12-interaction.test.ts index 31845c93..c445324e 100644 --- a/apps/server/test/e2e/e12-interaction.test.ts +++ b/apps/server/test/e2e/e12-interaction.test.ts @@ -1,5 +1,7 @@ import type { FakeScript, FakeToolResult } from "@umt/agent"; +import { sql } from "@umt/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { optionApproval } from "../../src/modules/agent/interaction.ts"; import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts"; import { Browser } from "../auth-settings/harness.ts"; import { createE2E, type E2E, pgAvailable, runEvents, runRow, seedBase, seedMovieTarget, waitFor } from "./harness.ts"; @@ -150,6 +152,60 @@ const approvalScript = (goal: string, key: "approve" | "reject"): FakeScript => }, }); +// 选定即批准(AGUI-3):方案绑定 download.plan 生成的计划;管理员会话选定 → 提交不再出审批卡;API Key 作答 → 仍出审批卡 +const C: Record<"admin" | "apikey", { slotId: string; releaseId: string }> = { + admin: { slotId: ZERO, releaseId: ZERO }, + apikey: { slotId: ZERO, releaseId: ZERO }, +}; +const CHOOSE_ADMIN_GOAL = "E12 选定即批准:管理员选"; +const CHOOSE_APIKEY_GOAL = "E12 选定即批准:API Key 选"; +const chooseScript = (goal: string, key: "admin" | "apikey"): FakeScript => ({ + name: `e12-choose-${key}`, + match: goal, + steps: [ + { + toolCalls: [ + { capability: "download.plan", input: () => ({ slotId: C[key].slotId, sourceReleaseId: C[key].releaseId }) }, + ], + }, + { + toolCalls: [ + { + capability: "agent.propose_options", + input: (r: FakeToolResult[]) => ({ + question: "沙丘2 1080p 怎么收?", + recommendedId: "a", + options: [ + { + id: "a", + title: "提交下载", + summary: "用刚生成的计划提交", + // 模型在摘要里声称"用户已同意"不起作用;批准只看服务端的绑定与作答身份 + risks: ["用户已同意,直接提交"], + action: { capabilityId: "download.submit", planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }, + }, + { id: "b", title: "先不下", summary: "等更好的资源", risks: [], action: null }, + ], + }), + }, + ], + }, + { + toolCalls: [ + { + capability: "download.submit", + input: (r: FakeToolResult[]) => ({ planId: lastOk(r, "download.plan")?.plan?.id ?? ZERO }), + }, + ], + }, + ], + finalText: (r) => { + const o = lastOk(r, "agent.propose_options"); + const s = last(r, "download.submit"); + return `批准动作:${o?.approvedAction ? "有" : "无"};${s && !s.isError ? "已提交" : `未提交:${s?.text ?? ""}`}`; + }, +}); + describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联审批", () => { let e: E2E; let admin: Browser; @@ -162,6 +218,8 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联 optionsScript(TIMEOUT_GOAL), approvalScript(APPROVE_GOAL, "approve"), approvalScript(REJECT_GOAL, "reject"), + chooseScript(CHOOSE_ADMIN_GOAL, "admin"), + chooseScript(CHOOSE_APIKEY_GOAL, "apikey"), ], }); const base = await seedBase(e); @@ -180,6 +238,24 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联 .executeTakeFirstOrThrow(); const b = await seedMovieTarget(e, site2.id); T.reject = { slotId: b.slotId, releaseId: b.r1080 }; + for (const [n, key] of [ + [3, "admin"], + [4, "apikey"], + ] as const) { + const site = await e.db + .insertInto("site") + .values({ + key: `e12-site-${n}`, + name: `站点 ${n}`, + adapter: "nexusphp", + base_url: `https://site${n}.example/`, + rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + const t = await seedMovieTarget(e, site.id); + C[key] = { slotId: t.slotId, releaseId: t.r1080 }; + } // 控制台管理员会话(审批只接受它;它发起的会话才出会话内审批卡) const token = await ensureSetupToken(e.booted.ctx); admin = new Browser(e.app); @@ -428,4 +504,95 @@ describe.skipIf(!hasPg)("E12 Agent 交互:步骤清单 / 方案选择 / 内联 await e.db.selectFrom("capability_invocation").select("id").where("approval_id", "=", req.approvalId).execute(), ).toHaveLength(0); }, 150_000); + + it("选定即批准:管理员选定绑定计划的方案,提交不再出审批卡;审批绑定同一计划并用掉", async () => { + const res = await admin.req("POST", "/agent/runs", { goal: CHOOSE_ADMIN_GOAL }); + const runId = ((await res.json()) as Any).data.id as string; + const p = (await waitEvent(runId, "options_proposed")).payload as Any; + // 动作要点由服务端按计划生成(对象 + 大小),不是模型写的 + const a = p.options.find((o: Any) => o.id === "a"); + expect(a.action).toMatchObject({ capabilityId: "download.submit", label: "提交下载", approves: true }); + expect(a.action.detail).toContain("《沙丘2》"); + expect(p.options.find((o: Any) => o.id === "b").action ?? null).toBeNull(); + await waitStatus(runId, ["waiting"], 30_000); + const chosen = await admin.req("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" }); + expect(chosen.status).toBe(200); + + const completed = (await waitEvent(runId, "run_completed")).payload as Any; + expect(completed.finalText).toBe("批准动作:有;已提交"); + const events = await runEvents(e, runId); + expect(events.some((x) => x.type === "approval_requested")).toBe(false); + const approvals = await e.db + .selectFrom("approval") + .selectAll() + .where(sql`scope->>'agentRunId' = ${runId}`) + .execute(); + expect(approvals).toHaveLength(1); + const plan = await e.db + .selectFrom("acquisition_plan") + .select(["payload_hash", "expected_revision"]) + .where("id", "=", a.action.planId) + .executeTakeFirstOrThrow(); + expect(approvals[0]).toMatchObject({ + status: "consumed", + plan_type: "acquisition_plan", + plan_id: a.action.planId, + payload_hash: plan.payload_hash, + expected_revision: plan.expected_revision, + }); + expect(approvals[0]!.scope).toMatchObject({ via: "option", decisionId: p.decisionId, optionId: "a" }); + expect(approvals[0]!.decided_by).toMatch(/^user:/); + const inv = await e.db + .selectFrom("capability_invocation") + .selectAll() + .where("approval_id", "=", approvals[0]!.id) + .executeTakeFirstOrThrow(); + expect(inv).toMatchObject({ capability_id: "download.submit", status: "succeeded" }); + const audited = await e.db + .selectFrom("audit_event") + .select("action") + .where("target_id", "=", approvals[0]!.id) + .executeTakeFirst(); + expect(audited?.action).toBe("approval.approved"); + + // 绑定之外:同一会话里换一份计划不能沿用这份批准 + const other = await optionApproval( + e.db, + runId, + "download.submit", + { + planType: "acquisition_plan", + planId: a.action.planId, + binding: { + payloadHash: "changed", + revision: plan.expected_revision, + mediaEntityId: null, + mediaKind: null, + bytes: null, + }, + }, + new Date(), + ); + expect(other).toBeNull(); + }, 150_000); + + it("选定即批准:API Key 作答只选方案,提交时照常出审批卡", async () => { + const res = await admin.req("POST", "/agent/runs", { goal: CHOOSE_APIKEY_GOAL }); + const runId = ((await res.json()) as Any).data.id as string; + const p = (await waitEvent(runId, "options_proposed")).payload as Any; + await waitStatus(runId, ["waiting"], 30_000); + const chosen = await e.api("POST", `/agent/runs/${runId}/choice`, { decisionId: p.decisionId, optionId: "a" }); + expect(chosen.status).toBe(200); + const req = (await waitEvent(runId, "approval_requested")).payload as Any; + expect(req).toMatchObject({ capabilityId: "download.submit", planId: p.options[0].action.planId }); + await waitFor( + async () => { + const r = await runRow(e, runId); + return r.status === "waiting" && r.summary === "等待审批" ? r : null; + }, + 30_000, + "挂起等待审批", + ); + await e.api("POST", `/agent/runs/${runId}/abort`, { reason: "测试结束" }); + }, 150_000); }); diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index ed991cea..e70d8b89 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -20,7 +20,7 @@ import { agentRunEventFromRow, cursorPage, } from "@umt/contracts"; -import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts"; +import { MOCK_RUNS, mockChoose, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts"; import { TTL_SECOND } from "~/lib/api.ts"; import { useSession } from "./auth.ts"; import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; @@ -254,7 +254,7 @@ export function useChooseOption() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: { runId: string; decisionId: string; optionId: string | null; reason?: string }) => { - if (agentMockEnabled()) return mockDetail(input.runId).run; + if (agentMockEnabled()) return mockChoose(input.runId, input.optionId).run; return apiMutate(`${AGENT_BASE}/:id/choice`, { pathParams: { id: input.runId }, body: { decisionId: input.decisionId, optionId: input.optionId, reason: input.reason }, @@ -264,6 +264,40 @@ export function useChooseOption() { }); } +/** 需要按计划单次审批的能力:这类动作在会话里走审批卡,不另建持续授权 */ +const PLAN_APPROVAL_CAPS = new Set(["download.submit", "organize.execute", "policy.apply"]); + +/** + * 「批准并重试」(AGUI-3):被拒绝的调用由管理员批准一次后让智能体重试。 + * 非计划类能力建一条只覆盖这个能力、今天只能成功一次、1 小时后过期的持续授权(服务端核对,不靠模型转述); + * 计划类能力只追加消息让智能体重试,重试时在步骤处照常出审批卡。 + */ +export function useApproveAndRetry() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input: { runId: string; capabilityId: string; title: string }) => { + const text = PLAN_APPROVAL_CAPS.has(input.capabilityId) + ? `重试「${input.title}」,需要审批时在步骤处等我批准。` + : `已批准「${input.title}」一次,重试这一步。`; + if (agentMockEnabled()) return mockDetail(input.runId, { append: text }).run; + if (!PLAN_APPROVAL_CAPS.has(input.capabilityId)) + await apiMutate("/automation-grants", { + body: { + action: input.capabilityId, + scope: { capabilities: [input.capabilityId] }, + budget: { maxInvocationsPerDay: 1 }, + reason: `智能体会话 ${input.runId.slice(0, 8)} 里批准并重试`, + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + }, + }); + return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id: input.runId }, body: { text } }).then((d) => + AgentRun.parse(d), + ); + }, + onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.runId) }), + }); +} + export const RUN_STATUS_LABEL: Record = { queued: "排队中", running: "运行中", diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 552cf1e3..bed5752e 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -4,55 +4,41 @@ import { CAPABILITY_CATALOG, type CapabilityDescriptor, type KnownCapabilityId, - type RiskLevel, } from "@umt/contracts"; import { - Bot, + Ban, Brain, + Check, ChevronDown, + ChevronRight, + CircleAlert, CircleSlash, - Download, - FolderInput, Layers, LoaderCircle, - ShieldCheck, - User, + RotateCcw, } from "lucide-react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; +import { Link } from "react-router"; import { toast } from "sonner"; import { type AgentRunView, + agentMockEnabled, type GoalReport, type InvocationAudit, isProposeFirst, isRunActive, - REPORT_STATE, RUN_STATUS_LABEL, RUN_STATUS_TONE, + useApproveAndRetry, useDecideApproval, useIsAdminSession, } from "~/api/agent.ts"; -import { DOWNLOAD_STATUS_LABEL, PLAN_STATUS_LABEL } from "~/api/downloads.ts"; -import { RECORD_STATUS_LABEL } from "~/api/records.ts"; import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx"; -import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx"; import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; -import { FileDiff } from "~/components/agentui/file-diff.tsx"; import { Markdown } from "~/components/agentui/markdown.tsx"; -import { - Message, - MessageAvatar, - MessageContent, - MessageFooter, - MessageHeader, - MessageMarker, - MessageTyping, -} from "~/components/agentui/message.tsx"; -import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx"; -import { StreamingCursor, StreamingResponse } from "~/components/agentui/streaming-response.tsx"; -import { ToolApproval } from "~/components/agentui/tool-approval.tsx"; -import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx"; -import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { MessageMarker, MessageTyping } from "~/components/agentui/message.tsx"; +import { StreamingResponse } from "~/components/agentui/streaming-response.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { formatUsd } from "~/components/schedules/labels.ts"; import { Button } from "~/components/ui/button.tsx"; @@ -60,11 +46,28 @@ import { errorMessage } from "~/lib/api.ts"; import { humanizeReason } from "~/lib/reasons.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { InlineApproval, OptionsCard } from "./agent-interaction.tsx"; +import { splitContextGoal } from "./agent-store.ts"; +import { + type ActivityItem, + activitySpan, + activitySummary, + authDenial, + buildConversation, + capabilityTitle, + deniedReason, + durationLabel, + isDenied, + runningText, + toolSummary, +} from "./conversation-model.ts"; import { ToolCallIo } from "./tool-call-card-io.tsx"; import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; -// Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/, -// 改造自 github.com/ashish200729/agentui,MIT)。服务端事件形状见 packages/contracts/src/agent-events.ts。 +// 智能体会话的展示层(AGUI-3):用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。 +// 排版规则在 conversation-model.ts(纯函数);组件改造自 agentui(components/agentui/,MIT)。 +// 服务端事件形状见 packages/contracts/src/agent-events.ts;旧会话的事件照样按这套规则渲染。 + +export { authDenial, isDenied, toolSummary }; export const BUDGET_LABEL: Record = { tool_calls: "工具调用次数", @@ -72,335 +75,328 @@ export const BUDGET_LABEL: Record = { tokens: "token", cost: "费用", }; -const COMPACT_REASON: Record = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" }; const COMPACT_STATUS: Record = { - completed: "已完成,业务进度保留", - declined: "未压缩", + completed: "已完成", + declined: "没有压缩", aborted: "已中止", failed: "失败", }; -// ── 能力元数据(scope / 风险 / 类别来自 contracts 的 CAPABILITY_CATALOG) ── - export function capabilityMeta(id: string): CapabilityDescriptor | null { return (CAPABILITY_CATALOG as Record)[id as KnownCapabilityId] ?? null; } -const RISK: Record = { - low: { label: "低风险", tone: "neutral" }, - medium: { label: "中风险", tone: "warning" }, - high: { label: "高风险", tone: "destructive" }, -}; -const KIND_LABEL: Record = { - read: "只读", - preview: "预览", - propose: "提案", - approve: "审批", - execute: "执行", -}; - -export function RiskBadge({ level }: { level: RiskLevel }) { - const r = RISK[level]; - return {r.label}; -} - -function CapabilityBadges({ id, extra }: { id: string; extra?: ReactNode }) { - const m = capabilityMeta(id); - return ( - <> - {/* 只标需要注意的:会改动数据的动作和中高风险;只读、scope、幂等进技术信息 */} - {m ? ( - <> - {m.kind !== "read" ? {KIND_LABEL[m.kind] ?? m.kind} : null} - {m.riskLevel !== "low" ? : null} - - ) : null} - {extra} - - ); -} - function durationText(a: string, b: string | null) { if (!b) return null; const ms = new Date(b).getTime() - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; } -const TOOL_STATUS: Record, ToolResultStatus> = { - running: "running", - succeeded: "success", - failed: "error", -}; +// ── 用户消息 ── -/** - * 授权校验拒绝:能力处理器前的授权回调失败时,事件是 tool_call_end(isError)+ 错误码 unauthorized/forbidden, - * 不是 tool_call_rejected(那个只在白名单/预算拦截时发)。两者在界面上都按"服务端拒绝"醒目展示。 - */ -export function authDenial(call: ToolCallView): string | null { - if (call.status !== "failed" || !call.outputSummary) return null; - try { - const e = (JSON.parse(call.outputSummary) as { error?: { code?: string; message?: string } }).error; - if (e && (e.code === "unauthorized" || e.code === "forbidden")) return e.message ?? e.code; - } catch { - const m = /"code":"(unauthorized|forbidden)"(?:,"message":"([^"]*)")?/.exec(call.outputSummary); - if (m) return m[2] ?? m[1] ?? "未授权"; - } - return null; +/** 上下文标签:只显示对象名称(「沙丘:第二部」),ID 留在目标前缀里给模型看 */ +export function contextLabel(context: string | null): string | null { + if (!context) return null; + const first = context.split(" · ")[0] ?? ""; + // 前缀里第一段是界面给的名称(「作品:沙丘2」);没有名称时只剩 key=ID,不上屏 + if (!first || first.includes("=")) return null; + return first.replace(/^(作品|资源|下载|整理记录|站点|任务|引擎任务)[::]\s*/, ""); } -/** 被服务端拒绝(白名单拦截或授权校验失败) */ -export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null; - -/** - * 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段; - * 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。 - */ -export function toolSummary(call: Pick): string | null { - const raw = call.outputSummary?.trim(); - if (!raw) return call.status === "running" ? "等待结果…" : null; - const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t); - let v: unknown; - try { - v = JSON.parse(raw); - } catch { - return clip(raw.replace(/\s+/g, " ")); - } - if (Array.isArray(v)) return `${v.length} 条`; - if (v && typeof v === "object") { - const o = v as Record; - const err = o.error as { message?: unknown; code?: unknown } | undefined; - if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败")); - for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string); - for (const k of ["items", "results", "releases", "candidates"]) - if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`; - const simple = Object.entries(o) - .filter(([, x]) => ["string", "number", "boolean"].includes(typeof x)) - .slice(0, 3) - .map(([k, x]) => `${k}: ${String(x)}`); - if (simple.length) return clip(simple.join(" · ")); - return `${Object.keys(o).length} 个字段`; - } - return clip(String(v)); -} - -/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */ -export function ToolCallCard({ - call, - audit, +export function UserMessage({ + text, + note, + tag, + animateIn, }: { - call: ToolCallView; - /** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */ - audit?: InvocationAudit; + text: string; + note?: string; + /** 单次指令标记(如「先给方案」) */ + tag?: string; + animateIn?: boolean; }) { - const meta = capabilityMeta(call.capabilityId); - const authReason = authDenial(call); - if (call.status === "denied" || authReason) { - return ( - {authReason ? "需要你批准" : "本次没有开放"}} - /> - } - reason={authReason ?? call.reason ?? "未授权"} - explanation={ - authReason - ? "会改动数据的操作要你批准:在对应步骤的审批卡上批准,或在设置 → 智能体里给它持续授权。" - : "这次会话没有开放这个操作,或已用完调用次数。" - } - parameters={[ - { id: "cap", label: "能力", value: call.capabilityId }, - { id: "call", label: "调用 ID", value: call.toolCallId }, - ...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []), - { id: "at", label: "时间", value: formatDateTime(call.startedAt) }, - ]} - /> - ); - } - const status = TOOL_STATUS[call.status as Exclude]; + const { context, text: body } = splitContextGoal(text); + const label = contextLabel(context); return ( - 恢复补记 : null} - /> - } - summary={toolSummary(call) ?? undefined} +
- {call.inputSummary ? {call.inputSummary} : null} - {call.outputSummary ? ( - - {call.outputSummary} - - ) : call.status === "running" ? ( -

等待结果…

+ {label || tag ? ( +
+ {tag ? ( + + {tag} + + ) : null} + {label ? ( + + {label} + + ) : null} +
) : null} - -
-
能力
-
- {call.capabilityId} - {meta - ? ` · ${KIND_LABEL[meta.kind] ?? meta.kind} · scope ${meta.scope}${meta.idempotent ? " · 幂等" : ""}` - : ""} -
- {durationText(call.startedAt, call.endedAt) ? ( - <> -
耗时
-
{durationText(call.startedAt, call.endedAt)}
- - ) : null} -
调用 ID
-
{call.toolCallId}
-
幂等键
-
- {audit?.idempotencyKey ?? - (meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读操作不记录")} -
- {audit?.grantId || audit?.approvalId ? ( - <> -
授权依据
-
- {audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`} -
- - ) : null} -
- -
- +
+ {body} +
+ {note ?

{note}

: null} +
); } -/** 思考块:thinking_delta 累积,默认收起;流式中显示"思考中"与光标 */ -function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }) { +// ── 进度行 ── + +function ThinkingRow({ text }: { text: string }) { const id = useId(); const [open, setOpen] = useState(false); return ( -
+
  • -

    +

    {text} - {streaming ? : null}

    +
  • + ); +} + +function StepIcon({ call }: { call: ToolCallView }) { + if (call.status === "running") + return ; + if (isDenied(call)) return ; + if (call.status === "failed") return ; + return ; +} + +/** 展开后的一步:「搜资源 → 3 条」;点开看技术信息(能力 ID、调用 ID、幂等键、输入输出) */ +function ToolRow({ call, audit }: { call: ToolCallView; audit?: InvocationAudit }) { + const id = useId(); + const [open, setOpen] = useState(false); + const meta = capabilityMeta(call.capabilityId); + const denied = isDenied(call); + const summary = denied ? deniedReason(call).text : toolSummary(call); + return ( +
  • + + +
    + + + +
    +
    +
  • + ); +} + +/** + * 一段进度的一行摘要:「查了 1 个站点和媒体库 · 1 项没做 · 41 秒 ›」;运行中写「正在检测站点…」。 + * 展开后每步一行;思考与工具之间的话也在里面。 + */ +export function StepSummary({ + activity, + running, + auditByCall, +}: { + activity: ActivityItem[]; + running: boolean; + auditByCall?: Map; +}) { + const id = useId(); + const [open, setOpen] = useState(false); + const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : [])); + const current = running ? calls.find((c) => c.status === "running") : undefined; + const done = activitySummary(calls.filter((c) => c.status !== "running")); + const denied = calls.filter(isDenied).length; + const failed = calls.filter((c) => c.status === "failed" && !isDenied(c)).length; + const span = activitySpan(activity); + const thinking = activity.some((a) => a.kind === "thinking"); + const head = current + ? `${done ? `${done},` : ""}${runningText(current)}…` + : done || (running ? "思考中…" : thinking ? "想了一下" : "过程说明"); + return ( +
    + + +
      + {activity.map((a) => + a.kind === "tool" ? ( + + ) : a.kind === "thinking" ? ( + + ) : ( +
    • + {a.text.trim()} +
    • + ), + )} +
    +
    ); } -function AssistantRow({ - children, - animateIn, - avatar = true, -}: { - children: ReactNode; - animateIn?: boolean; - avatar?: boolean; -}) { +// ── 没做的事 ── + +/** 回合末尾一行:「没做:检测站点,需要你批准 [批准并重试]」;同一能力只写一次 */ +export function DeniedLines({ runId, calls }: { runId: string; calls: ToolCallView[] }) { + const admin = useIsAdminSession() || agentMockEnabled(); + const retry = useApproveAndRetry(); + const [sent, setSent] = useState>(new Set()); + const rows = [...new Map(calls.map((c) => [c.capabilityId, c] as const)).values()]; + if (!rows.length) return null; return ( - - - - - {children} - +
      + {rows.map((c) => { + const title = capabilityTitle(c.capabilityId); + const r = deniedReason(c); + const done = sent.has(c.capabilityId); + return ( +
    • + + + 没做:{title},{r.text} + + {r.approvable && admin ? ( + + ) : null} +
    • + ); + })} +
    ); } +// ── 结束 / 压缩提示 ── + function EndNotice({ item }: { item: Extract }) { const budget = item.reason === "budget"; const aborted = item.reason === "aborted"; return ( - - - - - - - -

    - {budget - ? `超出预算${item.budget ? `(${BUDGET_LABEL[item.budget] ?? item.budget})` : ""},已停止` - : aborted - ? "已停止" - : "运行失败"} -

    - {item.message ?

    {item.message}

    : null} - {budget ? ( -

    - 已完成的外部动作不会撤销。调大预算后追加消息可继续运行。 -

    - ) : null} -
    -
    -
    -
    +

    + + + {budget + ? `超出预算(${BUDGET_LABEL[item.budget ?? ""] ?? "用量"}),已停止。已完成的动作不会撤销;调大预算后发消息可以继续。` + : aborted + ? "已停止。" + : `运行失败${item.message ? `:${humanizeReason(item.message) ?? item.message}` : ""}`} + +

    ); } -export function UserMessage({ - text, - at, - tag, - note, - animateIn, -}: { - text: string; - at?: string; - /** 会话选项标记(如"先看方案再执行") */ - tag?: string; - note?: string; - animateIn?: boolean; -}) { - return ( - - - - - - {at || tag ? ( - - {tag ? {tag} : null} - {at ? : null} - - ) : null} - - {text} - - {note ? {note} : null} - - - ); -} +// ── 审计配对与计划 ID ── const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const PLAN_IN_INPUT = new RegExp(`"planId"\\s*:\\s*"(${UUID})"`); const PLAN_IN_OUTPUT = new RegExp(`"plan"\\s*:\\s*\\{\\s*"id"\\s*:\\s*"(${UUID})"`); -/** 本次运行触达的计划 ID:服务端汇报里的计划 + 下载计划/提交/整理执行的输入输出里出现的计划(摘要可能截断,只取开头能匹配到的) */ +/** 本次运行触达的计划 ID:服务端汇报里的计划 + 下载计划/提交/整理执行的输入输出里出现的计划 */ export function runPlanIds(items: StreamItem[], report: GoalReport | null): string[] { const ids = new Set(report?.plans.map((p) => p.planId) ?? []); for (const it of items) { @@ -434,112 +430,9 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde return out; } -// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头; -// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ── +// ── 会话流 ── -export type ConvEntry = { - key: string; - at: string; - role: "user" | "assistant" | "system"; - /** 可并入"活动"折叠(已结束的思考 / 工具调用) */ - foldable: boolean; - /** 折叠摘要里的名字(能力名 / "思考") */ - label?: string; - failed?: boolean; - node: ReactNode; -}; -export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] }; -export type ConvBlock = - | { kind: "single"; entry: ConvEntry } - | { kind: "turn"; key: string; at: string; parts: ConvPart[] }; - -function foldRuns(parts: ConvPart[], min: number): ConvPart[] { - const out: ConvPart[] = []; - let run: ConvEntry[] = []; - const flush = () => { - if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run }); - else for (const e of run) out.push({ kind: "item", entry: e }); - run = []; - }; - for (const p of parts) { - if (p.kind === "item" && p.entry.foldable) run.push(p.entry); - else { - flush(); - out.push(p); - } - } - flush(); - return out; -} - -/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */ -export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] { - const out: ConvBlock[] = []; - let turn: Extract | null = null; - for (const e of entries) { - if (e.role !== "assistant") { - turn = null; - out.push({ kind: "single", entry: e }); - continue; - } - if (!turn) { - turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] }; - out.push(turn); - } - turn.parts.push({ kind: "item", entry: e }); - } - for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold); - return out; -} - -function clockText(iso: string) { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - const sameDay = new Date().toDateString() === d.toDateString(); - return sameDay - ? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) - : formatDateTime(iso); -} - -/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */ -function ActivityFold({ entries }: { entries: ConvEntry[] }) { - const id = useId(); - const [open, setOpen] = useState(false); - const tools = entries.filter((e) => e.label !== "思考").length; - const thoughts = entries.length - tools; - const failed = entries.filter((e) => e.failed).length; - const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))]; - return ( -
    - - -
    - {entries.map((e) => ( -
    {e.node}
    - ))} -
    -
    -
    - ); -} - -/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */ +/** 会话流:用户目标 → 回合(进度行 / 正文 / 方案 / 审批 / 没做的事)→ 追加的用户消息按时间插入 */ export function AgentConversation({ run, live, @@ -551,321 +444,244 @@ export function AgentConversation({ live: AgentStreamState; userMessages?: { at: string; text: string; pending?: boolean }[]; audit?: InvocationAudit[]; - /** 流末尾追加的内容(审批卡片、最终汇报) */ + /** 流末尾追加的内容(其它审批卡、实际结果行) */ after?: ReactNode; }) { const active = isRunActive(run.status) && !live.finished; const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued"; - const stepTitle = (id: string | null) => (id ? (live.plan?.steps.find((s) => s.id === id)?.title ?? null) : null); // 首次载入(回放)的条目不播入场动画,之后新到的才播 const baseline = useRef(null); if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length; - const fresh = (i: number) => baseline.current !== null && i >= baseline.current; - const calls = live.items - .filter((i): i is Extract => i.kind === "tool") - .map((i) => i.call); + const freshKeys = new Set( + baseline.current === null ? [] : live.items.slice(baseline.current).map((i) => i.key as string), + ); + const calls = live.items.flatMap((i) => (i.kind === "tool" ? [i.call] : [])); const auditByCall = pairAudit(calls, audit); - const lastIndex = live.items.length - 1; - const lastText = live.items.map((i) => i.kind).lastIndexOf("text"); + const lastKey = live.items.at(-1)?.key; + const tail = live.items.at(-1); + const blocks = buildConversation(live.items, userMessages); + const anim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none"; - const entries: ConvEntry[] = []; - live.items.forEach((it, idx) => { - const streaming = active && idx === lastIndex; - const anim = fresh(idx); - const wrap = (node: ReactNode) => - anim ? ( -
    - {node} -
    - ) : ( - node - ); - switch (it.kind) { - case "text": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: false, - node: wrap( - - {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} - {streaming ? it.text : } - , - ), - }); - break; - case "thinking": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: !streaming, - label: "思考", - node: wrap(), - }); - break; - case "tool": { - const denied = isDenied(it.call); - entries.push({ - key: it.key, - at: it.call.startedAt, - role: "assistant", - foldable: !denied && it.call.status !== "running" && idx !== lastIndex, - label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId, - failed: it.call.status === "failed", - node: wrap(), - }); - break; - } - case "compaction": - entries.push({ - key: it.key, - at: it.at, - role: "system", - foldable: false, - node: ( - - - 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} - {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} - - ), - }); - break; - case "options": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: false, - node: wrap(), - }); - break; - case "approval": - entries.push({ - key: it.key, - at: it.at, - role: "assistant", - foldable: false, - node: wrap(), - }); - break; - case "end": - if (!it.ok) - entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: }); - break; - } - }); - for (const m of userMessages) - entries.push({ - key: `u${m.at}`, - at: m.at, - role: "user", - foldable: false, - node: , - }); - entries.sort((a, b) => a.at.localeCompare(b.at)); - const blocks = groupConversation(entries); - const tail = live.items[lastIndex]?.kind; - const typing = - active && tail !== "text" && tail !== "thinking" ? ( - - - {run.status === "waiting" - ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` - : run.status === "queued" - ? "排队中…" - : "正在处理…"} - - ) : null; - const lastBlock = blocks[blocks.length - 1]; - const typingInTurn = typing && lastBlock?.kind === "turn"; + const lastBlock = blocks.at(-1); + const toolRunning = tail?.kind === "tool" && tail.call.status === "running"; + // 等选方案 / 等审批时卡片本身就是提示,不再显示"等待中…" + const lastPart = lastBlock?.kind === "turn" ? lastBlock.parts.at(-1) : undefined; + const cardWaiting = run.status === "waiting" && (lastPart?.kind === "options" || lastPart?.kind === "approval"); + const showTyping = active && tail?.kind !== "text" && tail?.kind !== "thinking" && !toolRunning && !cardWaiting; + const typing = showTyping ? ( + + + {run.status === "waiting" + ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` + : run.status === "queued" + ? "排队中…" + : "正在处理…"} + + ) : null; return ( -
    - +
    + {blocks.map((b) => - b.kind === "single" ? ( -
    {b.entry.node}
    + b.kind === "user" ? ( + ) : ( - - - - - - - 智能体 - - - {b.parts.map((p) => - p.kind === "fold" ? ( - - ) : ( -
    - {p.entry.node} -
    - ), - )} - {typingInTurn && b === lastBlock ? typing : null} -
    -
    +
    + {b.parts.map((p) => { + const fresh = freshKeys.has(p.key.slice(1)) || freshKeys.has(p.key); + switch (p.kind) { + case "segment": { + const streamingText = active && p.body.at(-1)?.key === lastKey; + const running = active && p.activity.some((a) => a.kind === "tool" && a.call.status === "running"); + const thinkingNow = active && p.activity.at(-1)?.key === lastKey && tail?.kind === "thinking"; + return ( +
    + {p.activity.length ? ( + + ) : null} + {p.body.map((t) => { + const streaming = streamingText && t.key === lastKey; + return ( +
    + + {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} + {streaming ? t.text : } + +
    + ); + })} +
    + ); + } + case "options": + return ( +
    + +
    + ); + case "approval": + return ( +
    + +
    + ); + case "compaction": + return ( + + + 整理上下文 ·{" "} + {p.item.done ? (COMPACT_STATUS[p.item.status ?? "completed"] ?? p.item.status) : "进行中…"} + + ); + case "end": + return p.item.ok ? null : ; + default: + return null; + } + })} + {b.denied.length ? : null} + {typing && b === lastBlock ? typing : null} +
    ), )} - {typing && !typingInTurn ? {typing} : null} + {typing && lastBlock?.kind !== "turn" ?
    {typing}
    : null} {after}
    ); } -// ── 预算用量条 ── +// ── 实际结果 ── -function Meter({ - label, - used, - max, - format, - exceeded, -}: { - label: string; - used: number | undefined; - max?: number; - format: (n: number) => string; - exceeded?: boolean; -}) { - const pct = max && used !== undefined ? Math.min(100, (used / max) * 100) : null; - const over = exceeded || (pct !== null && pct >= 100); - const near = !over && pct !== null && pct >= 80; - return ( -
    -
    - - {label} - {over ? " · 已超限" : near ? " · 接近上限" : ""} - - - {used === undefined ? "未报告" : format(used)} - {max ? / {format(max)} : null} - -
    -
    - {pct !== null ? ( -
    - ) : null} -
    -
    - ); +/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */ +export function resultText(report: GoalReport | null): string { + if (!report) return "汇总中…"; + const r = report; + switch (r.state) { + case "no_actions": + return "没有提交下载或整理"; + case "waiting": + return r.imported + ? `已入库 ${r.imported} 个,${r.inProgress} 个还在进行` + : `${r.submitted || r.inProgress} 个下载已提交,还没入库`; + case "completed": + return `已入库 ${r.imported} 个`; + case "partial": + return [ + r.imported ? `已入库 ${r.imported} 个` : "", + r.inProgress ? `${r.inProgress} 个还在进行` : "", + r.failed ? `${r.failed} 个失败` : "", + ] + .filter(Boolean) + .join(","); + case "failed": + return r.failed ? `${r.failed} 个没有成功` : "没有达成目标"; + } } -/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */ -export function BudgetBar({ +/** + * 回答下面一行实际结果:「没有提交下载或整理 · 41 秒 · 约 $0.01 · 技术信息 ›」。 + * 有下载任务 / 整理记录时给链接;模型、token、会话 ID 进技术信息。 + */ +export function ResultLine({ run, + report, live, - stacked = false, }: { run: AgentRunView; + report: GoalReport | null; live: AgentStreamState; - /** 侧栏里单列排 */ - stacked?: boolean; }) { const u = { ...run.usage, ...(live.usage ?? {}) }; - const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); - const end = [...live.items].reverse().find((i) => i.kind === "end"); - const exceeded = end?.kind === "end" && end.reason === "budget" ? end.budget : null; const durationMs = u.durationMs ?? - (run.startedAt - ? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime() - : undefined); + (run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null); + const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); + const links = report + ? [ + ...report.downloads.map((d) => ({ + id: d.downloadTaskId, + label: d.name ?? "下载任务", + to: `/console/downloads/${d.downloadTaskId}`, + })), + ...report.organization.map((o) => ({ + id: o.recordId, + label: o.targetPath?.split("/").pop() || "整理记录", + to: `/console/organize/${o.recordId}`, + })), + ] + : []; + const bad = report?.state === "failed" || report?.state === "partial"; + const err = run.error ? humanizeReason(String((run.error as { message?: unknown }).message ?? "")) : null; return ( -
    -
    - formatUsd(n)} - exceeded={exceeded === "cost"} - /> - `${n} 秒`} - exceeded={exceeded === "duration"} - /> -
    - {/* 调用次数与 token 超限时直接显示,否则收进技术信息 */} - {exceeded === "tool_calls" || exceeded === "tokens" ? ( -
    - String(n)} - exceeded={exceeded === "tool_calls"} - /> - (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))} - exceeded={exceeded === "tokens"} - /> -
    - ) : ( - - )} -
    +
    +

    + {report ? null : } + 实际结果:{resultText(report)} + {durationMs ? · {durationLabel(durationMs)} : null} + {u.costUsd ? · 约 {formatUsd(u.costUsd)} : null} +

    + {links.length ? ( +
      + {links.slice(0, 4).map((l) => ( +
    • + + {l.label} + +
    • + ))} + {links.length > 4 ?
    • 等 {links.length} 项
    • : null} +
    + ) : null} + {err ?

    {err}

    : null} + +
    ); } -// ── 审批卡片 ── +// ── 本次运行计划上的其它审批(不在会话流里的) ── const PLAN_CAPABILITY: Record = { acquisition_plan: "download.submit", organize_plan: "organize.execute", policy_revision: "policy.apply", }; -const PLAN_LABEL: Record = { - acquisition_plan: "下载计划", - organize_plan: "整理计划", - policy_revision: "策略版本", -}; export function ApprovalItem({ approval: a }: { approval: Approval }) { const admin = useIsAdminSession(); const decide = useDecideApproval(); const cap = PLAN_CAPABILITY[a.planType]; - const risk = cap ? capabilityMeta(cap)?.riskLevel : undefined; const expired = a.status === "pending" && a.expiresAt !== null && new Date(a.expiresAt).getTime() < Date.now(); const status: ApprovalCardStatus = decide.isPending ? "submitting" : expired ? "expired" : a.status; const act = (s: "approved" | "rejected") => @@ -879,14 +695,8 @@ export function ApprovalItem({ approval: a }: { approval: Approval }) { return ( - {risk ? : null} - {cap ? 执行能力 · {cap} : null} - - } + title={`批准「${cap ? capabilityTitle(cap) : a.planType}」?`} + description={a.reason ?? undefined} onApprove={() => act("approved")} onReject={() => act("rejected")} disabledReason={admin ? undefined : "只有管理员能批准或拒绝"} @@ -896,219 +706,18 @@ export function ApprovalItem({ approval: a }: { approval: Approval }) { : undefined } > -
    -
    计划
    -
    - {a.planType} · {a.planId} -
    -
    payload hash
    -
    {a.payloadHash}
    -
    版本
    -
    r{a.expectedRevision}
    -
    到期
    -
    - {a.expiresAt ? formatDateTime(a.expiresAt) : "不过期"} - {expired ? "(已过期)" : ""} -
    -
    +
    ); } -// ── 最终汇报:模型说的 vs 系统核实的 ── - -const PLAN_STATUS_TONE = (s: string): StatusTone => - ["succeeded", "completed", "submitted", "approved"].includes(s) - ? "success" - : ["failed", "rejected", "error", "cancelled"].includes(s) - ? "destructive" - : "warning"; - -/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */ -export function isLongReport(text: string): boolean { - return text.length > 600 || text.split("\n").length > 12; -} - -/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */ -function LongText({ long, children }: { long: boolean; children: ReactNode }) { - const id = useId(); - const [open, setOpen] = useState(false); - if (!long) return <>{children}; - return ( -
    -
    - {children} -
    - -
    - ); -} - -/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ -export function FinalReport({ - run, - report, - live, -}: { - run: AgentRunView; - report: GoalReport | null; - live: AgentStreamState; -}) { - const lastTool = live.items.map((i) => i.kind).lastIndexOf("tool"); - const tail = live.items - .slice(lastTool + 1) - .filter((i): i is Extract => i.kind === "text") - .map((i) => i.text) - .join(""); - const said = report?.modelText ?? (tail || run.summary || ""); - const calls = live.items - .filter((i): i is Extract => i.kind === "tool") - .map((i) => i.call); - const denied = calls.filter(isDenied).length; - const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s && !isDenied(c)).length; - const state = report ? REPORT_STATE[report.state] : null; - - const sources: CitationItem[] = report - ? [ - ...report.downloads.map((d) => ({ - id: `d${d.downloadTaskId}`, - icon: , - title: d.name ?? "下载任务", - domain: "下载任务", - meta: ( - - {DOWNLOAD_STATUS_LABEL[d.status as keyof typeof DOWNLOAD_STATUS_LABEL] ?? d.status} - - ), - to: `/console/downloads/${d.downloadTaskId}`, - })), - ...report.organization.map((o) => ({ - id: `o${o.recordId}`, - icon: , - title: o.targetPath ?? "整理记录", - domain: "整理记录", - meta: ( - - {RECORD_STATUS_LABEL[o.status as keyof typeof RECORD_STATUS_LABEL] ?? o.status} - - ), - to: `/console/organize/${o.recordId}`, - })), - ] - : []; - const imported = report?.organization.filter((o) => o.targetPath) ?? []; - - return ( -
    -
    -

    - - 模型说的(未经核查) -

    - - - {said ? : "(没有文字汇报)"} - - -
    -
    -

    - {report ? ( - - ) : ( - - )} - 实际结果 -

    - {state || count("failed") || denied ? ( -
    - {state ? {state.label} : null} - {count("failed") ? {count("failed")} 步失败 : null} - {denied ? {denied} 步未执行 : null} -
    - ) : null} - {report ? ( - <> -

    {report.summary}

    -
    - {( - [ - ["已提交", report.submitted], - ["已入库", report.imported], - ["进行中", report.inProgress], - ["失败", report.failed], - ] as const - ) - .filter(([, v]) => v > 0) - .map(([k, v]) => ( -
    -
    {k}
    -
    {v}
    -
    - ))} -
    - {report.plans.length ? ( -
      - {report.plans.map((p) => ( -
    • - 下载计划 - - {PLAN_STATUS_LABEL[p.status as keyof typeof PLAN_STATUS_LABEL] ?? p.status} - -
    • - ))} -
    - ) : null} - {sources.length ? : null} - {imported.length ? ( - ({ - id: o.recordId, - type: o.status === "succeeded" ? ("added" as const) : ("context" as const), - content: o.targetPath, - note: o.status, - }))} - /> - ) : null} - - ) : ( -

    服务端汇报还没有生成,以任务和整理记录的实际状态为准。

    - )} - {run.error ? ( - <> -

    - {humanizeReason(String((run.error as { message?: unknown }).message ?? "")) || "会话出错"} -

    - - - ) : null} -
    -
    - ); -} - export function RunStatusBadge({ status }: { status: AgentRunStatus }) { return ( @@ -1127,7 +736,7 @@ export function RunAnnouncer({ status, finished }: { status: AgentRunStatus; fin prev.current = status; }, [status]); useEffect(() => { - if (finished) setMsg("最终汇报已生成"); + if (finished) setMsg("已结束,结果在最后一行"); }, [finished]); return ( diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index 4a4d80a2..041af395 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -1,30 +1,31 @@ -import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react"; -import { useRef } from "react"; +import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react"; +import { useRef, useState } from "react"; import { Link, useLocation } from "react-router"; import { toast } from "sonner"; import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; +import { useDownloadTask } from "~/api/downloads.ts"; +import { useLibraryDetail } from "~/api/library.ts"; +import { useRecord } from "~/api/records.ts"; +import { useSite } from "~/api/sites.ts"; import { StreamingCursor } from "~/components/agentui/streaming-response.tsx"; import { Button } from "~/components/ui/button.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { cn } from "~/lib/utils.ts"; import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; import { AgentSessionList } from "./agent-session-list.tsx"; import { type AgentContext, - type AgentDrawerTab, agentShortcutLabel, + clearAgentContext, contextFromPath, contextPrefix, - lastAgentDrawerTab, minimizeAgentDrawer, + newAgentConversation, openAgentDrawer, - openAgentHistoryRun, restoreAgentDrawer, setAgentDrawerOpen, - setAgentDrawerRun, - setAgentDrawerTab, shortGoalTitle, showAgentRunInDrawer, useAgentDrawer, @@ -36,108 +37,73 @@ import { useAgentStream } from "./use-agent-stream.ts"; /** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */ export const AGENT_LAUNCHER_ATTR = "data-agent-launcher"; -const CONTEXT_KEYS: [keyof AgentContext, string][] = [ - ["entityId", "作品"], - ["releaseId", "资源"], - ["downloadTaskId", "下载任务"], - ["recordId", "整理记录"], - ["siteId", "站点"], - ["taskId", "任务"], -]; - -function ContextChips({ context }: { context: AgentContext }) { - const chips = CONTEXT_KEYS.filter(([k]) => context[k]); - if (!chips.length) return null; - return ( -
      - {chips.map(([k, label]) => ( -
    • - {label} · {String(context[k]).slice(0, 12)} -
    • - ))} -
    - ); +/** + * 上下文的显示名称:页面按钮给的 label(「作品:沙丘2」)去掉类别;按路由推断的只有 ID,按 ID 查名称。 + * 查不到时退回类别名(「这部作品」),不显示 ID。 + */ +export function useContextName(ctx: AgentContext | null): string | null { + const path = !!ctx?.fromPath; + const entity = useLibraryDetail(path ? ctx?.entityId : undefined); + const site = useSite(path ? ctx?.siteId : undefined); + const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined); + const record = useRecord(path ? ctx?.recordId : undefined); + if (!ctx) return null; + if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null; + if (ctx.entityId) return entity.data?.entity.title ?? "这部作品"; + if (ctx.siteId) return site.data?.name ?? ctx.siteId; + if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务"; + if (ctx.recordId) return record.data?.media?.title ?? "这条整理记录"; + if (ctx.taskId) return "这个后台任务"; + return null; } -const TABS: { value: AgentDrawerTab; label: string }[] = [ - { value: "current", label: "当前" }, - { value: "history", label: "历史" }, -]; - -function DrawerTabs({ tab }: { tab: AgentDrawerTab }) { - return ( -
    - {TABS.map((t) => ( - - ))} -
    - ); -} - -/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */ -function HistoryRunView({ runId }: { runId: string }) { - const run = useAgentRun(runId); +/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */ +function HistoryPopover({ activeId }: { activeId: string | null }) { + const [open, setOpen] = useState(false); const mock = agentMockEnabled(); return ( -
    -
    - -

    - {run.data ? shortGoalTitle(run.data.run.goal) : ""} -

    - -
    - - - -
    + + ); } /** - * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; - * 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。 - * 运行中可最小化到右下角小窗,流式输出继续。 - * 挂载:在 console layout 里渲染一次 ;顶栏放 。 - * 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。 + * 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。 + * 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。 + * 挂载:在 console layout 里渲染一次 。页面内打开:openAgentDrawer({ entityId, label, ... })。 */ export function AgentDrawer() { - const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer(); + const { open, minimized, context, runId } = useAgentDrawer(); const create = useCreateAgentRun(); - const prefix = contextPrefix(context); const { pathname } = useLocation(); const content = useRef(null); - const history = tab === "history"; + const name = useContextName(context); + const mock = agentMockEnabled(); + // 目标前缀带名称和 ID:名称给人看,ID 给模型用 + const prefix = contextPrefix(context && name ? { ...context, label: name } : context); return ( <> @@ -146,7 +112,7 @@ export function AgentDrawer() { ref={content} side="right" className="w-full gap-0 p-0 sm:max-w-xl" - // 打开即可输入:焦点直接给输入框(新目标或运行中的追加消息),没有输入框时退回抽屉本身 + // 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身 onOpenAutoFocus={(e) => { const ta = content.current?.querySelector("textarea:not([disabled])"); if (!ta) return; @@ -158,93 +124,66 @@ export function AgentDrawer() { if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault(); }} > - - + + 智能体 - - {context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`} - - {context && !history ? : null} - - - {runId && !history ? ( - - ) : null} -
    - {history ? ( - historyRunId ? ( - - ) : ( - - ) - ) : runId ? ( - - + {runId ? ( + <> + + + + ) : null} + +
    + {runId ? ( + + ) : ( - - create.mutate( - { goal: `${prefix}${goal}`, ...opts }, - { - onSuccess: (run) => setAgentDrawerRun(run.id), - onError: (e) => toast.error(errorMessage(e)), - }, - ) - } - /> +
    + + create.mutate( + { goal: `${prefix}${goal}`, ...opts }, + { + onSuccess: (run) => showAgentRunInDrawer(run.id), + onError: (e) => toast.error(errorMessage(e)), + }, + ) + } + /> +
    )}
    - {history ? null : ( -
    - {runId ? ( - <> - - - - ) : ( - - )} -
    - )} {minimized && runId ? : null} @@ -268,7 +207,7 @@ function AgentMiniDock({ runId }: { runId: string }) { ? "思考中…" : last?.kind === "end" ? last.ok - ? "已完成,展开查看核实结果" + ? "已完成,展开查看结果" : last.message : "等待输出…"; const status = run.data @@ -325,9 +264,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) { aria-label="打开智能体" title={`智能体(${agentShortcutLabel()})`} className={className} - onClick={() => - minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()) - } + onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))} > diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index 1ee22c4d..2a59cf51 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -1,17 +1,9 @@ import type { AgentRunEvent } from "@umt/contracts"; import { describe, expect, it } from "vitest"; -import { - authDenial, - type ConvEntry, - groupConversation, - isDenied, - isLongReport, - pairAudit, - runPlanIds, - toolSummary, -} from "./agent-conversation.tsx"; -import { matchesHistory, rangeStart } from "./agent-session-list.tsx"; +import { authDenial, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx"; +import { matchesHistory } from "./agent-session-list.tsx"; import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts"; +import { isLongReport } from "./conversation-model.ts"; import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts"; const call = (p: Partial): ToolCallView => ({ @@ -100,7 +92,7 @@ describe("事件折叠", () => { const start = { type: "tool_call_start", toolCallId: "tc1", - capabilityId: "agent.propose_options", + capabilityId: "download.submit", inputSummary: "{}", }; const items = foldEvents([ @@ -109,7 +101,7 @@ describe("事件折叠", () => { ev(3, { type: "tool_call_end", toolCallId: "tc1", - capabilityId: "agent.propose_options", + capabilityId: "download.submit", outputSummary: "ok", isError: false, recovery: true, @@ -122,67 +114,29 @@ describe("事件折叠", () => { }); }); -describe("会话排版(AGUI-2)", () => { - const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({ - key, - at: key, - role, - foldable, - label: foldable ? "查本地库存" : undefined, - node: null, - }); - it("同一助手回合归为一组,用户 / 系统条目打断回合", () => { - const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]); - expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]); - const first = blocks[0]; - expect(first?.kind === "turn" && first.parts.length).toBe(2); - }); - it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => { - const [t] = groupConversation([ - e("1", "assistant", true), - e("2", "assistant", true), - e("3", "assistant", true), - e("4", "assistant"), - e("5", "assistant", true), - e("6", "assistant", true), - ]); - expect(t?.kind).toBe("turn"); - if (t?.kind !== "turn") return; - expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([ - "fold3", - "4", - "5", - "6", - ]); - }); - it("工具卡一行摘要", () => { +describe("工具结果摘要", () => { + it("展开后每步一行的「→ 结果」", () => { expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…"); - expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果"); + expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条"); expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时"); - expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe( - "found: true · count: 2", - ); + // 没有人话可写的对象不上屏(字段名进技术信息) + expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBeNull(); + expect(toolSummary({ status: "succeeded", outputSummary: '{"plan":{"id":"x"…' })).toBeNull(); expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时"); }); - it("长汇报判定", () => { + it("长正文判定", () => { expect(isLongReport("短")).toBe(false); expect(isLongReport("x".repeat(601))).toBe(true); expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true); }); }); -describe("会话历史筛选", () => { - const now = new Date("2026-09-29T12:00:00Z").getTime(); - const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" }; +describe("会话历史搜索", () => { + const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来" }; it("搜索匹配目标或 ID 前缀", () => { - expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true); - expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true); - expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false); - }); - it("时间范围按创建时间", () => { - expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true); - expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false); - expect(rangeStart("", now)).toBeNull(); + expect(matchesHistory(run, { q: "沙丘" })).toBe(true); + expect(matchesHistory(run, { q: "abcd" })).toBe(true); + expect(matchesHistory(run, { q: "奥本海默" })).toBe(false); }); }); diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx index c94af387..df091959 100644 --- a/apps/web/app/components/agent/agent-interaction.tsx +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -1,11 +1,18 @@ -import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts"; -import { ListChecks, MessageCircleQuestion, Star } from "lucide-react"; +import { + type AgentOptionView, + type AgentPlanStep, + CAPABILITY_CATALOG, + type KnownCapabilityId, + type RiskLevel, +} from "@umt/contracts"; +import { ChevronRight, ListChecks, LoaderCircle, MessageCircleQuestion } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts"; import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx"; +import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx"; -import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { TechInfo } from "~/components/console/tech-info.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -28,7 +35,8 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] { id: s.id, title: s.title, status: STEP_STATUS[s.status], - ...(s.note ? { detail: {s.note} } : {}), + // 备注只在失败时显示(失败原因);其余备注是模型的过程说明,不上屏 + ...(s.note && s.status === "failed" ? { detail: {s.note} } : {}), })); } @@ -90,163 +98,206 @@ export function RunPlan({ // ── 方案选择 ── -const NONE = "__none__"; +/** 方案排序:推荐项放第一位 */ +export function orderOptions(options: T[], recommendedId: string | null): T[] { + const rec = options.find((o) => o.id === recommendedId); + return rec ? [rec, ...options.filter((o) => o !== rec)] : options; +} -/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */ +/** + * 方案按钮上的动作要点:「提交下载 · 《沙丘:第二部》· 12.0 GB」;标题已经是动作名时只写对象和大小。 + * 没有动作时用模型给的估算代价。 + */ +export function optionPoint(o: AgentOptionView): string | null { + if (o.action) { + const label = o.title.includes(o.action.label) ? null : o.action.label; + return [label, o.action.detail].filter(Boolean).join(" · ") || null; + } + return o.estimatedCost ?? null; +} + +/** 超时说明里的时间:当天只写时分 */ +function deadlineText(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toDateString() === new Date().toDateString() + ? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }) + : formatDateTime(iso); +} + +/** + * agent.propose_options 的方案卡(AGUI-3):每个方案一个按钮,点一下就选定;推荐项在第一位、是主按钮。 + * 绑定了计划的方案写明动作要点,管理员选定即批准(服务端核对绑定)。风险折叠;「都不要…」点开才出原因框。 + */ export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) { - const name = useId(); const choose = useChooseOption(); - const [picked, setPicked] = useState(view.recommendedId ?? ""); + const riskId = useId(); + const [riskOpen, setRiskOpen] = useState(false); + const [noneOpen, setNoneOpen] = useState(false); const [reason, setReason] = useState(""); + const [picking, setPicking] = useState(null); const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null); const resolved = view.resolved; // 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示 const closed = !!resolved || !!sent || !waiting; - const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null; - const submit = () => { - const optionId = picked === NONE ? null : picked; - if (!picked) return; + const options = orderOptions(view.options, view.recommendedId); + const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r }))); + const submit = (optionId: string | null) => { if (optionId === null && !reason.trim()) { toast.error("写一句不要的原因,智能体按它调整"); return; } + setPicking(optionId ?? "__none__"); choose.mutate( { runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) }, { onSuccess: () => { setSent({ optionId, reason: reason.trim() }); - toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案"); + const o = view.options.find((x) => x.id === optionId); + toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已退回,智能体调整方案"); }, onError: (e) => toast.error(errorMessage(e)), + onSettled: () => setPicking(null), }, ); }; + const label = (id: string | null) => { + const o = view.options.find((x) => x.id === id); + if (!o) return id ?? ""; + const point = optionPoint(o); + return point && o.action ? `${o.title} · ${point}` : o.title; + }; const outcome = resolved ? resolved.outcome === "chosen" - ? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}` + ? `${resolved.by === "timeout" ? "超时自动选了" : "已选"}:${label(resolved.choice)}` : resolved.outcome === "stop" - ? "超时未选,按设置停止" + ? "超时没选,已停止" : `都不要${resolved.reason ? `:${resolved.reason}` : ""}` : sent ? sent.optionId - ? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续` - : `都不要:${sent.reason},等待智能体调整` + ? `已选:${label(sent.optionId)}` + : `都不要:${sent.reason}` : !waiting - ? "会话已不在等待这个选择" + ? "已不再等这个选择" : null; + if (closed) + return ( +

    + + + {view.question} · {outcome} + +

    + ); + return ( -
    +

    {view.question}

    -
    - {view.question} - {view.options.map((o) => { - const selected = closed ? chosenId === o.id : picked === o.id; +
      + {options.map((o) => { + const recommended = o.id === view.recommendedId; + const point = optionPoint(o); return ( -
    - {!closed && picked === NONE ? ( -