diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index a023935d..3cbeed2e 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -1,4 +1,11 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { AgentGoalReport, AgentRun, @@ -7,11 +14,15 @@ import { type AgentRunMessage, AgentRunMode, type AgentRunStatus, + Approval, + type ApprovalStatus, + AuditEvent, agentRunEventFromRow, cursorPage, } from "@umt/contracts"; import { MOCK_RUNS, 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"; import { useSettings } from "./settings.ts"; @@ -242,3 +253,102 @@ export const REPORT_STATE: Record< partial: { label: "部分完成", tone: "warning" }, failed: { label: "未达成", tone: "destructive" }, }; + +// ── 会话页附属数据:审批、幂等键、管理员会话判定 ── + +const ApprovalPage = cursorPage(Approval); + +/** 本次运行产生的计划上的审批(按 report.plans 的 planId 逐个查;空列表不发请求) */ +export function useRunApprovals(planIds: string[]) { + const mock = agentMockEnabled(); + const results = useQueries({ + queries: planIds.map((planId) => ({ + queryKey: ["agent", "approvals", planId], + enabled: !mock, + queryFn: ({ signal }: { signal: AbortSignal }) => + apiParsed(ApprovalPage, "/approvals", { params: { planId, limit: 20 }, signal }), + refetchInterval: 15 * TTL_SECOND, + })), + }); + return { + items: results.flatMap((r) => r.data?.items ?? []), + isPending: results.some((r) => r.isPending && r.fetchStatus !== "idle"), + error: results.find((r) => r.error)?.error ?? null, + }; +} + +/** 批准/拒绝审批:回传 payloadHash,服务端比对防挪用;只接受控制台管理员会话 */ +export function useDecideApproval() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { + id: string; + status: Extract; + payloadHash: string; + }) => + apiMutate(`/approvals/:id`, { + method: "PATCH", + pathParams: { id: input.id }, + body: { status: input.status, payloadHash: input.payloadHash }, + }).then((d) => Approval.parse(d)), + onSuccess: (a) => qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] }), + }); +} + +/** 当前是否为控制台管理员会话(cookie 会话;API Key 不算)。审批按钮只在这里可用 */ +export function useIsAdminSession(): boolean { + const s = useSession(); + return s.data?.actor?.type === "admin" && s.data.expiresAt !== null; +} + +export type InvocationAudit = { + capabilityId: string; + at: string; + outcome: string; + idempotencyKey: string | null; + durationMs: number | null; + grantId: string | null; + approvalId: string | null; +}; + +const AuditPage = cursorPage(AuditEvent); + +/** + * 本次运行的能力调用审计(capability.invoke / capability.denied,按时间升序)。 + * 审计按全站新→旧分页,这里最多翻 3 页、按 payload.agentRunId 过滤;较早的运行可能拿不全,界面按"未取到"处理。 + */ +export function useRunInvocationAudit(runId: string, enabled: boolean, version = 0) { + const mock = agentMockEnabled(); + return useQuery({ + queryKey: ["agent", "invocation-audit", runId, version], + placeholderData: keepPreviousData, + enabled: enabled && !mock && !!runId, + staleTime: 10 * TTL_SECOND, + queryFn: async ({ signal }): Promise => { + const out: InvocationAudit[] = []; + let cursor: string | undefined; + for (let page = 0; page < 3; page++) { + const d = await apiParsed(AuditPage, "/audit-events", { + params: cleanParams({ action: "capability.*", targetType: "capability", limit: 200, cursor }), + signal, + }); + for (const e of d.items) { + const p = e.payload as Record; + if (p.agentRunId !== runId) continue; + out.push({ + capabilityId: e.targetId ?? "", + at: e.at, + outcome: String(p.outcome ?? e.action), + idempotencyKey: typeof p.idempotencyKey === "string" ? p.idempotencyKey : null, + durationMs: typeof p.durationMs === "number" ? p.durationMs : null, + grantId: typeof p.grantId === "string" ? p.grantId : null, + approvalId: typeof p.approvalId === "string" ? p.approvalId : null, + }); + } + if (!d.nextCursor) break; + cursor = d.nextCursor; + } + return out.sort((a, b) => a.at.localeCompare(b.at)); + }, + }); +} diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 4aae7063..08506f3b 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -1,37 +1,118 @@ -import type { AgentRunStatus } from "@umt/contracts"; +import { + type AgentRunStatus, + type Approval, + CAPABILITY_CATALOG, + type CapabilityDescriptor, + type KnownCapabilityId, + type RiskLevel, +} from "@umt/contracts"; import { Bot, - CheckCircle2, - ChevronRight, - CircleDashed, - CircleX, + Brain, + ChevronDown, + CircleSlash, + Download, + FolderInput, Layers, LoaderCircle, - ShieldAlert, + ShieldCheck, User, - Wrench, } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { Link } from "react-router"; +import { type ReactNode, useEffect, useId, useRef, useState } from "react"; +import { toast } from "sonner"; import { type AgentRunView, type GoalReport, + type InvocationAudit, isRunActive, REPORT_STATE, RUN_STATUS_LABEL, RUN_STATUS_TONE, + useDecideApproval, + useIsAdminSession, } from "~/api/agent.ts"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { cn } from "~/lib/utils.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 { + 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 { type TodoItem, TodoList } from "~/components/agentui/todo-list.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 { errorMessage } from "~/lib/api.ts"; +import { cn, formatDateTime } from "~/lib/utils.ts"; import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; -const BUDGET_LABEL: Record = { +// Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/, +// 改造自 github.com/ashish200729/agentui,MIT)。服务端事件形状见 packages/contracts/src/agent-events.ts。 + +export const BUDGET_LABEL: Record = { tool_calls: "工具调用次数", duration: "运行时长", tokens: "token", cost: "费用", }; const COMPACT_REASON: Record = { manual: "手动", threshold: "达到阈值", overflow: "上下文溢出" }; +const COMPACT_STATUS: Record = { + 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 ( + <> + {m ? ( + <> + {KIND_LABEL[m.kind] ?? m.kind} + scope · {m.scope} + + {m.idempotent ? 幂等 : null} + + ) : ( + 未登记能力 + )} + {extra} + + ); +} function durationText(a: string, b: string | null) { if (!b) return null; @@ -39,198 +120,437 @@ function durationText(a: string, b: string | null) { return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`; } -/** 摘要块:长文本在块内滚动,不撑坏布局 */ -function Summary({ label, text, tone }: { label: string; text: string; tone?: "destructive" }) { +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; +} + +/** 被服务端拒绝(白名单拦截或授权校验失败) */ +export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null; + +/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */ +export function ToolCallCard({ + call, + mode, + audit, +}: { + call: ToolCallView; + mode?: string; + /** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */ + audit?: InvocationAudit; +}) { + const meta = capabilityMeta(call.capabilityId); + const authReason = authDenial(call); + if (call.status === "denied" || authReason) { + const writeKind = meta && !["read", "preview", "propose"].includes(meta.kind); + return ( + {authReason ? "授权校验未通过" : "白名单拦截"}} + /> + } + reason={authReason ?? call.reason ?? "未授权"} + explanation={ + authReason + ? '有副作用的能力必须命中有效的持续授权(automation_grant)或单次审批(approval),由服务端在执行前核对;模型声称"用户已同意"不会被采信。需要放行时到「设置 → 持续授权」发放,或对计划申请单次审批。' + : mode === "plan_only" && writeKind + ? "本次是「只要方案」:白名单只放行只读、预览、提案类能力,执行类调用一律拒绝。要真正执行,请按方案以执行模式重新发起。" + : "这个能力不在本次运行的能力白名单内(或已超出调用预算),调用在到达处理器前就被拦下。Agent 无法绕过。" + } + parameters={[ + { 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]; return ( -
-

{label}

-
-        {text}
-      
-
+ 恢复补记 : null} + /> + } + summary={call.outputSummary ?? undefined} + > + {call.inputSummary ? {call.inputSummary} : null} + {call.outputSummary ? ( + + {call.outputSummary} + + ) : call.status === "running" ? ( +

等待结果…

+ ) : 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} +
+
); } -const TOOL_STATUS: Record< - ToolCallView["status"], - { label: string; tone: "success" | "destructive" | "warning" | "neutral" } -> = { - running: { label: "执行中", tone: "neutral" }, - succeeded: { label: "成功", tone: "success" }, - failed: { label: "失败", tone: "destructive" }, - denied: { label: "已拒绝", tone: "destructive" }, -}; - -/** 工具调用卡片:默认折叠,展开看脱敏输入/输出 */ -export function ToolCallCard({ call }: { call: ToolCallView }) { +/** 思考块:thinking_delta 累积,默认收起;流式中显示"思考中"与光标 */ +function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean }) { + const id = useId(); const [open, setOpen] = useState(false); - const st = TOOL_STATUS[call.status]; - if (call.status === "denied") { - return ( -
-
- - 服务端拒绝调用 - {call.capabilityId} -
-

{call.reason ?? "未授权"}

-

授权校验在服务端完成;Agent 没有绕过的途径。

-
- ); - } - const Icon = call.status === "running" ? LoaderCircle : call.status === "failed" ? CircleX : CheckCircle2; return ( -
+
- {!open && call.outputSummary ? ( -

{call.outputSummary}

- ) : null} - {open ? ( -
- {call.inputSummary ? : null} - {call.outputSummary ? ( - - ) : call.status === "running" ? ( -

等待结果…

- ) : null} -
- ) : null} + +

+ {text} + {streaming ? : null} +

+
); } -function StreamBlock({ item }: { item: StreamItem }) { - const [open, setOpen] = useState(false); - switch (item.kind) { - case "text": - return ( -
- - - -

- {item.text} -

-
- ); - case "thinking": - return ( -
- - {open ? ( -

- {item.text} -

- ) : null} -
- ); - case "tool": - return ( -
- -
- ); - case "compaction": - return ( -
- - - 上下文压缩({COMPACT_REASON[item.reason] ?? item.reason}) - {item.done ? `· ${item.status === "completed" ? "已完成,业务进度保留" : item.status}` : "· 进行中…"} - -
- ); - case "end": - if (item.ok) return null; - return ( -
-

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

-

{item.message}

-
- ); - } +function AssistantRow({ + children, + animateIn, + avatar = true, +}: { + children: ReactNode; + animateIn?: boolean; + avatar?: boolean; +}) { + return ( + + + + + {children} + + ); } +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} +
+
+
+
+ ); +} + +export function UserMessage({ + text, + at, + mode, + note, + animateIn, +}: { + text: string; + at?: string; + mode?: string; + note?: string; + animateIn?: boolean; +}) { + return ( + + + + + + {at || mode ? ( + + {mode ? {mode === "plan_only" ? "只要方案" : "授权内执行"} : null} + {at ? : null} + + ) : null} + + {text} + + {note ? {note} : null} + + + ); +} + +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:服务端汇报里的计划 + 下载计划/提交/整理执行的输入输出里出现的计划(摘要可能截断,只取开头能匹配到的) */ +export function runPlanIds(items: StreamItem[], report: GoalReport | null): string[] { + const ids = new Set(report?.plans.map((p) => p.planId) ?? []); + for (const it of items) { + if (it.kind !== "tool") continue; + const c = it.call; + if (["download.submit", "organize.execute"].includes(c.capabilityId)) { + const m = c.inputSummary ? PLAN_IN_INPUT.exec(c.inputSummary) : null; + if (m?.[1]) ids.add(m[1]); + } + if (c.capabilityId === "download.plan" && c.outputSummary) { + const m = PLAN_IN_OUTPUT.exec(c.outputSummary); + if (m?.[1]) ids.add(m[1]); + } + } + return [...ids]; +} + +/** 把工具调用按能力 ID 顺序与审计记录配对(审计没有 toolCallId,按同能力的先后对齐) */ +export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | undefined) { + const out = new Map(); + if (!audit?.length) return out; + const byCap = new Map(); + for (const a of audit) byCap.set(a.capabilityId, [...(byCap.get(a.capabilityId) ?? []), a]); + const used = new Map(); + for (const c of [...calls].sort((a, b) => a.startedAt.localeCompare(b.startedAt))) { + const list = byCap.get(c.capabilityId); + const i = used.get(c.capabilityId) ?? 0; + if (list?.[i]) out.set(c.toolCallId, list[i]); + used.set(c.capabilityId, i + 1); + } + return out; +} + +/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */ +export function AgentConversation({ + run, + live, + userMessages = [], + audit, + after, +}: { + run: AgentRunView; + live: AgentStreamState; + userMessages?: { at: string; text: string; pending?: boolean }[]; + audit?: InvocationAudit[]; + /** 流末尾追加的内容(审批卡片、最终汇报) */ + after?: ReactNode; +}) { + const active = isRunActive(run.status) && !live.finished; + const mode = run.mode ?? (run.scope.mode as string | undefined); + // 首次载入(回放)的条目不播入场动画,之后新到的才播 + 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 auditByCall = pairAudit(calls, audit); + const lastIndex = live.items.length - 1; + const lastText = live.items.map((i) => i.kind).lastIndexOf("text"); + + type Row = { key: string; at: string; node: ReactNode }; + const rows: Row[] = live.items.map((it, idx) => { + const streaming = active && idx === lastIndex; + const anim = fresh(idx); + let node: ReactNode = null; + switch (it.kind) { + case "text": + node = ( + + + {it.text} + + + ); + break; + case "thinking": + node = ( + + + + ); + break; + case "tool": + node = ( + + + + ); + break; + case "compaction": + node = ( + + + 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} + {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} + + ); + break; + case "end": + node = it.ok ? null : ; + break; + } + return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node }; + }); + for (const m of userMessages) + rows.push({ + key: `u${m.at}`, + at: m.at, + node: , + }); + rows.sort((a, b) => a.at.localeCompare(b.at)); + + return ( +
+ + {rows.map((r) => (r.node ?
{r.node}
: null))} + {active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? ( + + + + {run.status === "waiting" ? "等待中…" : run.status === "queued" ? "排队中…" : "正在处理…"} + + + ) : null} + {after} +
+ ); +} + +// ── plan_only:调查步骤清单 ── + +export function PlanSteps({ live, active }: { live: AgentStreamState; active: boolean }) { + const items: TodoItem[] = live.items + .filter((i): i is Extract => i.kind === "tool") + .map(({ call }) => ({ + id: call.toolCallId, + title: capabilityMeta(call.capabilityId)?.title ?? call.capabilityId, + detail: {call.capabilityId}, + status: + call.status === "running" + ? "in-progress" + : call.status === "succeeded" + ? "completed" + : isDenied(call) + ? "cancelled" + : "error", + })); + items.push({ + id: "report", + title: "整理方案并汇报", + status: active ? (items.some((i) => i.status === "in-progress") ? "pending" : "in-progress") : "completed", + }); + return ; +} + +// ── 预算用量条 ── + 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} + + {label} + {over ? " · 已超限" : near ? " · 接近上限" : ""} + {used === undefined ? "未报告" : format(used)} {max ? / {format(max)} : null}
-
+
{pct !== null ? (
= 90 ? "bg-warning" : "bg-foreground/60")} + className={cn( + "h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none", + over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60", + )} style={{ width: `${pct}%` }} /> ) : null} @@ -239,10 +559,12 @@ function Meter({ ); } -/** 预算用量条:上游未报告的数据显示"未报告",费用标注估算 */ +/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) { 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 @@ -250,25 +572,115 @@ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamS : undefined); return (
- String(n)} /> + String(n)} + exceeded={exceeded === "tool_calls"} + /> (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))} + exceeded={exceeded === "tokens"} /> `${n}s`} + exceeded={exceeded === "duration"} + /> + `$${n.toFixed(3)}`} + exceeded={exceeded === "cost"} /> - `$${n.toFixed(3)}`} />
); } -/** 最终汇报:模型说的 vs 系统核实的(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ +// ── 审批卡片 ── + +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") => + decide.mutate( + { id: a.id, status: s, payloadHash: a.payloadHash }, + { + onSuccess: () => toast.success(s === "approved" ? "已批准" : "已拒绝"), + onError: (e) => toast.error(errorMessage(e)), + }, + ); + return ( + + {risk ? : null} + {cap ? 执行能力 · {cap} : null} + + } + onApprove={() => act("approved")} + onReject={() => act("rejected")} + disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"} + result={ + a.decidedAt + ? `${a.status === "approved" ? "批准" : a.status === "rejected" ? "拒绝" : "处理"}于 ${formatDateTime(a.decidedAt)}` + : 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"; + +/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ export function FinalReport({ run, report, @@ -288,24 +700,51 @@ export function FinalReport({ const calls = live.items .filter((i): i is Extract => i.kind === "tool") .map((i) => i.call); - const count = (s: ToolCallView["status"]) => calls.filter((c) => c.status === s).length; + 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 ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`, + domain: "下载任务", + meta: {d.status}, + to: `/console/tasks/${d.downloadTaskId}`, + })), + ...report.organization.map((o) => ({ + id: `o${o.recordId}`, + icon: , + title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`, + domain: "整理记录", + meta: {o.status}, + to: `/console/organize/${o.recordId}`, + })), + ] + : []; + const imported = report?.organization.filter((o) => o.targetPath) ?? []; + return ( -
-
+
+

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

-

+ {said || "(没有文字汇报)"} -

-

仅供参考,不作为完成依据。

+ +

模型的自述,仅供参考,不作为完成依据。

-
+

- + {report ? ( + + ) : ( + + )} 系统核实的结果

@@ -313,7 +752,7 @@ export function FinalReport({ 运行{RUN_STATUS_LABEL[run.status]} 工具成功 {count("succeeded")} {count("failed") ? 失败 {count("failed")} : null} - {count("denied") ? 被拒绝 {count("denied")} : null} + {denied ? 被拒绝 {denied} : null}
{report ? ( <> @@ -327,36 +766,41 @@ export function FinalReport({ ["失败", report.failed], ] as const ).map(([k, v]) => ( -
-
{v}
+
{k}
+
{v}
))} -
    - {report.downloads.map((d) => ( -
  • - - 下载 · {d.name ?? d.downloadTaskId.slice(0, 8)} - - · {d.status} -
  • - ))} - {report.organization.map((o) => ( -
  • - - 整理记录 · {o.targetPath ?? o.recordId.slice(0, 8)} - - · {o.status} -
  • - ))} -
+ {report.plans.length ? ( +
    + {report.plans.map((p) => ( +
  • + 下载计划 + {p.planId.slice(0, 8)} + {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} +

+ 汇总自下载计划、下载任务与整理记录(生成于 {formatDateTime(report.generatedAt)} + );"已提交"只代表进入下载器,整理成功才算入库。 +

) : (

服务端汇报尚未生成;以任务页、整理记录的实际状态为准。

@@ -367,97 +811,33 @@ export function FinalReport({

) : null}
-
- ); -} - -/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */ -/** 末尾标记离视口底部不超过这么多像素算"停在底部" */ -const FOLLOW_SLACK_PX = 160; - -export function AgentConversation({ - run, - live, - userMessages = [], - compact = false, -}: { - run: AgentRunView; - live: AgentStreamState; - userMessages?: { at: string; text: string; pending?: boolean }[]; - compact?: boolean; -}) { - const endRef = useRef(null); - const active = isRunActive(run.status) && !live.finished; - const count = live.items.length; - // 有新内容时跟随到底,但只在用户停在底部附近时跟随:往上翻(去点"中止"、看早先的工具卡)后不再把视口拽回去, - // 翻回底部附近自动恢复。捕获阶段监听 scroll,侧栏抽屉里的滚动容器也算。 - const stick = useRef(true); - useEffect(() => { - const onScroll = () => { - const el = endRef.current; - if (el) stick.current = el.getBoundingClientRect().top - window.innerHeight < FOLLOW_SLACK_PX; - }; - document.addEventListener("scroll", onScroll, { capture: true, passive: true }); - return () => document.removeEventListener("scroll", onScroll, { capture: true }); - }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底 - useEffect(() => { - if (active && stick.current) endRef.current?.scrollIntoView({ block: "nearest" }); - }, [count, active]); - - // 追加的用户消息按时间插入 - type Row = { key: string; at: string; node: ReactNode }; - const rows: Row[] = live.items.map((it) => ({ - key: it.key, - at: it.kind === "tool" ? it.call.startedAt : it.at, - node: , - })); - for (const m of userMessages) - rows.push({ - key: `u${m.at}`, - at: m.at, - node: , - }); - rows.sort((a, b) => a.at.localeCompare(b.at)); - - return ( -
- - {rows.map((r) => ( -
{r.node}
- ))} - {active ? ( -
- - {run.status === "waiting" ? "等待中…" : "正在处理…"} -
- ) : null} - {live.error ?

事件读取失败:{live.error}(稍后自动重试)

: null} -
-
- ); -} - -function UserBubble({ text, mode, note }: { text: string; mode?: string; note?: string }) { - return ( -
-
- {mode === "plan_only" ?

只要方案

: null} -

{text}

- {note ?

{note}

: null} -
- - - -
+
); } export function RunStatusBadge({ status }: { status: AgentRunStatus }) { return ( - {isRunActive(status) ? : null} + {isRunActive(status) ? : null} {RUN_STATUS_LABEL[status]} ); } + +/** 滚动时保持最新一次状态播报(给读屏用的礼貌播报,流式正文本身不逐字播报) */ +export function RunAnnouncer({ status, finished }: { status: AgentRunStatus; finished: boolean }) { + const [msg, setMsg] = useState(""); + const prev = useRef(status); + useEffect(() => { + if (prev.current !== status) setMsg(`运行${RUN_STATUS_LABEL[status]}`); + prev.current = status; + }, [status]); + useEffect(() => { + if (finished) setMsg("回复完成,最终汇报已生成"); + }, [finished]); + return ( + + {msg} + + ); +} diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index f717beec..880aa412 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -1,94 +1,216 @@ -import { Bot, ExternalLink, Plus } from "lucide-react"; -import { Link } from "react-router"; +import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react"; +import { Link, useLocation } from "react-router"; import { toast } from "sonner"; -import { useCreateAgentRun } from "~/api/agent.ts"; +import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; +import { StreamingCursor } from "~/components/agentui/streaming-response.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { errorMessage } from "~/lib/api.ts"; +import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; import { + type AgentContext, + contextFromPath, contextPrefix, + minimizeAgentDrawer, openAgentDrawer, + restoreAgentDrawer, setAgentDrawerOpen, setAgentDrawerRun, + splitContextGoal, useAgentDrawer, } from "./agent-store.ts"; +import { useAgentStream } from "./use-agent-stream.ts"; + +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)} +
  • + ))} +
+ ); +} /** - * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId 等)作为 goal 前缀。 + * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; + * 运行中可最小化到右下角小窗,流式输出继续。 * 挂载:在 console layout 里渲染一次 ;顶栏放 。 * 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。 */ export function AgentDrawer() { - const { open, context, runId } = useAgentDrawer(); + const { open, minimized, context, runId } = useAgentDrawer(); const create = useCreateAgentRun(); const prefix = contextPrefix(context); return ( - - - - - - Agent - - - {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"} - - -
+ <> + + + + + + Agent + + + {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"} + + {context ? : null} + {runId ? ( - - ) : ( - - create.mutate( - { goal: `${prefix}${goal}`, mode }, - { - onSuccess: (run) => setAgentDrawerRun(run.id), - onError: (e) => toast.error(errorMessage(e)), - }, - ) - } - /> - )} -
-
- {runId ? ( - <> - - + ) : null} +
+ {runId ? ( + + ) : ( + + create.mutate( + { goal: `${prefix}${goal}`, mode }, + { + onSuccess: (run) => setAgentDrawerRun(run.id), + onError: (e) => toast.error(errorMessage(e)), + }, + ) + } + /> + )} +
+
+ {runId ? ( + <> + + + + ) : ( + - - ) : ( - - )} -
- - + )} +
+
+
+ {minimized && runId ? : null} + ); } -/** 顶栏按钮:打开抽屉(不带上下文;页面内按钮用 openAgentDrawer 传上下文) */ -export function AgentDrawerTrigger() { +/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */ +function AgentMiniDock({ runId }: { runId: string }) { + const run = useAgentRun(runId); + const active = isRunActive(run.data?.run.status); + const live = useAgentStream(runId, active || !run.data); + const last = live.items[live.items.length - 1]; + const streaming = active && !live.finished; + const line = + last?.kind === "text" + ? last.text.trim().split("\n").pop() + : last?.kind === "tool" + ? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}` + : last?.kind === "thinking" + ? "思考中…" + : last?.kind === "end" + ? last.ok + ? "已完成,展开查看核实结果" + : last.message + : "等待输出…"; + const status = run.data + ? live.finished && isRunActive(run.data.run.status) + ? "running" + : run.data.run.status + : null; return ( - + +
+ + + ); +} + +/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */ +export function AgentDrawerTrigger() { + const { minimized, runId } = useAgentDrawer(); + const { pathname } = useLocation(); + return ( + ); diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts new file mode 100644 index 00000000..597d6200 --- /dev/null +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx"; +import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts"; +import type { StreamItem, ToolCallView } from "./use-agent-stream.ts"; + +const call = (p: Partial): ToolCallView => ({ + toolCallId: "t1", + capabilityId: "task.retry", + inputSummary: null, + outputSummary: null, + status: "succeeded", + reason: null, + startedAt: "2026-09-27T00:00:00.000Z", + endedAt: null, + recovery: false, + ...p, +}); + +describe("拒绝判定", () => { + it("授权校验失败(tool_call_end + unauthorized)按拒绝展示", () => { + const c = call({ + status: "failed", + outputSummary: '{"error":{"code":"unauthorized","message":"缺少 task.retry 的持续授权"}}', + }); + expect(authDenial(c)).toBe("缺少 task.retry 的持续授权"); + expect(isDenied(c)).toBe(true); + }); + it("截断的摘要也能认出授权错误;普通失败不算拒绝", () => { + expect(authDenial(call({ status: "failed", outputSummary: '{"error":{"code":"forbidden","message":"x"}…' }))).toBe( + "x", + ); + expect(isDenied(call({ status: "failed", outputSummary: '{"error":{"code":"internal"}}' }))).toBe(false); + expect(isDenied(call({ status: "denied", reason: "不在白名单" }))).toBe(true); + }); +}); + +describe("计划与审计", () => { + const plan = "32320000-0000-4000-8000-000700000002"; + it("从提交输入与下载计划输出里收集计划 ID", () => { + const items: StreamItem[] = [ + { kind: "tool", key: "a", call: call({ capabilityId: "download.submit", inputSummary: `{"planId":"${plan}"}` }) }, + { + kind: "tool", + key: "b", + call: call({ + toolCallId: "t2", + capabilityId: "download.plan", + outputSummary: '{"plan":{"id":"33000000-0000-4000-8000-000000000701","status":"draft"…', + }), + }, + ]; + expect(runPlanIds(items, null).sort()).toEqual([plan, "33000000-0000-4000-8000-000000000701"]); + }); + it("同能力的审计按先后与工具调用配对", () => { + const calls = [ + call({ toolCallId: "a", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:01.000Z" }), + call({ toolCallId: "b", capabilityId: "download.submit", startedAt: "2026-09-27T00:00:02.000Z" }), + ]; + const audit = ["k1", "k2"].map((k, i) => ({ + capabilityId: "download.submit", + at: `2026-09-27T00:00:0${i + 1}.500Z`, + outcome: "succeeded", + idempotencyKey: k, + durationMs: 1, + grantId: null, + approvalId: null, + })); + const m = pairAudit(calls, audit); + expect(m.get("a")?.idempotencyKey).toBe("k1"); + expect(m.get("b")?.idempotencyKey).toBe("k2"); + }); +}); + +describe("侧栏上下文", () => { + it("按路由推断上下文并生成前缀,列表里能拆回正文", () => { + const ctx = contextFromPath("/console/engine/5b4e76e0-f27f-4e7a-8e08-71486387df1d"); + expect(ctx?.taskId).toBe("5b4e76e0-f27f-4e7a-8e08-71486387df1d"); + expect(contextFromPath("/console/sites/hdhome")?.siteId).toBe("hdhome"); + expect(contextFromPath("/console/agent")).toBeNull(); + const goal = `${contextPrefix(ctx)}排查这个任务`; + expect(splitContextGoal(goal)).toEqual({ context: expect.stringContaining("taskId="), text: "排查这个任务" }); + }); +}); diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index 7193b354..f6c8acce 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { EventType } from "@umt/contracts"; -import { Send, Square } from "lucide-react"; +import { FlaskConical, Play, RefreshCw, WifiOff } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -11,21 +11,67 @@ import { useAbortAgentRun, useAgentRun, useAppendAgentMessage, + useCreateAgentRun, + useRunApprovals, + useRunInvocationAudit, } from "~/api/agent.ts"; +import { MessageScroller } from "~/components/agentui/message-scroller.tsx"; +import { PromptInput } from "~/components/agentui/prompt-input.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; -import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; -import { useSse } from "~/lib/sse.ts"; +import { useSse, useSseStatus } from "~/lib/sse.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; -import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx"; +import { + AgentConversation, + ApprovalItem, + BudgetBar, + FinalReport, + PlanSteps, + RunAnnouncer, + RunStatusBadge, + runPlanIds, +} from "./agent-conversation.tsx"; import { useAgentStream } from "./use-agent-stream.ts"; const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const; -/** 目标输入:execute / plan_only 分段切换 + 文本框 */ +export const MODE_HELP: Record = { + execute: { + label: "授权内执行", + help: "在已授权范围内执行;未授权的动作会被服务端拒绝并在对话里标出。", + }, + plan_only: { + label: "只要方案", + help: "只调查、出方案,不执行任何写操作;满意后可一键按方案执行。", + }, +}; + +/** 运行模式分段切换 */ +export function ModeSwitch({ mode, onChange }: { mode: AgentMode; onChange: (m: AgentMode) => void }) { + return ( +
+ {(["execute", "plan_only"] as const).map((v) => ( + + ))} +
+ ); +} + +/** 新目标输入:execute / plan_only 切换 + 自增高输入框(Enter 发送,Shift+Enter 换行) */ export function GoalComposer({ onSubmit, pending, @@ -35,96 +81,71 @@ export function GoalComposer({ }: { onSubmit: (goal: string, mode: AgentMode) => void; pending: boolean; - /** 上下文前缀(只读展示) */ + /** 上下文前缀(只读展示,提交时拼在目标前) */ prefix?: string; defaultGoal?: string; compact?: boolean; }) { const [goal, setGoal] = useState(defaultGoal); const [mode, setMode] = useState("execute"); - const submit = () => { - const g = goal.trim(); - if (!g) { - toast.error("先写下目标"); - return; - } - onSubmit(g, mode); - }; return ( -
{ - e.preventDefault(); - submit(); - }} - > - {prefix ? ( -

- {prefix} -

- ) : null} -