feat(web): 智能体对话重做(AGUI-3)

- 会话流:用户消息 → 一行进度 → 结论正文 → 没做的事 → 一行实际结果。进度行把一段里的工具调用、思考和过程中的话
  聚成一行(1 次也折叠),按能力 verb/noun 人话计数(「查了站点、2 个下载任务和媒体库 · 1 项没做 · 3 秒」),
  展开每步一行;工具名、调用 ID、幂等键、输入输出进技术信息。去掉回合头、「模型说的」重复卡和系统核实大卡。
- agent.plan / agent.propose_options 不作为工具调用出现(旧事件回放同样跳过);被拒调用不出红卡,
  回合末尾一行「没做:检测站点,需要你批准 [批准并重试]」(非计划类能力建一次性持续授权后重试)。
- 方案卡:每个方案一个按钮,点一下就选定;推荐项第一位、主按钮;按钮写服务端给的动作要点;风险折叠;
  「都不要…」点开才出原因框;选完收成一行。审批卡只留摘要和批准 / 拒绝,计划 ID、hash、版本、到期进技术信息。
- 会话页去掉右侧栏和页头目标,步骤 / 子会话 / 模型 / 预算 / ID 进顶部概况条;输入区只有输入框和发送 / 停止,
  / 指令 /先给方案、/重跑(「先看方案再执行」开关原本只是新建时的本地状态,不需要迁移)。
- 抽屉去掉「当前 / 历史」Tab:头部「历史」弹层(最近 20 条)+「新对话」;上下文显示成名称标签,可去掉。
- 会话列表:搜索 + 全部 / 等你处理 / 失败;等你处理置顶,子会话收在父会话下,正常结束不加徽章。
- 入口页与空态给 3 个快捷目标。mock 加审查报告 3 个示例的演示数据;smoke 改测历史弹层。
- 组件测试:聚合、隐藏、拒绝一行、方案一键选定、历史置顶与子会话、指令。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:41:34 +08:00
1 parent 2ab6d452dd
commit 6fcf82afb0
17 files changed
+2214 -1679

No files matched your search

+36 -2
View File
@@ -20,7 +20,7 @@ import {
agentRunEventFromRow, agentRunEventFromRow,
cursorPage, cursorPage,
} from "@umt/contracts"; } from "@umt/contracts";
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts"; import { MOCK_RUNS, mockChoose, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
import { TTL_SECOND } from "~/lib/api.ts"; import { TTL_SECOND } from "~/lib/api.ts";
import { useSession } from "./auth.ts"; import { useSession } from "./auth.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
@@ -254,7 +254,7 @@ export function useChooseOption() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (input: { runId: string; decisionId: string; optionId: string | null; reason?: string }) => { mutationFn: async (input: { runId: string; decisionId: string; optionId: string | null; reason?: string }) => {
if (agentMockEnabled()) return mockDetail(input.runId).run; if (agentMockEnabled()) return mockChoose(input.runId, input.optionId).run;
return apiMutate(`${AGENT_BASE}/:id/choice`, { return apiMutate(`${AGENT_BASE}/:id/choice`, {
pathParams: { id: input.runId }, pathParams: { id: input.runId },
body: { decisionId: input.decisionId, optionId: input.optionId, reason: input.reason }, body: { decisionId: input.decisionId, optionId: input.optionId, reason: input.reason },
@@ -264,6 +264,40 @@ export function useChooseOption() {
}); });
} }
/** 需要按计划单次审批的能力:这类动作在会话里走审批卡,不另建持续授权 */
const PLAN_APPROVAL_CAPS = new Set(["download.submit", "organize.execute", "policy.apply"]);
/**
* 「批准并重试」(AGUI-3):被拒绝的调用由管理员批准一次后让智能体重试。
* 非计划类能力建一条只覆盖这个能力、今天只能成功一次、1 小时后过期的持续授权(服务端核对,不靠模型转述);
* 计划类能力只追加消息让智能体重试,重试时在步骤处照常出审批卡。
*/
export function useApproveAndRetry() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: { runId: string; capabilityId: string; title: string }) => {
const text = PLAN_APPROVAL_CAPS.has(input.capabilityId)
? `重试「${input.title}」,需要审批时在步骤处等我批准。`
: `已批准「${input.title}」一次,重试这一步。`;
if (agentMockEnabled()) return mockDetail(input.runId, { append: text }).run;
if (!PLAN_APPROVAL_CAPS.has(input.capabilityId))
await apiMutate("/automation-grants", {
body: {
action: input.capabilityId,
scope: { capabilities: [input.capabilityId] },
budget: { maxInvocationsPerDay: 1 },
reason: `智能体会话 ${input.runId.slice(0, 8)} 里批准并重试`,
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
},
});
return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id: input.runId }, body: { text } }).then((d) =>
AgentRun.parse(d),
);
},
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.runId) }),
});
}
export const RUN_STATUS_LABEL: Record<AgentRunStatus, string> = { export const RUN_STATUS_LABEL: Record<AgentRunStatus, string> = {
queued: "排队中", queued: "排队中",
running: "运行中", running: "运行中",
File diff suppressed because it is too large. Load diff
+92 -155
View File
@@ -1,30 +1,31 @@
import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react"; import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { useDownloadTask } from "~/api/downloads.ts";
import { useLibraryDetail } from "~/api/library.ts";
import { useRecord } from "~/api/records.ts";
import { useSite } from "~/api/sites.ts";
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx"; import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList } from "./agent-session-list.tsx"; import { AgentSessionList } from "./agent-session-list.tsx";
import { import {
type AgentContext, type AgentContext,
type AgentDrawerTab,
agentShortcutLabel, agentShortcutLabel,
clearAgentContext,
contextFromPath, contextFromPath,
contextPrefix, contextPrefix,
lastAgentDrawerTab,
minimizeAgentDrawer, minimizeAgentDrawer,
newAgentConversation,
openAgentDrawer, openAgentDrawer,
openAgentHistoryRun,
restoreAgentDrawer, restoreAgentDrawer,
setAgentDrawerOpen, setAgentDrawerOpen,
setAgentDrawerRun,
setAgentDrawerTab,
shortGoalTitle, shortGoalTitle,
showAgentRunInDrawer, showAgentRunInDrawer,
useAgentDrawer, useAgentDrawer,
@@ -36,108 +37,73 @@ import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */ /** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher"; export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
const CONTEXT_KEYS: [keyof AgentContext, string][] = [ /**
["entityId", "作品"], * 上下文的显示名称:页面按钮给的 label(「作品:沙丘2」)去掉类别;按路由推断的只有 ID,按 ID 查名称。
["releaseId", "资源"], * 查不到时退回类别名(「这部作品」),不显示 ID。
["downloadTaskId", "下载任务"], */
["recordId", "整理记录"], export function useContextName(ctx: AgentContext | null): string | null {
["siteId", "站点"], const path = !!ctx?.fromPath;
["taskId", "任务"], const entity = useLibraryDetail(path ? ctx?.entityId : undefined);
]; const site = useSite(path ? ctx?.siteId : undefined);
const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined);
function ContextChips({ context }: { context: AgentContext }) { const record = useRecord(path ? ctx?.recordId : undefined);
const chips = CONTEXT_KEYS.filter(([k]) => context[k]); if (!ctx) return null;
if (!chips.length) return null; if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null;
return ( if (ctx.entityId) return entity.data?.entity.title ?? "这部作品";
<ul aria-label="当前页面上下文" className="mt-1.5 flex flex-wrap gap-1"> if (ctx.siteId) return site.data?.name ?? ctx.siteId;
{chips.map(([k, label]) => ( if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务";
<li if (ctx.recordId) return record.data?.media?.title ?? "这条整理记录";
key={k} if (ctx.taskId) return "这个后台任务";
className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 font-mono text-label text-muted-foreground" return null;
title={String(context[k])}
>
{label} · {String(context[k]).slice(0, 12)}
</li>
))}
</ul>
);
} }
const TABS: { value: AgentDrawerTab; label: string }[] = [ /** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */
{ value: "current", label: "当前" }, function HistoryPopover({ activeId }: { activeId: string | null }) {
{ value: "history", label: "历史" }, const [open, setOpen] = useState(false);
];
function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
return (
<div
role="tablist"
aria-label="智能体视图"
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
>
{TABS.map((t) => (
<button
key={t.value}
type="button"
role="tab"
id={`agent-tab-${t.value}`}
aria-selected={tab === t.value}
aria-controls="agent-tabpanel"
onClick={() => setAgentDrawerTab(t.value)}
className={cn(
"h-7 cursor-pointer rounded-full border border-transparent px-3 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
tab === t.value && "pill-selected text-foreground",
)}
>
{t.label}
</button>
))}
</div>
);
}
/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */
function HistoryRunView({ runId }: { runId: string }) {
const run = useAgentRun(runId);
const mock = agentMockEnabled(); const mock = agentMockEnabled();
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2"> <Popover open={open} onOpenChange={setOpen}>
<div className="flex min-w-0 items-center gap-1"> <PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="-ml-2 shrink-0" onClick={() => openAgentHistoryRun(null)}> <Button variant="ghost" size="sm" aria-label="历史会话" title="历史会话">
<ArrowLeft /> <History />
返回列表 历史
</Button> </Button>
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}> </PopoverTrigger>
{run.data ? shortGoalTitle(run.data.run.goal) : ""} <PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
</p> <AgentSessionList
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground"> remember
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}> limit={20}
<ExternalLink /> activeId={activeId ?? undefined}
在页面打开 onOpen={(id) => {
setOpen(false);
showAgentRunInDrawer(id);
}}
className="min-h-0 flex-1"
/>
<Button asChild variant="ghost" size="sm" className="mt-1 shrink-0 text-muted-foreground">
<Link to={`/console/agent${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link> </Link>
</Button> </Button>
</div> </PopoverContent>
<AgentRunOpenContext.Provider value={openAgentHistoryRun}> </Popover>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
</div>
); );
} }
/** /**
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; * 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。
* 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。 * 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。
* 运行中可最小化到右下角小窗,流式输出继续。 * 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
*/ */
export function AgentDrawer() { export function AgentDrawer() {
const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer(); const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun(); const create = useCreateAgentRun();
const prefix = contextPrefix(context);
const { pathname } = useLocation(); const { pathname } = useLocation();
const content = useRef<HTMLDivElement>(null); const content = useRef<HTMLDivElement>(null);
const history = tab === "history"; const name = useContextName(context);
const mock = agentMockEnabled();
// 目标前缀带名称和 ID:名称给人看,ID 给模型用
const prefix = contextPrefix(context && name ? { ...context, label: name } : context);
return ( return (
<> <>
@@ -146,7 +112,7 @@ export function AgentDrawer() {
ref={content} ref={content}
side="right" side="right"
className="w-full gap-0 p-0 sm:max-w-xl" className="w-full gap-0 p-0 sm:max-w-xl"
// 打开即可输入:焦点直接给输入框(新目标或运行中的追加消息),没有输入框时退回抽屉本身 // 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
onOpenAutoFocus={(e) => { onOpenAutoFocus={(e) => {
const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])"); const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])");
if (!ta) return; if (!ta) return;
@@ -158,93 +124,66 @@ export function AgentDrawer() {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault(); if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}} }}
> >
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24"> <SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-12 pl-5">
<SheetTitle className="flex items-center gap-2 text-lead"> <SheetTitle className="mr-auto flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
智能体 智能体
</SheetTitle> </SheetTitle>
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}> <SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`} <HistoryPopover activeId={runId} />
</SheetDescription> <Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
{context && !history ? <ContextChips context={context} /> : null} <SquarePen />
<DrawerTabs tab={tab} /> 新对话
</SheetHeader> </Button>
{runId && !history ? ( {runId ? (
<>
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
<Link
to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`}
onClick={() => setAgentDrawerOpen(false)}
>
<ExternalLink />
</Link>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="absolute top-4 right-14"
aria-label="最小化(运行继续)" aria-label="最小化(运行继续)"
title="最小化(运行继续)" title="最小化(运行继续)"
onClick={minimizeAgentDrawer} onClick={minimizeAgentDrawer}
> >
<Minus /> <Minus />
</Button> </Button>
</>
) : null} ) : null}
<div </SheetHeader>
id="agent-tabpanel" <div className="flex min-h-0 flex-1 flex-col px-4 py-3">
role="tabpanel" {runId ? (
aria-labelledby={`agent-tab-${tab}`} <AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
className="flex min-h-0 flex-1 flex-col px-4 py-3" <AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
>
{history ? (
historyRunId ? (
<HistoryRunView runId={historyRunId} />
) : (
<AgentSessionList
remember
activeId={runId ?? undefined}
onOpen={openAgentHistoryRun}
className="flex-1"
/>
)
) : runId ? (
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
<AgentRunPanel runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider> </AgentRunOpenContext.Provider>
) : ( ) : (
<div className="flex min-h-0 flex-1 flex-col justify-end">
<GoalComposer <GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`} key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)} suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
prefix={prefix || undefined} context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""} defaultGoal={context?.goal ?? ""}
pending={create.isPending} pending={create.isPending}
onSubmit={(goal, opts) => onSubmit={(goal, opts) =>
create.mutate( create.mutate(
{ goal: `${prefix}${goal}`, ...opts }, { goal: `${prefix}${goal}`, ...opts },
{ {
onSuccess: (run) => setAgentDrawerRun(run.id), onSuccess: (run) => showAgentRunInDrawer(run.id),
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
}, },
) )
} }
/> />
)}
</div>
{history ? null : (
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-2.5">
{runId ? (
<>
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
<Plus />
新目标
</Button>
<Button asChild variant="ghost" size="sm" className="text-muted-foreground">
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在页面打开
</Link>
</Button>
</>
) : (
<Button variant="ghost" size="sm" onClick={() => setAgentDrawerTab("history")}>
<History />
历史会话
</Button>
)}
</div> </div>
)} )}
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null} {minimized && runId ? <AgentMiniDock runId={runId} /> : null}
@@ -268,7 +207,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
? "思考中…" ? "思考中…"
: last?.kind === "end" : last?.kind === "end"
? last.ok ? last.ok
? "已完成,展开查看核实结果" ? "已完成,展开查看结果"
: last.message : last.message
: "等待输出…"; : "等待输出…";
const status = run.data const status = run.data
@@ -325,9 +264,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
aria-label="打开智能体" aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`} title={`智能体(${agentShortcutLabel()})`}
className={className} className={className}
onClick={() => onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
}
> >
<Bot /> <Bot />
</Button> </Button>
@@ -1,17 +1,9 @@
import type { AgentRunEvent } from "@umt/contracts"; import type { AgentRunEvent } from "@umt/contracts";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { authDenial, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx";
authDenial, import { matchesHistory } from "./agent-session-list.tsx";
type ConvEntry,
groupConversation,
isDenied,
isLongReport,
pairAudit,
runPlanIds,
toolSummary,
} from "./agent-conversation.tsx";
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts"; import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
import { isLongReport } from "./conversation-model.ts";
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts"; import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
const call = (p: Partial<ToolCallView>): ToolCallView => ({ const call = (p: Partial<ToolCallView>): ToolCallView => ({
@@ -100,7 +92,7 @@ describe("事件折叠", () => {
const start = { const start = {
type: "tool_call_start", type: "tool_call_start",
toolCallId: "tc1", toolCallId: "tc1",
capabilityId: "agent.propose_options", capabilityId: "download.submit",
inputSummary: "{}", inputSummary: "{}",
}; };
const items = foldEvents([ const items = foldEvents([
@@ -109,7 +101,7 @@ describe("事件折叠", () => {
ev(3, { ev(3, {
type: "tool_call_end", type: "tool_call_end",
toolCallId: "tc1", toolCallId: "tc1",
capabilityId: "agent.propose_options", capabilityId: "download.submit",
outputSummary: "ok", outputSummary: "ok",
isError: false, isError: false,
recovery: true, recovery: true,
@@ -122,67 +114,29 @@ describe("事件折叠", () => {
}); });
}); });
describe("会话排版(AGUI-2)", () => { describe("工具结果摘要", () => {
const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({ it("展开后每步一行的「→ 结果」", () => {
key,
at: key,
role,
foldable,
label: foldable ? "查本地库存" : undefined,
node: null,
});
it("同一助手回合归为一组,用户 / 系统条目打断回合", () => {
const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]);
expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]);
const first = blocks[0];
expect(first?.kind === "turn" && first.parts.length).toBe(2);
});
it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => {
const [t] = groupConversation([
e("1", "assistant", true),
e("2", "assistant", true),
e("3", "assistant", true),
e("4", "assistant"),
e("5", "assistant", true),
e("6", "assistant", true),
]);
expect(t?.kind).toBe("turn");
if (t?.kind !== "turn") return;
expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([
"fold3",
"4",
"5",
"6",
]);
});
it("工具卡一行摘要", () => {
expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…"); expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…");
expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果"); expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条");
expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时"); expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时");
expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe( // 没有人话可写的对象不上屏(字段名进技术信息)
"found: true · count: 2", expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBeNull();
); expect(toolSummary({ status: "succeeded", outputSummary: '{"plan":{"id":"x"…' })).toBeNull();
expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时"); expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时");
}); });
it("长汇报判定", () => { it("长正文判定", () => {
expect(isLongReport("短")).toBe(false); expect(isLongReport("短")).toBe(false);
expect(isLongReport("x".repeat(601))).toBe(true); expect(isLongReport("x".repeat(601))).toBe(true);
expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true); expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true);
}); });
}); });
describe("会话历史筛选", () => { describe("会话历史搜索", () => {
const now = new Date("2026-09-29T12:00:00Z").getTime(); const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来" };
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" };
it("搜索匹配目标或 ID 前缀", () => { it("搜索匹配目标或 ID 前缀", () => {
expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true); expect(matchesHistory(run, { q: "沙丘" })).toBe(true);
expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true); expect(matchesHistory(run, { q: "abcd" })).toBe(true);
expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false); expect(matchesHistory(run, { q: "奥本海默" })).toBe(false);
});
it("时间范围按创建时间", () => {
expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true);
expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false);
expect(rangeStart("", now)).toBeNull();
}); });
}); });
@@ -1,11 +1,18 @@
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts"; import {
import { ListChecks, MessageCircleQuestion, Star } from "lucide-react"; type AgentOptionView,
type AgentPlanStep,
CAPABILITY_CATALOG,
type KnownCapabilityId,
type RiskLevel,
} from "@umt/contracts";
import { ChevronRight, ListChecks, LoaderCircle, MessageCircleQuestion } from "lucide-react";
import { useId, useState } from "react"; import { useId, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts"; import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx"; import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx"; import { type TodoItem, type TodoItemStatus, TodoList } from "~/components/agentui/todo-list.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Textarea } from "~/components/ui/textarea.tsx"; import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
@@ -28,7 +35,8 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
id: s.id, id: s.id,
title: s.title, title: s.title,
status: STEP_STATUS[s.status], status: STEP_STATUS[s.status],
...(s.note ? { detail: <span title={s.note}>{s.note}</span> } : {}), // 备注只在失败时显示(失败原因);其余备注是模型的过程说明,不上屏
...(s.note && s.status === "failed" ? { detail: <span title={s.note}>{s.note}</span> } : {}),
})); }));
} }
@@ -90,163 +98,206 @@ export function RunPlan({
// ── 方案选择 ── // ── 方案选择 ──
const NONE = "__none__"; /** 方案排序:推荐项放第一位 */
export function orderOptions<T extends { id: string }>(options: T[], recommendedId: string | null): T[] {
const rec = options.find((o) => o.id === recommendedId);
return rec ? [rec, ...options.filter((o) => o !== rec)] : options;
}
/** agent.propose_options 的选择卡:单选一个方案,或"都不要"并写原因;选定后会话继续 */ /**
* 方案按钮上的动作要点:「提交下载 · 《沙丘:第二部》· 12.0 GB」;标题已经是动作名时只写对象和大小。
* 没有动作时用模型给的估算代价。
*/
export function optionPoint(o: AgentOptionView): string | null {
if (o.action) {
const label = o.title.includes(o.action.label) ? null : o.action.label;
return [label, o.action.detail].filter(Boolean).join(" · ") || null;
}
return o.estimatedCost ?? null;
}
/** 超时说明里的时间:当天只写时分 */
function deadlineText(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toDateString() === new Date().toDateString()
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
: formatDateTime(iso);
}
/**
* agent.propose_options 的方案卡(AGUI-3):每个方案一个按钮,点一下就选定;推荐项在第一位、是主按钮。
* 绑定了计划的方案写明动作要点,管理员选定即批准(服务端核对绑定)。风险折叠;「都不要…」点开才出原因框。
*/
export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) { export function OptionsCard({ runId, view, waiting }: { runId: string; view: OptionsView; waiting: boolean }) {
const name = useId();
const choose = useChooseOption(); const choose = useChooseOption();
const [picked, setPicked] = useState<string>(view.recommendedId ?? ""); const riskId = useId();
const [riskOpen, setRiskOpen] = useState(false);
const [noneOpen, setNoneOpen] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [picking, setPicking] = useState<string | null>(null);
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null); const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
const resolved = view.resolved; const resolved = view.resolved;
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示 // 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
const closed = !!resolved || !!sent || !waiting; const closed = !!resolved || !!sent || !waiting;
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null; const options = orderOptions(view.options, view.recommendedId);
const submit = () => { const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r })));
const optionId = picked === NONE ? null : picked; const submit = (optionId: string | null) => {
if (!picked) return;
if (optionId === null && !reason.trim()) { if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,智能体按它调整"); toast.error("写一句不要的原因,智能体按它调整");
return; return;
} }
setPicking(optionId ?? "__none__");
choose.mutate( choose.mutate(
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) }, { runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
{ {
onSuccess: () => { onSuccess: () => {
setSent({ optionId, reason: reason.trim() }); setSent({ optionId, reason: reason.trim() });
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案"); const o = view.options.find((x) => x.id === optionId);
toast.success(optionId ? (o?.action?.approves ? "已选定并批准" : "已选定") : "已退回,智能体调整方案");
}, },
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
onSettled: () => setPicking(null),
}, },
); );
}; };
const label = (id: string | null) => {
const o = view.options.find((x) => x.id === id);
if (!o) return id ?? "";
const point = optionPoint(o);
return point && o.action ? `${o.title} · ${point}` : o.title;
};
const outcome = resolved const outcome = resolved
? resolved.outcome === "chosen" ? resolved.outcome === "chosen"
? `${resolved.by === "timeout" ? "超时自动选择" : "已选择"}:${view.options.find((o) => o.id === resolved.choice)?.title ?? resolved.choice}` ? `${resolved.by === "timeout" ? "超时自动选了" : "已选"}:${label(resolved.choice)}`
: resolved.outcome === "stop" : resolved.outcome === "stop"
? "超时未选,按设置停止" ? "超时没选,已停止"
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}` : `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
: sent : sent
? sent.optionId ? sent.optionId
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续` ? `已选:${label(sent.optionId)}`
: `都不要:${sent.reason},等待智能体调整` : `都不要:${sent.reason}`
: !waiting : !waiting
? "会话已不在等待这个选择" ? "已不再等这个选择"
: null; : null;
if (closed)
return ( return (
<section <p
aria-label="选择方案" data-slot="options-resolved"
className={cn( className="flex w-full min-w-0 items-start gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption text-muted-foreground"
"w-full min-w-0 rounded-xl border p-3",
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
)}
> >
<MessageCircleQuestion aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
<span className="text-foreground/85">{view.question}</span> · {outcome}
</span>
</p>
);
return (
<section aria-label="选择方案" className="w-full min-w-0 rounded-xl border border-warning/40 bg-warning/[0.06] p-3">
<p className="flex items-start gap-2 text-caption font-medium text-foreground"> <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" /> <MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 break-words">{view.question}</span> <span className="min-w-0 break-words">{view.question}</span>
</p> </p>
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}> <ul className="mt-2.5 flex flex-col gap-1.5">
<legend className="sr-only">{view.question}</legend> {options.map((o) => {
{view.options.map((o) => { const recommended = o.id === view.recommendedId;
const selected = closed ? chosenId === o.id : picked === o.id; const point = optionPoint(o);
return ( return (
<label <li key={o.id}>
key={o.id} <button
type="button"
disabled={choose.isPending}
onClick={() => submit(o.id)}
data-recommended={recommended || undefined}
className={cn( 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", "flex w-full min-w-0 cursor-pointer flex-col gap-0.5 rounded-lg border px-3 py-2 text-left outline-none transition-colors duration-micro focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
selected && "border-foreground/40 bg-foreground/[0.04]", recommended
closed && !selected && "opacity-60", ? "pill-primary border-transparent"
closed && "cursor-default", : "border-border/70 bg-background/40 hover:bg-foreground/[0.04]",
)} )}
> >
<input <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-caption font-medium">
type="radio" {picking === o.id ? (
name={name} <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
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} ) : null}
{o.estimatedCost ? <StatusBadge>估算 {o.estimatedCost}</StatusBadge> : null} <span className="min-w-0 break-words">{o.title}</span>
{point ? <span className="font-normal opacity-80">· {point}</span> : null}
{recommended ? <span className="font-normal opacity-80">(推荐)</span> : null}
</span> </span>
<span className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span> <span className={cn("text-meta break-words", recommended ? "opacity-80" : "text-muted-foreground")}>
{o.risks.length ? ( {o.summary}
<ul className="mt-1 space-y-0.5 text-meta text-warning"> {o.action ? (o.action.approves ? "。选定即执行。" : "。执行前还会再确认。") : ""}
{o.risks.map((r) => ( </span>
<li key={r} className="break-words"> </button>
风险:{r} </li>
);
})}
</ul>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
{risks.length ? (
<button
type="button"
aria-expanded={riskOpen}
aria-controls={riskId}
onClick={() => setRiskOpen((v) => !v)}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{risks.length} 条风险
<ChevronRight aria-hidden className={cn("size-3.5", CHEVRON_CLASS, riskOpen && "rotate-90")} />
</button>
) : null}
<button
type="button"
aria-expanded={noneOpen}
onClick={() => setNoneOpen((v) => !v)}
className="cursor-pointer rounded-md underline-offset-2 outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
都不要…
</button>
<span className="ml-auto">
{deadlineText(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}
</span>
</div>
<Disclosure id={riskId} open={riskOpen}>
<ul className="mt-1.5 space-y-0.5 text-meta text-warning">
{risks.map((r) => (
<li key={r.key} className="break-words">
{r.option}:{r.text}
</li> </li>
))} ))}
</ul> </ul>
) : null} </Disclosure>
</span> {noneOpen ? (
</label> <div className="mt-2 flex flex-col gap-2">
);
})}
{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 <Textarea
className="mt-2 min-h-16 text-caption" className="min-h-16 text-caption"
value={reason} value={reason}
maxLength={1000} maxLength={1000}
aria-label="不要的原因" aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL" placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
/> />
) : null} <Button
{closed ? ( size="sm"
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p> variant="outline"
) : ( className="self-start"
<div className="mt-2.5 flex flex-wrap items-center gap-2"> disabled={!reason.trim() || choose.isPending}
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}> onClick={() => submit(null)}
{picked === NONE ? "退回" : "选定"} >
退回
</Button> </Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
</span>
</div> </div>
)} ) : null}
</section> </section>
); );
} }
// ── 步骤处的审批卡 ── // ── 步骤处的审批卡 ──
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = { const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const PLAN_LABEL: Record<string, string> = { const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划", acquisition_plan: "下载计划",
organize_plan: "整理计划", organize_plan: "整理计划",
@@ -261,11 +312,12 @@ const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
consumed: "consumed", consumed: "consumed",
}; };
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */ /** 需审批的能力调用:只显示摘要和【批准】【拒绝】;计划 ID、payload hash、版本、到期进技术信息 */
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) { export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
const admin = useIsAdminSession(); const admin = useIsAdminSession();
const live = useApproval(view.approvalId); const live = useApproval(view.approvalId);
const decide = useDecideApproval(); const decide = useDecideApproval();
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const a = live.data; const a = live.data;
const expired = new Date(view.expiresAt).getTime() < Date.now(); const expired = new Date(view.expiresAt).getTime() < Date.now();
@@ -295,15 +347,10 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
status={status} status={status}
title={`批准「${cap?.title ?? view.capabilityId}」?`} title={`批准「${cap?.title ?? view.capabilityId}」?`}
description={view.summary} description={view.summary}
badges={
<>
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
</>
}
onApprove={() => act("approved")} onApprove={() => act("approved")}
onReject={() => act("rejected")} onReject={() => (rejecting ? act("rejected") : setRejecting(true))}
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"} rejectLabel={rejecting ? "确认拒绝" : "拒绝"}
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={ result={
status === "rejected" && decidedReason status === "rejected" && decidedReason
? `已拒绝:${decidedReason}` ? `已拒绝:${decidedReason}`
@@ -312,30 +359,27 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
: undefined : undefined
} }
> >
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta"> {rejecting && status === "pending" ? (
<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 <Textarea
className="mt-2 min-h-14 text-caption" className="mb-2 min-h-14 text-caption"
value={reason} value={reason}
maxLength={2000} maxLength={2000}
aria-label="拒绝理由" aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),智能体按它调整" placeholder="理由或建议(可选),智能体按它调整"
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
/> />
) : null} ) : null}
<TechInfo
items={[
{ label: "步骤", value: stepTitle ?? null },
{ label: "风险", value: RISK_LABEL[view.riskLevel] },
{ label: "计划", value: `${PLAN_LABEL[view.planType] ?? view.planType} · ${view.planId}`, wide: true },
{ label: "payload hash", value: view.payloadHash, wide: true },
{ label: "版本", value: `r${view.expectedRevision}` },
{ label: "到期", value: formatDateTime(view.expiresAt) },
{ label: "审批 ID", value: view.approvalId, wide: true },
]}
/>
</ApprovalCard> </ApprovalCard>
); );
} }
+121 -167
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type AgentRunStatus, EventType } from "@umt/contracts"; import { type AgentRunStatus, EventType } from "@umt/contracts";
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react"; import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react";
import { useId, useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -8,7 +8,6 @@ import {
type AgentSessionOptions, type AgentSessionOptions,
agentKeys, agentKeys,
agentMockEnabled, agentMockEnabled,
isProposeFirst,
isRunActive, isRunActive,
type RunSnapshot, type RunSnapshot,
TRIGGER_LABEL, TRIGGER_LABEL,
@@ -25,59 +24,61 @@ import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { formatUsd } from "~/components/schedules/labels.ts"; import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.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 { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts"; import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatDateTime } from "~/lib/utils.ts"; import { cn, formatDateTime } from "~/lib/utils.ts";
import { import {
AgentConversation, AgentConversation,
ApprovalItem, ApprovalItem,
BudgetBar, ResultLine,
FinalReport,
RunAnnouncer, RunAnnouncer,
RunStatusBadge, RunStatusBadge,
runPlanIds, runPlanIds,
} from "./agent-conversation.tsx"; } from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.tsx"; import { RunPlan } from "./agent-interaction.tsx";
import { SubagentTree } from "./agent-subagent-tree.tsx"; import { SubagentTree } from "./agent-subagent-tree.tsx";
import {
NEW_RUN_COMMANDS,
PROPOSE_FIRST,
PROPOSE_FIRST_MESSAGE,
parseCommand,
RERUN,
RUN_COMMANDS,
} from "./slash-commands.ts";
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts"; import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */ /** 新目标的提交内容:`/先给方案` 开头 → proposeFirst(只作用于这一次) */
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { export function goalFromInput(text: string): { goal: string; opts: AgentSessionOptions } | null {
const id = useId(); const { command, rest } = parseCommand(text, NEW_RUN_COMMANDS);
return ( if (!rest) return null;
<div className="flex items-center gap-2 px-1"> return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } };
<Switch id={id} checked={checked} onCheckedChange={onChange} />
<Label htmlFor={id} className="cursor-pointer text-sm font-normal text-muted-foreground">
先看方案再执行
</Label>
</div>
);
} }
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(Enter 发送,Shift+Enter 换行) */ /**
* 新目标输入:只有输入框和发送;`/先给方案` 让这一次先给方案。空态下面 3 个快捷目标,点一下填进输入框
* (以空格或冒号结尾的是半句,光标停在末尾等补完)。上下文(当前页面的对象)显示成名称标签,可以去掉。
*/
export function GoalComposer({ export function GoalComposer({
onSubmit, onSubmit,
pending, pending,
prefix, context,
onClearContext,
defaultGoal = "", defaultGoal = "",
compact = false,
suggestions, suggestions,
}: { }: {
onSubmit: (goal: string, opts: AgentSessionOptions) => void; onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean; pending: boolean;
/** 上下文前缀(只读展示,提交时拼在目标前) */ /** 上下文标签(对象名称) */
prefix?: string; context?: string | null;
onClearContext?: () => void;
defaultGoal?: string; defaultGoal?: string;
compact?: boolean; /** 快捷目标:输入框为空时显示 */
/** 快捷目标:点一下填入输入框(不直接提交),光标停在末尾 */
suggestions?: string[]; suggestions?: string[];
}) { }) {
const [goal, setGoal] = useState(defaultGoal); const [goal, setGoal] = useState(defaultGoal);
const [proposeFirst, setProposeFirst] = useState(false);
const box = useRef<HTMLDivElement>(null); const box = useRef<HTMLDivElement>(null);
const fill = (text: string) => { const fill = (text: string) => {
setGoal(text); setGoal(text);
@@ -88,87 +89,55 @@ export function GoalComposer({
ta.setSelectionRange(text.length, text.length); ta.setSelectionRange(text.length, text.length);
}); });
}; };
const submit = (text: string) => {
const g = goalFromInput(text);
if (!g) {
toast.error("在指令后面写上目标");
return;
}
onSubmit(g.goal, g.opts);
};
return ( return (
<div ref={box} className="space-y-1.5"> <div ref={box} className="flex flex-col gap-3">
<PromptInput <PromptInput
value={goal} value={goal}
onValueChange={setGoal} onValueChange={setGoal}
onSubmit={(g) => onSubmit(g, { proposeFirst })} onSubmit={submit}
pending={pending} pending={pending}
minRows={compact ? 2 : 3} minRows={2}
maxRows={10} maxRows={10}
maxLength={4000} maxLength={4000}
aria-label="目标" aria-label="目标"
placeholder="例如:把沙丘:第二部的 4K 收进来" placeholder="例如:把沙丘:第二部的 4K 收进来"
submitLabel={pending ? "提交中" : "开始"} submitLabel={pending ? "提交中" : "开始"}
header={ commands={NEW_RUN_COMMANDS}
prefix ? ( context={context}
<p onClearContext={onClearContext}
className="mx-1 mb-1 truncate rounded-lg border border-border/70 px-2.5 py-1 font-mono text-meta text-muted-foreground"
title={prefix}
>
{prefix}
</p>
) : null
}
toolbar={<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />}
/> />
<p className="px-1 text-meta text-muted-foreground" aria-live="polite"> {suggestions?.length && !goal.trim() ? (
{proposeFirst
? "智能体先调查并给出方案,你选定后才执行。"
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}
</p>
{suggestions?.length ? (
<div className="pt-2">
<p className="mb-1.5 flex items-center gap-1.5 px-1 text-meta text-muted-foreground">
<Sparkles aria-hidden className="size-3.5" />
常用目标
</p>
<ul aria-label="快捷目标" className="flex flex-col gap-1.5"> <ul aria-label="快捷目标" className="flex flex-col gap-1.5">
{suggestions.map((s) => ( {suggestions.slice(0, 3).map((s) => (
<li key={s}> <li key={s}>
<button <button
type="button" type="button"
onClick={() => fill(s)} onClick={() => fill(s)}
className="w-full cursor-pointer rounded-xl border border-border/70 px-3 py-2 text-left text-caption text-foreground/90 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" className="w-full cursor-pointer rounded-xl border border-border/70 px-3 py-2 text-left text-caption text-foreground/90 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
> >
{s.endsWith(" ") || s.endsWith(":") ? `${s}…` : s} {s.endsWith(" ") || s.endsWith(":") ? `${s.trimEnd()}…` : s}
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
</div>
) : null} ) : null}
</div> </div>
); );
} }
/** 状态 + 来源 + 时间 + 模型 */
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
return (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={status} />
{isProposeFirst(run) ? <StatusBadge>先看方案再执行</StatusBadge> : null}
{agentMockEnabled() ? (
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
mock 数据
</StatusBadge>
) : null}
</div>
<p className="text-meta break-words text-muted-foreground">
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
</p>
</div>
);
}
/** /**
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。 * 顶部概况条:一行「步骤 4/5 · 当前:核对下载任务」;展开看步骤清单、子会话和技术信息(模型、预算、用量、ID)。
* 不 sticky、不压在会话上,方案卡与审批卡始终在会话流里完整可见。 * 不 sticky、不压在会话上;没有步骤和子会话时只在运行中显示状态。
*/ */
function RunOverviewBar({ export function RunOverviewBar({
snapshot, snapshot,
status, status,
live, live,
@@ -187,9 +156,10 @@ function RunOverviewBar({
const steps = live.plan?.steps ?? []; const steps = live.plan?.steps ?? [];
const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length; const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length;
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending"); const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
const failed = steps.some((s) => s.status === "failed"); const failed = steps.find((s) => s.status === "failed");
const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent;
if (!steps.length && !family && !isRunActive(status)) return null;
const u = { ...r.usage, ...(live.usage ?? {}) }; const u = { ...r.usage, ...(live.usage ?? {}) };
const usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
return ( return (
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}> <section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
<button <button
@@ -197,9 +167,9 @@ function RunOverviewBar({
aria-expanded={open} aria-expanded={open}
aria-controls={id} aria-controls={id}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring" className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
> >
<RunStatusBadge status={status} /> {isRunActive(status) ? <RunStatusBadge status={status} /> : null}
{steps.length ? ( {steps.length ? (
<> <>
<ListChecks <ListChecks
@@ -209,16 +179,19 @@ function RunOverviewBar({
<span className="shrink-0 text-foreground tabular-nums"> <span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{steps.length} 步骤 {done}/{steps.length}
</span> </span>
</>
) : null}
<span className="min-w-0 flex-1 truncate text-muted-foreground"> <span className="min-w-0 flex-1 truncate text-muted-foreground">
{steps.length ? (current ? current.title : failed ? "有步骤失败" : "全部完成") : ""} {failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"}
</span> </span>
<span className="hidden shrink-0 text-muted-foreground tabular-nums sm:inline">{usage}</span> </>
) : (
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{family ? `${snapshot.children?.length ?? 0} 个子会话` : ""}
</span>
)}
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} /> <ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button> </button>
{steps.length ? ( {steps.length ? (
<div className="mx-3 mb-2 h-0.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden> <div className="mx-3 mb-1.5 h-0.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div <div
className={cn( className={cn(
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none", "h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
@@ -230,48 +203,37 @@ function RunOverviewBar({
) : null} ) : null}
<Disclosure id={id} open={open}> <Disclosure id={id} open={open}>
<div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3"> <div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3">
<RunMeta run={r} status={status} />
<RunPlan plan={live.plan} active={active} fill /> <RunPlan plan={live.plan} active={active} fill />
<SubagentTree snapshot={snapshot} className="p-2.5" /> <SubagentTree snapshot={snapshot} className="p-2.5" />
<div> <p className="text-meta text-muted-foreground">
<p className="mb-2 text-meta text-muted-foreground">用量</p> {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
<BudgetBar run={r} live={live} /> </p>
</div> <TechInfo
items={[
{ label: "模型", value: r.model ? `${r.provider ? `${r.provider}/` : ""}${r.model}` : null },
{
label: "费用",
value: u.costUsd
? `约 ${formatUsd(u.costUsd)}${r.budget.maxCostUsd ? ` / 上限 ${formatUsd(r.budget.maxCostUsd)}` : ""}`
: null,
},
{ label: "token", value: u.totalTokens !== undefined ? u.totalTokens.toLocaleString("zh-CN") : null },
{
label: "工具调用",
value: `${r.toolCallCount}${r.budget.maxToolCalls ? ` / ${r.budget.maxToolCalls}` : ""}`,
},
{ label: "会话 ID", value: r.id, wide: true },
]}
/>
</div> </div>
</Disclosure> </Disclosure>
</section> </section>
); );
} }
/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */
function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) {
const create = useCreateAgentRun();
const [proposeFirst, setProposeFirst] = useState(false);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
size="xs"
variant="outline"
disabled={create.isPending}
onClick={() =>
create.mutate(
{ goal, proposeFirst },
{ onSuccess: (run) => onCreated(run.id), onError: (e) => toast.error(errorMessage(e)) },
)
}
>
<RotateCcw />
{create.isPending ? "新建中…" : "用同目标新建"}
</Button>
<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />
</div>
);
}
/** /**
* 一次运行。page:会话列 + 右侧栏(状态、步骤、子会话,底部预算与用量);窄于 lg 时侧栏换成顶部可折叠概况条。 * 一次运行:顶部概况条 → 会话流(统一最大宽度)→ 底部输入区(只有输入框和发送 / 停止)。
* compact(抽屉):只有概况条。会话列统一最大宽度,输入区固定在底部(停止、续跑、用同目标新建)。 * 输入区的 `/重跑` 用同一个目标新开;`/先给方案` 让智能体先给方案。compact 用于抽屉。
*/ */
export function AgentRunPanel({ export function AgentRunPanel({
runId, runId,
@@ -282,7 +244,7 @@ export function AgentRunPanel({
runId: string; runId: string;
compact?: boolean; compact?: boolean;
className?: string; className?: string;
/** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */ /** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
onCreated?: (runId: string) => void; onCreated?: (runId: string) => void;
}) { }) {
const run = useAgentRun(runId); const run = useAgentRun(runId);
@@ -290,6 +252,7 @@ export function AgentRunPanel({
const live = useAgentStream(runId, active || !run.data); const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun(); const abort = useAbortAgentRun();
const append = useAppendAgentMessage(); const append = useAppendAgentMessage();
const create = useCreateAgentRun();
const navigate = useNavigate(); const navigate = useNavigate();
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [confirmStop, setConfirmStop] = useState(false); const [confirmStop, setConfirmStop] = useState(false);
@@ -298,10 +261,9 @@ export function AgentRunPanel({
const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length; const toolCount = live.items.filter((i) => i.kind === "tool" && i.call.status !== "running").length;
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount); const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null)); const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上的其它审批 // 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上还在等的其它审批
const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : []))); const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : [])));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询 // run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
const related = new Set([ const related = new Set([
runId, runId,
...(run.data?.children ?? []).map((c) => c.id), ...(run.data?.children ?? []).map((c) => c.id),
@@ -335,9 +297,24 @@ export function AgentRunPanel({
const ended = !isRunActive(r.status); const ended = !isRunActive(r.status);
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`)); const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
const send = (text: string) => const send = (text: string) => {
const { command, rest } = parseCommand(text, RUN_COMMANDS);
if (command === RERUN.name) {
create.mutate(
{ goal: r.goal, proposeFirst: r.scope.proposeFirst === true },
{
onSuccess: (n) => {
setMsg("");
created(n.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
);
return;
}
const body = command === PROPOSE_FIRST.name ? `${PROPOSE_FIRST_MESSAGE}${rest ? `\n${rest}` : ""}` : text;
append.mutate( append.mutate(
{ id: r.id, text }, { id: r.id, text: body },
{ {
onSuccess: () => { onSuccess: () => {
setMsg(""); setMsg("");
@@ -346,19 +323,27 @@ export function AgentRunPanel({
onError: (err) => toast.error(errorMessage(err)), onError: (err) => toast.error(errorMessage(err)),
}, },
); );
};
const column = "mx-auto w-full max-w-[88ch]"; const column = "mx-auto w-full max-w-[88ch]";
return ( return (
<div className={cn("flex min-h-0 gap-4", className)}> <div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} /> <RunAnnouncer status={shownStatus} finished={live.finished} />
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3"> {agentMockEnabled() && !compact ? (
<div className={column}>
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
演示数据
</StatusBadge>
</div>
) : null}
<RunOverviewBar <RunOverviewBar
snapshot={run.data} snapshot={run.data}
status={shownStatus} status={shownStatus}
live={live} live={live}
active={streaming || r.status === "waiting"} active={streaming || r.status === "waiting"}
className={cn(column, !compact && "lg:hidden")} className={column}
/> />
{streaming && sseStatus === "reconnecting" ? ( {streaming && sseStatus === "reconnecting" ? (
@@ -370,7 +355,7 @@ export function AgentRunPanel({
)} )}
> >
<WifiOff aria-hidden className="size-4 shrink-0" /> <WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span> <span className="min-w-0 flex-1">实时连接中断,正在重连。</span>
</div> </div>
) : null} ) : null}
{live.error ? ( {live.error ? (
@@ -404,70 +389,39 @@ export function AgentRunPanel({
after={ after={
<> <>
{approvals.items {approvals.items
.filter((a) => !inline.has(a.id)) .filter((a) => !inline.has(a.id) && a.status === "pending")
.map((a) => ( .map((a) => (
<ApprovalItem key={a.id} approval={a} /> <ApprovalItem key={a.id} approval={a} />
))} ))}
{finished ? <FinalReport run={r} report={report} live={live} /> : null} {finished ? <ResultLine run={r} report={report} live={live} /> : null}
</> </>
} }
/> />
</MessageScroller> </MessageScroller>
<div className={cn(column, "shrink-0 space-y-1")}> <div className={cn(column, "shrink-0")}>
<PromptInput <PromptInput
value={msg} value={msg}
onValueChange={setMsg} onValueChange={setMsg}
onSubmit={send} onSubmit={send}
pending={append.isPending} pending={append.isPending || create.isPending}
loading={streaming} loading={streaming}
onStop={() => setConfirmStop(true)} onStop={() => setConfirmStop(true)}
stopLabel={abort.isPending ? "停止中…" : "停止"} stopLabel={abort.isPending ? "停止中…" : "停止"}
minRows={1} minRows={1}
maxRows={6} maxRows={6}
maxLength={8000} maxLength={8000}
aria-label={ended ? "追加消息续跑" : "追加消息"} aria-label="发消息"
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"} placeholder={ended ? "接着说,输入 / 看指令" : "补充或纠正"}
submitLabel={ended ? "续跑" : "发送"} commands={RUN_COMMANDS}
toolbar={
ended ? (
<NewFromGoal goal={r.goal} onCreated={created} />
) : isProposeFirst(r) ? (
<span className="px-1 text-meta text-muted-foreground">先看方案再执行</span>
) : null
}
/> />
<p className="px-1 text-meta text-muted-foreground">
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
? `${r.summary}:在会话里的卡片上处理,会话随即继续。`
: ended
? "续跑沿用本会话的上下文与预算。"
: "追加的消息在本轮结束后读取。"}{" "}
Enter 发送,Shift+Enter 换行。
</p>
</div> </div>
</div>
{compact ? null : (
<aside
aria-label="会话概况"
className="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-[var(--shell-border)] pl-4 lg:flex"
>
<RunMeta run={r} status={shownStatus} />
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
<SubagentTree snapshot={run.data} className="p-2.5" />
<section aria-label="用量" className="mt-auto rounded-xl border border-border/70 p-3">
<p className="mb-2 text-meta text-muted-foreground">用量</p>
<BudgetBar run={r} live={live} stacked />
</section>
</aside>
)}
<ConfirmDialog <ConfirmDialog
open={confirmStop} open={confirmStop}
onOpenChange={setConfirmStop} onOpenChange={setConfirmStop}
title="停止这次运行?" title="停止这次运行?"
description="后续步骤不再执行,已完成的外部动作(如已提交的下载)不会撤销。" description="后续步骤不再执行,已完成的动作(如已提交的下载)不会撤销。"
confirmLabel="停止运行" confirmLabel="停止运行"
destructive destructive
onConfirm={() => onConfirm={() =>
@@ -1,154 +1,199 @@
import type { AgentRunStatus, AgentTrigger } from "@umt/contracts";
import { Bot, ChevronRight } from "lucide-react"; import { Bot, ChevronRight } from "lucide-react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts"; import { type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx"; import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts"; import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx"; import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts"; import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页), // 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。 // 筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
export type TimeRange = "" | "today" | "7d" | "30d"; export type HistoryView = "all" | "pending" | "failed";
export type HistoryFilters = { q: string; status: "" | AgentRunStatus; trigger: "" | AgentTrigger; range: TimeRange }; export type HistoryFilters = { q: string; view: HistoryView };
export const EMPTY_FILTERS: HistoryFilters = { q: "", status: "", trigger: "", range: "" }; export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" };
const STATUS_OPTIONS = ( const VIEWS: { value: HistoryView; label: string }[] = [
["running", "waiting", "succeeded", "failed", "budget_exceeded", "cancelled"] as AgentRunStatus[] { value: "all", label: "全部" },
).map((s) => ({ value: s, label: RUN_STATUS_LABEL[s] })); { value: "pending", label: "等你处理" },
const TRIGGER_OPTIONS = (["user", "schedule", "event", "subagent"] as AgentTrigger[]).map((t) => ({ { value: "failed", label: "失败" },
value: t,
label: TRIGGER_LABEL[t],
}));
const RANGE_OPTIONS = [
{ value: "today", label: "今天" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
]; ];
/** 时间范围的起点(毫秒);不限为 null */ /** 等用户处理:挂起在方案选择或审批上 */
export function rangeStart(range: TimeRange, now = Date.now()): number | null { export const awaitingUser = (r: Pick<AgentRunView, "status" | "summary">) =>
if (range === "today") { r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批");
const d = new Date(now); const isFailed = (r: Pick<AgentRunView, "status">) => r.status === "failed" || r.status === "budget_exceeded";
d.setHours(0, 0, 0, 0);
return d.getTime();
}
if (range === "7d") return now - 7 * 86_400_000;
if (range === "30d") return now - 30 * 86_400_000;
return null;
}
/** 纯函数(单测覆盖):搜索词匹配目标正文、上下文或会话 ID 前缀;时间按创建时间 */ /** 纯函数(单测覆盖):搜索词匹配目标正文或会话 ID 前缀 */
export function matchesHistory( export function matchesHistory(run: Pick<AgentRunView, "id" | "goal">, f: Pick<HistoryFilters, "q">): boolean {
run: Pick<AgentRunView, "id" | "goal" | "createdAt">,
f: Pick<HistoryFilters, "q" | "range">,
now = Date.now(),
): boolean {
const start = rangeStart(f.range, now);
if (start !== null && new Date(run.createdAt).getTime() < start) return false;
const q = f.q.trim().toLowerCase(); const q = f.q.trim().toLowerCase();
if (!q) return true; if (!q) return true;
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q); return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
} }
/** 列表副行:只写费用(token 与调用次数在会话的技术信息里) */ export type HistoryRow = { run: AgentRunView; children: AgentRunView[] };
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
return r.usage.costUsd ? `费用约 ${formatUsd(r.usage.costUsd)}` : ""; /**
* 纯函数(单测覆盖):已加载的会话 → 置顶的「等你处理」+ 其余;子会话挂到已加载的父会话下
* (父会话不在列表里的子会话照常单列)。筛选 pending / failed 时不再分组,也不收子会话。
*/
export function arrangeHistory(
runs: AgentRunView[],
f: HistoryFilters,
limit = Number.POSITIVE_INFINITY,
): { pinned: HistoryRow[]; rows: HistoryRow[] } {
const uniq = [...new Map(runs.map((r) => [r.id, r] as const)).values()];
const hit = uniq.filter((r) => matchesHistory(r, f));
if (f.view !== "all") {
const pick = f.view === "pending" ? awaitingUser : isFailed;
return {
pinned: [],
rows: hit
.filter(pick)
.slice(0, limit)
.map((run) => ({ run, children: [] })),
};
} }
const ids = new Set(hit.map((r) => r.id));
const ALL = "__all__"; const kids = new Map<string, AgentRunView[]>();
for (const r of hit)
/** 窄栏里的下拉:只显示取值("全部"时显示维度名),读屏名带维度 */ if (r.parentRunId && ids.has(r.parentRunId)) kids.set(r.parentRunId, [...(kids.get(r.parentRunId) ?? []), r]);
function MiniSelect({ const top = hit.filter((r) => !(r.parentRunId && ids.has(r.parentRunId)));
label, const row = (run: AgentRunView): HistoryRow => ({ run, children: kids.get(run.id) ?? [] });
allLabel, const pinned = top.filter(awaitingUser).map(row);
value, const rows = top
onChange, .filter((r) => !awaitingUser(r))
options, .slice(0, Math.max(0, limit - pinned.length))
}: { .map(row);
label: string; return { pinned, rows };
allLabel: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Select value={value || ALL} onValueChange={(v) => onChange(v === ALL ? "" : v)}>
<SelectTrigger
size="sm"
aria-label={label}
className={cn("h-7 min-w-0 flex-1 gap-1 px-2 text-meta", !value && "text-muted-foreground")}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={ALL}>{allLabel}</SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
} }
/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */ /** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 }; const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 };
function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) { function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; trailing?: ReactNode }) {
const goal = splitContextGoal(r.goal); const goal = splitContextGoal(r.goal);
const ctx = contextLabel(goal.context);
// 正常结束不加徽章;运行中、等待、失败才标
const badge = r.status !== "succeeded" && r.status !== "cancelled";
const meta = [r.trigger !== "user" ? TRIGGER_LABEL[r.trigger] : null, r.status === "cancelled" ? "已停止" : null, ctx]
.filter(Boolean)
.join(" · ");
return ( return (
<> <>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}> <p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
{shortGoalTitle(r.goal)} {shortGoalTitle(r.goal)}
</p> </p>
{children}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
<RunStatusBadge status={r.status} />
<span className="text-meta text-muted-foreground">{TRIGGER_LABEL[r.trigger]}</span>
{r.scope.proposeFirst === true ? <span className="text-meta text-muted-foreground">· 先看方案</span> : null}
<time <time
dateTime={r.createdAt} dateTime={r.createdAt}
className={cn("ml-auto text-meta whitespace-nowrap text-muted-foreground", current && "text-foreground/80")} className={cn("shrink-0 text-meta whitespace-nowrap text-muted-foreground", current && "text-foreground/80")}
> >
{relTime(r.createdAt)} {relTime(r.createdAt)}
</time> </time>
{trailing}
</div>
{badge || meta ? (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{badge ? (
awaitingUser(r) ? (
<span className="shrink-0 rounded-full border border-warning/40 bg-warning/10 px-2 py-0.5 text-label text-warning">
{r.summary === "等待审批" ? "等你批准" : "等你选方案"}
</span>
) : (
<RunStatusBadge status={r.status} />
)
) : null}
{meta ? <span className="min-w-0 truncate text-meta text-muted-foreground">{meta}</span> : null}
</div> </div>
{goal.context || usageText(r) ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
{[goal.context?.split(" · ")[0], usageText(r)].filter(Boolean).join(" · ")}
</p>
) : null} ) : null}
</> </>
); );
} }
function Row({
row,
activeId,
hrefFor,
onOpen,
}: {
row: HistoryRow;
activeId?: string;
hrefFor?: (id: string) => string;
onOpen?: (id: string) => void;
}) {
const [open, setOpen] = useState(row.children.some((c) => c.id === activeId));
const item = (r: AgentRunView) => {
const current = r.id === activeId;
const cls = cn(
"block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring",
current && "pill-selected",
);
return onOpen ? (
<button type="button" className={cls} onClick={() => onOpen(r.id)}>
<RunRow r={r} current={current} />
</button>
) : (
<Link
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
aria-current={current ? "page" : undefined}
className={cls}
>
<RunRow r={r} current={current} />
</Link>
);
};
return (
<li>
{item(row.run)}
{row.children.length ? (
<>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="ml-3 inline-flex cursor-pointer items-center gap-0.5 rounded-md px-1 text-meta text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-90")} />
{row.children.length} 个子会话
</button>
{open ? (
<ul className="ml-4 border-l border-border/70 pl-1">
{row.children.map((c) => (
<li key={c.id}>{item(c)}</li>
))}
</ul>
) : null}
</>
) : null}
</li>
);
}
/** /**
* 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。 * 会话列表:搜索 + 全部 / 等你处理 / 失败 + 游标加载(滚到底自动加载)。
* - hrefFor:会话页左栏,点一条跳到该会话页。 * - hrefFor:会话页左栏,点一条跳到该会话页。
* - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。 * - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。
* - limit:历史弹层只列最近 N 条(置顶的算在内),不自动翻页。
*/ */
export function AgentSessionList({ export function AgentSessionList({
activeId, activeId,
hrefFor, hrefFor,
onOpen, onOpen,
remember = false, remember = false,
limit,
className, className,
}: { }: {
activeId?: string; activeId?: string;
hrefFor?: (id: string) => string; hrefFor?: (id: string) => string;
onOpen?: (id: string) => void; onOpen?: (id: string) => void;
remember?: boolean; remember?: boolean;
limit?: number;
className?: string; className?: string;
}) { }) {
const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS); const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS);
@@ -158,15 +203,14 @@ export function AgentSessionList({
if (remember) drawerMemory.filters = next; if (remember) drawerMemory.filters = next;
return next; return next;
}); });
const runs = useAgentRuns(f.status || undefined, f.trigger || undefined); const runs = useAgentRuns();
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
const waiting = useAgentRuns("waiting");
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? []; const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
const now = Date.now(); const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
const rows = loaded.filter((r) => matchesHistory(r, f, now)); const { pinned, rows } = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f, limit);
const start = rangeStart(f.range, now); const canLoadMore = !limit && !!runs.hasNextPage;
const oldest = loaded.at(-1); const filtered = !!f.q.trim() || f.view !== "all";
// 列表按创建时间倒序:已加载的最早一条早于时间范围,再往后翻也不会命中
const canLoadMore = !!runs.hasNextPage && !(start !== null && oldest && new Date(oldest.createdAt).getTime() < start);
const filtered = !!(f.q.trim() || f.status || f.trigger || f.range);
const scroller = useRef<HTMLDivElement>(null); const scroller = useRef<HTMLDivElement>(null);
const sentinel = useRef<HTMLDivElement>(null); const sentinel = useRef<HTMLDivElement>(null);
@@ -190,6 +234,7 @@ export function AgentSessionList({
return () => io.disconnect(); return () => io.disconnect();
}, [canLoadMore, isFetchingNextPage, fetchNextPage]); }, [canLoadMore, isFetchingNextPage, fetchNextPage]);
const rowProps = { activeId, hrefFor, onOpen };
return ( return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}> <nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5"> <div className="mb-2 space-y-1.5">
@@ -197,33 +242,26 @@ export function AgentSessionList({
type="search" type="search"
value={f.q} value={f.q}
onChange={(e) => set({ q: e.target.value })} onChange={(e) => set({ q: e.target.value })}
placeholder="搜索目标" placeholder="搜索会话"
aria-label="搜索会话目标" aria-label="搜索会话"
className="h-8 md:text-caption" className="h-8 md:text-caption"
/> />
<div className="flex flex-wrap gap-1.5"> <fieldset aria-label="筛选会话" className="m-0 flex min-w-0 gap-1 border-0 p-0">
<MiniSelect {VIEWS.map((v) => (
label="按状态筛选" <button
allLabel="全部状态" key={v.value}
value={f.status} type="button"
onChange={(v) => set({ status: v as HistoryFilters["status"] })} aria-pressed={f.view === v.value}
options={STATUS_OPTIONS} onClick={() => set({ view: v.value })}
/> className={cn(
<MiniSelect "h-7 cursor-pointer rounded-full border border-transparent px-2.5 text-meta text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
label="按来源筛选" f.view === v.value && "pill-selected text-foreground",
allLabel="全部来源" )}
value={f.trigger} >
onChange={(v) => set({ trigger: v as HistoryFilters["trigger"] })} {v.label}
options={TRIGGER_OPTIONS} </button>
/> ))}
<MiniSelect </fieldset>
label="按时间筛选"
allLabel="不限时间"
value={f.range}
onChange={(v) => set({ range: v as TimeRange })}
options={RANGE_OPTIONS}
/>
</div>
</div> </div>
<div <div
ref={scroller} ref={scroller}
@@ -236,50 +274,39 @@ export function AgentSessionList({
<LoadingState rows={4} /> <LoadingState rows={4} />
) : runs.error ? ( ) : runs.error ? (
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} /> <ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
) : rows.length === 0 && !canLoadMore ? ( ) : !pinned.length && !rows.length && !canLoadMore ? (
<EmptyState plain icon={Bot} title={filtered ? "没有符合条件的会话" : "还没有会话,交代一个目标开始"} /> <EmptyState
) : ( plain
<ul className="space-y-0.5"> icon={Bot}
{rows.map((r) => { title={
const current = r.id === activeId; filtered
const cls = cn( ? f.view === "pending"
"block w-full min-w-0 cursor-pointer rounded-xl border border-transparent px-3 py-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring", ? "没有等你处理的会话"
current && "pill-selected", : "没有符合条件的会话"
); : "还没有会话,写一个目标开始"
return ( }
<li key={r.id}>
{onOpen ? (
<button type="button" className={cn(cls, "group")} onClick={() => onOpen(r.id)}>
<RunRow r={r} current={current}>
<ChevronRight
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-micro group-hover:opacity-100 group-focus-visible:opacity-100"
/> />
</RunRow>
</button>
) : ( ) : (
<Link <>
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`} {pinned.length ? (
aria-current={current ? "page" : undefined} <section aria-label="等你处理" className="mb-2">
className={cls} <p className="px-3 pb-1 text-label text-warning">等你处理</p>
> <ul className="space-y-0.5">
<RunRow r={r} current={current}> {pinned.map((row) => (
{null} <Row key={row.run.id} row={row} {...rowProps} />
</RunRow> ))}
</Link>
)}
</li>
);
})}
</ul> </ul>
</section>
) : null}
<ul className="space-y-0.5">
{rows.map((row) => (
<Row key={row.run.id} row={row} {...rowProps} />
))}
</ul>
</>
)} )}
{canLoadMore ? ( {canLoadMore ? (
<div ref={sentinel} className="pt-2"> <div ref={sentinel} className="pt-2">
{filtered && rows.length === 0 ? (
<p className="px-1 pb-1 text-meta text-muted-foreground">
已加载的 {loaded.length} 条里没有匹配,继续往前找。
</p>
) : null}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
+33 -73
View File
@@ -15,84 +15,48 @@ export type AgentContext = {
taskId?: string; taskId?: string;
/** 预填目标 */ /** 预填目标 */
goal?: string; goal?: string;
/** 按路由推断(label 里只有 ID,抽屉按 ID 查名称) */
fromPath?: boolean;
}; };
/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */
export type AgentDrawerTab = "current" | "history";
type State = { type State = {
open: boolean; open: boolean;
minimized: boolean; minimized: boolean;
context: AgentContext | null; context: AgentContext | null;
/** 抽屉里正在看的会话(null = 新对话) */
runId: string | null; runId: string | null;
tab: AgentDrawerTab;
/** 历史 Tab 里打开的会话(null = 列表) */
historyRunId: string | null;
}; };
const TAB_KEY = "umt-agent-drawer-tab"; // AGUI-3:抽屉去掉「当前 / 历史」Tab。头部「历史」弹层点一条就在抽屉里打开,「新对话」回到输入框。
// 旧版本记在 localStorage 的 Tab(umt-agent-drawer-tab)不再读取。
function readTab(): AgentDrawerTab { let state: State = { open: false, minimized: false, context: null, runId: null };
try {
return globalThis.localStorage?.getItem(TAB_KEY) === "history" ? "history" : "current";
} catch {
return "current";
}
}
function writeTab(tab: AgentDrawerTab) {
try {
globalThis.localStorage?.setItem(TAB_KEY, tab);
} catch {
// 存储不可用时只在本页记住
}
}
let state: State = { open: false, minimized: false, context: null, runId: null, tab: "current", historyRunId: null };
let tabLoaded = false;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const emit = () => { const emit = () => {
for (const l of listeners) l(); for (const l of listeners) l();
}; };
/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */ /** 打开抽屉开始新对话(页面内按钮带着对象打开) */
export function lastAgentDrawerTab(): AgentDrawerTab { export function openAgentDrawer(context: AgentContext | null = null) {
if (!tabLoaded) { state = { ...state, open: true, minimized: false, context, runId: null };
tabLoaded = true;
state = { ...state, tab: readTab() };
}
return state.tab;
}
/**
* 打开抽屉。页面内按钮带着对象打开时落在「当前」开始新目标;
* 快捷键 / 悬浮按钮不指定 tab,回到上次停留的 Tab。
*/
export function openAgentDrawer(context: AgentContext | null = null, tab: AgentDrawerTab = "current") {
tabLoaded = true;
state = { ...state, open: true, minimized: false, context, runId: null, tab };
if (tab === "current") writeTab(tab);
emit(); emit();
} }
export function setAgentDrawerTab(tab: AgentDrawerTab) { /** 在抽屉里打开一条会话(历史弹层、/重跑、子会话链接) */
tabLoaded = true;
state = { ...state, tab };
writeTab(tab);
emit();
}
/** 历史 Tab:在抽屉内打开一条会话(null 回到列表) */
export function openAgentHistoryRun(runId: string | null) {
state = { ...state, tab: "history", historyRunId: runId };
emit();
}
/** 把一条会话放到「当前」(同目标新建后、从历史里继续时) */
export function showAgentRunInDrawer(runId: string) { export function showAgentRunInDrawer(runId: string) {
tabLoaded = true; state = { ...state, open: true, minimized: false, runId };
state = { ...state, open: true, minimized: false, runId, tab: "current" }; emit();
writeTab("current"); }
/** 新对话:保留当前页面上下文,回到输入框 */
export function newAgentConversation() {
state = { ...state, runId: null };
emit();
}
/** 去掉上下文(输入框上的名称标签 ×) */
export function clearAgentContext() {
state = { ...state, context: null };
emit(); emit();
} }
@@ -107,9 +71,9 @@ export function minimizeAgentDrawer() {
emit(); emit();
} }
/** 从小窗恢复抽屉(小窗里的运行在「当前」) */ /** 从小窗恢复抽屉 */
export function restoreAgentDrawer() { export function restoreAgentDrawer() {
state = { ...state, open: true, minimized: false, tab: "current" }; state = { ...state, open: true, minimized: false };
emit(); emit();
} }
@@ -128,12 +92,13 @@ export function isAgentShortcut(
/** /**
* 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换: * 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换:
* 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开新目标。 * 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开(上次看的会话还在就接着看)。
*/ */
export function toggleAgentDrawer(pathname: string) { export function toggleAgentDrawer(pathname: string) {
if (state.open) setAgentDrawerOpen(false); if (state.open) setAgentDrawerOpen(false);
else if (state.minimized && state.runId) restoreAgentDrawer(); else if (state.minimized && state.runId) restoreAgentDrawer();
else openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()); else if (state.runId) restoreAgentDrawer();
else openAgentDrawer(contextFromPath(pathname));
} }
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */ /** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
@@ -144,25 +109,20 @@ export function contextFromPath(pathname: string): AgentContext | null {
const short = id.slice(0, 8); const short = id.slice(0, 8);
switch (kind) { switch (kind) {
case "library": case "library":
return { label: `作品 ${short}`, entityId: id }; return { label: `作品 ${short}`, entityId: id, fromPath: true };
case "sites": case "sites":
return { label: `站点 ${id}`, siteId: id }; return { label: `站点 ${id}`, siteId: id, fromPath: true };
case "tasks": case "tasks":
return { label: `下载任务 ${short}`, downloadTaskId: id }; return { label: `下载任务 ${short}`, downloadTaskId: id, fromPath: true };
case "organize": case "organize":
return { label: `整理记录 ${short}`, recordId: id }; return { label: `整理记录 ${short}`, recordId: id, fromPath: true };
case "engine": case "engine":
return { label: `引擎任务 ${short}`, taskId: id }; return { label: `引擎任务 ${short}`, taskId: id, fromPath: true };
default: default:
return null; return null;
} }
} }
export function setAgentDrawerRun(runId: string | null) {
state = { ...state, runId };
emit();
}
export function useAgentDrawer(): State { export function useAgentDrawer(): State {
return useSyncExternalStore( return useSyncExternalStore(
(l) => { (l) => {
+392
View File
@@ -0,0 +1,392 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AgentRunEvent } from "@umt/contracts";
import { createElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { AgentRunView } from "~/api/agent.ts";
import { qk } from "~/lib/query-keys.ts";
import { AgentConversation, contextLabel, DeniedLines, StepSummary } from "./agent-conversation.tsx";
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
import { goalFromInput } from "./agent-run-panel.tsx";
import { arrangeHistory } from "./agent-session-list.tsx";
import { type ActivityItem, activitySummary, buildConversation, deniedReason } from "./conversation-model.ts";
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
import {
type AgentStreamState,
foldEvents,
latestPlan,
type OptionsView,
type ToolCallView,
} from "./use-agent-stream.ts";
// AGUI-3 组件测试:进度行聚合、隐藏步骤清单 / 方案调用、拒绝一行、方案一键选定、历史置顶与子会话、指令。
// 渲染用 react-dom/server(没有 DOM 环境),交互逻辑放在纯函数里单测;绑定批准见服务端 e2e(e12-interaction)。
const AT = "2026-09-29T08:00:00.000Z";
const at = (s: number) => new Date(Date.parse(AT) + s * 1000).toISOString();
let seq = 0;
const ev = (s: number, body: Record<string, unknown>) =>
({ runId: "r1", seq: seq++, at: at(s), ...body }) as unknown as AgentRunEvent;
const tool = (s: number, id: string, cap: string, out: string, isError = false): AgentRunEvent[] => [
ev(s, { type: "tool_call_start", toolCallId: id, capabilityId: cap, inputSummary: "{}", recovery: false }),
ev(s + 1, {
type: "tool_call_end",
toolCallId: id,
capabilityId: cap,
outputSummary: out,
isError,
recovery: false,
}),
];
const call = (p: Partial<ToolCallView>): ToolCallView => ({
toolCallId: "c1",
capabilityId: "site.list",
inputSummary: null,
outputSummary: null,
status: "succeeded",
reason: null,
startedAt: AT,
endedAt: at(1),
recovery: false,
...p,
});
/** 旧会话形状的事件:步骤清单与方案都作为工具调用出现、授权拒绝、最终正文(9883534f 的样子) */
function legacyRun(): AgentRunEvent[] {
seq = 0;
return [
ev(0, { type: "run_started", goal: "检查站点、下载器和媒体服务器", resumed: false }),
...tool(1, "p1", "agent.plan", '{"steps":[],"revision":1}'),
ev(2, {
type: "plan_updated",
revision: 1,
steps: [
{ id: "s1", title: "看站点", status: "running", note: null },
{ id: "s2", title: "核对下载任务", status: "pending", note: null },
],
}),
ev(3, { type: "text_delta", delta: "先看站点列表。" }),
...tool(4, "t1", "site.list", '{"items":[{"name":"憨憨"}]}'),
...tool(6, "t2", "download.inspect", '{"summary":"没有进行中的下载"}'),
...tool(8, "t3", "download.inspect", '{"summary":"没有进行中的下载"}'),
...tool(10, "t4", "inventory.query", '{"items":[]}'),
...tool(
12,
"t5",
"site.health_check",
'{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权"}}',
true,
),
...tool(14, "p2", "agent.plan", '{"steps":[],"revision":2}'),
ev(16, { type: "thinking_delta", delta: "下载器没接入。" }),
ev(17, {
type: "text_delta",
delta: "下载器和媒体服务器都还没接入,所以没法检查。站点「憨憨」正常,在做种 24 个。",
}),
ev(41, { type: "run_completed", finalText: "", usage: { totalTokens: 1000, costUsd: 0.01 } }),
];
}
const liveOf = (events: AgentRunEvent[]): AgentStreamState => ({
events,
items: foldEvents(events),
plan: latestPlan(events),
usage: null,
finished: true,
error: null,
connected: "idle",
retry: () => {},
});
const RUN = {
id: "00000000-0000-4000-8000-000000000001",
createdAt: AT,
updatedAt: at(41),
taskId: null,
parentRunId: null,
trigger: "user",
goal: "[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 检查站点、下载器和媒体服务器",
status: "succeeded",
provider: "opencode-go",
model: "deepseek-v4.1-flash",
thinkingLevel: "medium",
scope: { interactive: true },
budget: { maxToolCalls: 20 },
usage: {},
evidenceVersion: null,
sessionState: null,
summary: null,
toolCallCount: 5,
lastEventSeq: 0,
error: null,
cancelRequestedAt: null,
startedAt: AT,
finishedAt: at(41),
createdBy: "admin",
dedupeKey: null,
depth: 0,
spawnReason: null,
} as unknown as AgentRunView;
function render(node: ReactNode, admin = true): string {
const client = new QueryClient();
if (admin) client.setQueryData(qk.session, { actor: { type: "admin" }, expiresAt: "2099-01-01T00:00:00.000Z" });
return renderToStaticMarkup(createElement(QueryClientProvider, { client }, createElement(MemoryRouter, null, node)));
}
/** 去掉标签,只看文字 */
const text = (html: string) => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
describe("隐藏:步骤清单与方案不作为工具调用出现", () => {
it("新旧事件里的 agent.plan 调用都被滤掉,步骤清单照样取到", () => {
const events = legacyRun();
const items = foldEvents(events);
const caps = items.flatMap((i) => (i.kind === "tool" ? [i.call.capabilityId] : []));
expect(caps).not.toContain("agent.plan");
expect(caps).toEqual(["site.list", "download.inspect", "download.inspect", "inventory.query", "site.health_check"]);
expect(latestPlan(events)?.steps).toHaveLength(2);
});
it("方案调用只留方案卡", () => {
seq = 0;
const items = foldEvents([
ev(0, {
type: "tool_call_start",
toolCallId: "o1",
capabilityId: "agent.propose_options",
inputSummary: "{}",
recovery: false,
}),
ev(1, {
type: "options_proposed",
invocationId: "o1",
decisionId: "00000000-0000-4000-8000-00000000d001",
question: "收哪个?",
options: [],
recommendedId: null,
stepId: null,
expiresAt: at(600),
timeoutAction: "recommended",
}),
]);
expect(items.map((i) => i.kind)).toEqual(["options"]);
});
});
describe("聚合:进度行用能力元数据的人话计数", () => {
it("同一动作合并对象,数字前后空格,被拒绝的不算", () => {
const calls = [
call({ capabilityId: "site.list" }),
call({ capabilityId: "download.inspect" }),
call({ capabilityId: "download.inspect" }),
call({ capabilityId: "inventory.query" }),
call({ capabilityId: "release.search" }),
call({ capabilityId: "release.search" }),
call({ capabilityId: "site.health_check", status: "denied", reason: "不在白名单" }),
call({ capabilityId: "agent.plan" }),
];
expect(activitySummary(calls)).toBe("查了站点、2 个下载任务和媒体库,搜了 2 次资源");
expect(activitySummary([call({ capabilityId: "download.inspect" }), call({ capabilityId: "site.list" })])).toBe(
"查了 1 个下载任务和站点",
);
});
it("一回合的全部调用聚成一行(1 次也折叠),展开前看不到能力 ID", () => {
const activity: ActivityItem[] = [{ kind: "tool", key: "c1", call: call({ capabilityId: "site.list" }) }];
const html = render(createElement(StepSummary, { activity, running: false }));
const line = text(html.slice(0, html.indexOf("</button>")));
expect(line).toContain("查了站点");
expect(line).toContain("1 秒");
expect(line).not.toContain("site.list");
});
it("旧会话整段:一行进度 + 正文 + 一行没做,不出现回合头、步骤清单调用和模型原文副本", () => {
const html = render(createElement(AgentConversation, { run: RUN, live: liveOf(legacyRun()) }));
const t = text(html);
expect(html.match(/data-slot="step-summary"/g)).toHaveLength(1);
expect(t).toContain("查了站点、2 个下载任务和媒体库");
expect(t).toContain("1 项没做");
expect(t).toContain("站点「憨憨」正常,在做种 24 个。");
expect(t).toContain("没做:检测站点,需要你批准");
expect(t).not.toContain("更新步骤清单");
expect(t).not.toContain("模型说的");
expect(t).not.toMatch(/智能体\s+\d{2}:\d{2}/);
// 过程中的话收进进度行,不在正文里
const body = t.slice(t.indexOf("1 项没做"));
expect(body.indexOf("先看站点列表")).toBeLessThan(body.indexOf("下载器和媒体服务器都还没接入"));
// 上下文标签只显示名称
expect(t).toContain("沙丘:第二部");
expect(t).not.toContain("entityId=");
});
});
describe("拒绝:缩成一行", () => {
const denied = call({
toolCallId: "t5",
capabilityId: "site.health_check",
status: "failed",
outputSummary: '{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权"}}',
});
it("授权拒绝 → 需要你批准 + [批准并重试];白名单拦截没有按钮", () => {
expect(deniedReason(denied)).toEqual({ text: "需要你批准", approvable: true });
expect(deniedReason(call({ status: "denied", reason: "不在白名单" }))).toEqual({
text: "本次没有开放",
approvable: false,
});
const t = text(
render(createElement(DeniedLines, { runId: RUN.id, calls: [denied, { ...denied, toolCallId: "t6" }] })),
);
expect(t.match(/没做:/g)).toHaveLength(1);
expect(t).toContain("没做:检测站点,需要你批准");
expect(t).toContain("批准并重试");
// 不是管理员会话:只写一行,不给按钮
expect(text(render(createElement(DeniedLines, { runId: RUN.id, calls: [denied] }), false))).not.toContain(
"批准并重试",
);
});
it("原因里提到「单次审批」的普通授权拒绝仍算没做", () => {
seq = 0;
const items = foldEvents(
tool(
0,
"h1",
"site.health_check",
'{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权或有效的单次审批"}}',
true,
),
);
const [turn] = buildConversation(items);
expect(turn?.kind === "turn" && turn.denied.map((c) => c.capabilityId)).toEqual(["site.health_check"]);
});
it("已转成审批卡的拒绝不算没做", () => {
const items = foldEvents([
...tool(
0,
"s1",
"download.submit",
'{"error":{"code":"unauthorized","message":"已在步骤处发起审批,等待管理员批准"}}',
true,
),
ev(3, {
type: "approval_requested",
approvalId: "00000000-0000-4000-8000-00000000a001",
decisionId: null,
invocationId: "s1",
capabilityId: "download.submit",
planType: "acquisition_plan",
planId: "00000000-0000-4000-8000-00000000b001",
payloadHash: "h",
expectedRevision: 1,
riskLevel: "high",
summary: "下载《沙丘2》",
stepId: null,
expiresAt: at(3600),
}),
]);
const [turn] = buildConversation(items);
expect(turn?.kind === "turn" && turn.denied).toEqual([]);
});
});
describe("方案:一键选定", () => {
const view: OptionsView = {
decisionId: "00000000-0000-4000-8000-00000000d001",
question: "《沙丘2》1080p 怎么收?",
options: [
{ id: "b", title: "先修站点", summary: "站点登录过期,先刷新", risks: [], estimatedCost: null, action: null },
{
id: "a",
title: "提交下载",
summary: "用刚生成的计划",
risks: ["缓存卷会到 86%", "免费还剩 6 小时"],
estimatedCost: "12 GB",
action: {
capabilityId: "download.submit",
planId: "00000000-0000-4000-8000-00000000b001",
label: "提交下载",
detail: "《沙丘2》· 12.0 GB",
approves: true,
},
},
{ id: "c", title: "先不下", summary: "等 REMUX", risks: [], estimatedCost: null, action: null },
],
recommendedId: "a",
stepId: null,
expiresAt: at(1800),
timeoutAction: "recommended",
resolved: null,
};
it("推荐项第一位;动作要点来自服务端的计划摘要", () => {
expect(orderOptions(view.options, "a").map((o) => o.id)).toEqual(["a", "b", "c"]);
// 标题已经是动作名:只写对象和大小
expect(optionPoint(view.options[1]!)).toBe("《沙丘2》· 12.0 GB");
expect(optionPoint({ ...view.options[1]!, title: "收这个" })).toBe("提交下载 · 《沙丘2》· 12.0 GB");
expect(optionPoint(view.options[0]!)).toBeNull();
});
it("每个方案一个按钮(没有单选框和「选定」),推荐项是主按钮;风险折叠成「2 条风险」,都不要是链接", () => {
const html = render(createElement(OptionsCard, { runId: RUN.id, view, waiting: true }));
expect(html).not.toContain('type="radio"');
const buttons = [...html.matchAll(/<button[^>]*>([\s\S]*?)<\/button>/g)];
const labels = buttons.map((m) => text(m[1] ?? "").trim());
expect(labels[0]).toContain("提交下载");
expect(labels[0]).toContain("12.0 GB");
expect(labels[0]).toContain("(推荐)");
expect(labels[0]).toContain("选定即执行");
expect(buttons[0]?.[0]).toContain("pill-primary");
expect(labels.slice(1, 3).map((l) => l.split(" ")[0])).toEqual(["先修站点", "先不下"]);
expect(labels).toContain("2 条风险");
expect(labels).toContain("都不要…");
expect(labels.some((l) => l === "选定")).toBe(false);
expect(html).not.toContain("<textarea");
});
it("选完收成一行", () => {
const t = text(
render(
createElement(OptionsCard, {
runId: RUN.id,
view: { ...view, resolved: { outcome: "chosen", choice: "a", reason: null, by: "user" } },
waiting: true,
}),
),
);
expect(t).toContain("已选:提交下载 · 《沙丘2》· 12.0 GB");
expect(t).not.toContain("先修站点");
});
});
describe("历史:等你处理置顶,子会话收在父会话下", () => {
const r = (id: string, p: Partial<AgentRunView>) => ({ ...RUN, id, ...p }) as AgentRunView;
const runs = [
r("a", { goal: "巡检" }),
r("b", { goal: "子任务", parentRunId: "a", trigger: "subagent" }),
r("c", { goal: "收沙丘", status: "waiting", summary: "等待审批" }),
r("d", { goal: "失败的", status: "failed" }),
r("e", { goal: "孤儿子会话", parentRunId: "zzz", trigger: "subagent" }),
r("f", { goal: "等下载", status: "waiting", summary: "等待下载" }),
];
it("全部:等你处理置顶;子会话挂到父会话;父会话不在列表的单列", () => {
const { pinned, rows } = arrangeHistory(runs, { q: "", view: "all" });
expect(pinned.map((x) => x.run.id)).toEqual(["c"]);
expect(rows.map((x) => x.run.id)).toEqual(["a", "d", "e", "f"]);
expect(rows[0]?.children.map((x) => x.id)).toEqual(["b"]);
});
it("等你处理 / 失败 / 条数上限", () => {
expect(arrangeHistory(runs, { q: "", view: "pending" }).rows.map((x) => x.run.id)).toEqual(["c"]);
expect(arrangeHistory(runs, { q: "", view: "failed" }).rows.map((x) => x.run.id)).toEqual(["d"]);
const limited = arrangeHistory(runs, { q: "", view: "all" }, 3);
expect(limited.pinned.length + limited.rows.length).toBe(3);
});
});
describe("输入区:/ 指令与上下文标签", () => {
it("/先给方案 只作用于这一次", () => {
expect(goalFromInput("/先给方案 把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: true } });
expect(goalFromInput("把沙丘收进来")).toEqual({ goal: "把沙丘收进来", opts: { proposeFirst: false } });
expect(goalFromInput("/先给方案")).toBeNull();
expect(parseCommand("/重跑", RUN_COMMANDS)).toEqual({ command: "/重跑", rest: "" });
expect(parseCommand("/先给方案x", RUN_COMMANDS).command).toBeNull();
});
it("上下文标签显示名称,只有 ID 时不显示", () => {
expect(contextLabel("作品:沙丘:第二部 · entityId=74beab51")).toBe("沙丘:第二部");
expect(contextLabel("entityId=74beab51")).toBeNull();
expect(contextLabel(null)).toBeNull();
});
});
@@ -0,0 +1,268 @@
import { CAPABILITY_ACTIVITY, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import type { ApprovalView, OptionsView, StreamItem, ToolCallView } from "./use-agent-stream.ts";
// 会话流的排版模型(AGUI-3,纯函数,单测覆盖):
// 用户消息 → 一行进度(这一段里的工具调用、思考和过程中的话) → 结论正文 → 没做的事 → 一行实际结果。
// 方案卡 / 审批卡 / 上下文压缩把一回合切成几段,各段有自己的进度行和正文。
export const capabilityTitle = (id: string): string =>
(CAPABILITY_CATALOG as Record<string, { title?: string }>)[id as KnownCapabilityId]?.title ?? id;
// ── 拒绝判定 ──
/**
* 授权校验拒绝:能力处理器前的授权回调失败时,事件是 tool_call_end(isError)+ 错误码 unauthorized/forbidden,
* 不是 tool_call_rejected(那个只在白名单/预算拦截时发)。
*/
export function authDenial(call: Pick<ToolCallView, "status" | "outputSummary">): 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;
/** 拒绝的一行说明:需要批准 / 本次没有开放 / 次数用完 */
export function deniedReason(call: ToolCallView): { text: string; approvable: boolean } {
const auth = authDenial(call);
if (auth) {
if (/先看方案/.test(auth)) return { text: "要先选定方案", approvable: false };
if (/自动化总开关|只读对账/.test(auth)) return { text: "自动执行已关闭", approvable: false };
return { text: "需要你批准", approvable: true };
}
if (call.reason && /预算|次数/.test(call.reason)) return { text: "调用次数用完了", approvable: false };
return { text: "本次没有开放", approvable: false };
}
// ── 进度行的人话计数 ──
type Group = { verb: string; nouns: { noun: string; unit: string | null; n: number }[] };
function activityOf(id: string) {
return (
CAPABILITY_ACTIVITY[id as KnownCapabilityId] ?? {
verb: "执行",
noun: capabilityTitle(id) === id ? "其他操作" : capabilityTitle(id),
unit: "次",
}
);
}
/** 中文与数字之间空一格;顿号旁不空 */
const cjkJoin = (a: string, b: string) => (/^\d/.test(b) ? `${a} ${b}` : `${a}${b}`);
const joinNouns = (xs: string[]) =>
xs.length <= 1 ? (xs[0] ?? "") : cjkJoin(`${xs.slice(0, -1).join("、")}和`, xs.at(-1) ?? "");
/**
* 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数,
* 同一动作的对象合并,动作按第一次出现排序;被拒绝和隐藏的调用不计入。超过 4 种动作只写前 3 种。
*/
export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): string {
const groups: Group[] = [];
for (const c of calls) {
const a = activityOf(c.capabilityId);
if (a.hidden) continue;
if (c.status === "denied" || authDenial(c)) continue;
let g = groups.find((x) => x.verb === a.verb);
if (!g) {
g = { verb: a.verb, nouns: [] };
groups.push(g);
}
const n = g.nouns.find((x) => x.noun === a.noun);
if (n) n.n++;
else g.nouns.push({ noun: a.noun, unit: a.unit, n: 1 });
}
const text = (g: Group) =>
cjkJoin(`${g.verb}了`, joinNouns(g.nouns.map((x) => (x.unit ? `${x.n} ${x.unit}${x.noun}` : x.noun))));
if (groups.length > 4) return `${groups.slice(0, 3).map(text).join(",")}等 ${groups.length} 类操作`;
return groups.map(text).join(",");
}
/** 正在进行的那一步:「正在检测站点」 */
export function runningText(call: Pick<ToolCallView, "capabilityId">): string {
const a = activityOf(call.capabilityId);
return `正在${a.verb}${a.noun}`;
}
/** 毫秒 → 「41 秒」「2 分 5 秒」 */
export function durationLabel(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} 秒`;
const m = Math.floor(s / 60);
return s % 60 ? `${m} 分 ${s % 60} 秒` : `${m} 分钟`;
}
/**
* 工具结果的一句话摘要(展开后每步一行的「→ 3 条结果」):错误取 message;数组 / items 取条数;
* 对象取 summary / message;其余不写(原文在技术信息里)。
*/
export function toolSummary(call: Pick<ToolCallView, "status" | "outputSummary">): string | null {
const raw = call.outputSummary?.trim();
if (!raw) return call.status === "running" ? "等待结果…" : null;
const clip = (t: string) => (t.length > 80 ? `${t.slice(0, 80)}…` : t);
let v: unknown;
try {
v = JSON.parse(raw);
} catch {
// 截断的 JSON 不上屏;普通文本截一句
return /^[[{]/.test(raw) ? null : clip(raw.replace(/\s+/g, " "));
}
if (Array.isArray(v)) return `${v.length} 条`;
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const err = o.error as { message?: unknown; code?: unknown } | undefined;
if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败"));
for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string);
for (const k of ["items", "results", "releases", "candidates", "sites", "tasks"])
if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条`;
return null;
}
return clip(String(v));
}
// ── 分段 ──
/** 授权器已在步骤处发起审批 / 正在等审批(interaction.ts inlineApproval 的原因文字) */
const WAITING_APPROVAL = /已在步骤处发起审批|等待管理员审批/;
export type ActivityItem =
| { kind: "tool"; key: string; call: ToolCallView }
| { kind: "thinking"; key: string; text: string; at: string }
| { kind: "note"; key: string; text: string; at: string };
export type BodyText = { key: string; text: string; at: string };
export type TurnPart =
| { kind: "segment"; key: string; activity: ActivityItem[]; body: BodyText[] }
| { kind: "options"; key: string; at: string; view: OptionsView }
| { kind: "approval"; key: string; at: string; view: ApprovalView }
| { kind: "compaction"; key: string; at: string; item: Extract<StreamItem, { kind: "compaction" }> }
| { kind: "end"; key: string; at: string; item: Extract<StreamItem, { kind: "end" }> };
export type ConvBlock =
| { kind: "user"; key: string; at: string; text: string; pending: boolean }
| { kind: "turn"; key: string; at: string; parts: TurnPart[]; denied: ToolCallView[] };
const itemAt = (it: StreamItem) => (it.kind === "tool" ? it.call.startedAt : it.at);
/**
* 纯函数:事件展示项 + 追加的用户消息 → 用户消息与智能体回合。
* 回合内:进度行收工具调用、思考和工具之间的话;最后一个工具之后的文字是结论正文。
* 被拒绝的调用不单独出卡,收进回合末尾的「没做」;已转成审批卡的(同能力的审批项在后面)不算。
*/
export function buildConversation(
items: StreamItem[],
userMessages: { at: string; text: string; pending?: boolean }[] = [],
): ConvBlock[] {
type Entry =
| { at: string; order: number; item: StreamItem }
| { at: string; order: number; user: (typeof userMessages)[number] };
const entries: Entry[] = [
...items.map((item, order) => ({ at: itemAt(item), order, item })),
...userMessages.map((user, i) => ({ at: user.at, order: items.length + i, user })),
];
// 同一时刻:事件保持原顺序,用户消息排在后面
entries.sort((a, b) => a.at.localeCompare(b.at) || a.order - b.order);
const out: ConvBlock[] = [];
let turn: Extract<ConvBlock, { kind: "turn" }> | null = null;
let seg: Extract<TurnPart, { kind: "segment" }> | null = null;
let texts: BodyText[] = [];
const closeSegment = () => {
if (!turn) return;
if (seg || texts.length) {
const s = seg ?? { kind: "segment" as const, key: `s${texts[0]?.key}`, activity: [], body: [] };
s.body = texts;
turn.parts.push(s);
}
seg = null;
texts = [];
};
const openTurn = (at: string, key: string) => {
if (!turn) {
turn = { kind: "turn", key: `t${key}`, at, parts: [], denied: [] };
out.push(turn);
}
return turn;
};
const segment = (key: string) => {
if (!seg) seg = { kind: "segment", key: `s${key}`, activity: [], body: [] };
return seg;
};
for (const e of entries) {
if ("user" in e) {
closeSegment();
turn = null;
out.push({ kind: "user", key: `u${e.at}`, at: e.at, text: e.user.text, pending: !!e.user.pending });
continue;
}
const it = e.item;
const t = openTurn(e.at, it.key);
switch (it.kind) {
case "text":
texts.push({ key: it.key, text: it.text, at: it.at });
break;
case "thinking":
case "tool": {
const s = segment(it.key);
// 工具之间的话算过程,收进进度行
for (const x of texts) s.activity.push({ kind: "note", key: x.key, text: x.text, at: x.at });
texts = [];
s.activity.push(
it.kind === "tool"
? { kind: "tool", key: it.key, call: it.call }
: { kind: "thinking", key: it.key, text: it.text, at: it.at },
);
if (it.kind === "tool" && isDenied(it.call)) t.denied.push(it.call);
break;
}
case "options":
closeSegment();
t.parts.push({ kind: "options", key: it.key, at: it.at, view: it.view });
break;
case "approval":
closeSegment();
t.parts.push({ kind: "approval", key: it.key, at: it.at, view: it.view });
// 这次拒绝已经转成审批卡:不再算"没做"
t.denied = t.denied.filter((c) => c.capabilityId !== it.view.capabilityId);
break;
case "compaction":
closeSegment();
t.parts.push({ kind: "compaction", key: it.key, at: it.at, item: it });
break;
case "end":
closeSegment();
t.parts.push({ kind: "end", key: it.key, at: it.at, item: it });
break;
}
}
closeSegment();
for (const b of out)
if (b.kind === "turn")
// 等待审批的那次拒绝(授权器已在步骤处发起审批)不算没做;恢复后重放成功的同一调用也不算
b.denied = b.denied.filter((c) => isDenied(c) && !WAITING_APPROVAL.test(authDenial(c) ?? ""));
return out;
}
/** 一段进度的起止时间(毫秒);没有工具调用为 null */
export function activitySpan(activity: ActivityItem[]): number | null {
const calls = activity.flatMap((a) => (a.kind === "tool" ? [a.call] : []));
if (!calls.length) return null;
const start = Math.min(...calls.map((c) => Date.parse(c.startedAt)));
const end = Math.max(...calls.map((c) => Date.parse(c.endedAt ?? c.startedAt)));
return Number.isFinite(start) && Number.isFinite(end) ? end - start : null;
}
/** 长正文:超过 ~12 行或 600 字按长文处理(收起时限高) */
export function isLongReport(text: string): boolean {
return text.length > 600 || text.split("\n").length > 12;
}
+241 -3
View File
@@ -10,6 +10,8 @@ type Entry = {
startedAt: number; startedAt: number;
abortedAt: number | null; abortedAt: number | null;
appended: RunMessage[]; appended: RunMessage[];
/** 挂起等用户(没有结束事件时状态为 waiting,summary 用它) */
waiting?: string | null;
}; };
const store = new Map<string, Entry>(); const store = new Map<string, Entry>();
@@ -239,10 +241,242 @@ function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: numb
return e; return e;
} }
// ── AGUI-3 演示:审查报告的 3 个示例(9883534f 巡检汇报、31d16a1a 方案 + 审批),截图 before / after 共用 ──
const at0 = (steps: [number, AgentRunEventBody][]): Step[] => {
let t = 0;
return steps.map(([dt, body]) => {
t += dt;
return { delayMs: t, body };
});
};
const toolPair = (
id: string,
cap: string,
input: string,
out: string,
isError = false,
): [number, AgentRunEventBody][] => [
[120, { type: "tool_call_start", toolCallId: id, capabilityId: cap, inputSummary: input, recovery: false }],
[380, { type: "tool_call_end", toolCallId: id, capabilityId: cap, isError, outputSummary: out, recovery: false }],
];
const textSteps = (text: string): [number, AgentRunEventBody][] =>
chunks(text, 40).map((c) => [20, { type: "text_delta", delta: c }]);
/** 旧汇报格式(改 prompt 之前模型写的):函数名、字段名、UUID、6 列表格 */
export const LEGACY_REPORT = `## 巡检结果
已调用 \`site.list(includeDisabled=true)\` 与 \`download.inspect\`、\`inventory.query\` 核对,结论如下:
| 站点 ID | 名称 | enabled | loginStatus | seeding | health_check |
| --- | --- | --- | --- | --- | --- |
| 062e5c51-9d1e-4c55-a5a2-3f7b1f3e0c11 | 憨憨 | true | ok | 24 | unauthorized |
- \`download.inspect\` 返回 downloader=null:下载器未配置(qb-main 未登记)。
- \`inventory.query\` 返回 mediaServer=null:媒体服务器未接入。
- \`site.health_check\` 被拒绝(缺少 site.health_check 的持续授权),未能检测连通性。
下一步:配置下载器后再调用 download.inspect;授权 site.health_check 后重试。`;
/** 新汇报格式(AGUI-3 prompt):第一句结论、用名字、≤10 行 */
export const NEW_REPORT =
"下载器和媒体服务器都还没接入,所以没法检查。站点「憨憨」正常,在做种 24 个。\n\n- 接入下载器:设置 → 下载器\n- 接入媒体服务器:设置 → 媒体服务器";
function checkupSteps(goal: string, report: string): Step[] {
const plan = (rev: number, s1: AgentPlanStep["status"], s2: AgentPlanStep["status"], s3: AgentPlanStep["status"]) =>
[
[
80,
{
type: "plan_updated",
revision: rev,
steps: [
{ id: "s1", title: "看站点状态", status: s1, note: null },
{ id: "s2", title: "核对下载器", status: s2, note: null },
{ id: "s3", title: "核对媒体服务器", status: s3, note: null },
],
},
],
] as [number, AgentRunEventBody][];
return at0([
[0, { type: "run_started", goal, resumed: false }],
...toolPair("p1", "agent.plan", '{"steps":[{"id":"s1"}]}', '{"revision":1}'),
...plan(1, "running", "pending", "pending"),
[60, { type: "thinking_delta", delta: "先列站点,再看下载器和媒体服务器有没有接入。" }],
...toolPair("c1", "site.list", '{"includeDisabled":true}', '{"items":[{"name":"憨憨","seeding":24}]}'),
...toolPair(
"c2",
"site.health_check",
'{"siteId":"062e5c51-9d1e-4c55-a5a2-3f7b1f3e0c11"}',
'{"error":{"code":"unauthorized","message":"缺少 site.health_check 的持续授权或有效的单次审批"}}',
true,
),
...toolPair("p2", "agent.plan", '{"steps":[{"id":"s1","status":"done"}]}', '{"revision":2}'),
...plan(2, "done", "running", "pending"),
...toolPair("c3", "download.inspect", '{"status":"downloading"}', '{"summary":"没有配置下载器"}'),
...toolPair("c4", "download.inspect", '{"status":"stalled"}', '{"summary":"没有配置下载器"}'),
[60, { type: "thinking_delta", delta: "下载器没配置。再看媒体库。" }],
...toolPair("c5", "inventory.query", "{}", '{"items":[],"mediaServer":null}'),
...toolPair("p3", "agent.plan", '{"steps":[{"id":"s3","status":"done"}]}', '{"revision":3}'),
...plan(3, "done", "done", "done"),
...textSteps(report),
[200, { type: "run_completed", finalText: report, usage: { ...USAGE, costUsd: 0.014, totalTokens: 320_700 } }],
]);
}
/** 例 3:方案绑定下载计划(选定即批准),选了之后直接提交 */
const DUNE_PLAN = "00000000-0000-4000-8000-00000000b003";
const DUNE_DECISION = "00000000-0000-4000-8000-00000000d003";
function optionSteps(goal: string): Step[] {
return at0([
[0, { type: "run_started", goal, resumed: false }],
...toolPair("p1", "agent.plan", "{}", '{"revision":1}'),
[
60,
{
type: "plan_updated",
revision: 1,
steps: [
{ id: "s1", title: "找 1080p 资源", status: "done", note: null },
{ id: "s2", title: "生成下载计划", status: "done", note: null },
{ id: "s3", title: "等你选方案", status: "running", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
],
},
],
...toolPair("c1", "release.search", '{"q":"Dune Part Two 1080p"}', '{"items":[1,2]}'),
...toolPair("c2", "download.plan", `{"sourceReleaseId":"…"}`, `{"plan":{"id":"${DUNE_PLAN}","status":"draft"}}`),
...textSteps("找到 1 个合适的 1080p 资源,站点「憨憨」的登录明天过期。"),
...toolPair("p2", "agent.propose_options", "{}", '{"outcome":"pending"}'),
[
80,
{
type: "options_proposed",
invocationId: "mock-dune-options",
decisionId: DUNE_DECISION,
question: "《沙丘:第二部》1080p 怎么收?",
options: [
{
id: "a",
title: "提交下载",
summary: "Dune.Part.Two.2024.1080p.BluRay.x264-GRP,免费,做种 41",
risks: ["站点登录明天过期,之后下载可能断", "缓存卷会用到 86%"],
estimatedCost: "12 GB",
action: {
capabilityId: "download.submit",
planId: DUNE_PLAN,
label: "提交下载",
detail: "《沙丘:第二部》· 12.0 GB",
approves: true,
},
},
{
id: "b",
title: "先修站点",
summary: "先刷新「憨憨」的登录,再提交",
risks: [],
estimatedCost: null,
action: null,
},
{ id: "c", title: "先不下", summary: "等 REMUX 出现再说", risks: [], estimatedCost: null, action: null },
],
recommendedId: "a",
stepId: "s3",
expiresAt: iso(Date.now() + 1_800_000),
timeoutAction: "recommended",
},
],
]);
}
/** 选定方案后的续跑:选了带动作的方案 → 直接提交,不出审批卡 */
function afterChoice(e: Entry, optionId: string | null) {
const base = Date.now() - e.startedAt;
const done: [number, AgentRunEventBody][] =
optionId === "a"
? [
[
100,
{
type: "options_resolved",
invocationId: "mock-dune-options",
decisionId: DUNE_DECISION,
outcome: "chosen",
choice: "a",
reason: null,
by: "user",
},
],
...toolPair("c3", "download.submit", `{"planId":"${DUNE_PLAN}"}`, '{"taskId":"…","status":"submitted"}'),
...textSteps("已提交《沙丘:第二部》1080p(12 GB),下载完成后自动整理入库。"),
[100, { type: "run_completed", finalText: "", usage: USAGE }],
]
: [
[
100,
{
type: "options_resolved",
invocationId: "mock-dune-options",
decisionId: DUNE_DECISION,
outcome: optionId ? "chosen" : "rejected",
choice: optionId,
reason: null,
by: "user",
},
],
...textSteps(optionId === "b" ? "好,先不提交,等站点登录刷新后再说。" : "好,这次先不下。"),
[100, { type: "run_completed", finalText: "", usage: USAGE }],
];
let t = base;
for (const [dt, body] of done) {
t += dt;
e.steps.push({ delayMs: t, body });
}
e.waiting = null;
}
/** mock 的方案作答 */
export function mockChoose(runId: string, optionId: string | null): RunSnapshot {
seed();
const e = store.get(runId);
if (e?.waiting === "等待选择方案") afterChoice(e, optionId);
return mockDetail(runId);
}
function seed() { function seed() {
if (seeded) return; if (seeded) return;
seeded = true; seeded = true;
const now = Date.now(); const now = Date.now();
const fixture = (n: number, goal: string, steps: Step[], ago: number, extra: Partial<Entry> = {}) => {
const e: Entry = {
run: baseRun(uuid(n), goal, { proposeFirst: false }, now - ago, "running"),
steps,
startedAt: now - ago,
abortedAt: null,
appended: [],
...extra,
};
e.run = { ...e.run, provider: "opencode-go", model: "deepseek-v4.1-flash" };
store.set(e.run.id, e);
return e;
};
const CHECKUP = "检查站点、下载器和媒体服务器是否正常";
fixture(11, `${CHECKUP}(旧汇报格式)`, checkupSteps(CHECKUP, LEGACY_REPORT), 40 * 60_000);
const parent = fixture(12, CHECKUP, checkupSteps(CHECKUP, NEW_REPORT), 30 * 60_000);
const child = fixture(
14,
"看站点「憨憨」最近的抓取记录",
checkupSteps("看站点「憨憨」最近的抓取记录", "「憨憨」最近 24 小时抓取 96 次,全部成功。"),
29 * 60_000,
);
child.run = { ...child.run, parentRunId: parent.run.id, trigger: "subagent", depth: 1 };
fixture(
13,
"[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 把 1080p 收进来",
optionSteps("把沙丘:第二部的 1080p 收进来"),
20 * 60_000,
{ waiting: "等待选择方案" },
);
add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1); add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1);
add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2); add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2);
const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3); const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3);
@@ -321,7 +555,9 @@ function view(e: Entry): AgentRunView {
.map((x) => (x.type === "text_delta" ? x.delta : "")) .map((x) => (x.type === "text_delta" ? x.delta : ""))
.join(""); .join("");
const status: AgentRunView["status"] = !end const status: AgentRunView["status"] = !end
? "running" ? e.waiting && evs.length === e.steps.length
? "waiting"
: "running"
: end.type === "run_completed" : end.type === "run_completed"
? "succeeded" ? "succeeded"
: end.reason === "budget" : end.reason === "budget"
@@ -344,7 +580,7 @@ function view(e: Entry): AgentRunView {
costUsd: +(USAGE.costUsd * frac).toFixed(4), costUsd: +(USAGE.costUsd * frac).toFixed(4),
durationMs: Date.now() - e.startedAt, durationMs: Date.now() - e.startedAt,
}, },
summary: end ? (end.type === "run_failed" ? end.message : text) : null, summary: end ? (end.type === "run_failed" ? end.message : text) : status === "waiting" ? (e.waiting ?? null) : null,
error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null, error: end?.type === "run_failed" ? { reason: end.reason, message: end.message } : null,
finishedAt: end ? end.at : null, finishedAt: end ? end.at : null,
updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt, updatedAt: evs.length ? evs[evs.length - 1]!.at : e.run.updatedAt,
@@ -415,7 +651,9 @@ function report(e: Entry, run: AgentRunView): GoalReport | null {
downloadTaskId: uuid(7002), downloadTaskId: uuid(7002),
planId: uuid(7001), planId: uuid(7001),
status: "downloading", status: "downloading",
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX", name: run.goal.includes("1080p")
? "Dune.Part.Two.2024.1080p.BluRay.x264-GRP"
: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
}, },
] ]
: [], : [],
@@ -0,0 +1,30 @@
// 输入框里的 `/` 指令(AGUI-3)。只作用于这一次输入,不是开关:
// - /先给方案:新会话按「先看方案再执行」建(proposeFirst,服务端在选定方案前拒绝执行类能力);
// 在已有会话里发,转成一句"先给方案"的要求(模型按它做,服务端不强制)。
// - /重跑:用同一个目标新开一次会话(已有会话里才有)。
// 旧的「先看方案再执行」开关只是每次新建时的本地状态,没有存进设置;旧会话 scope.proposeFirst 照常生效。
export type AgentCommand = { name: "/先给方案" | "/重跑"; description: string };
export const PROPOSE_FIRST: AgentCommand = { name: "/先给方案", description: "这次先给方案,选定后再执行" };
export const RERUN: AgentCommand = { name: "/重跑", description: "用同一个目标重新开始" };
/** 新会话可用的指令 */
export const NEW_RUN_COMMANDS: AgentCommand[] = [PROPOSE_FIRST];
/** 会话里可用的指令 */
export const RUN_COMMANDS: AgentCommand[] = [PROPOSE_FIRST, RERUN];
/** 拆出开头的指令:「/先给方案 把沙丘收进来」→ { command: "/先给方案", rest: "把沙丘收进来" } */
export function parseCommand(
text: string,
commands: AgentCommand[] = RUN_COMMANDS,
): { command: AgentCommand["name"] | null; rest: string } {
const t = text.trim();
for (const c of commands)
if (t === c.name || t.startsWith(`${c.name} `) || t.startsWith(`${c.name}\n`))
return { command: c.name, rest: t.slice(c.name.length).trim() };
return { command: null, rest: t };
}
/** 已有会话里发 /先给方案:转成给模型的一句要求 */
export const PROPOSE_FIRST_MESSAGE = "先给出方案让我选,选定前不要执行有副作用的操作。";
@@ -1,9 +1,11 @@
import { import {
type AgentOptionItem, type AgentOptionView,
type AgentPlanStep, type AgentPlanStep,
type AgentRunEvent, type AgentRunEvent,
type AgentUsageTotals, type AgentUsageTotals,
CAPABILITY_ACTIVITY,
EventType, EventType,
type KnownCapabilityId,
} from "@umt/contracts"; } from "@umt/contracts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts"; import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
@@ -45,7 +47,7 @@ export type StreamItem =
export type OptionsView = { export type OptionsView = {
decisionId: string; decisionId: string;
question: string; question: string;
options: AgentOptionItem[]; options: AgentOptionView[];
recommendedId: string | null; recommendedId: string | null;
stepId: string | null; stepId: string | null;
expiresAt: string; expiresAt: string;
@@ -88,7 +90,14 @@ export type AgentStreamState = {
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus]; const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
/** 事件 → 展示项:相邻文本增量合并;工具调用按 toolCallId 折叠成一张卡 */ /**
* 不作为工具调用出现的能力(AGUI-3):步骤清单(agent.plan)在概况条里,方案(agent.propose_options)有自己的方案卡。
* 旧会话的事件里照样有它们的 tool_call_*,回放时一并跳过。
*/
export const isHiddenCapability = (id: string | null | undefined) =>
!!id && CAPABILITY_ACTIVITY[id as KnownCapabilityId]?.hidden === true;
/** 事件 → 展示项:相邻文本增量合并;工具调用按 toolCallId 折叠成一项;步骤清单 / 方案的调用不出现 */
export function foldEvents(events: AgentRunEvent[]): StreamItem[] { export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
const items: StreamItem[] = []; const items: StreamItem[] = [];
const tools = new Map<string, ToolCallView>(); const tools = new Map<string, ToolCallView>();
@@ -96,6 +105,11 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
const approvals = new Map<string, ApprovalView>(); const approvals = new Map<string, ApprovalView>();
for (const e of events) { for (const e of events) {
const last = items[items.length - 1]; const last = items[items.length - 1];
if (
(e.type === "tool_call_start" || e.type === "tool_call_end" || e.type === "tool_call_rejected") &&
isHiddenCapability(e.capabilityId)
)
continue;
switch (e.type) { switch (e.type) {
case "text_delta": case "text_delta":
if (last?.kind === "text") last.text += e.delta; if (last?.kind === "text") last.text += e.delta;
+108 -17
View File
@@ -1,45 +1,56 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx // Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md // Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、 // 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单,换成 `toolbar` 槽(放模式切换等); // loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单;
// AGUI-3:输入区只留输入框和发送 / 停止,新增 `/` 指令菜单(commands)与上下文标签(context,可移除);
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。 // 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
import { ArrowUp, Square } from "lucide-react"; import { ArrowUp, Square, X } from "lucide-react";
import { import {
type FormEvent, type FormEvent,
type KeyboardEvent, type KeyboardEvent,
type ReactNode,
type TextareaHTMLAttributes, type TextareaHTMLAttributes,
useCallback, useCallback,
useId,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState,
} from "react"; } from "react";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
export type PromptCommand = { name: string; description: string };
export interface PromptInputProps export interface PromptInputProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> { extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> {
value: string; value: string;
onValueChange: (v: string) => void; onValueChange: (v: string) => void;
onSubmit: (v: string) => void; onSubmit: (v: string) => void;
/** 运行中:发送键变"停止" */ /** 运行中:发送键旁出现"停止" */
loading?: boolean; loading?: boolean;
onStop?: () => void; onStop?: () => void;
/** 提交中(禁用发送) */ /** 提交中(禁用发送) */
pending?: boolean; pending?: boolean;
minRows?: number; minRows?: number;
maxRows?: number; maxRows?: number;
/** 输入框下方左侧工具条 */
toolbar?: ReactNode;
submitLabel?: string; submitLabel?: string;
stopLabel?: string; stopLabel?: string;
/** 顶部附加内容(如上下文前缀) */ /** 以 / 开头时弹出的指令菜单 */
header?: ReactNode; commands?: PromptCommand[];
/** 上下文标签(对象名称);给了 onClearContext 就带 × */
context?: string | null;
onClearContext?: () => void;
className?: string; className?: string;
} }
/** 行高 24px(text-sm leading-6),与测量层一致 */ /** 行高 24px(text-sm leading-6),与测量层一致 */
const LINE = 24; const LINE = 24;
/** 正在输入指令(以 / 开头、还没空格)时匹配的指令 */
export function commandMatches(value: string, commands: PromptCommand[]): PromptCommand[] {
if (!value.startsWith("/") || /\s/.test(value)) return [];
return commands.filter((c) => c.name.startsWith(value));
}
export function PromptInput({ export function PromptInput({
value, value,
onValueChange, onValueChange,
@@ -49,10 +60,11 @@ export function PromptInput({
pending = false, pending = false,
minRows = 2, minRows = 2,
maxRows = 8, maxRows = 8,
toolbar,
submitLabel = "发送", submitLabel = "发送",
stopLabel = "停止", stopLabel = "停止",
header, commands = [],
context,
onClearContext,
className, className,
disabled, disabled,
placeholder, placeholder,
@@ -61,6 +73,9 @@ export function PromptInput({
}: PromptInputProps) { }: PromptInputProps) {
const ta = useRef<HTMLTextAreaElement>(null); const ta = useRef<HTMLTextAreaElement>(null);
const measure = useRef<HTMLDivElement>(null); const measure = useRef<HTMLDivElement>(null);
const menuId = useId();
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState<string | null>(null);
const resize = useCallback(() => { const resize = useCallback(() => {
const t = ta.current; const t = ta.current;
const m = measure.current; const m = measure.current;
@@ -71,6 +86,14 @@ export function PromptInput({
// biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量 // biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量
useLayoutEffect(resize, [value, resize]); useLayoutEffect(resize, [value, resize]);
const matches = dismissed === value ? [] : commandMatches(value, commands);
const menuOpen = matches.length > 0;
const pick = (c: PromptCommand) => {
onValueChange(`${c.name} `);
setActive(0);
requestAnimationFrame(() => ta.current?.focus());
};
const canSubmit = !!value.trim() && !disabled && !pending; const canSubmit = !!value.trim() && !disabled && !pending;
const submit = (e?: FormEvent) => { const submit = (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -78,6 +101,23 @@ export function PromptInput({
onSubmit(value.trim()); onSubmit(value.trim());
}; };
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (menuOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => (i + (e.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length);
return;
}
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
pick(matches[Math.min(active, matches.length - 1)]!);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setDismissed(value);
return;
}
}
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return; if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault(); e.preventDefault();
submit(); submit();
@@ -92,12 +132,60 @@ export function PromptInput({
className, className,
)} )}
> >
{header} {menuOpen ? (
<div className="relative"> <div
id={menuId}
role="listbox"
aria-label="指令"
className="pg-float absolute inset-x-2 bottom-full z-20 mb-1.5 flex flex-col gap-0.5 rounded-xl p-1"
>
{matches.map((c, i) => (
<div
key={c.name}
role="option"
tabIndex={-1}
aria-selected={i === active}
// 按下就选(不等 click),避免输入框先失焦
onMouseDown={(e) => {
e.preventDefault();
pick(c);
}}
className={cn(
"flex w-full cursor-pointer items-baseline gap-2 rounded-lg px-2.5 py-1.5 text-left text-caption outline-none",
i === active ? "bg-foreground/[0.06] text-foreground" : "text-foreground/85",
)}
>
<span className="shrink-0 font-medium">{c.name}</span>
<span className="min-w-0 truncate text-meta text-muted-foreground">{c.description}</span>
</div>
))}
</div>
) : null}
{context ? (
<div className="mb-1 flex px-1">
<span className="inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border border-border/70 py-0.5 pr-1 pl-2 text-meta text-muted-foreground">
<span className="min-w-0 truncate" title={context}>
{context}
</span>
{onClearContext ? (
<button
type="button"
aria-label={`去掉上下文「${context}」`}
onClick={onClearContext}
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-full outline-none hover:bg-foreground/[0.08] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="size-3" />
</button>
) : null}
</span>
</div>
) : null}
<div className="flex items-end gap-1.5">
<div className="relative min-w-0 flex-1">
<div <div
ref={measure} ref={measure}
aria-hidden aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 pt-1.5 text-sm leading-6 break-words whitespace-pre-wrap" className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 py-1 text-sm leading-6 break-words whitespace-pre-wrap"
> >
{`${value}​`} {`${value}​`}
</div> </div>
@@ -107,15 +195,18 @@ export function PromptInput({
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-autocomplete={commands.length ? "list" : undefined}
aria-controls={menuOpen ? menuId : undefined}
rows={minRows} rows={minRows}
{...rest} {...rest}
onChange={(e) => onValueChange(e.target.value)} onChange={(e) => {
onValueChange(e.target.value);
setActive(0);
}}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className="block w-full resize-none overflow-y-auto bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70" className="block w-full resize-none overflow-y-auto bg-transparent px-2 py-1 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
/> />
</div> </div>
<div className="mt-1 flex min-h-8 flex-wrap items-center gap-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">{toolbar}</div>
{loading && onStop ? ( {loading && onStop ? (
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}> <Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
<Square className="size-3.5" /> <Square className="size-3.5" />
+11 -27
View File
@@ -1,47 +1,31 @@
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { agentMockEnabled, useAgentRun } from "~/api/agent.ts"; import { agentMockEnabled } from "~/api/agent.ts";
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx"; import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx"; import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { shortGoalTitle } from "~/components/agent/agent-store.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上; // 会话页(AGUI-3):左栏会话列表(xl 以上)· 会话列占满其余宽度(顶部概况条、底部输入区)。
// 更窄时换成会话列顶部的可折叠概况条)。 // 不再有页头目标描述和右侧栏;步骤、子会话、预算与模型在概况条里。
export default function AgentRunPage() { export default function AgentRunPage() {
const { runId = "" } = useParams(); const { runId = "" } = useParams();
const run = useAgentRun(runId);
const mock = agentMockEnabled(); const mock = agentMockEnabled();
const suffix = mock ? "?mock=1" : ""; const suffix = mock ? "?mock=1" : "";
return ( return (
<> <>
<PageHeader <h1 className="sr-only">智能体会话</h1>
title="智能体会话" <div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
description={ <aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-7rem)] xl:overflow-hidden">
run.data ? ( <AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} className="h-full" />
<span className="line-clamp-2 break-words" title={run.data.run.goal}> </aside>
{shortGoalTitle(run.data.run.goal)} <div className="pg-slab flex h-[calc(100dvh-7rem)] min-h-[28rem] flex-col gap-2 rounded-xl p-3 md:p-4">
</span> <Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground xl:hidden">
) : (
"加载中…"
)
}
actions={
<Button asChild variant="ghost" size="sm">
<Link to={`/console/agent${suffix}`}> <Link to={`/console/agent${suffix}`}>
<ArrowLeft /> <ArrowLeft />
全部会话 全部会话
</Link> </Link>
</Button> </Button>
} <AgentRunPanel key={runId} runId={runId} className="min-h-0 flex-1" />
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,16rem)_minmax(0,1fr)]">
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-13rem)] xl:overflow-hidden">
<AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} 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" />
</div> </div>
</div> </div>
</> </>
+6 -10
View File
@@ -1,13 +1,15 @@
import { FlaskConical, ShieldCheck } from "lucide-react"; import { FlaskConical } from "lucide-react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts"; import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx"; import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx"; import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { quickGoalsFor } from "~/components/agent/quick-goals.ts";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
// 智能体入口页(AGUI-3):左栏会话列表(等你处理置顶),右边新对话输入框 + 3 个快捷目标。
export default function AgentPage() { export default function AgentPage() {
const create = useCreateAgentRun(); const create = useCreateAgentRun();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -15,7 +17,7 @@ export default function AgentPage() {
return ( return (
<> <>
<PageHeader title="智能体会话" /> <PageHeader title="智能体" />
{mock ? ( {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"> <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" /> <FlaskConical className="h-4 w-4 shrink-0" />
@@ -29,11 +31,10 @@ export default function AgentPage() {
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden"> <div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden">
<AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" /> <AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" />
</div> </div>
<div className="order-1 mx-auto w-full max-w-[88ch] space-y-3 lg:order-2"> <div className="pg-slab order-1 mx-auto w-full max-w-[88ch] rounded-xl p-4 lg:order-2">
<div className="pg-slab rounded-xl p-4">
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
<GoalComposer <GoalComposer
pending={create.isPending} pending={create.isPending}
suggestions={quickGoalsFor("/console/agent")}
onSubmit={(goal, opts) => onSubmit={(goal, opts) =>
create.mutate( create.mutate(
{ goal, ...opts }, { goal, ...opts },
@@ -45,11 +46,6 @@ export default function AgentPage() {
} }
/> />
</div> </div>
<p className="flex items-center gap-2 px-1 text-meta text-muted-foreground">
<ShieldCheck aria-hidden className="size-3.5 shrink-0" />
每次工具调用都经服务端授权校验,汇报以系统核实的结果为准。
</p>
</div>
</div> </div>
</> </>
); );
+12 -9
View File
@@ -256,9 +256,9 @@ async function main() {
} }
} }
// ── 交互:智能体抽屉「历史」Tab(AGUI-2;演示数据,点一条在抽屉内打开、返回列表、关了再开仍停在历史)── // ── 交互:智能体抽屉「历史」弹层(AGUI-3;演示数据,点一条在抽屉内打开、新对话回到输入框、关了再开仍是那条会话)──
{ {
const label = "智能体抽屉 → 历史 → 抽屉内打开会话"; const label = "智能体抽屉 → 历史弹层 → 抽屉内打开会话";
problems = []; problems = [];
const t0 = Date.now(); const t0 = Date.now();
try { try {
@@ -266,17 +266,20 @@ async function main() {
await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 }); await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 });
const launcher = page.locator("[data-agent-launcher]:visible").last(); const launcher = page.locator("[data-agent-launcher]:visible").last();
await launcher.click(); await launcher.click();
await page.getByRole("tab", { name: "历史" }).click(); await page.getByRole("button", { name: "历史会话" }).click();
const list = page.getByRole("navigation", { name: "智能体会话列表" }); const list = page.getByRole("navigation", { name: "智能体会话列表" });
await list.getByRole("button").first().click(); await list
await page.getByRole("button", { name: "返回列表" }).waitFor({ timeout: 10_000 }); .getByRole("button")
.filter({ hasNotText: /^(全部|等你处理|失败)$/ })
.first()
.click();
await page.getByRole("button", { name: "新对话" }).and(page.locator(":enabled")).waitFor({ timeout: 10_000 });
if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面"); if (new URL(page.url()).pathname !== "/console/audit") problems.push("点历史会话跳走了页面");
await page.getByRole("button", { name: "返回列表" }).click();
await list.waitFor({ timeout: 5_000 });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await launcher.click(); await launcher.click();
const tab = await page.getByRole("tab", { name: "历史" }).getAttribute("aria-selected"); if (!(await page.getByRole("button", { name: "新对话" }).isEnabled())) problems.push("再次打开没有回到那条会话");
if (tab !== "true") problems.push("再次打开没有停在「历史」"); await page.getByRole("button", { name: "新对话" }).click();
await page.getByRole("textbox", { name: "目标" }).waitFor({ timeout: 5_000 });
if (SHOTS) await page.screenshot({ path: join(OUT, "agent_drawer_history.png") }).catch(() => {}); if (SHOTS) await page.screenshot({ path: join(OUT, "agent_drawer_history.png") }).catch(() => {});
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 }); await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 });