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:
rootandClaude Opus 5.5 committed 2026-09-28 21:07:01 +08:00
1 parent cf2c4ff0e6
commit 9649854ab3
15 files changed
+731 -205

No files matched your search

+45 -9
View File
@@ -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<AgentRun, "scope">) => 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<AgentRunStatus, string> = {
queued: "排队中",
running: "运行中",
@@ -292,13 +308,33 @@ export function useDecideApproval() {
id: string;
status: Extract<ApprovalStatus, "approved" | "rejected">;
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),
});
}
@@ -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({
@@ -114,7 +114,7 @@ export function AgentDrawer() {
) : null}
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunPanel runId={runId} compact className="flex-1" onRunCreated={setAgentDrawerRun} />
<AgentRunPanel runId={runId} compact className="flex-1" />
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
@@ -123,9 +123,9 @@ export function AgentDrawer() {
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, mode) =>
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, mode },
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => setAgentDrawerRun(run.id),
onError: (e) => toast.error(errorMessage(e)),
@@ -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<AgentPlanStep["status"], TodoItemStatus> = {
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: <span className="block max-w-48 truncate">{s.note}</span> } : {}),
}));
}
/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */
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 (
<section aria-label="步骤进度" className="rounded-xl border border-border/70 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-meta">
<ListChecks
aria-hidden
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{plan.steps.length}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
</span>
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
failed ? "bg-destructive" : "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
</section>
);
}
return (
<TodoList
title="步骤"
items={planTodoItems(plan.steps)}
defaultOpen
collapseOnComplete={!active}
maxHeightClassName="max-h-56"
/>
);
}
// ── 方案选择 ──
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<string>(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 (
<section
aria-label="选择方案"
className={cn(
"w-full min-w-0 rounded-xl border p-3",
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
)}
>
<p className="flex items-start gap-2 text-caption font-medium text-foreground">
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 break-words">{view.question}</span>
</p>
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
<legend className="sr-only">{view.question}</legend>
{view.options.map((o) => {
const selected = closed ? chosenId === o.id : picked === o.id;
return (
<label
key={o.id}
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
selected && "border-foreground/40 bg-foreground/[0.04]",
closed && !selected && "opacity-60",
closed && "cursor-default",
)}
>
<input
type="radio"
name={name}
value={o.id}
checked={selected}
onChange={() => setPicked(o.id)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-1.5">
<span className="text-caption font-medium text-foreground">{o.title}</span>
{view.recommendedId === o.id ? (
<StatusBadge>
<Star className="size-3" />
推荐
</StatusBadge>
) : null}
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null}
</span>
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
{o.risks.length ? (
<ul className="mt-1 space-y-0.5 text-meta text-warning">
{o.risks.map((r) => (
<li key={r} className="break-words">
风险:{r}
</li>
))}
</ul>
) : null}
</span>
</label>
);
})}
{closed ? null : (
<label
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-dashed border-border px-3 py-2 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
picked === NONE && "border-foreground/40",
)}
>
<input
type="radio"
name={name}
value={NONE}
checked={picked === NONE}
onChange={() => setPicked(NONE)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
</label>
)}
</fieldset>
{!closed && picked === NONE ? (
<Textarea
className="mt-2 min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
{closed ? (
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
) : (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
{picked === NONE ? "退回" : "选定"}
</Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "Agent 停止"}。
</span>
</div>
)}
</section>
);
}
// ── 步骤处的审批卡 ──
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划",
organize_plan: "整理计划",
policy_revision: "策略版本",
};
const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
approved: "approved",
rejected: "rejected",
revoked: "revoked",
expired: "expired",
invalid: "expired",
consumed: "consumed",
};
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
const admin = useIsAdminSession();
const live = useApproval(view.approvalId);
const decide = useDecideApproval();
const [reason, setReason] = useState("");
const a = live.data;
const expired = new Date(view.expiresAt).getTime() < Date.now();
const serverStatus = a?.status ?? (view.resolved ? view.resolved.status : "pending");
const status: ApprovalCardStatus = decide.isPending
? "submitting"
: serverStatus === "pending" && expired
? "expired"
: (RESOLVED_STATUS[serverStatus] ?? (serverStatus as ApprovalCardStatus));
const cap = CAPABILITY_CATALOG[view.capabilityId as KnownCapabilityId];
const act = (s: "approved" | "rejected") =>
decide.mutate(
{
id: view.approvalId,
status: s,
payloadHash: a?.payloadHash ?? view.payloadHash,
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
},
{
onSuccess: () => toast.success(s === "approved" ? "已批准,Agent 继续执行" : "已拒绝,Agent 按理由调整"),
onError: (e) => toast.error(errorMessage(e)),
},
);
const decidedReason = a?.reason && a.status === "rejected" ? a.reason : view.resolved?.reason;
return (
<ApprovalCard
status={status}
title={`批准「${cap?.title ?? view.capabilityId}」?`}
description={view.summary}
badges={
<>
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
</>
}
onApprove={() => act("approved")}
onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
result={
status === "rejected" && decidedReason
? `已拒绝:${decidedReason}`
: a?.decidedAt
? `${status === "approved" || status === "consumed" ? "批准" : "处理"}于 ${formatDateTime(a.decidedAt)}`
: undefined
}
>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">计划</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
</dd>
<dt className="text-muted-foreground">payload hash</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
<dt className="text-muted-foreground">版本</dt>
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
<dt className="text-muted-foreground">到期</dt>
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
{formatDateTime(view.expiresAt)}
</dd>
</dl>
{status === "pending" && admin ? (
<Textarea
className="mt-2 min-h-14 text-caption"
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),Agent 按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
</ApprovalCard>
);
}
@@ -1,17 +1,17 @@
import { useQueryClient } from "@tanstack/react-query";
import { EventType } from "@umt/contracts";
import { FlaskConical, Play, RefreshCw, Sparkles, WifiOff } from "lucide-react";
import { useRef, useState } from "react";
import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react";
import { useId, useRef, useState } from "react";
import { toast } from "sonner";
import {
type AgentMode,
type AgentSessionOptions,
agentKeys,
agentMockEnabled,
isProposeFirst,
isRunActive,
useAbortAgentRun,
useAgentRun,
useAppendAgentMessage,
useCreateAgentRun,
useRunApprovals,
useRunInvocationAudit,
} from "~/api/agent.ts";
@@ -21,6 +21,8 @@ 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 { Label } from "~/components/ui/label.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
@@ -29,50 +31,30 @@ import {
ApprovalItem,
BudgetBar,
FinalReport,
PlanSteps,
RunAnnouncer,
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import { useAgentStream } from "./use-agent-stream.ts";
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const;
export const MODE_HELP: Record<AgentMode, { label: string; help: string }> = {
execute: {
label: "授权内执行",
help: "未授权的动作会被服务端拒绝并在对话里标出。",
},
plan_only: {
label: "只要方案",
help: "只调查、出方案,不执行写操作。之后可按方案执行。",
},
};
/** 运行模式分段切换 */
export function ModeSwitch({ mode, onChange }: { mode: AgentMode; onChange: (m: AgentMode) => void }) {
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
const id = useId();
return (
<fieldset aria-label="运行模式" className="flex items-center gap-1">
{(["execute", "plan_only"] as const).map((v) => (
<button
key={v}
type="button"
aria-pressed={mode === v}
onClick={() => onChange(v)}
className={cn(
"h-8 cursor-pointer rounded-full border border-transparent px-3 text-sm text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
mode === v && "pill-selected text-foreground",
)}
>
{MODE_HELP[v].label}
</button>
))}
</fieldset>
<div className="flex items-center gap-2 px-1">
<Switch id={id} checked={checked} onCheckedChange={onChange} />
<Label htmlFor={id} className="cursor-pointer text-sm font-normal text-muted-foreground">
先看方案再执行
</Label>
</div>
);
}
/** 新目标输入:execute / plan_only 切换 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
export function GoalComposer({
onSubmit,
pending,
@@ -81,7 +63,7 @@ export function GoalComposer({
compact = false,
suggestions,
}: {
onSubmit: (goal: string, mode: AgentMode) => void;
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean;
/** 上下文前缀(只读展示,提交时拼在目标前) */
prefix?: string;
@@ -91,7 +73,7 @@ export function GoalComposer({
suggestions?: string[];
}) {
const [goal, setGoal] = useState(defaultGoal);
const [mode, setMode] = useState<AgentMode>("execute");
const [proposeFirst, setProposeFirst] = useState(false);
const box = useRef<HTMLDivElement>(null);
const fill = (text: string) => {
setGoal(text);
@@ -107,7 +89,7 @@ export function GoalComposer({
<PromptInput
value={goal}
onValueChange={setGoal}
onSubmit={(g) => onSubmit(g, mode)}
onSubmit={(g) => onSubmit(g, { proposeFirst })}
pending={pending}
minRows={compact ? 2 : 3}
maxRows={10}
@@ -125,10 +107,13 @@ export function GoalComposer({
</p>
) : null
}
toolbar={<ModeSwitch mode={mode} onChange={setMode} />}
toolbar={<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />}
/>
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。
{proposeFirst
? "Agent 先调查并给出方案,你选定后才执行。"
: "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
Enter 发送,Shift+Enter 换行。
</p>
{suggestions?.length ? (
<div className="pt-2">
@@ -155,25 +140,21 @@ export function GoalComposer({
);
}
/** 一次运行:头部状态 + 预算条 + (只要方案的步骤清单)+ 流式对话 + 审批 + 最终汇报 + 输入框 */
/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */
export function AgentRunPanel({
runId,
compact = false,
className,
onRunCreated,
}: {
runId: string;
compact?: boolean;
className?: string;
/** 从本会话派生了新运行(按方案执行)时 */
onRunCreated?: (id: string) => void;
}) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun();
const append = useAppendAgentMessage();
const create = useCreateAgentRun();
const [msg, setMsg] = useState("");
const [confirmStop, setConfirmStop] = useState(false);
const sseStatus = useSseStatus();
@@ -181,6 +162,8 @@ export function AgentRunPanel({
const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上的其它审批
const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : [])));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
const related = new Set([
@@ -207,7 +190,7 @@ export function AgentRunPanel({
const { run: r, report, messages } = run.data;
const finished = !isRunActive(r.status) || live.finished;
const streaming = active && !live.finished;
const mode: AgentMode = (r.mode ?? (r.scope.mode as AgentMode | undefined)) || "execute";
const proposeFirst = isProposeFirst(r);
const shownStatus =
// 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示
(live.finished && (r.status === "queued" || r.status === "running")) ||
@@ -226,21 +209,13 @@ export function AgentRunPanel({
onError: (err) => toast.error(errorMessage(err)),
},
);
const executePlan = () =>
create.mutate(
{ goal: `${r.goal}\n\n(按会话 ${r.id.slice(0, 8)} 的方案执行)`, mode: "execute" },
{
onSuccess: (n) => onRunCreated?.(n.id),
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} />
<div className="flex flex-wrap items-center gap-2">
<RunStatusBadge status={shownStatus} />
<StatusBadge tone={mode === "plan_only" ? "warning" : "neutral"}>{MODE_HELP[mode].label}</StatusBadge>
{proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
<span className="min-w-0 text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
@@ -259,7 +234,7 @@ export function AgentRunPanel({
<SubagentTree snapshot={run.data} />
{mode === "plan_only" ? <PlanSteps live={live} active={streaming} /> : null}
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} compact={compact} />
{streaming && sseStatus === "reconnecting" ? (
<div
@@ -296,9 +271,11 @@ export function AgentRunPanel({
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items.map((a) => (
<ApprovalItem key={a.id} approval={a} />
))}
{approvals.items
.filter((a) => !inline.has(a.id))
.map((a) => (
<ApprovalItem key={a.id} approval={a} />
))}
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
</>
}
@@ -319,22 +296,12 @@ export function AgentRunPanel({
maxLength={8000}
aria-label="追加消息"
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
toolbar={
mode === "plan_only" && finished ? (
<Button type="button" size="sm" variant="outline" onClick={executePlan} disabled={create.isPending}>
<Play />
按方案执行
</Button>
) : (
<span className="px-1 text-meta text-muted-foreground">本会话:{MODE_HELP[mode].label}</span>
)
}
/>
<p className="px-1 text-meta text-muted-foreground">
{mode === "plan_only" && finished
? "「按方案执行」会新开一次授权内执行的运行,仍受服务端授权校验。"
: MODE_HELP[mode].help}{" "}
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 中止本次运行" : ""}。
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
: "追加的消息在本轮结束后读取。"}{" "}
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
</p>
</div>
@@ -101,8 +101,8 @@ export function AgentSessionList({
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={r.status} />
{(r.mode ?? r.scope.mode) === "plan_only" ? (
<span className="text-meta text-muted-foreground">只要方案</span>
{r.scope.proposeFirst === true ? (
<span className="text-meta text-muted-foreground">先看方案</span>
) : null}
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
<time
@@ -57,7 +57,6 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
</Link>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} />
{node.mode === "plan_only" ? <span className="text-meta text-muted-foreground">只要方案</span> : null}
<span className="text-meta text-muted-foreground tabular-nums">
{usageLine({
toolCalls: node.toolCallCount,
+121 -53
View File
@@ -1,5 +1,5 @@
import type { AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentMode, AgentRunView, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
@@ -33,7 +33,11 @@ function chunks(text: string, size = 6): string[] {
return out;
}
function script(goal: string, mode: AgentMode): Step[] {
const MOCK_DECISION = "00000000-0000-4000-8000-00000000d001";
const MOCK_APPROVAL = "00000000-0000-4000-8000-00000000a001";
const MOCK_PLAN = "00000000-0000-4000-8000-00000000b001";
function script(goal: string, opts: AgentSessionOptions): Step[] {
const s: Step[] = [];
let t = 0;
const push = (dt: number, body: AgentRunEventBody) => {
@@ -43,7 +47,22 @@ function script(goal: string, mode: AgentMode): Step[] {
const say = (text: string) => {
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
};
const steps: AgentPlanStep[] = [
{ id: "s1", title: "查本地库存和版本目标", status: "running", note: null },
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
];
let revision = 0;
const plan = (id: string, status: AgentPlanStep["status"], next?: string) => {
for (const st of steps) {
if (st.id === id) st.status = status;
if (next && st.id === next) st.status = "running";
}
push(150, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
};
push(0, { type: "run_started", goal, resumed: false });
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
say("先确认本地库存与版本目标,再去站点找候选。");
push(400, {
type: "tool_call_start",
@@ -60,6 +79,7 @@ function script(goal: string, mode: AgentMode): Step[] {
outputSummary: "作品 沙丘:第二部 (2024):已有 1080p WEB-DL(已核验);Slot「archival」缺 2160p;无下载中任务",
recovery: false,
});
plan("s1", "done", "s2");
push(300, {
type: "tool_call_start",
toolCallId: "t2",
@@ -79,53 +99,102 @@ function script(goal: string, mode: AgentMode): Step[] {
push(300, { type: "compaction", phase: "start", reason: "threshold" });
push(700, { type: "compaction", phase: "end", reason: "threshold", status: "completed" });
say("①符合 archival 的硬条件(REMUX、HDR),免费截止时间在 6 小时后。");
if (mode === "execute") {
plan("s2", "done", "s3");
if (opts.proposeFirst) {
push(300, {
type: "tool_call_start",
toolCallId: "t3",
capabilityId: "download.plan",
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
recovery: false,
type: "options_proposed",
invocationId: "mock-options",
decisionId: MOCK_DECISION,
question: "收哪一个 4K 版本?",
options: [
{
id: "a",
title: "① REMUX 78.4 GB",
summary: "画质最好,符合 archival 的硬条件;免费还剩 6 小时。",
risks: ["缓存卷使用率会到 86%"],
estimatedCost: "78.4 GB",
},
{
id: "b",
title: "② WEB-DL 24.1 GB",
summary: "体积小三分之二,不满足 archival 的 REMUX 条件。",
risks: ["之后可能还要升级"],
estimatedCost: "24.1 GB",
},
],
recommendedId: "a",
stepId: "s3",
expiresAt: iso(Date.now() + 1_800_000),
timeoutAction: "recommended",
});
push(800, {
type: "tool_call_end",
toolCallId: "t3",
capabilityId: "download.plan",
isError: false,
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
recovery: false,
});
push(300, {
type: "tool_call_rejected",
toolCallId: "t4",
toolName: "qb_delete_torrent",
capabilityId: "download.cleanup",
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
});
push(300, {
type: "tool_call_start",
toolCallId: "t5",
capabilityId: "download.submit",
inputSummary: '{"planId":"…9a2e"}',
recovery: false,
});
push(1000, {
type: "tool_call_end",
toolCallId: "t5",
capabilityId: "download.submit",
isError: false,
outputSummary: "已提交到 qb-main,标签 umt-managed,任务 ID …41d0",
recovery: false,
});
say("已提交 4K REMUX 下载。完成后会自动核验并 Copy 入库,原 1080p 保留。");
} else {
say("只要方案:建议提交①,预计占用缓存 78.4 GB,完成后 Copy 入库、保留 1080p。未执行任何写操作。");
push(200, { type: "run_completed", finalText: "", usage: USAGE });
return s;
}
push(300, {
type: "tool_call_start",
toolCallId: "t3",
capabilityId: "download.plan",
inputSummary: '{"sourceReleaseId":"…c3f1","slot":"archival"}',
recovery: false,
});
push(800, {
type: "tool_call_end",
toolCallId: "t3",
capabilityId: "download.plan",
isError: false,
outputSummary: "计划已预留:78.4 GB,缓存卷 cache-a 使用率 71% → 预计 86%(硬阈值 90%)",
recovery: false,
});
plan("s3", "done", "s4");
push(300, {
type: "tool_call_rejected",
toolCallId: "t4",
toolName: "qb_delete_torrent",
capabilityId: "download.cleanup",
reason: "未授权:删除下载器任务不在本次委托范围内(需要显式授权)",
});
push(300, {
type: "tool_call_start",
toolCallId: "t5",
capabilityId: "download.submit",
inputSummary: '{"planId":"…9a2e"}',
recovery: false,
});
push(1000, {
type: "tool_call_end",
toolCallId: "t5",
capabilityId: "download.submit",
isError: false,
outputSummary: '{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}',
recovery: false,
});
push(200, {
type: "approval_requested",
approvalId: MOCK_APPROVAL,
decisionId: null,
invocationId: "mock-submit",
capabilityId: "download.submit",
planType: "acquisition_plan",
planId: MOCK_PLAN,
payloadHash: "sha256:9a2e…",
expectedRevision: 1,
riskLevel: "high",
summary: "下载《沙丘:第二部》:Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX(78.4 GB)",
stepId: "s4",
expiresAt: iso(Date.now() + 86_400_000),
});
say("提交下载需要审批,已在「提交下载」这一步发起。批准后继续。");
push(200, { type: "run_completed", finalText: "", usage: USAGE });
return s;
}
function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, status: AgentRunView["status"]) {
function baseRun(
id: string,
goal: string,
opts: AgentSessionOptions,
createdAt: number,
status: AgentRunView["status"],
) {
return {
id,
createdAt: iso(createdAt),
@@ -138,7 +207,7 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
provider: "anthropic",
model: "claude-sonnet",
thinkingLevel: "medium",
scope: { mode },
scope: { proposeFirst: opts.proposeFirst, interactive: true },
budget: { maxToolCalls: 20, maxTokens: 200_000, maxDurationSec: 600, maxCostUsd: 1 },
usage: {},
evidenceVersion: null,
@@ -151,18 +220,17 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
startedAt: iso(createdAt),
finishedAt: null,
createdBy: "admin",
mode,
dedupeKey: null,
depth: 0,
spawnReason: null,
} satisfies AgentRunView;
}
function add(goal: string, mode: AgentMode, startedAt: number, n: number): Entry {
function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: number): Entry {
const id = uuid(n);
const e: Entry = {
run: baseRun(id, goal, mode, startedAt, "running"),
steps: script(goal, mode),
run: baseRun(id, goal, opts, startedAt, "running"),
steps: script(goal, opts),
startedAt,
abortedAt: null,
appended: [],
@@ -175,9 +243,9 @@ function seed() {
if (seeded) return;
seeded = true;
const now = Date.now();
add("把 沙丘:第二部 的 4K 收进来", "execute", now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K,只给方案", "plan_only", now - 7_200_000, 2);
const failed = add("检查 qb-main 为什么占满磁盘", "execute", now - 86_400_000, 3);
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
failed.steps = [
{ delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } },
{
@@ -356,8 +424,8 @@ export function mockEventsAfter(id: string, after: number): AgentRunEvent[] {
return revealed(e).filter((x) => x.seq > after);
}
export function mockCreateRun(goal: string, mode: AgentMode): AgentRunView {
export function mockCreateRun(goal: string, opts: AgentSessionOptions): AgentRunView {
seed();
const e = add(goal, mode, Date.now(), store.size + 1);
const e = add(goal, opts, Date.now(), store.size + 1);
return view(e);
}
@@ -4,7 +4,7 @@ import { quickGoalsFor } from "./quick-goals.ts";
describe("抽屉快捷目标", () => {
it("按页面给 3 个相关目标", () => {
expect(quickGoalsFor("/console/library/abc")[0]).toBe("检查这部作品缺什么版本");
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站找 ");
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站点找 ");
expect(quickGoalsFor("/console/tasks/t1")[0]).toBe("看看为什么没进展");
for (const p of ["/console", "/console/library", "/console/files", "/nope"]) {
expect(quickGoalsFor(p)).toHaveLength(3);
+4 -4
View File
@@ -1,12 +1,12 @@
// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站找 ")。
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站点找 ")。用词按 docs/design/glossary.md。
type Rule = { match: RegExp; goals: [string, string, string] };
const RULES: Rule[] = [
{
match: /^\/console\/library\/[^/]+/,
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的片源可以升级", "核对这部作品的文件和元数据是否一致"],
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的资源可以升级", "核对这部作品的文件和元数据是否一致"],
},
{
match: /^\/console\/library\/?$/,
@@ -14,7 +14,7 @@ const RULES: Rule[] = [
},
{
match: /^\/console\/sites\/[^/]+/,
goals: ["从这个站找 ", "看看这个站最近有哪些新的 4K 资源", "检查这个站的登录和抓取是否正常"],
goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和抓取是否正常"],
},
{
match: /^\/console\/sites\/?$/,
@@ -38,7 +38,7 @@ const RULES: Rule[] = [
},
{
match: /^\/console\/inbox/,
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项", "哪些事项可以直接按建议批量处理"],
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项待处理事项", "哪些待处理事项可以直接按建议批量处理"],
},
{
match: /^\/console\/identity/,
@@ -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;
}
@@ -2,13 +2,14 @@
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)与 `detail` 槽。
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)、
// `skipped` 状态(Agent 步骤清单里跳过的步骤)与 `detail` 槽。
import { ChevronDown, ListTodo } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error";
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error" | "skipped";
export interface TodoItem {
id: string;
@@ -23,6 +24,7 @@ const LABEL: Record<TodoItemStatus, string> = {
completed: "已完成",
cancelled: "被拒绝",
error: "失败",
skipped: "已跳过",
};
function StatusIcon({ status }: { status: TodoItemStatus }) {
@@ -45,7 +47,7 @@ function StatusIcon({ status }: { status: TodoItemStatus }) {
fillOpacity={status === "completed" ? 0.1 : 0}
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray={status === "pending" ? "2 3" : undefined}
strokeDasharray={status === "pending" || status === "skipped" ? "2 3" : undefined}
className={cn(status === "in-progress" && "opacity-20")}
/>
{status === "in-progress" ? (
@@ -72,6 +74,9 @@ function StatusIcon({ status }: { status: TodoItemStatus }) {
strokeLinejoin="round"
/>
) : null}
{status === "skipped" ? (
<path d="M8 12h8" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
) : null}
{status === "cancelled" || status === "error" ? (
<path
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
@@ -162,6 +167,7 @@ export function TodoList({
s === "pending" && "text-muted-foreground",
s === "in-progress" && "text-foreground",
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
s === "skipped" && "text-muted-foreground",
(s === "cancelled" || s === "error") && "text-destructive",
)}
>
@@ -150,6 +150,27 @@ const ADVANCED_CARD: CardSpec = {
kind: "number",
hint: "超时后按「仍在等待」汇报结束",
},
{
name: "agent.optionsTimeoutSec",
label: "方案选择等待(秒)",
kind: "number",
hint: "Agent 给出方案后等你选择的最长时间",
},
{
name: "agent.optionsTimeoutAction",
label: "超时未选",
kind: "select",
options: [
{ value: "recommended", label: "自动选推荐项" },
{ value: "stop", label: "不选,Agent 收尾" },
],
},
{
name: "agent.approvalTimeoutSec",
label: "会话内审批有效期(秒)",
kind: "number",
hint: "步骤处的审批卡过期未批准,按拒绝处理",
},
{
name: "agent.sessionDir",
label: "会话目录",
@@ -168,6 +189,9 @@ const ADVANCED_CARD: CardSpec = {
fromSettings: (s) => ({
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
"agent.optionsTimeoutSec": n(s.agent.optionsTimeoutSec),
"agent.optionsTimeoutAction": s.agent.optionsTimeoutAction,
"agent.approvalTimeoutSec": n(s.agent.approvalTimeoutSec),
"agent.sessionDir": s.agent.sessionDir ?? "",
"agent.fakeScript": s.agent.fakeScript ?? "",
}),
@@ -175,6 +199,9 @@ const ADVANCED_CARD: CardSpec = {
agent: {
goalPollSec: num(v["agent.goalPollSec"]),
goalWaitHours: num(v["agent.goalWaitHours"]),
optionsTimeoutSec: num(v["agent.optionsTimeoutSec"]),
optionsTimeoutAction: v["agent.optionsTimeoutAction"] === "stop" ? "stop" : "recommended",
approvalTimeoutSec: num(v["agent.approvalTimeoutSec"]),
sessionDir: str(v["agent.sessionDir"]) || null,
fakeScript: str(v["agent.fakeScript"]) || null,
},
+2 -8
View File
@@ -1,7 +1,7 @@
import type { AgentRunStatus } from "@umt/contracts";
import { ArrowLeft } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { Link, useParams } from "react-router";
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts";
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
@@ -12,7 +12,6 @@ export default function AgentRunPage() {
const { runId = "" } = useParams();
const run = useAgentRun(runId);
const mock = agentMockEnabled();
const navigate = useNavigate();
const [status, setStatus] = useState<"" | AgentRunStatus>("");
return (
<>
@@ -33,12 +32,7 @@ export default function AgentRunPage() {
<AgentSessionList activeId={runId} status={status} onStatusChange={setStatus} className="h-full" />
</aside>
<div className="pg-slab flex h-[calc(100dvh-11rem)] min-h-[32rem] flex-col rounded-xl p-3 md:p-4 lg:h-[calc(100dvh-13rem)]">
<AgentRunPanel
key={runId}
runId={runId}
className="flex-1"
onRunCreated={(id) => navigate(`/console/agent/${id}${mock ? "?mock=1" : ""}`)}
/>
<AgentRunPanel key={runId} runId={runId} className="flex-1" />
</div>
</div>
</>
+9 -4
View File
@@ -18,7 +18,10 @@ export default function AgentPage() {
return (
<>
<PageHeader title="Agent 会话" description="交代目标,Agent 在授权范围内执行并汇报。只要方案时只调查,不执行。" />
<PageHeader
title="Agent 会话"
description="交代目标,Agent 列出步骤后在授权范围内执行并汇报;需要审批的动作停在对应步骤等你批准。"
/>
{mock ? (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
<FlaskConical className="h-4 w-4 shrink-0" />
@@ -37,9 +40,9 @@ export default function AgentPage() {
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
<GoalComposer
pending={create.isPending}
onSubmit={(goal, mode) =>
onSubmit={(goal, opts) =>
create.mutate(
{ goal, mode },
{ goal, ...opts },
{
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
onError: (e) => toast.error(errorMessage(e)),
@@ -51,7 +54,9 @@ export default function AgentPage() {
<div className="flex gap-3 rounded-xl border border-border/70 p-4 text-caption text-muted-foreground">
<ShieldCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
<div className="space-y-1">
<p>每次工具调用都经服务端授权校验,不在白名单或没有授权的调用会被拒绝并在对话里标出。</p>
<p>
每次工具调用都经服务端授权校验。控制台发起的会话里,没有持续授权的下载、整理、策略发布会在对应步骤出审批卡。
</p>
<p>汇报分两栏:模型自述仅供参考,"系统核实的结果"取自下载计划、下载任务和整理记录。</p>
</div>
</div>