diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index 83bd0236..42f3da3e 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -12,7 +12,6 @@ import { AgentRunDetail, type AgentRunEvent, type AgentRunMessage, - AgentRunMode, type AgentRunStatus, Approval, type ApprovalStatus, @@ -33,9 +32,10 @@ import { usePiUpstreams } from "./pi.ts"; export const AGENT_BASE = "/api/v1/agent/runs"; -/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */ -export const AgentMode = AgentRunMode; -export type AgentMode = AgentRunMode; +/** 会话选项(运行模式已取消):proposeFirst = 先看方案再执行 */ +export type AgentSessionOptions = { proposeFirst: boolean }; +/** 会话是否要求先看方案再执行(存在 run.scope 里) */ +export const isProposeFirst = (run: Pick) => run.scope.proposeFirst === true; export type AgentRunView = AgentRun; /** 服务端从业务表汇总的目标完成度(不采信模型文本) */ @@ -196,9 +196,10 @@ export async function fetchRunEvents(id: string, after: number, signal?: AbortSi export function useCreateAgentRun() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (input: { goal: string; mode: AgentMode }) => { - if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode); - return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRun.parse(d)); + mutationFn: async (input: { goal: string; proposeFirst?: boolean }) => { + const proposeFirst = input.proposeFirst === true; + if (agentMockEnabled()) return mockCreateRun(input.goal, { proposeFirst }); + return apiMutate(AGENT_BASE, { body: { goal: input.goal, proposeFirst } }).then((d) => AgentRun.parse(d)); }, onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }), }); @@ -230,6 +231,21 @@ export function useAppendAgentMessage() { }); } +/** 回答方案选择:选一个方案,或都不要(optionId=null + 原因);挂起的会话随即继续 */ +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; + return apiMutate(`${AGENT_BASE}/:id/choice`, { + pathParams: { id: input.runId }, + body: { decisionId: input.decisionId, optionId: input.optionId, reason: input.reason }, + }).then((d) => AgentRun.parse(d)); + }, + onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.runId) }), + }); +} + export const RUN_STATUS_LABEL: Record = { queued: "排队中", running: "运行中", @@ -292,13 +308,33 @@ export function useDecideApproval() { id: string; status: Extract; payloadHash: string; + /** 拒绝理由 / 建议:写进待处理事项,会话内审批时 Agent 按它调整 */ + reason?: string; }) => apiMutate(`/approvals/:id`, { method: "PATCH", pathParams: { id: input.id }, - body: { status: input.status, payloadHash: input.payloadHash }, + body: { + status: input.status, + payloadHash: input.payloadHash, + ...(input.reason ? { reason: input.reason } : {}), + }, }).then((d) => Approval.parse(d)), - onSuccess: (a) => qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] }), + onSuccess: (a) => { + void qc.invalidateQueries({ queryKey: ["agent", "approvals", a.planId] }); + void qc.invalidateQueries({ queryKey: ["agent", "approval", a.id] }); + }, + }); +} + +/** 单个审批的当前状态(会话内审批卡用;挂起期间事件流里还没有决定结果) */ +export function useApproval(id: string | null) { + const mock = agentMockEnabled(); + return useQuery({ + queryKey: ["agent", "approval", id ?? ""], + enabled: !!id && !mock, + queryFn: ({ signal }) => apiParsed(Approval, "/approvals/:id", { pathParams: { id: id ?? "" }, signal }), + refetchInterval: (q) => (q.state.data?.status === "pending" ? 5 * TTL_SECOND : false), }); } diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 4880de46..6802ef48 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -24,6 +24,7 @@ import { type AgentRunView, type GoalReport, type InvocationAudit, + isProposeFirst, isRunActive, REPORT_STATE, RUN_STATUS_LABEL, @@ -46,12 +47,12 @@ import { } 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 { InlineApproval, OptionsCard } from "./agent-interaction.tsx"; import { ToolCallIo } from "./tool-call-card-io.tsx"; import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; @@ -149,18 +150,15 @@ export const isDenied = (call: ToolCallView) => call.status === "denied" || auth /** 工具调用卡片:脱敏输入/输出折叠、耗时、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 ( }) { export function UserMessage({ text, at, - mode, + tag, note, animateIn, }: { text: string; at?: string; - mode?: string; + /** 会话选项标记(如"先看方案再执行") */ + tag?: string; note?: string; animateIn?: boolean; }) { @@ -333,9 +330,9 @@ export function UserMessage({ - {at || mode ? ( + {at || tag ? ( - {mode ? {mode === "plan_only" ? "只要方案" : "授权内执行"} : null} + {tag ? {tag} : null} {at ? : null} ) : null} @@ -402,7 +399,8 @@ export function AgentConversation({ after?: ReactNode; }) { const active = isRunActive(run.status) && !live.finished; - const mode = run.mode ?? (run.scope.mode as string | undefined); + 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; @@ -444,7 +442,7 @@ export function AgentConversation({ case "tool": node = ( - + ); break; @@ -457,6 +455,20 @@ export function AgentConversation({ ); break; + case "options": + node = ( + + + + ); + break; + case "approval": + node = ( + + + + ); + break; case "end": node = it.ok ? null : ; break; @@ -473,13 +485,17 @@ export function AgentConversation({ 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" ? "排队中…" : "正在处理…"} + {run.status === "waiting" + ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` + : run.status === "queued" + ? "排队中…" + : "正在处理…"} ) : null} @@ -488,32 +504,6 @@ export function AgentConversation({ ); } -// ── 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({ diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index e4ec71dc..7901f814 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -114,7 +114,7 @@ export function AgentDrawer() { ) : null}
{runId ? ( - + ) : ( + onSubmit={(goal, opts) => create.mutate( - { goal: `${prefix}${goal}`, mode }, + { goal: `${prefix}${goal}`, ...opts }, { onSuccess: (run) => setAgentDrawerRun(run.id), onError: (e) => toast.error(errorMessage(e)), diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx new file mode 100644 index 00000000..acd83a7f --- /dev/null +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -0,0 +1,338 @@ +import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts"; +import { ListChecks, MessageCircleQuestion, Star } 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 { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx"; +import { StatusBadge, type StatusTone } 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 { cn, formatDateTime } from "~/lib/utils.ts"; +import type { ApprovalView, OptionsView } from "./use-agent-stream.ts"; + +// 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。 +// 事件形状见 packages/contracts/src/agent-events.ts;服务端逻辑见 apps/server/src/modules/agent/interaction.ts。 + +const STEP_STATUS: Record = { + pending: "pending", + running: "in-progress", + done: "completed", + skipped: "skipped", + failed: "error", +}; + +export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] { + return steps.map((s) => ({ + id: s.id, + title: s.title, + status: STEP_STATUS[s.status], + ...(s.note ? { detail: {s.note} } : {}), + })); +} + +/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */ +export function RunPlan({ + plan, + active, + compact = false, +}: { + plan: { steps: AgentPlanStep[]; revision: number } | null; + active: boolean; + compact?: boolean; +}) { + if (!plan?.steps.length) return null; + const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length; + const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending"); + const failed = plan.steps.some((s) => s.status === "failed"); + if (compact) { + const pct = Math.round((done / plan.steps.length) * 100); + return ( +
+
+ + + 步骤 {done}/{plan.steps.length} + + + {current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"} + +
+
+
+
+
+ ); + } + return ( + + ); +} + +// ── 方案选择 ── + +const NONE = "__none__"; + +/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */ +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 [reason, setReason] = useState(""); + 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; + if (optionId === null && !reason.trim()) { + toast.error("写一句不要的原因,Agent 按它调整"); + return; + } + choose.mutate( + { runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) }, + { + onSuccess: () => { + setSent({ optionId, reason: reason.trim() }); + toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案"); + }, + onError: (e) => toast.error(errorMessage(e)), + }, + ); + }; + const outcome = resolved + ? resolved.outcome === "chosen" + ? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}` + : resolved.outcome === "stop" + ? "超时未选,按设置停止" + : `都不要${resolved.reason ? `:${resolved.reason}` : ""}` + : sent + ? sent.optionId + ? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待 Agent 继续` + : `都不要:${sent.reason},等待 Agent 调整` + : !waiting + ? "会话已不在等待这个选择" + : null; + + return ( +
+

+ + {view.question} +

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