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
@@ -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 (
|
||||
<ToolApproval
|
||||
status="rejected"
|
||||
@@ -175,10 +173,8 @@ export function ToolCallCard({
|
||||
reason={authReason ?? call.reason ?? "未授权"}
|
||||
explanation={
|
||||
authReason
|
||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。发布收录策略会生成持续授权,也可以为计划申请单次审批。'
|
||||
: mode === "plan_only" && writeKind
|
||||
? "本次是「只要方案」,只放行只读、预览、提案类能力。要执行,点「按方案执行」。"
|
||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
||||
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
|
||||
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
|
||||
}
|
||||
parameters={[
|
||||
{ id: "call", label: "调用 ID", value: call.toolCallId },
|
||||
@@ -317,13 +313,14 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
|
||||
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({
|
||||
<User />
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
{at || mode ? (
|
||||
{at || tag ? (
|
||||
<MessageHeader>
|
||||
{mode ? <span>{mode === "plan_only" ? "只要方案" : "授权内执行"}</span> : null}
|
||||
{tag ? <span>{tag}</span> : null}
|
||||
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
|
||||
</MessageHeader>
|
||||
) : 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<number | null>(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 = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<ToolCallCard call={it.call} mode={mode} audit={auditByCall.get(it.call.toolCallId)} />
|
||||
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
break;
|
||||
@@ -457,6 +455,20 @@ export function AgentConversation({
|
||||
</MessageMarker>
|
||||
);
|
||||
break;
|
||||
case "options":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
|
||||
</AssistantRow>
|
||||
);
|
||||
break;
|
||||
case "approval":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
break;
|
||||
case "end":
|
||||
node = it.ok ? null : <EndNotice item={it} />;
|
||||
break;
|
||||
@@ -473,13 +485,17 @@ export function AgentConversation({
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-1 py-2">
|
||||
<UserMessage text={run.goal} at={run.createdAt} mode={mode} />
|
||||
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||||
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
||||
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
||||
<AssistantRow avatar={false}>
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting" ? "等待中…" : run.status === "queued" ? "排队中…" : "正在处理…"}
|
||||
{run.status === "waiting"
|
||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||
: run.status === "queued"
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
</span>
|
||||
</AssistantRow>
|
||||
) : 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<StreamItem, { kind: "tool" }> => i.kind === "tool")
|
||||
.map(({ call }) => ({
|
||||
id: call.toolCallId,
|
||||
title: capabilityMeta(call.capabilityId)?.title ?? call.capabilityId,
|
||||
detail: <span className="font-mono">{call.capabilityId}</span>,
|
||||
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 <TodoList title="调查步骤(只要方案)" items={items} defaultOpen={active} collapseOnComplete />;
|
||||
}
|
||||
|
||||
// ── 预算用量条 ──
|
||||
|
||||
function Meter({
|
||||
|
||||
Reference in new issue
Block a user