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
+2381 -1846

No files matched your search

+36 -2
View File
@@ -20,7 +20,7 @@ import {
agentRunEventFromRow,
cursorPage,
} 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 { useSession } from "./auth.ts";
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
@@ -254,7 +254,7 @@ export function useChooseOption() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: { runId: string; decisionId: string; optionId: string | null; reason?: string }) => {
if (agentMockEnabled()) return mockDetail(input.runId).run;
if (agentMockEnabled()) return mockChoose(input.runId, input.optionId).run;
return apiMutate(`${AGENT_BASE}/:id/choice`, {
pathParams: { id: input.runId },
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> = {
queued: "排队中",
running: "运行中",
File diff suppressed because it is too large. Load diff
+115 -178
View File
@@ -1,30 +1,31 @@
import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react";
import { useRef } from "react";
import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
import { useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
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 { 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 { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList } from "./agent-session-list.tsx";
import {
type AgentContext,
type AgentDrawerTab,
agentShortcutLabel,
clearAgentContext,
contextFromPath,
contextPrefix,
lastAgentDrawerTab,
minimizeAgentDrawer,
newAgentConversation,
openAgentDrawer,
openAgentHistoryRun,
restoreAgentDrawer,
setAgentDrawerOpen,
setAgentDrawerRun,
setAgentDrawerTab,
shortGoalTitle,
showAgentRunInDrawer,
useAgentDrawer,
@@ -36,108 +37,73 @@ import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
const CONTEXT_KEYS: [keyof AgentContext, string][] = [
["entityId", "作品"],
["releaseId", "资源"],
["downloadTaskId", "下载任务"],
["recordId", "整理记录"],
["siteId", "站点"],
["taskId", "任务"],
];
function ContextChips({ context }: { context: AgentContext }) {
const chips = CONTEXT_KEYS.filter(([k]) => context[k]);
if (!chips.length) return null;
return (
<ul aria-label="当前页面上下文" className="mt-1.5 flex flex-wrap gap-1">
{chips.map(([k, label]) => (
<li
key={k}
className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 font-mono text-label text-muted-foreground"
title={String(context[k])}
>
{label} · {String(context[k]).slice(0, 12)}
</li>
))}
</ul>
);
/**
* 上下文的显示名称:页面按钮给的 label(「作品:沙丘2」)去掉类别;按路由推断的只有 ID,按 ID 查名称。
* 查不到时退回类别名(「这部作品」),不显示 ID。
*/
export function useContextName(ctx: AgentContext | null): string | null {
const path = !!ctx?.fromPath;
const entity = useLibraryDetail(path ? ctx?.entityId : undefined);
const site = useSite(path ? ctx?.siteId : undefined);
const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined);
const record = useRecord(path ? ctx?.recordId : undefined);
if (!ctx) return null;
if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null;
if (ctx.entityId) return entity.data?.entity.title ?? "这部作品";
if (ctx.siteId) return site.data?.name ?? ctx.siteId;
if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务";
if (ctx.recordId) return record.data?.media?.title ?? "这条整理记录";
if (ctx.taskId) return "这个后台任务";
return null;
}
const TABS: { value: AgentDrawerTab; label: string }[] = [
{ value: "current", label: "当前" },
{ value: "history", label: "历史" },
];
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);
/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */
function HistoryPopover({ activeId }: { activeId: string | null }) {
const [open, setOpen] = useState(false);
const mock = agentMockEnabled();
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex min-w-0 items-center gap-1">
<Button variant="ghost" size="sm" className="-ml-2 shrink-0" onClick={() => openAgentHistoryRun(null)}>
<ArrowLeft />
返回列表
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" aria-label="历史会话" title="历史会话">
<History />
历史
</Button>
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</p>
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在页面打开
</PopoverTrigger>
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
<AgentSessionList
remember
limit={20}
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>
</Button>
</div>
<AgentRunOpenContext.Provider value={openAgentHistoryRun}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
</div>
</PopoverContent>
</Popover>
);
}
/**
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
* 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。
* 运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
* 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。
* 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
*/
export function AgentDrawer() {
const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer();
const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const prefix = contextPrefix(context);
const { pathname } = useLocation();
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 (
<>
@@ -146,7 +112,7 @@ export function AgentDrawer() {
ref={content}
side="right"
className="w-full gap-0 p-0 sm:max-w-xl"
// 打开即可输入:焦点直接给输入框(新目标或运行中的追加消息),没有输入框时退回抽屉本身
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
onOpenAutoFocus={(e) => {
const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])");
if (!ta) return;
@@ -158,93 +124,66 @@ export function AgentDrawer() {
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">
<SheetTitle className="flex items-center gap-2 text-lead">
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-12 pl-5">
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
智能体
</SheetTitle>
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}>
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`}
</SheetDescription>
{context && !history ? <ContextChips context={context} /> : null}
<DrawerTabs tab={tab} />
</SheetHeader>
{runId && !history ? (
<Button
variant="ghost"
size="icon-sm"
className="absolute top-4 right-14"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
onClick={minimizeAgentDrawer}
>
<Minus />
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<HistoryPopover activeId={runId} />
<Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
<SquarePen />
新对话
</Button>
) : null}
<div
id="agent-tabpanel"
role="tabpanel"
aria-labelledby={`agent-tab-${tab}`}
className="flex min-h-0 flex-1 flex-col px-4 py-3"
>
{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} />
{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
variant="ghost"
size="icon-sm"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
onClick={minimizeAgentDrawer}
>
<Minus />
</Button>
</>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => setAgentDrawerRun(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
<div className="flex min-h-0 flex-1 flex-col justify-end">
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => showAgentRunInDrawer(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
)}
</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>
)}
</SheetContent>
</Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
@@ -268,7 +207,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
? "思考中…"
: last?.kind === "end"
? last.ok
? "已完成,展开查看核实结果"
? "已完成,展开查看结果"
: last.message
: "等待输出…";
const status = run.data
@@ -325,9 +264,7 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`}
className={className}
onClick={() =>
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
}
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
>
<Bot />
</Button>
@@ -1,17 +1,9 @@
import type { AgentRunEvent } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import {
authDenial,
type ConvEntry,
groupConversation,
isDenied,
isLongReport,
pairAudit,
runPlanIds,
toolSummary,
} from "./agent-conversation.tsx";
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
import { authDenial, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx";
import { matchesHistory } from "./agent-session-list.tsx";
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";
const call = (p: Partial<ToolCallView>): ToolCallView => ({
@@ -100,7 +92,7 @@ describe("事件折叠", () => {
const start = {
type: "tool_call_start",
toolCallId: "tc1",
capabilityId: "agent.propose_options",
capabilityId: "download.submit",
inputSummary: "{}",
};
const items = foldEvents([
@@ -109,7 +101,7 @@ describe("事件折叠", () => {
ev(3, {
type: "tool_call_end",
toolCallId: "tc1",
capabilityId: "agent.propose_options",
capabilityId: "download.submit",
outputSummary: "ok",
isError: false,
recovery: true,
@@ -122,67 +114,29 @@ describe("事件折叠", () => {
});
});
describe("会话排版(AGUI-2)", () => {
const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({
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("工具卡一行摘要", () => {
describe("工具结果摘要", () => {
it("展开后每步一行的「→ 结果」", () => {
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: "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("下载器连接超时");
});
it("长汇报判定", () => {
it("长正文判定", () => {
expect(isLongReport("短")).toBe(false);
expect(isLongReport("x".repeat(601))).toBe(true);
expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true);
});
});
describe("会话历史筛选", () => {
const now = new Date("2026-09-29T12:00:00Z").getTime();
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" };
describe("会话历史搜索", () => {
const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来" };
it("搜索匹配目标或 ID 前缀", () => {
expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true);
expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true);
expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).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();
expect(matchesHistory(run, { q: "沙丘" })).toBe(true);
expect(matchesHistory(run, { q: "abcd" })).toBe(true);
expect(matchesHistory(run, { q: "奥本海默" })).toBe(false);
});
});
@@ -1,11 +1,18 @@
import { type AgentPlanStep, CAPABILITY_CATALOG, type KnownCapabilityId, type RiskLevel } from "@umt/contracts";
import { ListChecks, MessageCircleQuestion, Star } from "lucide-react";
import {
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 { toast } from "sonner";
import { useApproval, useChooseOption, useDecideApproval, useIsAdminSession } from "~/api/agent.ts";
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 { 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 { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -28,7 +35,8 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] {
id: s.id,
title: s.title,
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 }) {
const name = useId();
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 [picking, setPicking] = useState<string | null>(null);
const [sent, setSent] = useState<{ optionId: string | null; reason: string } | null>(null);
const resolved = view.resolved;
// 已作答但会话还没恢复(事件里没有 options_resolved):按本地提交结果显示
const closed = !!resolved || !!sent || !waiting;
const chosenId = resolved ? resolved.choice : sent ? sent.optionId : null;
const submit = () => {
const optionId = picked === NONE ? null : picked;
if (!picked) return;
const options = orderOptions(view.options, view.recommendedId);
const risks = options.flatMap((o) => o.risks.map((r) => ({ key: `${o.id}:${r}`, option: o.title, text: r })));
const submit = (optionId: string | null) => {
if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,智能体按它调整");
return;
}
setPicking(optionId ?? "__none__");
choose.mutate(
{ runId, decisionId: view.decisionId, optionId, ...(optionId === null ? { reason: reason.trim() } : {}) },
{
onSuccess: () => {
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)),
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
? 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.reason ? `:${resolved.reason}` : ""}`
: sent
? sent.optionId
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
: `都不要:${sent.reason},等待智能体调整`
? `已选:${label(sent.optionId)}`
: `都不要:${sent.reason}`
: !waiting
? "会话已不在等待这个选择"
? "已不再等这个选择"
: null;
if (closed)
return (
<p
data-slot="options-resolved"
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"
>
<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={cn(
"w-full min-w-0 rounded-xl border p-3",
closed ? "border-border/70" : "border-warning/40 bg-warning/[0.06]",
)}
>
<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">
<MessageCircleQuestion aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 break-words">{view.question}</span>
</p>
<fieldset className="mt-2.5 space-y-1.5" disabled={closed || choose.isPending}>
<legend className="sr-only">{view.question}</legend>
{view.options.map((o) => {
const selected = closed ? chosenId === o.id : picked === o.id;
<ul className="mt-2.5 flex flex-col gap-1.5">
{options.map((o) => {
const recommended = o.id === view.recommendedId;
const point = optionPoint(o);
return (
<label
key={o.id}
className={cn(
"flex min-w-0 cursor-pointer gap-2.5 rounded-lg border border-border/70 px-3 py-2 transition-colors duration-micro has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
selected && "border-foreground/40 bg-foreground/[0.04]",
closed && !selected && "opacity-60",
closed && "cursor-default",
)}
>
<input
type="radio"
name={name}
value={o.id}
checked={selected}
onChange={() => setPicked(o.id)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-1.5">
<span className="text-caption font-medium text-foreground">{o.title}</span>
{view.recommendedId === o.id ? (
<StatusBadge>
<Star className="size-3" />
推荐
</StatusBadge>
<li key={o.id}>
<button
type="button"
disabled={choose.isPending}
onClick={() => submit(o.id)}
data-recommended={recommended || undefined}
className={cn(
"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",
recommended
? "pill-primary border-transparent"
: "border-border/70 bg-background/40 hover:bg-foreground/[0.04]",
)}
>
<span className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-caption font-medium">
{picking === o.id ? (
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
) : 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 className="mt-0.5 block text-meta break-words text-muted-foreground">{o.summary}</span>
{o.risks.length ? (
<ul className="mt-1 space-y-0.5 text-meta text-warning">
{o.risks.map((r) => (
<li key={r} className="break-words">
风险:{r}
</li>
))}
</ul>
) : null}
</span>
</label>
<span className={cn("text-meta break-words", recommended ? "opacity-80" : "text-muted-foreground")}>
{o.summary}
{o.action ? (o.action.approves ? "。选定即执行。" : "。执行前还会再确认。") : ""}
</span>
</button>
</li>
);
})}
{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",
)}
</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"
>
<input
type="radio"
name={name}
value={NONE}
checked={picked === NONE}
onChange={() => setPicked(NONE)}
className="mt-1 size-3.5 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1 text-caption text-foreground">都不要,说明原因</span>
</label>
)}
</fieldset>
{!closed && picked === NONE ? (
<Textarea
className="mt-2 min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
{closed ? (
<p className="mt-2 text-meta text-muted-foreground">{outcome}</p>
) : (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={submit} disabled={!picked || choose.isPending}>
{picked === NONE ? "退回" : "选定"}
{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>
))}
</ul>
</Disclosure>
{noneOpen ? (
<div className="mt-2 flex flex-col gap-2">
<Textarea
className="min-h-16 text-caption"
value={reason}
maxLength={1000}
aria-label="不要的原因"
placeholder="例如:体积太大,只要 WEB-DL"
onChange={(e) => setReason(e.target.value)}
/>
<Button
size="sm"
variant="outline"
className="self-start"
disabled={!reason.trim() || choose.isPending}
onClick={() => submit(null)}
>
退回
</Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
</span>
</div>
)}
) : null}
</section>
);
}
// ── 步骤处的审批卡 ──
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
low: { label: "低风险", tone: "neutral" },
medium: { label: "中风险", tone: "warning" },
high: { label: "高风险", tone: "destructive" },
};
const RISK_LABEL: Record<RiskLevel, string> = { low: "低", medium: "中", high: "高" };
const PLAN_LABEL: Record<string, string> = {
acquisition_plan: "下载计划",
organize_plan: "整理计划",
@@ -261,11 +312,12 @@ const RESOLVED_STATUS: Record<string, ApprovalCardStatus> = {
consumed: "consumed",
};
/** 需审批的能力调用:在对应步骤处出审批卡(计划摘要、payload hash、风险、到期);批准 / 拒绝后会话继续 */
/** 需审批的能力调用:只显示摘要和【批准】【拒绝】;计划 ID、payload hash、版本、到期进技术信息 */
export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTitle?: string | null }) {
const admin = useIsAdminSession();
const live = useApproval(view.approvalId);
const decide = useDecideApproval();
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState("");
const a = live.data;
const expired = new Date(view.expiresAt).getTime() < Date.now();
@@ -295,15 +347,10 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
status={status}
title={`批准「${cap?.title ?? view.capabilityId}」?`}
description={view.summary}
badges={
<>
<StatusBadge tone={RISK[view.riskLevel].tone}>{RISK[view.riskLevel].label}</StatusBadge>
{stepTitle ? <StatusBadge>步骤 · {stepTitle}</StatusBadge> : null}
</>
}
onApprove={() => act("approved")}
onReject={() => act("rejected")}
disabledReason={admin ? undefined : "只有控制台管理员会话能批准或拒绝"}
onReject={() => (rejecting ? act("rejected") : setRejecting(true))}
rejectLabel={rejecting ? "确认拒绝" : "拒绝"}
disabledReason={admin ? undefined : "只有管理员能批准或拒绝"}
result={
status === "rejected" && decidedReason
? `已拒绝:${decidedReason}`
@@ -312,30 +359,27 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
: undefined
}
>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">计划</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">
{PLAN_LABEL[view.planType] ?? view.planType} · {view.planId}
</dd>
<dt className="text-muted-foreground">payload hash</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{view.payloadHash}</dd>
<dt className="text-muted-foreground">版本</dt>
<dd className="text-foreground/85 tabular-nums">r{view.expectedRevision}</dd>
<dt className="text-muted-foreground">到期</dt>
<dd className={cn("text-foreground/85", expired && status === "expired" && "text-destructive")}>
{formatDateTime(view.expiresAt)}
</dd>
</dl>
{status === "pending" && admin ? (
{rejecting && status === "pending" ? (
<Textarea
className="mt-2 min-h-14 text-caption"
className="mb-2 min-h-14 text-caption"
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
placeholder="理由或建议(可选),智能体按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : 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>
);
}
+200 -246
View File
@@ -1,6 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
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 { useNavigate } from "react-router";
import { toast } from "sonner";
@@ -8,7 +8,6 @@ import {
type AgentSessionOptions,
agentKeys,
agentMockEnabled,
isProposeFirst,
isRunActive,
type RunSnapshot,
TRIGGER_LABEL,
@@ -25,59 +24,61 @@ import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Label } from "~/components/ui/label.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import {
AgentConversation,
ApprovalItem,
BudgetBar,
FinalReport,
ResultLine,
RunAnnouncer,
RunStatusBadge,
runPlanIds,
} from "./agent-conversation.tsx";
import { RunPlan } from "./agent-interaction.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";
/** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */
export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
const id = useId();
return (
<div className="flex items-center gap-2 px-1">
<Switch id={id} checked={checked} onCheckedChange={onChange} />
<Label htmlFor={id} className="cursor-pointer text-sm font-normal text-muted-foreground">
先看方案再执行
</Label>
</div>
);
/** 新目标的提交内容:`/先给方案` 开头 → proposeFirst(只作用于这一次) */
export function goalFromInput(text: string): { goal: string; opts: AgentSessionOptions } | null {
const { command, rest } = parseCommand(text, NEW_RUN_COMMANDS);
if (!rest) return null;
return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } };
}
/** 新目标输入:"先看方案再执行"开关 + 自增高输入框(Enter 发送,Shift+Enter 换行) */
/**
* 新目标输入:只有输入框和发送;`/先给方案` 让这一次先给方案。空态下面 3 个快捷目标,点一下填进输入框
* (以空格或冒号结尾的是半句,光标停在末尾等补完)。上下文(当前页面的对象)显示成名称标签,可以去掉。
*/
export function GoalComposer({
onSubmit,
pending,
prefix,
context,
onClearContext,
defaultGoal = "",
compact = false,
suggestions,
}: {
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean;
/** 上下文前缀(只读展示,提交时拼在目标前) */
prefix?: string;
/** 上下文标签(对象名称) */
context?: string | null;
onClearContext?: () => void;
defaultGoal?: string;
compact?: boolean;
/** 快捷目标:点一下填入输入框(不直接提交),光标停在末尾 */
/** 快捷目标:输入框为空时显示 */
suggestions?: string[];
}) {
const [goal, setGoal] = useState(defaultGoal);
const [proposeFirst, setProposeFirst] = useState(false);
const box = useRef<HTMLDivElement>(null);
const fill = (text: string) => {
setGoal(text);
@@ -88,87 +89,55 @@ export function GoalComposer({
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 (
<div ref={box} className="space-y-1.5">
<div ref={box} className="flex flex-col gap-3">
<PromptInput
value={goal}
onValueChange={setGoal}
onSubmit={(g) => onSubmit(g, { proposeFirst })}
onSubmit={submit}
pending={pending}
minRows={compact ? 2 : 3}
minRows={2}
maxRows={10}
maxLength={4000}
aria-label="目标"
placeholder="例如:把 沙丘:第二部 的 4K 收进来"
placeholder="例如:把沙丘:第二部的 4K 收进来"
submitLabel={pending ? "提交中" : "开始"}
header={
prefix ? (
<p
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} />}
commands={NEW_RUN_COMMANDS}
context={context}
onClearContext={onClearContext}
/>
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{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">
{suggestions.map((s) => (
<li key={s}>
<button
type="button"
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"
>
{s.endsWith(" ") || s.endsWith(":") ? `${s}…` : s}
</button>
</li>
))}
</ul>
</div>
{suggestions?.length && !goal.trim() ? (
<ul aria-label="快捷目标" className="flex flex-col gap-1.5">
{suggestions.slice(0, 3).map((s) => (
<li key={s}>
<button
type="button"
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"
>
{s.endsWith(" ") || s.endsWith(":") ? `${s.trimEnd()}…` : s}
</button>
</li>
))}
</ul>
) : null}
</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>
);
}
/**
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。
* 不 sticky、不压在会话上,方案卡与审批卡始终在会话流里完整可见。
* 顶部概况条:一行「步骤 4/5 · 当前:核对下载任务」;展开看步骤清单、子会话和技术信息(模型、预算、用量、ID)。
* 不 sticky、不压在会话上;没有步骤和子会话时只在运行中显示状态。
*/
function RunOverviewBar({
export function RunOverviewBar({
snapshot,
status,
live,
@@ -187,9 +156,10 @@ function RunOverviewBar({
const steps = live.plan?.steps ?? [];
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 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 usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
return (
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
<button
@@ -197,9 +167,9 @@ function RunOverviewBar({
aria-expanded={open}
aria-controls={id}
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 ? (
<>
<ListChecks
@@ -209,16 +179,19 @@ function RunOverviewBar({
<span className="shrink-0 text-foreground tabular-nums">
步骤 {done}/{steps.length}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"}
</span>
</>
) : null}
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{steps.length ? (current ? current.title : failed ? "有步骤失败" : "全部完成") : ""}
</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")} />
</button>
{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
className={cn(
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
@@ -230,48 +203,37 @@ function RunOverviewBar({
) : null}
<Disclosure id={id} open={open}>
<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 />
<SubagentTree snapshot={snapshot} className="p-2.5" />
<div>
<p className="mb-2 text-meta text-muted-foreground">用量</p>
<BudgetBar run={r} live={live} />
</div>
<p className="text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
</p>
<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>
</Disclosure>
</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({
runId,
@@ -282,7 +244,7 @@ export function AgentRunPanel({
runId: string;
compact?: boolean;
className?: string;
/** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
/** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
onCreated?: (runId: string) => void;
}) {
const run = useAgentRun(runId);
@@ -290,6 +252,7 @@ export function AgentRunPanel({
const live = useAgentStream(runId, active || !run.data);
const abort = useAbortAgentRun();
const append = useAppendAgentMessage();
const create = useCreateAgentRun();
const navigate = useNavigate();
const [msg, setMsg] = useState("");
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 audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上的其它审批
// 会话内审批卡已在对应步骤处显示,这里只补本次运行计划上还在等的其它审批
const inline = new Set(live.items.flatMap((i) => (i.kind === "approval" ? [i.view.approvalId] : [])));
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
const related = new Set([
runId,
...(run.data?.children ?? []).map((c) => c.id),
@@ -335,9 +297,24 @@ export function AgentRunPanel({
const ended = !isRunActive(r.status);
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(
{ id: r.id, text },
{ id: r.id, text: body },
{
onSuccess: () => {
setMsg("");
@@ -346,128 +323,105 @@ export function AgentRunPanel({
onError: (err) => toast.error(errorMessage(err)),
},
);
};
const column = "mx-auto w-full max-w-[88ch]";
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} />
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3">
<RunOverviewBar
snapshot={run.data}
status={shownStatus}
live={live}
active={streaming || r.status === "waiting"}
className={cn(column, !compact && "lg:hidden")}
/>
{streaming && sseStatus === "reconnecting" ? (
<div
role="status"
className={cn(
column,
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
)}
>
<WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
</div>
) : null}
{live.error ? (
<div
role="alert"
className={cn(
column,
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
)}
>
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
<Button size="xs" variant="outline" onClick={live.retry}>
<RefreshCw />
重试
</Button>
</div>
) : null}
<MessageScroller
className={cn("min-h-48 flex-1", compact && "min-h-40")}
viewportClassName="pr-1"
contentClassName={column}
busy={streaming}
label="智能体会话记录"
>
<AgentConversation
run={r}
live={live}
audit={audit.data}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items
.filter((a) => !inline.has(a.id))
.map((a) => (
<ApprovalItem key={a.id} approval={a} />
))}
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
</>
}
/>
</MessageScroller>
<div className={cn(column, "shrink-0 space-y-1")}>
<PromptInput
value={msg}
onValueChange={setMsg}
onSubmit={send}
pending={append.isPending}
loading={streaming}
onStop={() => setConfirmStop(true)}
stopLabel={abort.isPending ? "停止中…" : "停止"}
minRows={1}
maxRows={6}
maxLength={8000}
aria-label={ended ? "追加消息续跑" : "追加消息"}
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"}
submitLabel={ended ? "续跑" : "发送"}
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>
{agentMockEnabled() && !compact ? (
<div className={column}>
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
演示数据
</StatusBadge>
</div>
</div>
) : null}
<RunOverviewBar
snapshot={run.data}
status={shownStatus}
live={live}
active={streaming || r.status === "waiting"}
className={column}
/>
{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"
{streaming && sseStatus === "reconnecting" ? (
<div
role="status"
className={cn(
column,
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
)}
>
<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>
)}
<WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。</span>
</div>
) : null}
{live.error ? (
<div
role="alert"
className={cn(
column,
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
)}
>
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
<Button size="xs" variant="outline" onClick={live.retry}>
<RefreshCw />
重试
</Button>
</div>
) : null}
<MessageScroller
className={cn("min-h-48 flex-1", compact && "min-h-40")}
viewportClassName="pr-1"
contentClassName={column}
busy={streaming}
label="智能体会话记录"
>
<AgentConversation
run={r}
live={live}
audit={audit.data}
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
after={
<>
{approvals.items
.filter((a) => !inline.has(a.id) && a.status === "pending")
.map((a) => (
<ApprovalItem key={a.id} approval={a} />
))}
{finished ? <ResultLine run={r} report={report} live={live} /> : null}
</>
}
/>
</MessageScroller>
<div className={cn(column, "shrink-0")}>
<PromptInput
value={msg}
onValueChange={setMsg}
onSubmit={send}
pending={append.isPending || create.isPending}
loading={streaming}
onStop={() => setConfirmStop(true)}
stopLabel={abort.isPending ? "停止中…" : "停止"}
minRows={1}
maxRows={6}
maxLength={8000}
aria-label="发消息"
placeholder={ended ? "接着说,输入 / 看指令" : "补充或纠正"}
commands={RUN_COMMANDS}
/>
</div>
<ConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
title="停止这次运行?"
description="后续步骤不再执行,已完成的外部动作(如已提交的下载)不会撤销。"
description="后续步骤不再执行,已完成的动作(如已提交的下载)不会撤销。"
confirmLabel="停止运行"
destructive
onConfirm={() =>
@@ -1,154 +1,199 @@
import type { AgentRunStatus, AgentTrigger } from "@umt/contracts";
import { Bot, ChevronRight } from "lucide-react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
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 { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.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 { 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";
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
// 筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
export type TimeRange = "" | "today" | "7d" | "30d";
export type HistoryFilters = { q: string; status: "" | AgentRunStatus; trigger: "" | AgentTrigger; range: TimeRange };
export const EMPTY_FILTERS: HistoryFilters = { q: "", status: "", trigger: "", range: "" };
export type HistoryView = "all" | "pending" | "failed";
export type HistoryFilters = { q: string; view: HistoryView };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" };
const STATUS_OPTIONS = (
["running", "waiting", "succeeded", "failed", "budget_exceeded", "cancelled"] as AgentRunStatus[]
).map((s) => ({ value: s, label: RUN_STATUS_LABEL[s] }));
const TRIGGER_OPTIONS = (["user", "schedule", "event", "subagent"] as AgentTrigger[]).map((t) => ({
value: t,
label: TRIGGER_LABEL[t],
}));
const RANGE_OPTIONS = [
{ value: "today", label: "今天" },
{ value: "7d", label: "近 7 天" },
{ value: "30d", label: "近 30 天" },
const VIEWS: { value: HistoryView; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "pending", label: "等你处理" },
{ value: "failed", label: "失败" },
];
/** 时间范围的起点(毫秒);不限为 null */
export function rangeStart(range: TimeRange, now = Date.now()): number | null {
if (range === "today") {
const d = new Date(now);
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;
}
/** 等用户处理:挂起在方案选择或审批上 */
export const awaitingUser = (r: Pick<AgentRunView, "status" | "summary">) =>
r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批");
const isFailed = (r: Pick<AgentRunView, "status">) => r.status === "failed" || r.status === "budget_exceeded";
/** 纯函数(单测覆盖):搜索词匹配目标正文、上下文或会话 ID 前缀;时间按创建时间 */
export function matchesHistory(
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;
/** 纯函数(单测覆盖):搜索词匹配目标正文或会话 ID 前缀 */
export function matchesHistory(run: Pick<AgentRunView, "id" | "goal">, f: Pick<HistoryFilters, "q">): boolean {
const q = f.q.trim().toLowerCase();
if (!q) return true;
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
}
/** 列表副行:只写费用(token 与调用次数在会话的技术信息里) */
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
return r.usage.costUsd ? `费用约 ${formatUsd(r.usage.costUsd)}` : "";
}
export type HistoryRow = { run: AgentRunView; children: AgentRunView[] };
const ALL = "__all__";
/** 窄栏里的下拉:只显示取值("全部"时显示维度名),读屏名带维度 */
function MiniSelect({
label,
allLabel,
value,
onChange,
options,
}: {
label: string;
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>
);
/**
* 纯函数(单测覆盖):已加载的会话 → 置顶的「等你处理」+ 其余;子会话挂到已加载的父会话下
* (父会话不在列表里的子会话照常单列)。筛选 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 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]);
const top = hit.filter((r) => !(r.parentRunId && ids.has(r.parentRunId)));
const row = (run: AgentRunView): HistoryRow => ({ run, children: kids.get(run.id) ?? [] });
const pinned = top.filter(awaitingUser).map(row);
const rows = top
.filter((r) => !awaitingUser(r))
.slice(0, Math.max(0, limit - pinned.length))
.map(row);
return { pinned, rows };
}
/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
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 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 (
<>
<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}>
{shortGoalTitle(r.goal)}
</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
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)}
</time>
{trailing}
</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>
{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>
) : 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:会话页左栏,点一条跳到该会话页。
* - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。
* - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。
* - limit:历史弹层只列最近 N 条(置顶的算在内),不自动翻页。
*/
export function AgentSessionList({
activeId,
hrefFor,
onOpen,
remember = false,
limit,
className,
}: {
activeId?: string;
hrefFor?: (id: string) => string;
onOpen?: (id: string) => void;
remember?: boolean;
limit?: number;
className?: string;
}) {
const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS);
@@ -158,15 +203,14 @@ export function AgentSessionList({
if (remember) drawerMemory.filters = 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 now = Date.now();
const rows = loaded.filter((r) => matchesHistory(r, f, now));
const start = rangeStart(f.range, now);
const oldest = loaded.at(-1);
// 列表按创建时间倒序:已加载的最早一条早于时间范围,再往后翻也不会命中
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 pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
const { pinned, rows } = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f, limit);
const canLoadMore = !limit && !!runs.hasNextPage;
const filtered = !!f.q.trim() || f.view !== "all";
const scroller = useRef<HTMLDivElement>(null);
const sentinel = useRef<HTMLDivElement>(null);
@@ -190,6 +234,7 @@ export function AgentSessionList({
return () => io.disconnect();
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
const rowProps = { activeId, hrefFor, onOpen };
return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5">
@@ -197,33 +242,26 @@ export function AgentSessionList({
type="search"
value={f.q}
onChange={(e) => set({ q: e.target.value })}
placeholder="搜索目标"
aria-label="搜索会话目标"
placeholder="搜索会话"
aria-label="搜索会话"
className="h-8 md:text-caption"
/>
<div className="flex flex-wrap gap-1.5">
<MiniSelect
label="按状态筛选"
allLabel="全部状态"
value={f.status}
onChange={(v) => set({ status: v as HistoryFilters["status"] })}
options={STATUS_OPTIONS}
/>
<MiniSelect
label="按来源筛选"
allLabel="全部来源"
value={f.trigger}
onChange={(v) => set({ trigger: v as HistoryFilters["trigger"] })}
options={TRIGGER_OPTIONS}
/>
<MiniSelect
label="按时间筛选"
allLabel="不限时间"
value={f.range}
onChange={(v) => set({ range: v as TimeRange })}
options={RANGE_OPTIONS}
/>
</div>
<fieldset aria-label="筛选会话" className="m-0 flex min-w-0 gap-1 border-0 p-0">
{VIEWS.map((v) => (
<button
key={v.value}
type="button"
aria-pressed={f.view === v.value}
onClick={() => set({ view: v.value })}
className={cn(
"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",
f.view === v.value && "pill-selected text-foreground",
)}
>
{v.label}
</button>
))}
</fieldset>
</div>
<div
ref={scroller}
@@ -236,50 +274,39 @@ export function AgentSessionList({
<LoadingState rows={4} />
) : runs.error ? (
<ErrorState plain error={runs.error} onRetry={() => void runs.refetch()} />
) : rows.length === 0 && !canLoadMore ? (
<EmptyState plain icon={Bot} title={filtered ? "没有符合条件的会话" : "还没有会话,交代一个目标开始"} />
) : !pinned.length && !rows.length && !canLoadMore ? (
<EmptyState
plain
icon={Bot}
title={
filtered
? f.view === "pending"
? "没有等你处理的会话"
: "没有符合条件的会话"
: "还没有会话,写一个目标开始"
}
/>
) : (
<ul className="space-y-0.5">
{rows.map((r) => {
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 (
<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}`}
aria-current={current ? "page" : undefined}
className={cls}
>
<RunRow r={r} current={current}>
{null}
</RunRow>
</Link>
)}
</li>
);
})}
</ul>
<>
{pinned.length ? (
<section aria-label="等你处理" className="mb-2">
<p className="px-3 pb-1 text-label text-warning">等你处理</p>
<ul className="space-y-0.5">
{pinned.map((row) => (
<Row key={row.run.id} row={row} {...rowProps} />
))}
</ul>
</section>
) : null}
<ul className="space-y-0.5">
{rows.map((row) => (
<Row key={row.run.id} row={row} {...rowProps} />
))}
</ul>
</>
)}
{canLoadMore ? (
<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
variant="ghost"
size="sm"
+33 -73
View File
@@ -15,84 +15,48 @@ export type AgentContext = {
taskId?: string;
/** 预填目标 */
goal?: string;
/** 按路由推断(label 里只有 ID,抽屉按 ID 查名称) */
fromPath?: boolean;
};
/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */
export type AgentDrawerTab = "current" | "history";
type State = {
open: boolean;
minimized: boolean;
context: AgentContext | null;
/** 抽屉里正在看的会话(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 {
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;
let state: State = { open: false, minimized: false, context: null, runId: null };
const listeners = new Set<() => void>();
const emit = () => {
for (const l of listeners) l();
};
/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */
export function lastAgentDrawerTab(): AgentDrawerTab {
if (!tabLoaded) {
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);
/** 打开抽屉开始新对话(页面内按钮带着对象打开) */
export function openAgentDrawer(context: AgentContext | null = null) {
state = { ...state, open: true, minimized: false, context, runId: null };
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) {
tabLoaded = true;
state = { ...state, open: true, minimized: false, runId, tab: "current" };
writeTab("current");
state = { ...state, open: true, minimized: false, runId };
emit();
}
/** 新对话:保留当前页面上下文,回到输入框 */
export function newAgentConversation() {
state = { ...state, runId: null };
emit();
}
/** 去掉上下文(输入框上的名称标签 ×) */
export function clearAgentContext() {
state = { ...state, context: null };
emit();
}
@@ -107,9 +71,9 @@ export function minimizeAgentDrawer() {
emit();
}
/** 从小窗恢复抽屉(小窗里的运行在「当前」) */
/** 从小窗恢复抽屉 */
export function restoreAgentDrawer() {
state = { ...state, open: true, minimized: false, tab: "current" };
state = { ...state, open: true, minimized: false };
emit();
}
@@ -128,12 +92,13 @@ export function isAgentShortcut(
/**
* 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换:
* 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开新目标。
* 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开(上次看的会话还在就接着看)。
*/
export function toggleAgentDrawer(pathname: string) {
if (state.open) setAgentDrawerOpen(false);
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);
switch (kind) {
case "library":
return { label: `作品 ${short}`, entityId: id };
return { label: `作品 ${short}`, entityId: id, fromPath: true };
case "sites":
return { label: `站点 ${id}`, siteId: id };
return { label: `站点 ${id}`, siteId: id, fromPath: true };
case "tasks":
return { label: `下载任务 ${short}`, downloadTaskId: id };
return { label: `下载任务 ${short}`, downloadTaskId: id, fromPath: true };
case "organize":
return { label: `整理记录 ${short}`, recordId: id };
return { label: `整理记录 ${short}`, recordId: id, fromPath: true };
case "engine":
return { label: `引擎任务 ${short}`, taskId: id };
return { label: `引擎任务 ${short}`, taskId: id, fromPath: true };
default:
return null;
}
}
export function setAgentDrawerRun(runId: string | null) {
state = { ...state, runId };
emit();
}
export function useAgentDrawer(): State {
return useSyncExternalStore(
(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;
abortedAt: number | null;
appended: RunMessage[];
/** 挂起等用户(没有结束事件时状态为 waiting,summary 用它) */
waiting?: string | null;
};
const store = new Map<string, Entry>();
@@ -239,10 +241,242 @@ function add(goal: string, opts: AgentSessionOptions, startedAt: number, n: numb
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() {
if (seeded) return;
seeded = true;
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: true }, now - 7_200_000, 2);
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 : ""))
.join("");
const status: AgentRunView["status"] = !end
? "running"
? e.waiting && evs.length === e.steps.length
? "waiting"
: "running"
: end.type === "run_completed"
? "succeeded"
: end.reason === "budget"
@@ -344,7 +580,7 @@ function view(e: Entry): AgentRunView {
costUsd: +(USAGE.costUsd * frac).toFixed(4),
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,
finishedAt: end ? end.at : null,
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),
planId: uuid(7001),
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 {
type AgentOptionItem,
type AgentOptionView,
type AgentPlanStep,
type AgentRunEvent,
type AgentUsageTotals,
CAPABILITY_ACTIVITY,
EventType,
type KnownCapabilityId,
} from "@umt/contracts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
@@ -45,7 +47,7 @@ export type StreamItem =
export type OptionsView = {
decisionId: string;
question: string;
options: AgentOptionItem[];
options: AgentOptionView[];
recommendedId: string | null;
stepId: string | null;
expiresAt: string;
@@ -88,7 +90,14 @@ export type AgentStreamState = {
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[] {
const items: StreamItem[] = [];
const tools = new Map<string, ToolCallView>();
@@ -96,6 +105,11 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
const approvals = new Map<string, ApprovalView>();
for (const e of events) {
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) {
case "text_delta":
if (last?.kind === "text") last.text += e.delta;
+122 -31
View File
@@ -1,45 +1,56 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单,换成 `toolbar` 槽(放模式切换等);
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单;
// AGUI-3:输入区只留输入框和发送 / 停止,新增 `/` 指令菜单(commands)与上下文标签(context,可移除);
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
import { ArrowUp, Square } from "lucide-react";
import { ArrowUp, Square, X } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
type TextareaHTMLAttributes,
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
export type PromptCommand = { name: string; description: string };
export interface PromptInputProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> {
value: string;
onValueChange: (v: string) => void;
onSubmit: (v: string) => void;
/** 运行中:发送键变"停止" */
/** 运行中:发送键旁出现"停止" */
loading?: boolean;
onStop?: () => void;
/** 提交中(禁用发送) */
pending?: boolean;
minRows?: number;
maxRows?: number;
/** 输入框下方左侧工具条 */
toolbar?: ReactNode;
submitLabel?: string;
stopLabel?: string;
/** 顶部附加内容(如上下文前缀) */
header?: ReactNode;
/** 以 / 开头时弹出的指令菜单 */
commands?: PromptCommand[];
/** 上下文标签(对象名称);给了 onClearContext 就带 × */
context?: string | null;
onClearContext?: () => void;
className?: string;
}
/** 行高 24px(text-sm leading-6),与测量层一致 */
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({
value,
onValueChange,
@@ -49,10 +60,11 @@ export function PromptInput({
pending = false,
minRows = 2,
maxRows = 8,
toolbar,
submitLabel = "发送",
stopLabel = "停止",
header,
commands = [],
context,
onClearContext,
className,
disabled,
placeholder,
@@ -61,6 +73,9 @@ export function PromptInput({
}: PromptInputProps) {
const ta = useRef<HTMLTextAreaElement>(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 t = ta.current;
const m = measure.current;
@@ -71,6 +86,14 @@ export function PromptInput({
// biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量
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 submit = (e?: FormEvent) => {
e?.preventDefault();
@@ -78,6 +101,23 @@ export function PromptInput({
onSubmit(value.trim());
};
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;
e.preventDefault();
submit();
@@ -92,30 +132,81 @@ export function PromptInput({
className,
)}
>
{header}
<div className="relative">
{menuOpen ? (
<div
ref={measure}
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"
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"
>
{`${value}​`}
{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
ref={measure}
aria-hidden
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}​`}
</div>
<textarea
ref={ta}
value={value}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
aria-autocomplete={commands.length ? "list" : undefined}
aria-controls={menuOpen ? menuId : undefined}
rows={minRows}
{...rest}
onChange={(e) => {
onValueChange(e.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
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>
<textarea
ref={ta}
value={value}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
rows={minRows}
{...rest}
onChange={(e) => onValueChange(e.target.value)}
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"
/>
</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 ? (
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
<Square className="size-3.5" />
+11 -27
View File
@@ -1,47 +1,31 @@
import { ArrowLeft } from "lucide-react";
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 { 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";
// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上;
// 更窄时换成会话列顶部的可折叠概况条)。
// 会话页(AGUI-3):左栏会话列表(xl 以上)· 会话列占满其余宽度(顶部概况条、底部输入区)。
// 不再有页头目标描述和右侧栏;步骤、子会话、预算与模型在概况条里。
export default function AgentRunPage() {
const { runId = "" } = useParams();
const run = useAgentRun(runId);
const mock = agentMockEnabled();
const suffix = mock ? "?mock=1" : "";
return (
<>
<PageHeader
title="智能体会话"
description={
run.data ? (
<span className="line-clamp-2 break-words" title={run.data.run.goal}>
{shortGoalTitle(run.data.run.goal)}
</span>
) : (
"加载中…"
)
}
actions={
<Button asChild variant="ghost" size="sm">
<h1 className="sr-only">智能体会话</h1>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-7rem)] xl:overflow-hidden">
<AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} className="h-full" />
</aside>
<div className="pg-slab flex h-[calc(100dvh-7rem)] min-h-[28rem] flex-col gap-2 rounded-xl p-3 md:p-4">
<Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground xl:hidden">
<Link to={`/console/agent${suffix}`}>
<ArrowLeft />
全部会话
</Link>
</Button>
}
/>
<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" />
<AgentRunPanel key={runId} runId={runId} className="min-h-0 flex-1" />
</div>
</div>
</>
+18 -22
View File
@@ -1,13 +1,15 @@
import { FlaskConical, ShieldCheck } from "lucide-react";
import { FlaskConical } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
import { GoalComposer } from "~/components/agent/agent-run-panel.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 { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
// 智能体入口页(AGUI-3):左栏会话列表(等你处理置顶),右边新对话输入框 + 3 个快捷目标。
export default function AgentPage() {
const create = useCreateAgentRun();
const navigate = useNavigate();
@@ -15,7 +17,7 @@ export default function AgentPage() {
return (
<>
<PageHeader title="智能体会话" />
<PageHeader title="智能体" />
{mock ? (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
<FlaskConical className="h-4 w-4 shrink-0" />
@@ -29,26 +31,20 @@ 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">
<AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" />
</div>
<div className="order-1 mx-auto w-full max-w-[88ch] space-y-3 lg:order-2">
<div className="pg-slab rounded-xl p-4">
<h2 className="mb-3 text-body font-medium text-foreground">新会话</h2>
<GoalComposer
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal, ...opts },
{
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</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 className="pg-slab order-1 mx-auto w-full max-w-[88ch] rounded-xl p-4 lg:order-2">
<GoalComposer
pending={create.isPending}
suggestions={quickGoalsFor("/console/agent")}
onSubmit={(goal, opts) =>
create.mutate(
{ goal, ...opts },
{
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
</div>
</>
+12 -9
View File
@@ -256,9 +256,9 @@ async function main() {
}
}
// ── 交互:智能体抽屉「历史」Tab(AGUI-2;演示数据,点一条在抽屉内打开、返回列表、关了再开仍停在历史)──
// ── 交互:智能体抽屉「历史」弹层(AGUI-3;演示数据,点一条在抽屉内打开、新对话回到输入框、关了再开仍是那条会话)──
{
const label = "智能体抽屉 → 历史 → 抽屉内打开会话";
const label = "智能体抽屉 → 历史弹层 → 抽屉内打开会话";
problems = [];
const t0 = Date.now();
try {
@@ -266,17 +266,20 @@ async function main() {
await page.goto(`${BASE}/console/audit?mock=1`, { waitUntil: "networkidle", timeout: 30_000 });
const launcher = page.locator("[data-agent-launcher]:visible").last();
await launcher.click();
await page.getByRole("tab", { name: "历史" }).click();
await page.getByRole("button", { name: "历史会话" }).click();
const list = page.getByRole("navigation", { name: "智能体会话列表" });
await list.getByRole("button").first().click();
await page.getByRole("button", { name: "返回列表" }).waitFor({ timeout: 10_000 });
await list
.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("点历史会话跳走了页面");
await page.getByRole("button", { name: "返回列表" }).click();
await list.waitFor({ timeout: 5_000 });
await page.keyboard.press("Escape");
await launcher.click();
const tab = await page.getByRole("tab", { name: "历史" }).getAttribute("aria-selected");
if (tab !== "true") problems.push("再次打开没有停在「历史」");
if (!(await page.getByRole("button", { name: "新对话" }).isEnabled())) 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(() => {});
await page.keyboard.press("Escape");
await page.goto(`${BASE}/console/agent?mock=0`, { waitUntil: "networkidle", timeout: 30_000 });