feat(web): live step list, option choice card and inline approval card; drop run mode switch
- session page shows the agent.plan TodoList (drawer: one-line progress); options and approval cards sit at their step; composer has a "先看方案再执行" toggle instead of the mode switch - settings expose option timeout, timeout action and approval validity; quick goals follow glossary Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cf2c4ff0e6
commit
9649854ab3
15 files changed
+731
-205
No files matched your search
@@ -1,4 +1,10 @@
|
||||
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
|
||||
import {
|
||||
type AgentOptionItem,
|
||||
type AgentPlanStep,
|
||||
type AgentRunEvent,
|
||||
type AgentUsageTotals,
|
||||
EventType,
|
||||
} from "@umt/contracts";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
|
||||
import { sse } from "~/lib/sse.ts";
|
||||
@@ -23,6 +29,8 @@ export type StreamItem =
|
||||
| { kind: "thinking"; key: string; text: string; at: string }
|
||||
| { kind: "tool"; key: string; call: ToolCallView }
|
||||
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
|
||||
| { kind: "options"; key: string; at: string; view: OptionsView }
|
||||
| { kind: "approval"; key: string; at: string; view: ApprovalView }
|
||||
| {
|
||||
kind: "end";
|
||||
key: string;
|
||||
@@ -33,9 +41,43 @@ export type StreamItem =
|
||||
budget: string | null;
|
||||
};
|
||||
|
||||
/** agent.propose_options 的一次提问(options_proposed + options_resolved) */
|
||||
export type OptionsView = {
|
||||
decisionId: string;
|
||||
question: string;
|
||||
options: AgentOptionItem[];
|
||||
recommendedId: string | null;
|
||||
stepId: string | null;
|
||||
expiresAt: string;
|
||||
timeoutAction: "recommended" | "stop";
|
||||
resolved: {
|
||||
outcome: "chosen" | "rejected" | "stop";
|
||||
choice: string | null;
|
||||
reason: string | null;
|
||||
by: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** 步骤处的审批卡(approval_requested + approval_resolved;挂起期间以审批对象的实时状态为准) */
|
||||
export type ApprovalView = {
|
||||
approvalId: string;
|
||||
capabilityId: string;
|
||||
planType: string;
|
||||
planId: string;
|
||||
payloadHash: string;
|
||||
expectedRevision: number;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
summary: string;
|
||||
stepId: string | null;
|
||||
expiresAt: string;
|
||||
resolved: { status: string; reason: string | null; decidedBy: string | null } | null;
|
||||
};
|
||||
|
||||
export type AgentStreamState = {
|
||||
events: AgentRunEvent[];
|
||||
items: StreamItem[];
|
||||
/** 最新的步骤清单(agent.plan);没有调用过为 null */
|
||||
plan: { steps: AgentPlanStep[]; revision: number } | null;
|
||||
usage: AgentUsageTotals | null;
|
||||
finished: boolean;
|
||||
error: string | null;
|
||||
@@ -50,6 +92,8 @@ const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall,
|
||||
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||
const items: StreamItem[] = [];
|
||||
const tools = new Map<string, ToolCallView>();
|
||||
const asks = new Map<string, OptionsView>();
|
||||
const approvals = new Map<string, ApprovalView>();
|
||||
for (const e of events) {
|
||||
const last = items[items.length - 1];
|
||||
switch (e.type) {
|
||||
@@ -146,6 +190,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "options_proposed": {
|
||||
const view: OptionsView = {
|
||||
decisionId: e.decisionId,
|
||||
question: e.question,
|
||||
options: e.options,
|
||||
recommendedId: e.recommendedId,
|
||||
stepId: e.stepId,
|
||||
expiresAt: e.expiresAt,
|
||||
timeoutAction: e.timeoutAction,
|
||||
resolved: null,
|
||||
};
|
||||
asks.set(e.decisionId, view);
|
||||
items.push({ kind: "options", key: `o${e.decisionId}`, at: e.at, view });
|
||||
break;
|
||||
}
|
||||
case "options_resolved": {
|
||||
const v = asks.get(e.decisionId);
|
||||
if (v) v.resolved = { outcome: e.outcome, choice: e.choice, reason: e.reason, by: e.by };
|
||||
break;
|
||||
}
|
||||
case "approval_requested": {
|
||||
const view: ApprovalView = {
|
||||
approvalId: e.approvalId,
|
||||
capabilityId: e.capabilityId,
|
||||
planType: e.planType,
|
||||
planId: e.planId,
|
||||
payloadHash: e.payloadHash,
|
||||
expectedRevision: e.expectedRevision,
|
||||
riskLevel: e.riskLevel,
|
||||
summary: e.summary,
|
||||
stepId: e.stepId,
|
||||
expiresAt: e.expiresAt,
|
||||
resolved: null,
|
||||
};
|
||||
approvals.set(e.approvalId, view);
|
||||
items.push({ kind: "approval", key: `a${e.approvalId}`, at: e.at, view });
|
||||
break;
|
||||
}
|
||||
case "approval_resolved": {
|
||||
const v = approvals.get(e.approvalId);
|
||||
if (v) v.resolved = { status: e.status, reason: e.reason, decidedBy: e.decidedBy };
|
||||
break;
|
||||
}
|
||||
case "run_completed":
|
||||
items.push({
|
||||
kind: "end",
|
||||
@@ -255,6 +342,15 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
|
||||
const items = foldEvents(events);
|
||||
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
|
||||
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
|
||||
return { events, items, usage, finished: !!end, error, connected, retry };
|
||||
return { events, items, plan: latestPlan(events), usage, finished: !!end, error, connected, retry };
|
||||
}, [events, error, connected, retry]);
|
||||
}
|
||||
|
||||
/** 最新的步骤清单(最后一条 plan_updated) */
|
||||
export function latestPlan(events: AgentRunEvent[]): AgentStreamState["plan"] {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i]!;
|
||||
if (e.type === "plan_updated") return { steps: e.steps, revision: e.revision };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user