feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见

- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换,
  头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗
- 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话
  (GET /agent/runs?roots=true,新增可选参数)
- 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用,
  可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs
- 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息
- 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 13:56:38 +08:00
1 parent dd0f6e2c09
commit 3692793dff
35 files changed
+1454 -1137

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。
## [Unreleased]
### 调整
- 智能体抽屉和智能体会话页用同一个对话界面,抽屉是它的紧凑版。抽屉标题就是最近会话切换(点开是和会话页同一份列表),右边是「新对话」「在页面打开」和关闭;不再有单独的「历史」弹层。
- 智能体只剩一个按钮入口:桌面是侧栏最上方的「智能体」(Ctrl+J),手机是顶栏胶囊里的智能体按钮;去掉右下角悬浮按钮和最小化小窗。关上抽屉会话照常在后台跑,再打开接着看。
- 会话列表合成一个:对话和自动巡检混在一起,巡检和事件发起的会话带一个小标签;筛选只剩搜索和一个状态下拉(全部状态 / 等你处理 / 进行中 / 失败);子会话不再单独列出。
- 子会话在父会话里看:派出的位置有一张卡片,实时显示任务、状态、正在做什么、用时、调用次数和费用,展开能看它的过程,也能单独打开;会话顶部的「N 个子会话运行中」点一下跳到卡片。
- 智能体的回答不再夹带内部字段:模型被要求转述子会话结论、不照抄 ID 和原始字段;旧会话里「childRunId:…」「decisionId: null」这类行和「返回字段」表格从正文里拿掉,原文放进「技术信息」。
- 新对话的开场建议改成按所在页面给两条完整的问法,点一下直接发出(例如在整理记录页是「整理记录里没识别出的作品是什么」);不再填半句话。
- 输入框只保留 `/先给方案` 一个指令;原来的 `/重跑` 改成会话结束后顶部的「重新运行」按钮。
## [0.3.40] - 2026-10-08
迁移 `0091`。
+1
View File
@@ -127,6 +127,7 @@ export function registerAgentRoutes(
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
if (query.parentRunId) q = q.where("parent_run_id", "=", query.parentRunId);
if (query.roots === "true") q = q.where("parent_run_id", "is", null);
if (query.group) {
// 自动 = 定时 / 事件触发,子会话按父会话归类
const auto = sql<boolean>`(trigger in ('schedule', 'event') or (trigger = 'subagent' and parent_run_id in
@@ -302,6 +302,9 @@ function completedBody(c: AgentRunRow): SubagentEventBody {
summary: safe(c.summary, 1500),
usage: usageOf(c),
toolCalls: c.tool_call_count,
...(c.started_at && c.finished_at
? { durationMs: Math.max(0, new Date(c.finished_at).getTime() - new Date(c.started_at).getTime()) }
: {}),
};
}
+10 -1
View File
@@ -3,7 +3,7 @@ import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// 智能体页会话分栏:对话(用户发起)/ 自动巡检(定时、事件触发);子会话随父会话归类
// 会话列表筛选:group 按来源分(对话 / 自动巡检,子会话随父会话);roots 只列顶层会话
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("会话列表 group 筛选(真实 PG)", () => {
@@ -39,5 +39,14 @@ describe.skipIf(!hasPg)("会话列表 group 筛选(真实 PG)", () => {
.sort();
expect(await goals("auto")).toEqual(["事件", "巡检", "巡检子会话"].sort());
expect(await goals("conversation")).toEqual(["对话", "对话子会话"].sort());
// 会话列表只列顶层会话:子会话在父会话的会话流里看
const roots = (
await json(
await h.app.request("/api/v1/agent/runs?roots=true", { headers: { authorization: `Bearer ${DEV_KEY}` } }),
)
).data.items
.map((r: { goal: string }) => r.goal)
.sort();
expect(roots).toEqual(["事件", "巡检", "对话"].sort());
});
});
+12 -25
View File
@@ -120,8 +120,8 @@ function useAgentMockGate(): boolean {
export const agentKeys = {
all: ["agent"] as const,
runs: (status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) =>
["agent", "list", status ?? "all", trigger ?? "any", group ?? "any"] as const,
runs: (status?: AgentRunStatus, trigger?: AgentTrigger, roots = false) =>
["agent", "list", status ?? "all", trigger ?? "any", roots ? "roots" : "any"] as const,
run: (id: string) => ["agent", "detail", id] as const,
};
@@ -136,41 +136,28 @@ export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
subagent: "子会话",
};
/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */
/** 会话分栏:对话 = 用户发起;自动 = 定时巡检 / 事件触发(子会话随父会话) */
export type AgentRunGroup = "conversation" | "auto";
const isAutoTrigger = (t: AgentTrigger) => t === "schedule" || t === "event";
/** 会话所在分栏:子会话随父会话(与服务端 group 筛选一致);父会话未知时子会话归「对话」 */
export function runGroup(
run: Pick<AgentRunView, "trigger">,
parent?: Pick<AgentRunView, "trigger"> | null,
): AgentRunGroup {
const t = run.trigger === "subagent" && parent ? parent.trigger : run.trigger;
return isAutoTrigger(t) ? "auto" : "conversation";
}
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) {
/**
* 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页。
* roots = 只列顶层会话(会话列表用;子会话在父会话的会话流里看)。
*/
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, opts: { roots?: boolean } = {}) {
const mock = useAgentMockGate();
const roots = opts.roots === true;
return useInfiniteQuery({
queryKey: [...agentKeys.runs(status, trigger, group), mock],
queryKey: [...agentKeys.runs(status, trigger, roots), mock],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) => {
if (mock) {
const all = MOCK_RUNS();
const byId = new Map(all.map((r) => [r.id, r] as const));
return {
items: all.filter(
items: MOCK_RUNS().filter(
(r) =>
(!status || r.status === status) &&
(!trigger || r.trigger === trigger) &&
(!group || runGroup(r, r.parentRunId ? byId.get(r.parentRunId) : null) === group),
(!status || r.status === status) && (!trigger || r.trigger === trigger) && (!roots || !r.parentRunId),
),
nextCursor: null,
};
}
return apiParsed(RunPage, AGENT_BASE, {
params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }),
params: cleanParams({ status, trigger, roots: roots ? "true" : undefined, cursor: pageParam, limit: 30 }),
signal,
});
},
@@ -50,6 +50,7 @@ import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { splitContextGoal } from "./agent-store.ts";
import { SubagentCard } from "./agent-subagents.tsx";
import {
type ActivityItem,
activitySpan,
@@ -458,6 +459,7 @@ export function AgentConversation({
audit,
after,
knownNames,
nested = false,
}: {
run: AgentRunView;
live: AgentStreamState;
@@ -467,6 +469,8 @@ export function AgentConversation({
after?: ReactNode;
/** 工具结果之外已知的「ID → 名字」(子会话),汇报兜底换名用 */
knownNames?: [string, string][];
/** 嵌在子会话卡片里:不重复目标(卡片上有),间距收一档 */
nested?: boolean;
}) {
const active = isRunActive(run.status) && !live.finished;
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
@@ -519,8 +523,8 @@ export function AgentConversation({
) : null;
return (
<div className="flex flex-col gap-5 px-1 py-2">
<UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />
<div className={cn("flex flex-col px-1", nested ? "gap-3 py-1.5 text-caption" : "gap-5 py-2")}>
{nested ? null : <UserMessage text={run.goal} tag={isProposeFirst(run) ? "先给方案" : undefined} />}
{blocks.map((b, bi) =>
b.kind === "user" ? (
<UserMessage key={b.key} text={b.text} note={b.pending ? "已发送,等智能体读取" : undefined} animateIn />
@@ -544,6 +548,14 @@ export function AgentConversation({
until={p.body[0]?.at ?? null}
/>
) : null}
{p.subagents.map((v) => (
<div
key={v.childRunId}
className={cn("w-full min-w-0 py-1", freshKeys.has(`sa${v.childRunId}`) && anim)}
>
<SubagentCard view={v} />
</div>
))}
{p.activity
.flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : []))
.map((d) => (
@@ -711,14 +723,11 @@ export function ResultLine({
run,
report,
live,
subagents = 0,
showCost = true,
}: {
run: AgentRunView;
report: GoalReport | null;
live: AgentStreamState;
/** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
subagents?: number;
/** 上方统计行已显示费用时传 false,不重复 */
showCost?: boolean;
}) {
@@ -756,7 +765,6 @@ export function ResultLine({
</span>
))}
</span>
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
{showCost && u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
{mismatch.length ? (
+113 -182
View File
@@ -1,18 +1,16 @@
import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
import { Bot, ChevronDown, Maximize2, SquarePen, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { agentMockEnabled, 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, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { DRAFT_DRAWER_GOAL, writeDraft } from "./agent-nav.ts";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx";
@@ -21,10 +19,8 @@ import {
agentShortcutLabel,
clearAgentContext,
contextPrefix,
minimizeAgentDrawer,
newAgentConversation,
noteAgentReturnPath,
restoreAgentDrawer,
setAgentDrawerOpen,
shortGoalTitle,
showAgentRunInDrawer,
@@ -33,11 +29,10 @@ import {
toggleAgentDrawer,
useAgentDrawer,
} from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
import { AgentRunOpenContext } from "./agent-subagents.tsx";
import { quickGoalsFor } from "./quick-goals.ts";
import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
/** 侧栏入口、顶栏按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
/**
@@ -60,23 +55,31 @@ export function useContextName(ctx: AgentContext | null): string | null {
return null;
}
/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */
function HistoryPopover({ activeId }: { activeId: string | null }) {
/** 抽屉标题 = 最近会话切换:显示当前会话的短标题,点开是和会话页同一份会话列表,点一条在抽屉里打开 */
function RecentSwitcher({ runId }: { runId: string | null }) {
const [open, setOpen] = useState(false);
const run = useAgentRun(runId ?? undefined);
const mock = agentMockEnabled();
const title = runId ? (run.data ? shortGoalTitle(run.data.run.goal, 28) : "会话") : "新对话";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" aria-label="历史会话" title="历史会话">
<History />
历史
</Button>
<button
type="button"
aria-label={`最近会话,当前:${title}`}
title="切换到最近的会话"
className="mr-auto flex h-8 min-w-0 cursor-pointer items-center gap-2 rounded-full px-2 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-foreground/[0.06]"
>
<Bot aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 truncate text-lead font-semibold">{title}</span>
<ChevronDown aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
<PopoverContent align="start" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
<AgentSessionList
memory={drawerListMemory}
limit={20}
activeId={activeId ?? undefined}
activeId={runId ?? undefined}
onOpen={(id) => {
setOpen(false);
showAgentRunInDrawer(id);
@@ -94,12 +97,13 @@ function HistoryPopover({ activeId }: { activeId: string | null }) {
}
/**
* 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。
* 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。
* 智能体抽屉:任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。内容和会话页是同一个组件
* (新对话 GoalComposer、会话 AgentRunPanel),这里是紧凑版。
* 头部:标题即最近会话切换、新对话、在页面打开、关闭。关上抽屉会话照常在后台跑,再打开接着看。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
*/
export function AgentDrawer() {
const { open, minimized, context, runId } = useAgentDrawer();
const { open, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const { pathname, search } = useLocation();
const content = useRef<HTMLDivElement>(null);
@@ -112,172 +116,99 @@ export function AgentDrawer() {
const prefix = contextPrefix(context && name ? { ...context, label: name } : context);
return (
<>
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent
ref={content}
side="right"
showCloseButton={false}
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;
e.preventDefault();
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}}
// 关闭后焦点回到打开前的位置(快捷键打开没有触发元素,缺省会落到 body)
onCloseAutoFocus={(e) => {
const el = takeReturnFocus();
if (!el) return;
e.preventDefault();
el.focus();
}}
onInteractOutside={(e) => {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}}
>
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-3 pl-5">
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
智能体
</SheetTitle>
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<HistoryPopover activeId={runId} />
<Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
<SquarePen />
新对话
</Button>
{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}
{/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */}
<SheetClose asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭" title="关闭(Esc)">
<X />
</Button>
</SheetClose>
</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>
) : (
<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 ?? ""}
draftKey={DRAFT_DRAWER_GOAL}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => {
writeDraft(DRAFT_DRAWER_GOAL, "");
showAgentRunInDrawer(run.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
)}
</div>
</SheetContent>
</Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
</>
);
}
/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */
function AgentMiniDock({ runId }: { runId: string }) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const last = live.items[live.items.length - 1];
const streaming = active && !live.finished;
const line =
last?.kind === "text"
? last.text.trim().split("\n").pop()
: last?.kind === "tool"
? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}`
: last?.kind === "thinking"
? "思考中…"
: last?.kind === "end"
? last.ok
? "已完成,展开查看结果"
: last.message
: "等待输出…";
const status = run.data
? live.finished && isRunActive(run.data.run.status)
? "running"
: run.data.run.status
: null;
return (
<section
aria-label="智能体小窗"
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
>
<div className="flex items-center gap-2">
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{status ? <RunStatusBadge status={status} /> : null}
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开智能体" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="关闭小窗,会话在后台继续"
title="关闭小窗,会话在后台继续"
onClick={() => setAgentDrawerOpen(false)}
>
<X />
</Button>
</div>
<button
type="button"
onClick={restoreAgentDrawer}
className="mt-2 block w-full cursor-pointer rounded-lg px-1 text-left text-caption leading-5 text-foreground/90 outline-none focus-visible:ring-2 focus-visible:ring-ring"
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent
ref={content}
side="right"
showCloseButton={false}
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;
e.preventDefault();
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}}
// 关闭后焦点回到打开前的位置(快捷键打开没有触发元素,缺省会落到 body)
onCloseAutoFocus={(e) => {
const el = takeReturnFocus();
if (!el) return;
e.preventDefault();
el.focus();
}}
onInteractOutside={(e) => {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}}
>
<span className="line-clamp-2 break-words">
{line}
{streaming && last?.kind === "text" ? <StreamingCursor /> : null}
</span>
</button>
</section>
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2 pr-3 pl-3">
<SheetTitle className="sr-only">智能体</SheetTitle>
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<RecentSwitcher runId={runId} />
<Button
variant="ghost"
size="icon-sm"
aria-label="新对话"
title="新对话"
onClick={newAgentConversation}
disabled={!runId}
>
<SquarePen />
</Button>
{runId ? (
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
<Maximize2 />
</Link>
</Button>
) : null}
{/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */}
<SheetClose asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="关闭,会话在后台继续"
title="关闭(Esc),会话在后台继续"
>
<X />
</Button>
</SheetClose>
</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 ?? ""}`}
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""}
draftKey={DRAFT_DRAWER_GOAL}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => {
writeDraft(DRAFT_DRAWER_GOAL, "");
showAgentRunInDrawer(run.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
)}
</div>
</SheetContent>
</Sheet>
);
}
/** 顶栏按钮(窄屏):与快捷键、悬浮按钮同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
/** 顶栏胶囊里的智能体按钮(没有侧栏的窄屏):与快捷键、侧栏入口同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
export function AgentDrawerTrigger({ className }: { className?: string }) {
const { pathname } = useLocation();
return (
+21 -30
View File
@@ -2,16 +2,13 @@ import type { AgentRunEvent } from "@umt/contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { runGroup } from "~/api/agent.ts";
import { followBehavior } from "~/components/agentui/message-scroller.tsx";
import {
agentListHref,
agentRunHref,
DRAFT_DRAWER_GOAL,
DRAFT_PAGE_GOAL,
groupFromSearch,
readDraft,
resolveGroup,
revealScrollTop,
runDraftKey,
writeDraft,
@@ -32,7 +29,7 @@ import {
} from "./agent-store.ts";
import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts";
// 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。
// 智能体页面切换的状态逻辑:整页重挂载 key、链接、草稿、滚动策略、快捷键在智能体页面的行为、路由上下文。
describe("主内容区重挂载 key", () => {
it("列表页与各会话页共用一个 key,切换会话不重挂载", () => {
@@ -47,35 +44,12 @@ describe("主内容区重挂载 key", () => {
});
});
describe("分栏:对话 / 自动巡检", () => {
it("会话所在分栏:巡检 / 事件在自动巡检,子会话随父会话,父会话未知归对话", () => {
expect(runGroup({ trigger: "schedule" })).toBe("auto");
expect(runGroup({ trigger: "event" })).toBe("auto");
expect(runGroup({ trigger: "user" })).toBe("conversation");
expect(runGroup({ trigger: "subagent" }, { trigger: "schedule" })).toBe("auto");
expect(runGroup({ trigger: "subagent" }, { trigger: "user" })).toBe("conversation");
expect(runGroup({ trigger: "subagent" })).toBe("conversation");
});
it("URL 的 tab 只认两个取值", () => {
expect(groupFromSearch("?tab=auto&mock=1")).toBe("auto");
expect(groupFromSearch("?tab=conversation")).toBe("conversation");
expect(groupFromSearch("?tab=xx")).toBeNull();
expect(groupFromSearch("")).toBeNull();
});
it("取值顺序:URL > 当前会话 > 上次 > 对话", () => {
expect(resolveGroup({ fromUrl: "conversation", activeRun: "auto", remembered: "auto" })).toBe("conversation");
expect(resolveGroup({ fromUrl: null, activeRun: "auto", remembered: "conversation" })).toBe("auto");
// 打开巡检会话、快照还没到:沿用点它时所在的分栏,不先闪到「对话」
expect(resolveGroup({ fromUrl: null, activeRun: null, remembered: "auto" })).toBe("auto");
expect(resolveGroup({ fromUrl: null })).toBe("conversation");
});
it("链接:会话页不带 tab(由会话决定),列表页只在自动巡检时带,保留演示开关", () => {
describe("链接", () => {
it("会话页、列表页链接保留演示开关;列表不再分栏,不带 tab", () => {
expect(agentRunHref("r1")).toBe("/console/agent/r1");
expect(agentRunHref("r1", true)).toBe("/console/agent/r1?mock=1");
expect(agentListHref()).toBe("/console/agent");
expect(agentListHref("auto")).toBe("/console/agent?tab=auto");
expect(agentListHref("auto", true)).toBe("/console/agent?tab=auto&mock=1");
expect(agentListHref("conversation", true)).toBe("/console/agent?mock=1");
expect(agentListHref(true)).toBe("/console/agent?mock=1");
});
});
@@ -207,6 +181,23 @@ describe("抽屉开着时路由变化", () => {
syncAgentContextToPath("/console/home");
expect(snapshot().context).toBeNull();
});
it("下载详情页带上下载任务;下载计划页不算", () => {
openAgentDrawer({ label: "作品 aaaa", entityId: "aaaa", fromPath: true });
syncAgentContextToPath("/console/downloads/t1");
expect(snapshot().context).toMatchObject({ downloadTaskId: "t1", fromPath: true });
syncAgentContextToPath("/console/downloads/plans");
expect(snapshot().context).toBeNull();
setAgentDrawerOpen(false);
});
it("关上再打开:接着看同一个会话(没有最小化小窗)", () => {
showAgentRunInDrawer("r9");
setAgentDrawerOpen(false);
expect(snapshot()).toMatchObject({ open: false, runId: "r9" });
expect(toggleAction(snapshot(), "/console/library")).toBe("restore");
setAgentDrawerOpen(true);
expect(snapshot()).toMatchObject({ open: true, runId: "r9" });
setAgentDrawerOpen(false);
});
it("页面按钮带的上下文、正在看的会话、抽屉关着时不动", () => {
openAgentDrawer({ label: "作品:沙丘", entityId: "e1" });
syncAgentContextToPath("/console/sites/hdh");
+10 -75
View File
@@ -1,28 +1,7 @@
import { useEffect } from "react";
import { useSearchParams } from "react-router";
import { type AgentRunGroup, runGroup, useAgentRun } from "~/api/agent.ts";
// 智能体页面的导航状态(纯函数 + 模块级记忆,单测覆盖):
// - 分栏「对话 / 自动巡检」:URL 的 ?tab= 优先,其次当前会话所在分栏,再次上次看的分栏;
// - 会话链接、列表页链接的拼法(保留演示开关);
// - 页面会话列表的筛选与各分栏滚动位置(列表页 ↔ 会话页之间保留,刷新页面才清)。
export const TAB_PARAM = "tab";
/** URL 里的分栏;没写或写错时为 null */
export function groupFromSearch(search: string | URLSearchParams): AgentRunGroup | null {
const v = (typeof search === "string" ? new URLSearchParams(search) : search).get(TAB_PARAM);
return v === "auto" || v === "conversation" ? v : null;
}
/** 分栏取值:URL 显式指定 > 当前会话所在分栏 > 上次看的分栏 > 对话 */
export function resolveGroup(o: {
fromUrl: AgentRunGroup | null;
activeRun?: AgentRunGroup | null;
remembered?: AgentRunGroup | null;
}): AgentRunGroup {
return o.fromUrl ?? o.activeRun ?? o.remembered ?? "conversation";
}
// - 会话列表的筛选与滚动位置(列表页 ↔ 会话页之间保留,刷新页面才清)。
// 列表不再分「对话 / 自动巡检」两栏:旧链接里的 ?tab= 直接忽略。
function withQuery(path: string, q: Record<string, string | null>): string {
const p = new URLSearchParams();
@@ -31,29 +10,23 @@ function withQuery(path: string, q: Record<string, string | null>): string {
return s ? `${path}?${s}` : path;
}
/** 会话页链接:分栏由会话本身决定,不带 tab */
/** 会话页链接 */
export function agentRunHref(id: string, mock = false): string {
return withQuery(`/console/agent/${id}`, { mock: mock ? "1" : null });
}
/** 列表页链接:「对话」是缺省,不写进 URL */
export function agentListHref(group: AgentRunGroup = "conversation", mock = false): string {
return withQuery("/console/agent", { [TAB_PARAM]: group === "auto" ? "auto" : null, mock: mock ? "1" : null });
/** 列表页链接 */
export function agentListHref(mock = false): string {
return withQuery("/console/agent", { mock: mock ? "1" : null });
}
/** 页面会话列表的记忆:上次看的分栏、搜索与筛选、各分栏的滚动位置 */
/** 会话列表的记忆:搜索、状态筛选、滚动位置 */
export type ListMemory = {
group: AgentRunGroup;
q: string;
view: "all" | "pending" | "failed";
scroll: Record<AgentRunGroup, number>;
view: "all" | "pending" | "active" | "failed";
scroll: number;
};
export const newListMemory = (): ListMemory => ({
group: "conversation",
q: "",
view: "all",
scroll: { conversation: 0, auto: 0 },
});
export const newListMemory = (): ListMemory => ({ q: "", view: "all", scroll: 0 });
export const pageListMemory: ListMemory = newListMemory();
/**
@@ -71,44 +44,6 @@ export function revealScrollTop(
return Math.max(0, bottom - viewport.height);
}
/** 会话所在分栏(子会话按父会话);快照没到之前为 null */
export function useRunGroup(runId: string | undefined): AgentRunGroup | null {
const run = useAgentRun(runId);
const r = run.data?.run;
const parentId = r?.trigger === "subagent" ? (r.parentRunId ?? undefined) : undefined;
const parent = useAgentRun(parentId);
if (!r) return null;
if (parentId && !parent.data) return null;
return runGroup(r, parent.data?.run ?? null);
}
/**
* 页面会话列表的分栏:URL 的 ?tab= 优先;会话页再按当前会话所在分栏,快照没到时沿用上次的分栏(避免闪一下)。
* 列表页没有 tab 就是「对话」,URL 与选中项一致。切换分栏只改 URL(replace,不进历史)。
*/
export function usePageListGroup(activeRun?: AgentRunGroup | null): [AgentRunGroup, (g: AgentRunGroup) => void] {
const [params, setParams] = useSearchParams();
const onRunPage = activeRun !== undefined;
const group = resolveGroup({
fromUrl: groupFromSearch(params),
activeRun: onRunPage ? activeRun : null,
remembered: onRunPage ? pageListMemory.group : null,
});
useEffect(() => {
pageListMemory.group = group;
}, [group]);
const change = (g: AgentRunGroup) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set(TAB_PARAM, g);
return next;
},
{ replace: true, preventScrollReset: true },
);
return [group, change];
}
// ── 输入草稿:按会话(和两处新对话输入框)分开记,切换会话、关抽屉不丢;发送成功后清掉。模块级,刷新页面才清 ──
const drafts = new Map<string, string>();
+207 -215
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { type AgentRunStatus, EventType } from "@umt/contracts";
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react";
import { type ReactNode, useId, useRef, useState } from "react";
import { Bot, ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, WifiOff } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
@@ -42,28 +42,21 @@ import {
import { RunPlan } from "./agent-interaction.tsx";
import { readDraft, runDraftKey, writeDraft } from "./agent-nav.ts";
import { shortGoalTitle } from "./agent-store.ts";
import { SubagentTree } from "./agent-subagent-tree.tsx";
import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts";
import {
NEW_RUN_COMMANDS,
PROPOSE_FIRST,
PROPOSE_FIRST_MESSAGE,
parseCommand,
RERUN,
RUN_COMMANDS,
} from "./slash-commands.ts";
import { ParentRunLink, SubagentChip, SubagentNodesContext } from "./agent-subagents.tsx";
import { callCounts, sessionStatsParts, stepProgress, subagentViews } from "./conversation-model.ts";
import { COMMANDS, PROPOSE_FIRST, PROPOSE_FIRST_MESSAGE, parseCommand } from "./slash-commands.ts";
import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts";
/** 新目标的提交内容:`/先给方案` 开头 → proposeFirst(只作用于这一次) */
export function goalFromInput(text: string): { goal: string; opts: AgentSessionOptions } | null {
const { command, rest } = parseCommand(text, NEW_RUN_COMMANDS);
const { command, rest } = parseCommand(text);
if (!rest) return null;
return { goal: rest, opts: { proposeFirst: command === PROPOSE_FIRST.name } };
}
/**
* 新目标输入:只有输入框和发送;`/先给方案` 让这一次先给方案。空态下面 3 个快捷目标,点一下填进输入框
* (以空格或冒号结尾的是半句,光标停在末尾等补完)。上下文(当前页面的对象)显示成名称标签,可以去掉。
* 新对话(抽屉和列表页是同一个组件):上面一句说明,下面开场建议和输入框。开场建议点一下直接发出;
* `/先给方案` 让这一次先给方案。上下文(当前页面的对象)显示成输入框上的名称标签,可以去掉。
*/
export function GoalComposer({
onSubmit,
@@ -73,6 +66,7 @@ export function GoalComposer({
defaultGoal = "",
suggestions,
draftKey,
className,
}: {
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
pending: boolean;
@@ -80,26 +74,17 @@ export function GoalComposer({
context?: string | null;
onClearContext?: () => void;
defaultGoal?: string;
/** 快捷目标:输入框为空时显示 */
/** 开场建议:输入框为空时显示 */
suggestions?: string[];
/** 草稿记在这个键下(没有预填目标时恢复);发送成功后由调用方清掉 */
draftKey?: string;
className?: string;
}) {
const [goal, setGoalState] = useState(() => defaultGoal || readDraft(draftKey));
const setGoal = (text: string) => {
setGoalState(text);
writeDraft(draftKey, text);
};
const box = useRef<HTMLDivElement>(null);
const fill = (text: string) => {
setGoal(text);
requestAnimationFrame(() => {
const ta = box.current?.querySelector("textarea");
if (!ta) return;
ta.focus();
ta.setSelectionRange(text.length, text.length);
});
};
const submit = (text: string) => {
const g = goalFromInput(text);
if (!g) {
@@ -109,7 +94,32 @@ export function GoalComposer({
onSubmit(g.goal, g.opts);
};
return (
<div ref={box} className="flex flex-col gap-3">
<div className={cn("flex min-h-0 flex-1 flex-col gap-3", className)}>
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-6 text-center">
<span aria-hidden className="grid size-10 place-items-center rounded-full pill-selected">
<Bot className="size-5" />
</span>
<p className="text-lead font-semibold text-foreground">有什么要我做的?</p>
<p className="max-w-[36ch] text-caption text-muted-foreground">
说一个目标就行,比如收一部电影、查一个下载为什么不动。需要批准的操作会先停下来问你。
</p>
</div>
{suggestions?.length && !goal.trim() ? (
<ul aria-label="开场建议" className="flex flex-wrap gap-2">
{suggestions.map((s) => (
<li key={s} className="min-w-0">
<button
type="button"
disabled={pending}
onClick={() => submit(s)}
className="max-w-full cursor-pointer truncate rounded-full border border-border/70 px-3 py-1.5 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 disabled:cursor-not-allowed disabled:opacity-50"
>
{s}
</button>
</li>
))}
</ul>
) : null}
<PromptInput
value={goal}
onValueChange={setGoal}
@@ -121,25 +131,10 @@ export function GoalComposer({
aria-label="目标"
placeholder="例如:下载沙丘 2 的 4K 版本"
submitLabel={pending ? "提交中" : "开始"}
commands={NEW_RUN_COMMANDS}
commands={COMMANDS}
context={context}
onClearContext={onClearContext}
/>
{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>
);
}
@@ -191,13 +186,11 @@ export function RunStatsLine({
export function RunOverviewBar({
snapshot,
status,
live,
active,
className,
}: {
snapshot: RunSnapshot;
status: AgentRunStatus;
live: AgentStreamState;
active: boolean;
className?: string;
@@ -208,8 +201,8 @@ export function RunOverviewBar({
const steps = live.plan?.steps ?? [];
const progress = stepProgress(steps);
const failed = progress.failedStep;
const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent;
if (!steps.length && !family && !isRunActive(status)) return null;
// 没有步骤清单就不显示(状态在顶部一行,子会话在会话流里)
if (!steps.length) return null;
const u = { ...r.usage, ...(live.usage ?? {}) };
return (
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
@@ -220,40 +213,28 @@ export function RunOverviewBar({
onClick={() => setOpen((v) => !v)}
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"
>
{isRunActive(status) ? <RunStatusBadge status={status} /> : null}
{steps.length ? (
<>
<ListChecks
aria-hidden
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {progress.done}/{progress.total}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
</>
) : (
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{family ? `${snapshot.children?.length ?? 0} 个子会话` : ""}
</span>
)}
<ListChecks
aria-hidden
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
/>
<span className="shrink-0 text-foreground tabular-nums">
步骤 {progress.done}/{progress.total}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
</button>
{steps.length ? (
<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",
failed ? "bg-destructive" : "bg-foreground/60",
)}
style={{ width: `${progress.pct}%` }}
/>
</div>
) : null}
<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",
failed ? "bg-destructive" : "bg-foreground/60",
)}
style={{ width: `${progress.pct}%` }}
/>
</div>
<Disclosure id={id} open={open}>
<div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3">
<RunPlan plan={live.plan} active={active} fill />
<SubagentTree snapshot={snapshot} className="p-2.5" />
<p className="text-meta text-muted-foreground">
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
</p>
@@ -281,8 +262,8 @@ export function RunOverviewBar({
}
/**
* 一次运行:顶部概况条 → 会话流(统一最大宽度)→ 底部输入区(只有输入框和发送 / 停止)。
* 输入区的 `/重跑` 用同一个目标新开;`/先给方案` 让智能体先给方案。compact 用于抽屉。
* 一次运行(抽屉和会话页是同一个组件,compact = 抽屉):顶部一行状态与统计 → 步骤清单(有才显示)→
* 会话流(子会话卡片在派出的位置)→ 底部输入区。结束后顶部有「重新运行」;输入框只有 `/先给方案` 一个指令。
*/
export function AgentRunPanel({
runId,
@@ -296,7 +277,7 @@ export function AgentRunPanel({
className?: string;
/** 统计行右侧的操作(会话页的「在侧边打开」) */
actions?: ReactNode;
/** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
/** 重新运行之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
onCreated?: (runId: string) => void;
}) {
const run = useAgentRun(runId);
@@ -354,21 +335,14 @@ export function AgentRunPanel({
const ended = !isRunActive(r.status);
const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`));
const rerun = () =>
create.mutate(
{ goal: r.goal, proposeFirst: r.scope.proposeFirst === true },
{ onSuccess: (n) => created(n.id), onError: (e) => toast.error(errorMessage(e)) },
);
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 { command, rest } = parseCommand(text);
const body = command === PROPOSE_FIRST.name ? `${PROPOSE_FIRST_MESSAGE}${rest ? `\n${rest}` : ""}` : text;
append.mutate(
{ id: r.id, text: body },
@@ -383,136 +357,154 @@ export function AgentRunPanel({
};
const column = "mx-auto w-full max-w-[88ch]";
const subagents = subagentViews(live.items);
const nodes = new Map((run.data.children ?? []).map((c) => [c.id, c] as const));
// 只标需要注意的状态;正常结束不加徽章(结果在会话末尾)
const showBadge = shownStatus !== "succeeded";
return (
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} />
{agentMockEnabled() && !compact ? (
<div className={column}>
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
演示数据
</StatusBadge>
</div>
) : null}
{actions ? (
<div className={cn(column, "-my-1 flex min-w-0 items-center gap-2")}>
<RunStatsLine snapshot={run.data} live={live} className="min-w-0 flex-1" />
<div className="flex shrink-0 items-center gap-1">{actions}</div>
</div>
) : (
<RunStatsLine snapshot={run.data} live={live} className={column} />
)}
<RunOverviewBar
snapshot={run.data}
status={shownStatus}
live={live}
active={streaming || r.status === "waiting"}
className={column}
/>
{streaming && sseStatus === "reconnecting" ? (
<SubagentNodesContext.Provider value={nodes}>
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
<RunAnnouncer status={shownStatus} finished={live.finished} />
{agentMockEnabled() && !compact ? (
<div className={column}>
<StatusBadge tone="warning">
<FlaskConical className="size-3" />
演示数据
</StatusBadge>
</div>
) : null}
{run.data.parent ? (
<div className={column}>
<ParentRunLink parent={run.data.parent} />
</div>
) : null}
{/* 顶部一行:状态 · 统计 · 子会话 · 操作;窄的时候折行 */}
<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",
)}
data-slot="run-header"
className={cn(column, "-my-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1.5")}
>
<WifiOff aria-hidden className="size-4 shrink-0" />
<span className="min-w-0 flex-1">实时连接中断,正在重连。</span>
{showBadge ? <RunStatusBadge status={shownStatus} /> : null}
<RunStatsLine snapshot={run.data} live={live} className="min-w-0 flex-1 basis-48" />
<div className="ml-auto flex shrink-0 items-center gap-1">
<SubagentChip views={subagents} />
{ended ? (
<Button
variant="ghost"
size="icon-sm"
aria-label="用同一个目标重新运行"
title="用同一个目标重新运行"
disabled={create.isPending}
onClick={rerun}
>
<RotateCcw />
</Button>
) : null}
{actions}
</div>
</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}
<RunOverviewBar
snapshot={run.data}
live={live}
knownNames={[
...(run.data.children ?? []).map((c) => [c.id, shortGoalTitle(c.goal, 24)] as [string, string]),
...(run.data.parent
? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]]
: []),
]}
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}
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
showCost={false}
/>
) : null}
</>
active={streaming || r.status === "waiting"}
className={column}
/>
{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">实时连接中断,正在重连。</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}
knownNames={[
...(run.data.children ?? []).map((c) => [c.id, shortGoalTitle(c.goal, 24)] as [string, string]),
...(run.data.parent
? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]]
: []),
]}
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} showCost={false} /> : 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={COMMANDS}
/>
</div>
<ConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
title="停止这个会话?"
description="后续步骤不再执行。已完成的操作不会撤销,已提交的下载会继续。"
confirmLabel="停止会话"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("正在停止"),
(e) => {
toast.error(errorMessage(e));
throw e;
},
)
}
/>
</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="后续步骤不再执行。已完成的操作不会撤销,已提交的下载会继续。"
confirmLabel="停止会话"
destructive
onConfirm={() =>
abort.mutateAsync(r.id).then(
() => toast.success("正在停止"),
(e) => {
toast.error(errorMessage(e));
throw e;
},
)
}
/>
</div>
</SubagentNodesContext.Provider>
);
}
@@ -1,36 +1,31 @@
import { Bot, ChevronLeft, ChevronRight, Search } from "lucide-react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
import { type AgentRunView, isRunActive, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import type { PaginationProps } from "~/components/ui/pagination.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.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 { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
import { type ListMemory, newListMemory, revealScrollTop } from "./agent-nav.ts";
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
// 分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话;栏内筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
// 会话列表:会话页左栏与抽屉的「最近会话」弹层共用同一份。
// 一个列表:对话和自动巡检混排(巡检 / 事件会话带一个小标签),子会话不单列(在父会话的会话流里看);
// 筛选只有搜索 + 一个状态下拉;「等你处理」(等选方案、等审批)置顶。
export type HistoryView = "all" | "pending" | "failed";
export type HistoryFilters = { q: string; view: HistoryView; group: AgentRunGroup };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all", group: "conversation" };
const GROUPS: { value: AgentRunGroup; label: string }[] = [
{ value: "conversation", label: "对话" },
{ value: "auto", label: "自动巡检" },
];
export type HistoryView = "all" | "pending" | "active" | "failed";
export type HistoryFilters = { q: string; view: HistoryView };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" };
const VIEWS: { value: HistoryView; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "all", label: "全部状态" },
{ value: "pending", label: "等你处理" },
{ value: "active", label: "进行中" },
{ value: "failed", label: "失败" },
];
@@ -38,6 +33,11 @@ const VIEWS: { value: HistoryView; label: string }[] = [
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";
const PICK: Record<Exclude<HistoryView, "all">, (r: AgentRunView) => boolean> = {
pending: awaitingUser,
active: (r) => isRunActive(r.status) && !awaitingUser(r),
failed: isFailed,
};
/** 纯函数(单测覆盖):搜索词匹配目标正文或会话 ID 前缀 */
export function matchesHistory(run: Pick<AgentRunView, "id" | "goal">, f: Pick<HistoryFilters, "q">): boolean {
@@ -46,54 +46,38 @@ export function matchesHistory(run: Pick<AgentRunView, "id" | "goal">, f: Pick<H
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
}
export type HistoryRow = { run: AgentRunView; children: AgentRunView[] };
/**
* 纯函数(单测覆盖):已加载的会话 → 置顶的「等你处理」+ 其余;子会话挂到已加载的父会话下
* (父会话不在列表里的子会话照常单列)。筛选 pending / failed 时不再分组,也不收子会话。
* 纯函数(单测覆盖):已加载的会话 → 置顶的「等你处理」+ 其余。子会话不单列(服务端已只给顶层会话,
* 这里再滤一次,防止旧缓存或演示数据混进来)。按状态筛选时不再分组。
*/
export function arrangeHistory(
runs: AgentRunView[],
f: Pick<HistoryFilters, "q" | "view">,
limit = Number.POSITIVE_INFINITY,
): { pinned: HistoryRow[]; rows: HistoryRow[] } {
): { pinned: AgentRunView[]; rows: AgentRunView[] } {
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);
const hit = uniq.filter((r) => !r.parentRunId && matchesHistory(r, f));
if (f.view !== "all") return { pinned: [], rows: hit.filter(PICK[f.view]).slice(0, limit) };
const pinned = hit.filter(awaitingUser);
const rows = hit.filter((r) => !awaitingUser(r)).slice(0, Math.max(0, limit - pinned.length));
return { pinned, rows };
}
/** 抽屉历史弹层跨开关保留的分栏、筛选与滚动位置(模块级,刷新页面才清) */
/** 抽屉「最近会话」弹层跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
export const drawerListMemory: ListMemory = newListMemory();
function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; trailing?: ReactNode }) {
/** 会话来源的小标签:只给自动发起的(巡检、事件),手动发起的不标 */
export function runTypeLabel(r: Pick<AgentRunView, "trigger">): string | null {
return r.trigger === "schedule" || r.trigger === "event" ? TRIGGER_LABEL[r.trigger] : null;
}
function RunRow({ r, current }: { r: AgentRunView; current: boolean }) {
const goal = splitContextGoal(r.goal);
const ctx = contextLabel(goal.context);
const type = runTypeLabel(r);
// 正常结束不加徽章;运行中、等待、失败才标
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(" · ");
const meta = [r.status === "cancelled" ? "已停止" : null, ctx].filter(Boolean).join(" · ");
return (
<>
<div className="flex min-w-0 items-center gap-2">
@@ -106,10 +90,17 @@ function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; t
>
{relTime(r.createdAt)}
</time>
{trailing}
</div>
{badge || meta ? (
{badge || meta || type ? (
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{type ? (
<span
data-slot="run-type"
className="shrink-0 rounded-full border border-border/70 px-1.5 text-label leading-5 text-muted-foreground"
>
{type}
</span>
) : null}
{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">
@@ -127,73 +118,50 @@ function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; t
}
function Row({
row,
run: r,
activeId,
hrefFor,
onOpen,
}: {
row: HistoryRow;
run: AgentRunView;
activeId?: string;
hrefFor?: (id: string) => string;
onOpen?: (id: string) => void;
}) {
const childActive = row.children.some((c) => c.id === activeId);
const [open, setOpen] = useState(childActive);
// 列表不随换会话重挂载:跳到子会话(会话内链接、概况条)时把父会话展开,当前项可见
useEffect(() => {
if (childActive) setOpen(true);
}, [childActive]);
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" data-run-id={r.id} className={cls} onClick={() => onOpen(r.id)}>
<RunRow r={r} current={current} />
</button>
) : (
<Link
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
data-run-id={r.id}
aria-current={current ? "page" : undefined}
className={cls}
>
<RunRow r={r} current={current} />
</Link>
);
};
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>
{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}
{onOpen ? (
<button
type="button"
data-run-id={r.id}
aria-current={current ? "true" : undefined}
className={cls}
onClick={() => onOpen(r.id)}
>
<RunRow r={r} current={current} />
</button>
) : (
<Link
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
data-run-id={r.id}
aria-current={current ? "page" : undefined}
className={cls}
>
<RunRow r={r} current={current} />
</Link>
)}
</li>
);
}
/**
* 窄列表用的分页:一行「共 N 条 · ‹ 1/3 ›」。整页表格的分页(每页条数 + 完整页码)放进 17rem 的左栏
* 或历史弹层会折成三行,占掉半个列表。每页条数沿用全站设置。只有一页时不显示。
* 或抽屉的最近会话弹层会折成三行,占掉半个列表。每页条数沿用全站设置。只有一页时不显示。
*/
export function CompactPager({ page, pageSize, total, hasMore = false, busy = false, onPageChange }: PaginationProps) {
const pages = Math.max(1, Math.ceil(total / pageSize)) + (hasMore ? 1 : 0);
@@ -237,20 +205,17 @@ export function CompactPager({ page, pageSize, total, hasMore = false, busy = fa
}
/**
* 会话列表:分栏 + 搜索 + 全部 / 等你处理 / 失败 + 按需加载的分页。
* - hrefFor:列表页 / 会话页左栏,点一条跳到该会话页。
* - onOpen:抽屉历史弹层,点一条在抽屉内打开。
* - group / onGroupChange:分栏受控(页面把分栏放在 URL 里);不传时列表自己记。
* - memory:筛选与各分栏滚动位置记在这里,列表重新挂载(下次打开弹层、列表页 ↔ 会话页)时恢复。
* - activeId:当前会话,高亮;第一次出现在列表里且看不见时滚进可见区,是子会话时展开父会话。
* - limit:历史弹层的默认每页条数。
* 会话列表:搜索 + 状态下拉 + 按需加载的分页。
* - hrefFor:会话页左栏,点一条跳到该会话页。
* - onOpen:抽屉的「最近会话」弹层,点一条在抽屉内打开。
* - memory:筛选与滚动位置记在这里,列表重新挂载(下次打开弹层、列表页 ↔ 会话页)时恢复。
* - activeId:当前会话,高亮;第一次出现在列表里且看不见时滚进可见区。
* - limit:弹层的默认每页条数。
*/
export function AgentSessionList({
activeId,
hrefFor,
onOpen,
group: controlledGroup,
onGroupChange,
memory,
limit,
className,
@@ -258,31 +223,24 @@ export function AgentSessionList({
activeId?: string;
hrefFor?: (id: string) => string;
onOpen?: (id: string) => void;
group?: AgentRunGroup;
onGroupChange?: (group: AgentRunGroup) => void;
memory?: ListMemory;
limit?: number;
className?: string;
}) {
const [own, setOwn] = useState<HistoryFilters>(
memory ? { q: memory.q, view: memory.view, group: memory.group } : EMPTY_FILTERS,
);
const f: HistoryFilters = controlledGroup ? { ...own, group: controlledGroup } : own;
const set = (patch: Partial<HistoryFilters>) => {
if (patch.group && controlledGroup) onGroupChange?.(patch.group);
setOwn((prev) => {
const [f, setF] = useState<HistoryFilters>(memory ? { q: memory.q, view: memory.view } : EMPTY_FILTERS);
const set = (patch: Partial<HistoryFilters>) =>
setF((prev) => {
const next = { ...prev, ...patch };
if (memory) Object.assign(memory, { q: next.q, view: next.view, group: next.group });
if (memory) Object.assign(memory, next);
return next;
});
};
const runs = useAgentRuns(undefined, undefined, f.group);
const runs = useAgentRuns(undefined, undefined, { roots: true });
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
const waiting = useAgentRuns("waiting", undefined, f.group);
const waiting = useAgentRuns("waiting", undefined, { roots: true });
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
const loadWaiting = f.view !== "failed" && !!waiting.hasNextPage;
const loadWaiting = (f.view === "all" || f.view === "pending") && !!waiting.hasNextPage;
const canLoadMore = loadWaiting || !!runs.hasNextPage;
const paged = useListPagination([...arranged.pinned, ...arranged.rows], {
hasMore: canLoadMore,
@@ -293,27 +251,26 @@ export function AgentSessionList({
defaultPageSize: limit,
});
const visible = paged.items ?? [];
const pinned = f.view === "all" ? visible.filter((row) => awaitingUser(row.run)) : [];
const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible;
const pinned = f.view === "all" ? visible.filter(awaitingUser) : [];
const rows = f.view === "all" ? visible.filter((r) => !awaitingUser(r)) : visible;
const filtered = !!f.q.trim() || f.view !== "all";
// 滚动:每个分栏第一次有内容时恢复记住的位置,再把当前会话滚进可见区(各只做一次,之后不跟着用户的滚动抢位置)
// 滚动:第一次有内容时恢复记住的位置,再把当前会话滚进可见区(各只做一次,之后不跟着用户的滚动抢位置)
const scroller = useRef<HTMLDivElement>(null);
const restoredGroup = useRef<AgentRunGroup | null>(null);
const restored = useRef(false);
const revealed = useRef<string | null>(null);
const ready = !runs.isPending && visible.length > 0;
useLayoutEffect(() => {
const el = scroller.current;
if (!el || !ready) return;
if (restoredGroup.current !== f.group) {
restoredGroup.current = f.group;
el.scrollTop = memory?.scroll[f.group] ?? 0;
if (!restored.current) {
restored.current = true;
el.scrollTop = memory?.scroll ?? 0;
}
const key = `${f.group}|${activeId ?? ""}`;
if (!activeId || revealed.current === key) return;
if (!activeId || revealed.current === activeId) return;
const item = el.querySelector<HTMLElement>(`[data-run-id="${activeId}"]`);
if (!item) return;
revealed.current = key;
revealed.current = activeId;
const top = item.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
const next = revealScrollTop(
{ top, height: item.offsetHeight },
@@ -324,17 +281,8 @@ export function AgentSessionList({
const rowProps = { activeId, hrefFor, onOpen };
return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-2.5 border-b border-border/60 px-0.5 pb-3">
<Tabs value={f.group} onValueChange={(v) => set({ group: v as AgentRunGroup })}>
<TabsList aria-label="会话分栏" className="grid w-full grid-cols-2">
{GROUPS.map((g) => (
<TabsTrigger key={g.value} value={g.value} className="justify-center">
{g.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="relative">
<div className="mb-2 flex items-center gap-2 border-b border-border/60 px-0.5 pb-3">
<div className="relative min-w-0 flex-1">
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
@@ -348,20 +296,23 @@ export function AgentSessionList({
className="h-8 rounded-full pl-8 md:text-caption"
/>
</div>
<SegmentedControl
label="筛选会话"
size="xs"
fill
value={f.view}
onChange={(v) => set({ view: v })}
options={VIEWS.map((v) => ({ value: v.value, label: v.label }))}
/>
<Select value={f.view} onValueChange={(v) => set({ view: v as HistoryView })}>
<SelectTrigger size="sm" aria-label="按状态筛选" className="h-8 w-auto shrink-0 gap-1 text-caption">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
{VIEWS.map((v) => (
<SelectItem key={v.value} value={v.value}>
{v.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
ref={scroller}
onScroll={(e) => {
// 换栏后还没恢复位置前的滚动(内容变短被浏览器夹回)不记
if (memory && restoredGroup.current === f.group) memory.scroll[f.group] = e.currentTarget.scrollTop;
if (memory && restored.current) memory.scroll = e.currentTarget.scrollTop;
}}
className="min-h-0 flex-1 overflow-y-auto"
>
@@ -378,9 +329,7 @@ export function AgentSessionList({
? f.view === "pending"
? "没有等你处理的会话"
: "没有符合条件的会话"
: f.group === "auto"
? "还没有巡检会话"
: "还没有会话,写一个目标开始"
: "还没有会话,写一个目标开始"
}
/>
) : (
@@ -389,15 +338,15 @@ export function AgentSessionList({
<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} />
{pinned.map((r) => (
<Row key={r.id} run={r} {...rowProps} />
))}
</ul>
</section>
) : null}
<ul className="space-y-0.5">
{rows.map((row) => (
<Row key={row.run.id} row={row} {...rowProps} />
{rows.map((r) => (
<Row key={r.id} run={r} {...rowProps} />
))}
</ul>
</>
@@ -18,15 +18,20 @@ import {
} from "./agent-conversation.tsx";
import { OptionsCard, optionPoint, orderOptions } from "./agent-interaction.tsx";
import { goalFromInput, RunStatsLine } from "./agent-run-panel.tsx";
import { arrangeHistory } from "./agent-session-list.tsx";
import { arrangeHistory, runTypeLabel } from "./agent-session-list.tsx";
import { subagentChipText } from "./agent-subagents.tsx";
import {
type ActivityItem,
activitySpan,
activitySummary,
buildConversation,
deniedReason,
subagentElapsed,
subagentResultLine,
subagentState,
subagentViews,
} from "./conversation-model.ts";
import { parseCommand, RUN_COMMANDS } from "./slash-commands.ts";
import { COMMANDS, parseCommand } from "./slash-commands.ts";
import {
type AgentStreamState,
foldEvents,
@@ -369,28 +374,153 @@ describe("方案:一键选定", () => {
});
});
describe("历史:等你处理置顶,子会话收在父会话下", () => {
describe("会话列表:一个列表,等你处理置顶,子会话不单列", () => {
const r = (id: string, p: Partial<AgentRunView>) => ({ ...RUN, id, ...p }) as AgentRunView;
const runs = [
r("a", { goal: "巡检" }),
r("a", { goal: "巡检", trigger: "schedule" }),
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: "等待下载" }),
r("g", { goal: "跑着的", status: "running" }),
];
it("全部:等你处理置顶;子会话挂到父会话;父会话不在列表的单列", () => {
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"]);
expect(pinned.map((x) => x.id)).toEqual(["c"]);
expect(rows.map((x) => x.id)).toEqual(["a", "d", "f", "g"]);
});
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"]);
it("状态下拉:等你处理 / 进行中 / 失败;条数上限", () => {
expect(arrangeHistory(runs, { q: "", view: "pending" }).rows.map((x) => x.id)).toEqual(["c"]);
expect(arrangeHistory(runs, { q: "", view: "active" }).rows.map((x) => x.id)).toEqual(["f", "g"]);
expect(arrangeHistory(runs, { q: "", view: "failed" }).rows.map((x) => x.id)).toEqual(["d"]);
const limited = arrangeHistory(runs, { q: "", view: "all" }, 3);
expect(limited.pinned.length + limited.rows.length).toBe(3);
});
it("类型小标签只给自动发起的会话", () => {
expect(runTypeLabel({ trigger: "schedule" })).toBe("巡检");
expect(runTypeLabel({ trigger: "event" })).toBe("事件");
expect(runTypeLabel({ trigger: "user" })).toBeNull();
});
});
describe("子会话:在父会话里派出的位置实时显示", () => {
const CHILD = "00000000-0000-4000-8000-0000000000c1";
const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 900 };
const spawned = (): AgentRunEvent[] => {
seq = 0;
return [
ev(0, { type: "run_started", goal: "查站点", resumed: false }),
ev(1, { type: "text_delta", delta: "派一个子会话去查。" }),
...tool(2, "s1", "agent.spawn", `{"childRunId":"${CHILD}"}`),
ev(4, {
type: "subagent_spawned",
childRunId: CHILD,
goal: "检查站点「观众」的登录状态",
mode: "execute",
depth: 1,
capabilities: ["site.health_check"],
budget: {},
}),
ev(5, { type: "subagent_event", childRunId: CHILD, childSeq: 0, childType: "run_started", summary: "开始" }),
ev(6, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 1,
childType: "tool_call_start",
capabilityId: "site.list",
summary: "{}",
}),
ev(7, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 2,
childType: "tool_call_end",
capabilityId: "site.list",
summary: "{}",
}),
ev(8, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 3,
childType: "tool_call_start",
capabilityId: "site.health_check",
summary: "{}",
}),
];
};
const done = (): AgentRunEvent[] => [
...spawned(),
ev(20, {
type: "subagent_event",
childRunId: CHILD,
childSeq: 4,
childType: "tool_call_end",
capabilityId: "site.health_check",
summary: "{}",
}),
ev(21, {
type: "subagent_completed",
childRunId: CHILD,
status: "succeeded",
summary:
"本次没有提交下载或整理,结论见模型汇报。\n 模型汇报(未经核查):站点「观众」登录正常。\n证据:childRunId=x",
usage: { ...usage, costUsd: 0.02 },
toolCalls: 2,
durationMs: 17000,
}),
ev(22, { type: "text_delta", delta: "站点「观众」登录正常。" }),
];
it("运行中:当前工具、调用次数;卡片切开进度行", () => {
const items = foldEvents(spawned());
const sub = items.find((i) => i.kind === "subagent");
expect(sub?.kind === "subagent" && sub.view).toMatchObject({
childRunId: CHILD,
goal: "检查站点「观众」的登录状态",
status: null,
currentTool: "site.health_check",
toolCalls: 1,
});
// 卡片跟在派出它的那段进度行下面,不另起一段
const turn = buildConversation(items)[0];
expect(turn?.kind === "turn" && turn.parts.map((p) => p.kind)).toEqual(["segment"]);
const seg = turn?.kind === "turn" ? turn.parts[0] : undefined;
expect(seg?.kind === "segment" && seg.subagents.map((v) => v.childRunId)).toEqual([CHILD]);
expect(subagentChipText(subagentViews(items).map((v) => subagentState(v)))).toBe("1 个子会话运行中");
});
it("运行中卡片:正在做什么、用时、调用次数,每秒更新", () => {
const html = text(
render(createElement(AgentConversation, { run: { ...RUN, status: "running" }, live: liveOf(spawned()) })),
);
expect(html).toContain("子会话");
expect(html).toContain("检查站点「观众」的登录状态");
expect(html).toContain("运行中");
expect(html).toMatch(/正在检测站点/);
expect(html).toContain("1 次调用");
expect(html).toContain("查看过程");
expect(html).toContain("打开会话");
});
it("结束后:状态、汇报第一句、用时、调用次数和费用;快照里的状态优先于未结束的镜像", () => {
const live = liveOf(done());
const html = text(render(createElement(AgentConversation, { run: RUN, live })));
expect(html).toContain("已完成");
expect(html).toContain("站点「观众」登录正常。");
expect(html).not.toContain("childRunId");
expect(html).toMatch(/17 秒 · 2 次调用 · \$0\.02/);
const v = subagentViews(live.items)[0]!;
expect(subagentState(v)).toBe("done");
expect(subagentState({ status: null }, "waiting")).toBe("waiting");
expect(subagentState({ status: null }, "cancelled")).toBe("stopped");
expect(subagentState({ status: "failed" }, "running")).toBe("failed");
expect(subagentElapsed({ startedAt: at(0), endedAt: null, durationMs: null }, Date.parse(at(65)))).toBe(65000);
expect(subagentChipText(["done", "failed"])).toBe("2 个子会话");
expect(subagentChipText([])).toBeNull();
expect(subagentResultLine("没有动作。")).toBe("没有动作。");
expect(subagentResultLine(null)).toBeNull();
});
});
describe("输入区:/ 指令与上下文标签", () => {
@@ -398,8 +528,10 @@ describe("输入区:/ 指令与上下文标签", () => {
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();
// 只有一个指令;原来的 /重跑 是会话顶部的「重新运行」按钮,发出去就是普通消息
expect(parseCommand("/重跑")).toEqual({ command: null, rest: "/重跑" });
expect(parseCommand("/先给方案x").command).toBeNull();
expect(COMMANDS.map((c) => c.name)).toEqual(["/先给方案"]);
});
it("上下文标签显示名称,只有 ID 时不显示", () => {
expect(contextLabel("作品:沙丘:第二部 · entityId=74beab51")).toBe("沙丘:第二部");
+16 -25
View File
@@ -21,19 +21,18 @@ export type AgentContext = {
type State = {
open: boolean;
minimized: boolean;
context: AgentContext | null;
/** 抽屉里正在看的会话(null = 新对话) */
runId: string | null;
};
// AGUI-3:抽屉去掉「当前 / 历史」Tab。头部「历史」弹层点一条就在抽屉里打开,「新对话」回到输入框。
// 旧版本记在 localStorage 的 Tab(umt-agent-drawer-tab)不再读取。
// 抽屉头部:标题就是最近会话切换(点开是和会话页同一份列表),「新对话」回到输入框,「在页面打开」去会话页。
// 没有最小化小窗:关上抽屉会话照常在后台跑,侧栏入口显示进行中的数量,再打开接着看。
/** 当前在不在智能体页面(列表页或会话页) */
export const isAgentPage = (pathname: string) => /^\/console\/agent(\/|$)/.test(pathname);
let state: State = { open: false, minimized: false, context: null, runId: null };
let state: State = { open: false, context: null, runId: null };
const listeners = new Set<() => void>();
const emit = () => {
for (const l of listeners) l();
@@ -67,14 +66,14 @@ export const agentReturnPath = () => returnPath;
/** 打开抽屉开始新对话(页面内按钮带着对象打开) */
export function openAgentDrawer(context: AgentContext | null = null) {
rememberFocus();
state = { ...state, open: true, minimized: false, context, runId: null };
state = { ...state, open: true, context, runId: null };
emit();
}
/** 在抽屉里打开一条会话(历史弹层、/重跑、子会话链接、会话页「在侧边打开」) */
/** 在抽屉里打开一条会话(最近会话切换、重新运行、子会话链接、会话页「在侧边打开」) */
export function showAgentRunInDrawer(runId: string) {
rememberFocus();
state = { ...state, open: true, minimized: false, runId };
state = { ...state, open: true, runId };
emit();
}
@@ -102,21 +101,10 @@ export function clearAgentContext() {
emit();
}
/** 关上抽屉会话照常在后台跑;再打开(Ctrl+J、侧栏入口)接着看同一个会话 */
export function setAgentDrawerOpen(open: boolean) {
state = { ...state, open, minimized: false };
emit();
}
/** 最小化:抽屉收起,运行继续在右下角的小窗里流式显示 */
export function minimizeAgentDrawer() {
state = { ...state, open: false, minimized: true };
emit();
}
/** 从小窗恢复抽屉 */
export function restoreAgentDrawer() {
rememberFocus();
state = { ...state, open: true, minimized: false };
if (open) rememberFocus();
state = { ...state, open };
emit();
}
@@ -137,7 +125,7 @@ export type ToggleAction = "close" | "focus-page" | "restore" | "open";
/**
* 切换要做什么(纯函数,单测覆盖):打开中 → 收起;已在智能体页面 → 聚焦页面的输入框(页面就是智能体,
* 不再在抽屉里叠一份同一会话);上次看的会话(含最小化的)还在 → 接着看;否则按当前路由带上下文开新对话。
* 不再在抽屉里叠一份同一会话);上次看的会话还在 → 接着看;否则按当前路由带上下文开新对话。
*/
export function toggleAction(s: Pick<State, "open" | "runId">, pathname: string): ToggleAction {
if (s.open) return "close";
@@ -155,20 +143,22 @@ function focusAgentPageInput(): boolean {
return true;
}
/** 快捷键 / 悬浮按钮 / 侧栏主入口 / 顶栏按钮共用的切换 */
/** 快捷键 / 侧栏主入口 / 顶栏胶囊里的按钮共用的切换 */
export function toggleAgentDrawer(pathname: string) {
const action = toggleAction(state, pathname);
if (action === "close") setAgentDrawerOpen(false);
// 智能体页面本身就是会话:聚焦页面的输入框,找不到输入框才开抽屉
else if (action === "focus-page" && focusAgentPageInput()) return;
else if (state.runId) restoreAgentDrawer();
else if (state.runId) setAgentDrawerOpen(true);
else openAgentDrawer(contextFromPath(pathname));
}
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
export function contextFromPath(pathname: string): AgentContext | null {
const m = pathname.match(/^\/console\/(library|sites|tasks|organize|engine)\/([^/?#]+)/);
const m = pathname.match(/^\/console\/(library|sites|tasks|downloads|organize|engine)\/([^/?#]+)/);
if (!m) return null;
const [, kind, id] = m as unknown as [string, string, string];
if (kind === "downloads" && id === "plans") return null;
const short = id.slice(0, 8);
switch (kind) {
case "library":
@@ -176,6 +166,7 @@ export function contextFromPath(pathname: string): AgentContext | null {
case "sites":
return { label: `站点 ${id}`, siteId: id, fromPath: true };
case "tasks":
case "downloads":
return { label: `下载任务 ${short}`, downloadTaskId: id, fromPath: true };
case "organize":
return { label: `整理记录 ${short}`, recordId: id, fromPath: true };
@@ -1,147 +0,0 @@
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
import { createContext, type ReactNode, useContext, useState } from "react";
import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
const tokens = (n: number | undefined) => formatTokens(n);
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
if (u.costUsd) parts.push(`$${u.costUsd.toFixed(3)}`);
return parts.join(" · ");
}
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
/** 抽屉里提供:点父/子会话在抽屉内打开,不跳页 */
export const AgentRunOpenContext = createContext<((runId: string) => void) | null>(null);
const LINK_CLASS =
"line-clamp-2 cursor-pointer rounded-tight text-left text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring";
function RunLink({ id, children }: { id: string; children: ReactNode }) {
const open = useContext(AgentRunOpenContext);
if (open)
return (
<button type="button" onClick={() => open(id)} className={LINK_CLASS}>
{children}
</button>
);
return (
<Link to={href(id)} className={LINK_CLASS}>
{children}
</Link>
);
}
function budgetText(b: Record<string, number | undefined>) {
const parts: string[] = [];
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec} 秒`);
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
return parts.length ? parts.join(" · ") : "沿用默认";
}
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
const kids = byParent.get(node.id) ?? [];
const [open, setOpen] = useState(true);
return (
<li>
<div className="flex min-w-0 items-start gap-1.5 rounded-lg px-1.5 py-1 hover:bg-foreground/[0.04]">
{kids.length ? (
<button
type="button"
aria-expanded={open}
aria-label={open ? "收起子会话" : "展开子会话"}
onClick={() => setOpen(!open)}
className="mt-0.5 cursor-pointer rounded-tight text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight className={cn("size-4 transition-transform duration-micro", open && "rotate-90")} />
</button>
) : (
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<RunLink id={node.id}>{shortGoalTitle(node.goal)}</RunLink>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} />
<span className="text-meta text-muted-foreground tabular-nums">
{usageLine({
toolCalls: node.toolCallCount,
totalTokens: node.usage.totalTokens,
costUsd: node.usage.costUsd,
})}
</span>
</div>
{node.spawnReason ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p>
) : null}
</div>
</div>
{kids.length && open ? (
<ul className={cn("ml-3 border-l border-border/70 pl-2", depth > 2 && "ml-1")}>
{kids.map((k) => (
<Node key={k.id} node={k} byParent={byParent} depth={depth + 1} />
))}
</ul>
) : null}
</li>
);
}
/** 子会话树 + 父会话链接;两者都没有时返回 null */
export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; className?: string }) {
const { run, parent, children = [], familyUsage } = snapshot;
if (!parent && children.length === 0) return null;
const byParent = new Map<string, AgentRunNode[]>();
for (const c of children) {
const k = c.parentRunId ?? "";
byParent.set(k, [...(byParent.get(k) ?? []), c]);
}
const direct = byParent.get(run.id) ?? [];
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
return (
<section aria-label="子会话" className={cn("space-y-2 rounded-xl border border-border/70 p-3", className)}>
{parent ? (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
<CornerLeftUp aria-hidden className="size-3.5" />
父会话
</p>
<RunLink id={parent.id}>{shortGoalTitle(parent.goal)}</RunLink>
<p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 权限不超过父会话 · 可用能力 {caps} 项 · 预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)}
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
</p>
</div>
) : null}
{direct.length ? (
<div className="space-y-1">
<p className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
<GitBranch aria-hidden className="size-3.5" />
{children.length} 个子会话{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
</p>
<ul className="space-y-0.5">
{direct.map((c) => (
<Node key={c.id} node={c} byParent={byParent} depth={1} />
))}
</ul>
</div>
) : null}
</section>
);
}
function FamilyUsage({ usage }: { usage: AgentFamilyUsage }) {
return <span className="ml-auto tabular-nums">合计 {usageLine(usage)}</span>;
}
@@ -0,0 +1,226 @@
import type { AgentRunNode, AgentRunStatus } from "@umt/contracts";
import { ChevronDown, CornerLeftUp, ExternalLink, GitBranch, LoaderCircle } from "lucide-react";
import { createContext, type ReactNode, useContext, useEffect, useId, useState } from "react";
import { Link } from "react-router";
import { agentMockEnabled, isRunActive, useAgentRun } from "~/api/agent.ts";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { cn } from "~/lib/utils.ts";
import { AgentConversation, RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle } from "./agent-store.ts";
import {
durationLabel,
runningText,
type SubagentState,
subagentElapsed,
subagentResultLine,
subagentState,
} from "./conversation-model.ts";
import { humanizeReport } from "./report-humanize.ts";
import { type SubagentView, useAgentStream } from "./use-agent-stream.ts";
// 子会话(agent.spawn)在父会话里的样子:派出它的位置一张卡片,实时显示任务、状态、当前步骤、时长、调用次数与费用;
// 展开后在卡片里看子会话的过程(同一套会话渲染,缩小一档);可在整页 / 抽屉里打开子会话。
// 数据来自父会话事件流里镜像的 subagent_* 事件,快照里的子会话(状态、用量)校正。
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
/** 抽屉里提供:点父 / 子会话在抽屉内打开,不跳页 */
export const AgentRunOpenContext = createContext<((runId: string) => void) | null>(null);
/** 会话快照里的子孙会话(按 ID):卡片用它校正状态与用量 */
export const SubagentNodesContext = createContext<Map<string, AgentRunNode>>(new Map());
const STATE_STATUS: Record<SubagentState, AgentRunStatus> = {
running: "running",
waiting: "waiting",
done: "succeeded",
failed: "failed",
stopped: "cancelled",
};
function RunLink({ id, className, children }: { id: string; className?: string; children: ReactNode }) {
const open = useContext(AgentRunOpenContext);
const cls = cn(
"cursor-pointer rounded-tight outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
className,
);
if (open)
return (
<button type="button" onClick={() => open(id)} className={cls}>
{children}
</button>
);
return (
<Link to={href(id)} className={cls}>
{children}
</Link>
);
}
/** 每秒走一次的时钟(只在运行中的卡片上开) */
function useNow(on: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!on) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [on]);
return now;
}
/** 子会话展开后的过程:读子会话自己的事件流,用同一套会话渲染(紧凑、不重复目标) */
function SubagentSteps({ childId, active }: { childId: string; active: boolean }) {
const run = useAgentRun(childId);
const live = useAgentStream(childId, active || !run.data);
const nodes = new Map((run.data?.children ?? []).map((c) => [c.id, c] as const));
if (!run.data) return <LoadingState rows={2} />;
return (
<SubagentNodesContext.Provider value={nodes}>
<AgentConversation run={run.data.run} live={live} nested />
</SubagentNodesContext.Provider>
);
}
/**
* 会话流里的子会话卡片。运行中:「正在搜资源… · 12 秒 · 3 次调用」,每秒更新;
* 结束后:状态 + 汇报第一句 + 用时、调用次数、费用。展开看子会话的过程,「打开」进子会话。
*/
export function SubagentCard({ view }: { view: SubagentView }) {
const id = useId();
const node = useContext(SubagentNodesContext).get(view.childRunId);
const state = subagentState(view, node?.status ?? null);
const live = state === "running" || state === "waiting";
const now = useNow(live);
const [open, setOpen] = useState(false);
const elapsed = subagentElapsed(view, now, node?.finishedAt ?? null);
const calls = Math.max(view.toolCalls, node?.toolCallCount ?? 0);
const cost = Math.max(view.usage?.costUsd ?? 0, node?.usage.costUsd ?? 0);
const result = subagentResultLine(view.summary ?? node?.summary);
const line =
state === "running"
? view.currentTool
? `${runningText({ capabilityId: view.currentTool })}…`
: "思考中…"
: state === "waiting"
? "等待中"
: result
? humanizeReport(result).text
: state === "done"
? "已完成"
: state === "stopped"
? "已停止"
: "没有完成";
const meta = [durationLabel(elapsed), `${calls} 次调用`, cost ? formatUsd(cost) : null].filter(Boolean).join(" · ");
return (
<section
aria-label={`子会话:${shortGoalTitle(view.goal, 60)}`}
data-subagent={view.childRunId}
data-state={state}
className="w-full max-w-[72ch] scroll-mt-4 rounded-xl border border-border/70 bg-foreground/[0.025]"
>
<div className="flex min-w-0 flex-col gap-1 px-3 pt-2.5 pb-2">
<div className="flex min-w-0 items-center gap-2 text-meta text-muted-foreground">
<GitBranch aria-hidden className="size-3.5 shrink-0" />
<span className="shrink-0">子会话</span>
<RunStatusBadge status={STATE_STATUS[state]} />
<span className="ml-auto shrink-0 tabular-nums" data-slot="subagent-meta">
{meta}
</span>
</div>
<p className="line-clamp-2 text-caption break-words text-foreground" title={view.goal}>
{shortGoalTitle(view.goal, 120)}
</p>
<p
className={cn(
"flex min-w-0 items-center gap-1.5 text-meta text-muted-foreground",
state === "failed" && "text-destructive",
)}
>
{state === "running" ? (
<LoaderCircle aria-hidden className="size-3 shrink-0 animate-spin motion-reduce:animate-none" />
) : null}
<span className="min-w-0 truncate">{line}</span>
</p>
</div>
<div className="flex items-center gap-1 border-t border-border/50 px-1.5 py-1">
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
className="inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-md px-1.5 text-meta text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
{open ? "收起过程" : "查看过程"}
</button>
<RunLink
id={view.childRunId}
className="ml-auto inline-flex min-h-7 items-center gap-1 px-1.5 text-meta text-muted-foreground hover:text-foreground"
>
打开会话
<ExternalLink aria-hidden className="size-3" />
</RunLink>
</div>
<Disclosure id={id} open={open}>
<div className="max-h-[28rem] overflow-y-auto border-t border-border/50 px-3 py-1">
{open ? <SubagentSteps childId={view.childRunId} active={live} /> : null}
</div>
</Disclosure>
</section>
);
}
/** 子会话的运行概况:「2 个子会话运行中」/「3 个子会话」;点一下滚到第一张运行中(没有就第一张)的卡片 */
export function subagentChipText(states: SubagentState[]): string | null {
if (!states.length) return null;
const running = states.filter((s) => s === "running" || s === "waiting").length;
return running ? `${running} 个子会话运行中` : `${states.length} 个子会话`;
}
export function SubagentChip({ views, className }: { views: SubagentView[]; className?: string }) {
const nodes = useContext(SubagentNodesContext);
const states = views.map((v) => subagentState(v, nodes.get(v.childRunId)?.status ?? null));
const text = subagentChipText(states);
if (!text) return null;
const running = states.some((s) => s === "running" || s === "waiting");
const target = views[states.findIndex((s) => s === "running" || s === "waiting")] ?? views[0];
return (
<button
type="button"
title="滚到子会话"
onClick={() => {
if (!target) return;
document
.querySelector(`[data-subagent="${target.childRunId}"]`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
className={cn(
"inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 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",
className,
)}
>
{running ? (
<LoaderCircle aria-hidden className="size-3 animate-spin motion-reduce:animate-none" />
) : (
<GitBranch aria-hidden className="size-3" />
)}
{text}
</button>
);
}
/** 子会话顶部一行:「父会话:xxx」,点了回到父会话 */
export function ParentRunLink({ parent }: { parent: Pick<AgentRunNode, "id" | "goal" | "status"> }) {
return (
<p className="flex min-w-0 items-center gap-1.5 text-meta text-muted-foreground">
<CornerLeftUp aria-hidden className="size-3.5 shrink-0" />
<span className="shrink-0">父会话</span>
<RunLink id={parent.id} className="min-w-0 truncate text-foreground/85">
{shortGoalTitle(parent.goal)}
</RunLink>
{isRunActive(parent.status) ? <RunStatusBadge status={parent.status} /> : null}
</p>
);
}
@@ -1,5 +1,5 @@
import { CAPABILITY_ACTIVITY, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import type { ApprovalView, OptionsView, StreamItem, ToolCallView } from "./use-agent-stream.ts";
import type { ApprovalView, OptionsView, StreamItem, SubagentView, ToolCallView } from "./use-agent-stream.ts";
// 会话流的排版模型(AGUI-3,纯函数,单测覆盖):
// 用户消息 → 一行进度(这一段里的工具调用、思考和过程中的话) → 结论正文 → 没做的事 → 一行实际结果。
@@ -178,7 +178,7 @@ export type ActivityItem =
export type BodyText = { key: string; text: string; at: string };
export type TurnPart =
| { kind: "segment"; key: string; activity: ActivityItem[]; body: BodyText[] }
| { kind: "segment"; key: string; activity: ActivityItem[]; body: BodyText[]; subagents: SubagentView[] }
| { 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" }> }
@@ -217,7 +217,7 @@ export function buildConversation(
const closeSegment = () => {
if (!turn) return;
if (seg || texts.length) {
const s = seg ?? { kind: "segment" as const, key: `s${texts[0]?.key}`, activity: [], body: [] };
const s = seg ?? { kind: "segment" as const, key: `s${texts[0]?.key}`, activity: [], body: [], subagents: [] };
s.body = texts;
turn.parts.push(s);
}
@@ -232,7 +232,7 @@ export function buildConversation(
return turn;
};
const segment = (key: string) => {
if (!seg) seg = { kind: "segment", key: `s${key}`, activity: [], body: [] };
if (!seg) seg = { kind: "segment", key: `s${key}`, activity: [], body: [], subagents: [] };
return seg;
};
@@ -273,6 +273,15 @@ export function buildConversation(
// 这次拒绝已经转成审批卡:不再算"没做"
t.denied = t.denied.filter((c) => c.capabilityId !== it.view.capabilityId);
break;
case "subagent": {
// 子会话卡片跟在派出它的那段进度行下面(在结论正文之前);不切开进度行,
// 一次派两个就是一行「派出了 2 个子会话」+ 两张卡片
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.subagents.push(it.view);
break;
}
case "compaction":
closeSegment();
t.parts.push({ kind: "compaction", key: it.key, at: it.at, item: it });
@@ -376,3 +385,61 @@ export function lastReportText(items: readonly StreamItem[]): string | null {
}
return null;
}
// ── 子会话卡片(纯函数) ──
export type SubagentState = "running" | "waiting" | "done" | "failed" | "stopped";
/**
* 子会话卡片的状态:镜像里收到 subagent_completed 就按它的结果;还没收到时用快照里的子会话状态
* (等待、已停止更新得更及时);都没有按运行中。
*/
export function subagentState(view: Pick<SubagentView, "status">, nodeStatus?: string | null): SubagentState {
switch (view.status ?? nodeStatus) {
case "succeeded":
return "done";
case "failed":
case "budget_exceeded":
return "failed";
case "cancelled":
return "stopped";
case "waiting":
return "waiting";
default:
return "running";
}
}
/** 子会话已运行多久(毫秒):结束的取 durationMs 或起止时间,运行中的到 now */
export function subagentElapsed(
view: Pick<SubagentView, "startedAt" | "endedAt" | "durationMs">,
now: number,
finishedAt?: string | null,
): number {
if (view.durationMs !== null) return view.durationMs;
const start = Date.parse(view.startedAt);
const end = view.endedAt ? Date.parse(view.endedAt) : finishedAt ? Date.parse(finishedAt) : now;
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
}
/**
* 卡片上的一句结果:服务端摘要是「核查结论\n 模型汇报(未经核查):…」,有模型汇报时取它的第一句,
* 否则取摘要第一行。
*/
export function subagentResultLine(summary: string | null | undefined): string | null {
const t = summary?.trim();
if (!t) return null;
const model = /模型汇报(未经核查):([\s\S]*)$/.exec(t)?.[1]?.trim();
const src = model || t;
return (
src
.split("\n")
.find((l) => l.trim())
?.trim() ?? null
);
}
/** 会话里的全部子会话卡片(只看直接派出的;按出现顺序) */
export function subagentViews(items: readonly StreamItem[]): SubagentView[] {
return items.flatMap((i) => (i.kind === "subagent" ? [i.view] : []));
}
+200 -2
View File
@@ -1,4 +1,4 @@
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentUsageTotals } from "@umt/contracts";
import type { AgentPlanStep, AgentRunEvent, AgentRunEventBody, AgentRunNode, AgentUsageTotals } from "@umt/contracts";
import type { AgentRunView, AgentSessionOptions, GoalReport, RunMessage, RunSnapshot } from "~/api/agent.ts";
// Agent 会话 mock(W2-D 端点就绪前开发用;?mock=1 打开)。事件按时间逐步"到达",模拟流式输出。
@@ -14,6 +14,55 @@ type Entry = {
waiting?: string | null;
};
/** 子会话事件镜像进父会话(和服务端 subagents.ts 一样:spawned → 每条工具 / 开始 / 结束事件 → completed) */
function mirrorChild(parent: Step[], child: Entry, offsetMs: number): Step[] {
const out: Step[] = [...parent];
const at = (d: number) => offsetMs + d + 100;
out.push({
delayMs: offsetMs,
body: {
type: "subagent_spawned",
childRunId: child.run.id,
goal: child.run.goal,
mode: "execute",
depth: 1,
capabilities: ["site.list", "site.health_check"],
budget: {},
},
});
child.steps.forEach((st, i) => {
const b = st.body;
if (b.type === "tool_call_start" || b.type === "tool_call_end" || b.type === "run_started") {
out.push({
delayMs: at(st.delayMs),
body: {
type: "subagent_event",
childRunId: child.run.id,
childSeq: i,
childType: b.type,
...(b.type === "run_started" ? {} : { capabilityId: b.capabilityId }),
summary: "",
},
});
}
if (b.type === "run_completed" || b.type === "run_failed") {
out.push({
delayMs: at(st.delayMs) + 50,
body: {
type: "subagent_completed",
childRunId: child.run.id,
status: b.type === "run_completed" ? "succeeded" : "failed",
summary: b.type === "run_completed" ? b.finalText : b.message,
usage: b.usage,
toolCalls: child.steps.filter((x) => x.body.type === "tool_call_end").length,
durationMs: st.delayMs,
},
});
}
});
return out.sort((a, b) => a.delayMs - b.delayMs);
}
const store = new Map<string, Entry>();
let seeded = false;
@@ -470,6 +519,130 @@ function seed() {
29 * 60_000,
);
child.run = { ...child.run, parentRunId: parent.run.id, trigger: "subagent", depth: 1 };
{
// 父会话第一次看站点之后派出子会话(子会话比父会话晚 1 分钟开始)
const spawnAt = parent.steps.findIndex(
(x) => x.body.type === "tool_call_end" && x.body.capabilityId === "site.list",
);
const base = parent.steps[spawnAt]?.delayMs ?? 1000;
parent.steps = mirrorChild(
[
...parent.steps.slice(0, spawnAt + 1),
...at0([
[base + 50, { type: "text_delta", delta: "派一个子会话去看「憨憨」的抓取记录。" }],
[
60,
{
type: "tool_call_start",
toolCallId: "sp1",
capabilityId: "agent.spawn",
inputSummary: '{"goal":"看站点「憨憨」最近的抓取记录"}',
recovery: false,
},
],
[
60,
{
type: "tool_call_end",
toolCallId: "sp1",
capabilityId: "agent.spawn",
isError: false,
outputSummary: `{"runId":"${child.run.id}"}`,
recovery: false,
},
],
]),
...parent.steps.slice(spawnAt + 1).map((x) => ({ ...x, delayMs: x.delayMs + 60_000 })),
],
child,
base + 200,
);
}
// 运行中的会话:派出两个子会话,一个很快查完,一个还在跑(看子会话卡片的实时样子)
{
const goal = "检查所有站点的登录状态和最近的抓取";
const live = fixture(15, goal, [], 0);
const quick = fixture(16, "看站点「憨憨」最近的抓取记录", [], 0);
const slow = fixture(17, "逐个检测站点「观众」「憨憨」的登录状态", [], 0);
for (const c of [quick, slow]) c.run = { ...c.run, parentRunId: live.run.id, trigger: "subagent", depth: 1 };
quick.startedAt = live.startedAt + 2_000;
slow.startedAt = live.startedAt + 2_100;
quick.steps = at0([
[0, { type: "run_started", goal: quick.run.goal, resumed: false }],
...toolPair("q1", "site.list", "{}", '{"items":[{"name":"憨憨"}]}'),
[2_000, { type: "text_delta", delta: "「憨憨」最近 24 小时抓取 96 次,全部成功。" }],
...toolPair("q2", "site.list", "{}", '{"items":[{"name":"憨憨"}]}'),
[
3_000,
{
type: "run_completed",
finalText: "「憨憨」最近 24 小时抓取 96 次,全部成功。",
usage: { ...USAGE, totalTokens: 8_200, costUsd: 0.006 },
},
],
]);
slow.steps = at0([
[0, { type: "run_started", goal: slow.run.goal, resumed: false }],
...toolPair("w1", "site.list", "{}", '{"items":[{"name":"观众"},{"name":"憨憨"}]}'),
[400, { type: "text_delta", delta: "先测「观众」。" }],
[
100,
{
type: "tool_call_start",
toolCallId: "w2",
capabilityId: "site.health_check",
inputSummary: '{"site":"观众"}',
recovery: false,
},
],
[
600_000,
{
type: "tool_call_end",
toolCallId: "w2",
capabilityId: "site.health_check",
isError: false,
outputSummary: "{}",
recovery: false,
},
],
[1_000, { type: "run_completed", finalText: "两个站点登录都正常。", usage: USAGE }],
]);
const spawn = (id: string, g: string, out: string): [number, AgentRunEventBody][] => [
[
100,
{
type: "tool_call_start",
toolCallId: id,
capabilityId: "agent.spawn",
inputSummary: `{"goal":"${g}"}`,
recovery: false,
},
],
[
100,
{
type: "tool_call_end",
toolCallId: id,
capabilityId: "agent.spawn",
isError: false,
outputSummary: `{"runId":"${out}"}`,
recovery: false,
},
],
];
let steps: Step[] = at0([
[0, { type: "run_started", goal, resumed: false }],
...textSteps("两件事互不相关,各派一个子会话去查。"),
...spawn("sa", quick.run.goal, quick.run.id),
...spawn("sb", slow.run.goal, slow.run.id),
[620_000, { type: "text_delta", delta: "两个站点登录都正常,「憨憨」最近抓取全部成功。" }],
[100, { type: "run_completed", finalText: "两个站点登录都正常。", usage: USAGE }],
]);
steps = mirrorChild(steps, quick, 2_000);
steps = mirrorChild(steps, slow, 2_100);
live.steps = steps;
}
fixture(
13,
"[上下文 作品:沙丘:第二部 · entityId=74beab51-0000-4000-8000-000000000000] 把 1080p 收进来",
@@ -619,7 +792,32 @@ export function mockDetail(id: string, op?: { abort?: boolean; append?: string }
e.steps.sort((a, b) => a.delayMs - b.delayMs);
}
const run = view(e);
return { run, messages: e.appended, report: report(e, run) };
const children = [...store.values()].filter((c) => c.run.parentRunId === id).map((c) => node(view(c)));
return {
run,
messages: e.appended,
report: report(e, run),
children,
...(run.parentRunId && store.get(run.parentRunId) ? { parent: node(view(store.get(run.parentRunId)!)) } : {}),
};
}
function node(r: AgentRunView): AgentRunNode {
return {
id: r.id,
parentRunId: r.parentRunId,
goal: r.goal,
status: r.status,
depth: r.depth,
spawnReason: r.spawnReason,
toolCallCount: r.toolCallCount,
usage: r.usage,
summary: r.summary,
capabilities: [],
budget: r.budget,
createdAt: r.createdAt,
finishedAt: r.finishedAt,
};
}
/** 服务端目标汇报的 mock:只按"业务表"口径给数,模型文本单独放 modelText */
@@ -1,13 +1,23 @@
import { describe, expect, it } from "vitest";
import { quickGoalsFor } from "./quick-goals.ts";
describe("抽屉快捷目标", () => {
it("按页面给 3 个相关目标", () => {
expect(quickGoalsFor("/console/library/abc")[0]).toBe("检查这部作品缺什么版本");
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站点找 ");
expect(quickGoalsFor("/console/tasks/t1")[0]).toBe("看看为什么没进展");
for (const p of ["/console", "/console/library", "/console/files", "/nope"]) {
expect(quickGoalsFor(p)).toHaveLength(3);
describe("新对话的开场建议", () => {
it("按页面给相关问法,最多 2 条", () => {
expect(quickGoalsFor("/console/library/abc")[0]).toBe("这部作品缺哪些版本");
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("这个站点登录和 RSS 抓取正常吗");
expect(quickGoalsFor("/console/downloads/t1")[0]).toBe("这个下载为什么没有进展");
expect(quickGoalsFor("/console/organize")[0]).toBe("整理记录里没识别出的作品是什么");
for (const p of ["/console", "/console/library", "/console/files", "/console/downloads/plans", "/nope"]) {
expect(quickGoalsFor(p)).toHaveLength(2);
}
});
it("没有半句话:每条都能直接发出", () => {
const paths = ["/console", "/console/library/x", "/console/sites", "/console/sites/x", "/console/organize/x"];
for (const p of paths)
for (const g of quickGoalsFor(p)) {
expect(g).toBe(g.trim());
expect(g).not.toMatch(/[::]$/);
}
});
});
+18 -51
View File
@@ -1,61 +1,28 @@
// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站点找 ")。用词按 docs/design/glossary.md。
// 新对话的开场建议:按打开智能体时所在的页面给 2 条最常见的问法,点一下直接发出。
// 都是完整的一句、以查看和排查为主(不放半句话,也不放直接改数据的动作:要改的让用户自己写)。
// 详情页带着当前对象(输入框上的名称标签),问法里用「这部作品」「这个站点」指代。用词按 docs/design/glossary.md。
type Rule = { match: RegExp; goals: [string, string, string] };
type Rule = { match: RegExp; goals: readonly string[] };
const RULES: Rule[] = [
{ match: /^\/console\/library\/[^/]+/, goals: ["这部作品缺哪些版本", "这部作品有没有更好的资源可以升级"] },
{ match: /^\/console\/library\/?$/, goals: ["媒体库里哪些作品还没有 4K 版本", "最近整理但没识别出的作品有哪些"] },
{ match: /^\/console\/sites\/[^/]+/, goals: ["这个站点登录和 RSS 抓取正常吗", "这个站点最近有哪些新的 4K 资源"] },
{ match: /^\/console\/sites\/?$/, goals: ["哪些站点最近抓取失败了,为什么"] },
{
match: /^\/console\/library\/[^/]+/,
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的资源可以升级", "核对这部作品的文件和元数据是否一致"],
},
{
match: /^\/console\/library\/?$/,
goals: ["盘点媒体库里还缺 4K 版本的作品", "找出最近整理但没识别成功的作品", "列出占用空间最大的 10 部作品"],
},
{
match: /^\/console\/sites\/[^/]+/,
goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和 RSS 抓取是否正常"],
},
{
match: /^\/console\/sites\/?$/,
goals: ["在所有站点里找 ", "哪些站点或 RSS 最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"],
},
{
match: /^\/console\/tasks\/[^/]+/,
goals: ["看看为什么没进展", "下载完成后帮我整理好", "换一个做种更多的资源重新下载"],
},
{
match: /^\/console\/tasks\/?$/,
goals: ["看看哪些下载卡住了、为什么没进展", "汇总今天的下载和整理结果", "找出失败的任务并说明原因"],
},
{
match: /^\/console\/organize/,
goals: ["识别整理记录里没识别出的作品", "这次整理为什么有文件失败", "把失败的文件重新整理一遍"],
},
{
match: /^\/console\/files/,
goals: ["把下载目录里还没整理的文件整理好", "找出重复的文件", "看看哪些文件还没被识别"],
},
{
match: /^\/console\/identity/,
goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
},
{
match: /^\/console\/subscriptions/,
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"],
},
{
match: /^\/console\/engine/,
goals: ["这个任务为什么失败", "哪些工作流一直在重试", "汇总最近一小时失败的运行"],
match: /^\/console\/(downloads|tasks)\/(?!plans)[^/]+/,
goals: ["这个下载为什么没有进展", "下载完成后能正常整理吗"],
},
{ match: /^\/console\/downloads\/?$/, goals: ["哪些下载卡住了,为什么"] },
{ match: /^\/console\/organize\/[^/]+/, goals: ["这条整理记录为什么没成功", "帮我识别这条记录是哪部作品"] },
{ match: /^\/console\/organize\/?$/, goals: ["整理记录里没识别出的作品是什么", "最近的整理为什么有失败"] },
{ match: /^\/console\/subscriptions/, goals: ["哪些订阅很久没有新收录了"] },
];
const FALLBACK: [string, string, string] = [
"收一部电影:",
"看看今天有什么需要我处理的",
"检查下载器和媒体服务器是否正常",
];
const FALLBACK = ["看看今天有什么需要我处理的", "检查下载器和媒体服务器是否正常"] as const;
/** 当前页面的开场建议(最多 2 条);只有一条页面相关的时候补一条通用的 */
export function quickGoalsFor(pathname: string): string[] {
return RULES.find((r) => r.match.test(pathname))?.goals ?? FALLBACK;
const own = RULES.find((r) => r.match.test(pathname))?.goals ?? [];
return [...own, ...FALLBACK].slice(0, 2);
}
@@ -8,7 +8,7 @@ import {
sessionStatsParts,
stepProgress,
} from "./conversation-model.ts";
import { collectNames, humanizeReport } from "./report-humanize.ts";
import { collectNames, humanizeReport, stripInternalFields } from "./report-humanize.ts";
import type { StreamItem, ToolCallView } from "./use-agent-stream.ts";
// FIX-7:旧会话汇报的展示层兜底 + 进度口径。样本取自用户库会话 82338768 / a855fb03 的最终汇报(只读核对)。
@@ -176,3 +176,39 @@ describe("调用计数与会话统计(AG-4)", () => {
expect(lastReportText([])).toBeNull();
});
});
describe("内部字段不进正文", () => {
const report = [
"按要求只调用了一次处理页面验证。",
"**返回字段**",
"",
"| 字段 | 值 |",
"| --- | --- |",
"| status | `solved` |",
"| steps | 2 |",
"**其他证据**",
"- childRunId:「处理浏览器会话当前页面上的挑战」",
"- 最终页面:https://www.iana.org/help/example-domains",
"- controller: `automation`",
"- decisionId: `null`,takeover: `null`(未触发二次验证)",
"还缺什么:无。",
].join("\n");
it("字段行、字段表和它的小标题拿掉,其余照常;原文记进技术信息", () => {
const r = humanizeReport(report);
expect(r.text).toContain("按要求只调用了一次处理页面验证。");
expect(r.text).toContain("最终页面");
expect(r.text).toContain("还缺什么:无。");
for (const leak of ["childRunId", "controller", "decisionId", "takeover", "null", "返回字段", "| status"])
expect(r.text).not.toContain(leak);
expect(r.replaced.length).toBeGreaterThanOrEqual(4);
});
it("行内的「字段: null」去掉;代码块和普通表格不动", () => {
expect(stripInternalFields("没找到,approvalId: null。").text).toBe("没找到。");
const table = "| 作品 | 大小 |\n| --- | --- |\n| 沙丘 | 12 GB |";
expect(stripInternalFields(table)).toEqual({ text: table, replaced: [] });
const code = "```\nchildRunId: x\n```";
expect(stripInternalFields(code).text).toBe(code);
});
});
@@ -76,12 +76,68 @@ function humanizePlain(
return out;
}
// ── 内部字段 ──
// 模型偶尔把工具 / 子会话返回的原始字段抄进汇报:「childRunId:…」「decisionId: null, takeover: null」、
// 「返回字段 | 字段 | 值 |」表格。这些对用户没有意义,整行(整张表)从正文里拿掉,原文留在「技术信息」。
/** 不是驼峰也要认的内部字段名 */
const INTERNAL_WORDS = ["controller", "takeover", "payload", "invocation", "idempotency"];
/** 字段名:驼峰(childRunId)、蛇形(decision_id)或上面的单词;可带反引号 */
const KEY = `\`?(?:[a-z][a-z0-9]*(?:[A-Z][A-Za-z0-9]*)+|[a-z]+(?:_[a-z0-9]+)+|${INTERNAL_WORDS.join("|")})\`?`;
/** 以字段名开头的一行(可带列表符号):「- childRunId:…」「decisionId: null, takeover: null」 */
const FIELD_LINE = new RegExp(`^\\s*(?:[-*+]\\s+|\\d+[.)]\\s+)?(?:\\*\\*)?${KEY}(?:\\*\\*)?\\s*[::=]`);
/** 行内的「字段: null」「字段=true」碎片 */
const FIELD_NULL = new RegExp(`[,,、;;]?\\s*${KEY}\\s*[::=]\\s*\`?(?:null|undefined|true|false)\`?`, "g");
/** 字段表的表头第一格 */
const FIELD_TABLE_HEAD = /^\|\s*(?:字段|字段名|field|key|键|参数)\s*\|/i;
/** 字段表上面的小标题:「**返回字段**」「返回字段:」 */
const FIELD_TABLE_TITLE = /^\s*(?:#+\s*)?(?:\*\*)?[^|]{0,10}字段(?:\*\*)?[::]?\s*$/;
/** 纯函数(单测覆盖):去掉内部字段的行、字段表和行内的「字段: null」;代码块不动 */
export function stripInternalFields(text: string): HumanizeResult {
const replaced: HumanizeResult["replaced"] = [];
const parts = text.split(/(```[\s\S]*?```)/g);
const out = parts.map((part, i) => {
if (i % 2 === 1) return part;
const lines = part.split("\n");
const keep: string[] = [];
for (let j = 0; j < lines.length; j++) {
const line = lines[j] ?? "";
if (FIELD_TABLE_HEAD.test(line.trim())) {
let end = j;
while (end + 1 < lines.length && (lines[end + 1] ?? "").trim().startsWith("|")) end++;
// 表格上面的小标题(中间可以隔空行)一起拿掉
let t = keep.length - 1;
while (t >= 0 && !(keep[t] ?? "").trim()) t--;
if (t >= 0 && FIELD_TABLE_TITLE.test(keep[t] ?? "")) replaced.push({ from: keep.splice(t)[0] ?? "", to: "" });
replaced.push({ from: lines.slice(j, end + 1).join("\n"), to: "" });
j = end;
continue;
}
if (FIELD_LINE.test(line)) {
replaced.push({ from: line, to: "" });
continue;
}
keep.push(
line.replace(FIELD_NULL, (m) => {
replaced.push({ from: m, to: "" });
return "";
}),
);
}
return keep.join("\n");
});
return { text: replaced.length ? out.join("").replace(/\n{3,}/g, "\n\n") : text, replaced };
}
/**
* 纯函数(单测覆盖):汇报正文里的 UUID / 能力 ID / 原因码 → 名称 / 人话。
* 纯函数(单测覆盖):汇报正文里的 UUID / 能力 ID / 原因码 → 名称 / 人话;内部字段的行与字段表拿掉。
* 代码块原样保留;行内代码里的内容被替换时去掉反引号,没有可换的保持原样;链接地址不动。
*/
export function humanizeReport(text: string, names: NameIndex = new Map()): HumanizeResult {
const replaced: HumanizeResult["replaced"] = [];
export function humanizeReport(source: string, names: NameIndex = new Map()): HumanizeResult {
const stripped = stripInternalFields(source);
const text = stripped.text;
const replaced: HumanizeResult["replaced"] = [...stripped.replaced];
const seen = new Set<string>([...text.matchAll(new RegExp(UUID, "gi"))].map((m) => m[0].toLowerCase()));
for (const id of names.keys()) seen.add(id);
const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`|\]\([^)\s]*\))/g);
@@ -1,23 +1,20 @@
// 输入框里的 `/` 指令(AGUI-3)。只作用于这一次输入,不是开关:
// 输入框里的 `/` 指令。只有一个,只作用于这一次输入,不是开关:
// - /先给方案:新会话按「先看方案再执行」建(proposeFirst,服务端在选定方案前拒绝执行类能力);
// 在已有会话里发,转成一句"先给方案"的要求(模型按它做,服务端不强制)。
// - /重跑:用同一个目标新开一次会话(已有会话里才有)。
// 旧的「先看方案再执行」开关只是每次新建时的本地状态,没有存进设置;旧会话 scope.proposeFirst 照常生效。
// 原来的 `/重跑` 改成会话顶部的「重新运行」按钮(结束后才有):藏在指令里很少有人发现。
// 旧会话 scope.proposeFirst 照常生效。
export type AgentCommand = { name: "/先给方案" | "/重跑"; description: string };
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];
/** 新会话和会话里可用的指令(同一份) */
export const COMMANDS: AgentCommand[] = [PROPOSE_FIRST];
/** 拆出开头的指令:「/先给方案 把沙丘收进来」→ { command: "/先给方案", rest: "把沙丘收进来" } */
export function parseCommand(
text: string,
commands: AgentCommand[] = RUN_COMMANDS,
commands: AgentCommand[] = COMMANDS,
): { command: AgentCommand["name"] | null; rest: string } {
const t = text.trim();
for (const c of commands)
@@ -33,6 +33,7 @@ export type StreamItem =
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
| { kind: "options"; key: string; at: string; view: OptionsView }
| { kind: "approval"; key: string; at: string; view: ApprovalView }
| { kind: "subagent"; key: string; at: string; view: SubagentView }
| {
kind: "end";
key: string;
@@ -75,6 +76,28 @@ export type ApprovalView = {
resolved: { status: string; reason: string | null; decidedBy: string | null } | null;
};
/**
* 一个子会话(subagent_spawned + 镜像的 subagent_event + subagent_completed):父会话事件流里就有,
* 不用另读子会话。状态在镜像结束前按运行中算;会话页再用快照里的子会话状态(等待、已停止)校正。
*/
export type SubagentView = {
childRunId: string;
goal: string;
startedAt: string;
endedAt: string | null;
/** subagent_completed 的状态(agent_run.status);没结束为 null */
status: string | null;
/** 正在调用的能力(最近一次 tool_call_start 还没有对应的 end) */
currentTool: string | null;
/** 已结束的工具调用数(失败的算,被拒绝的不算) */
toolCalls: number;
usage: AgentUsageTotals | null;
/** 结束时的汇报摘要 */
summary: string | null;
/** 可选:运行时长(毫秒);没有时按起止时间算 */
durationMs: number | null;
};
export type AgentStreamState = {
events: AgentRunEvent[];
items: StreamItem[];
@@ -103,6 +126,7 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
const tools = new Map<string, ToolCallView>();
const asks = new Map<string, OptionsView>();
const approvals = new Map<string, ApprovalView>();
const subs = new Map<string, SubagentView & { open: Set<string> }>();
for (const e of events) {
const last = items[items.length - 1];
if (
@@ -255,6 +279,60 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
if (v) v.resolved = { status: e.status, reason: e.reason, decidedBy: e.decidedBy };
break;
}
case "subagent_spawned": {
if (subs.has(e.childRunId)) break;
const view = {
childRunId: e.childRunId,
goal: e.goal,
startedAt: e.at,
endedAt: null,
status: null,
currentTool: null,
toolCalls: 0,
usage: null,
summary: null,
durationMs: null,
open: new Set<string>(),
};
subs.set(e.childRunId, view);
items.push({ kind: "subagent", key: `sa${e.childRunId}`, at: e.at, view });
break;
}
case "subagent_event": {
const v = subs.get(e.childRunId);
if (!v) break;
// 镜像事件只带能力 ID,没有 toolCallId:同一能力的开始 / 结束按次数配对
const cap = e.capabilityId ?? "";
if (e.childType === "tool_call_start" && cap) {
v.open.add(`${cap}#${e.childSeq}`);
v.currentTool = cap;
} else if (e.childType === "tool_call_end" || e.childType === "tool_call_rejected") {
const hit = [...v.open].find((k) => k.startsWith(`${cap}#`));
if (hit) v.open.delete(hit);
if (e.childType === "tool_call_end") v.toolCalls++;
const last = [...v.open].at(-1);
v.currentTool = last ? last.slice(0, last.lastIndexOf("#")) : null;
} else if (e.childType === "run_completed" || e.childType === "run_failed") {
v.open.clear();
v.currentTool = null;
}
break;
}
case "subagent_completed": {
const v = subs.get(e.childRunId);
if (!v) break;
Object.assign(v, {
status: e.status,
endedAt: e.at,
currentTool: null,
toolCalls: Math.max(v.toolCalls, e.toolCalls),
usage: e.usage,
summary: e.summary || null,
durationMs: e.durationMs ?? null,
});
v.open.clear();
break;
}
case "run_completed":
items.push({
kind: "end",
@@ -1,6 +1,5 @@
import { Bot, ChevronsRight, Keyboard } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Bot, Keyboard } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useLocation } from "react-router";
import { isRunActive, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
import { AGENT_LAUNCHER_ATTR } from "~/components/agent/agent-drawer.tsx";
@@ -9,16 +8,16 @@ import {
isAgentShortcut,
shortGoalTitle,
toggleAgentDrawer,
useAgentDrawer,
} from "~/components/agent/agent-store.ts";
import { cn } from "~/lib/utils.ts";
// Agent 的三处醒目入口:右下角悬浮按钮(FAB)、侧栏最上方主入口、全局快捷键 Ctrl/⌘+J。
// 三者都走 toggleAgentDrawer,行为一致:打开中 → 收起;有最小化的运行 → 恢复;否则带当前页面上下文打开。
// 智能体抽屉的入口只有一个按钮:桌面是侧栏最上方的「智能体」,没有侧栏的窄屏是顶栏胶囊里的智能体按钮
// (agent-drawer.tsx 的 AgentDrawerTrigger);再加全局快捷键 Ctrl/⌘+J。右下角悬浮按钮已去掉(2026-10-08)。
// 都走 toggleAgentDrawer,行为一致:打开中 → 收起;上次的会话还在 → 接着看;否则带当前页面上下文开新对话。
/** 进行中的会话数与最近一条的状态摘要(列表里有进行中的会话时 5 秒轮询一次) */
export function useActiveAgentRuns() {
const runs = useAgentRuns();
const runs = useAgentRuns(undefined, undefined, { roots: true });
return useMemo(() => {
const active = (runs.data?.pages ?? []).flatMap((p) => p.items).filter((r) => isRunActive(r.status));
const latest = active[0];
@@ -43,39 +42,20 @@ function useAgentHotkey() {
}, [pathname]);
}
/** 页面里的底部浮动操作栏(文件页批量操作等):FAB 与它横向重叠时抬到它上面 */
const FLOATING_BARS = '[data-floating-bar], [role="toolbar"][aria-label="批量操作"]';
const BASE_BOTTOM = 16;
/** 全局快捷键 Ctrl/⌘+J(没有界面,挂在 console layout 里一次) */
export function AgentHotkey() {
useAgentHotkey();
return null;
}
function useFloatingBarLift(): number {
const [lift, setLift] = useState(0);
useEffect(() => {
let frame = 0;
const measure = () => {
frame = 0;
let next = 0;
for (const bar of document.querySelectorAll<HTMLElement>(FLOATING_BARS)) {
const r = bar.getBoundingClientRect();
// 只看贴底、且伸到屏幕右侧 FAB 所在区域(右 30%)的栏
if (r.height === 0 || r.bottom < window.innerHeight - 160 || r.right < window.innerWidth * 0.7) continue;
next = Math.max(next, window.innerHeight - r.top + 12 - BASE_BOTTOM);
}
setLift((prev) => (prev === next ? prev : next));
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
const mo = new MutationObserver(schedule);
mo.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", schedule);
measure();
return () => {
mo.disconnect();
window.removeEventListener("resize", schedule);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return lift;
/** 进行中的脉冲点:减少动态效果时只留静态点 */
function PulseDot({ className }: { className?: string }) {
return (
<span aria-hidden className={cn("relative flex size-2.5", className)}>
<span className="absolute inline-flex size-full rounded-full bg-primary/60 motion-safe:animate-ping" />
<span className="relative inline-flex size-2.5 rounded-full bg-primary" />
</span>
);
}
function CountBadge({ count, className }: { count: number; className?: string }) {
@@ -92,102 +72,6 @@ function CountBadge({ count, className }: { count: number; className?: string })
);
}
/** 进行中的脉冲点:减少动态效果时只留静态点 */
function PulseDot({ className }: { className?: string }) {
return (
<span aria-hidden className={cn("relative flex size-2.5", className)}>
<span className="absolute inline-flex size-full rounded-full bg-primary/60 motion-safe:animate-ping" />
<span className="relative inline-flex size-2.5 rounded-full bg-primary" />
</span>
);
}
/**
* QA U6:页面向下滚动时 FAB 滑出右侧,向上滚动、停在页底或页面很短时回来,
* 滚动途中不再盖住右对齐的开关与状态栏。只看页面级滚动区域(高度 ≥ 半屏),列表内小滚动区不算。
*/
function useHideOnScroll(): boolean {
const [hidden, setHidden] = useState(false);
useEffect(() => {
const last = new WeakMap<object, number>();
const onScroll = (e: Event) => {
const t = e.target === document ? document.scrollingElement : e.target;
if (!(t instanceof HTMLElement) || t.clientHeight < window.innerHeight / 2) return;
const top = t.scrollTop;
const prev = last.get(t) ?? top;
last.set(t, top);
const atBottom = top + t.clientHeight >= t.scrollHeight - 4;
if (atBottom || top < 80 || top - prev < -8) setHidden(false);
else if (top - prev > 8) setHidden(true);
};
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () => document.removeEventListener("scroll", onScroll, { capture: true });
}, []);
return hidden;
}
/**
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
* - 只在没有侧栏的窄屏显示(xl 起侧栏顶部就是入口);抽屉打开时隐藏,用抽屉自带的关闭钮。
*/
export function AgentLauncher() {
useAgentHotkey();
const { pathname } = useLocation();
const { open, minimized, runId } = useAgentDrawer();
const { count, summary } = useActiveAgentRuns();
const lift = useFloatingBarLift();
const scrolledAway = useHideOnScroll();
const shortcut = agentShortcutLabel();
// 挂到 body:外壳是 isolate 的层叠上下文,留在里面的话抽屉打开后"收起"会被遮罩盖住
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => setHost(document.body), []);
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
const label = open ? "收起智能体" : count ? `打开智能体(${count} 个会话进行中)` : "打开智能体";
return createPortal(
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={() => toggleAgentDrawer(pathname)}
aria-label={label}
aria-keyshortcuts="Control+J Meta+J"
aria-expanded={open}
title={`${open ? "收起" : "打开"}智能体(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
style={{ bottom: `calc(${BASE_BOTTOM + lift}px + env(safe-area-inset-bottom))` }}
className={cn(
"pg-float prism-ring group pointer-events-auto fixed right-4 z-40 flex h-14 min-w-14 cursor-pointer items-center justify-center gap-3 rounded-full text-foreground outline-none select-none",
"transition-[transform,bottom,right] duration-layout ease-spring active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
"md:px-2 md:pr-5",
// 桌面(xl 起)侧栏已有入口,抽屉打开时用抽屉自己的关闭钮:这两种情况都不显示悬浮按钮
open ? "hidden" : "xl:hidden",
!open && scrolledAway && "translate-x-[calc(100%+2rem)] focus-visible:translate-x-0",
)}
>
<span className="relative grid size-10 shrink-0 place-items-center rounded-full pill-selected">
{open ? <ChevronsRight className="size-5" /> : <Bot className="size-5" />}
{count && !open ? <CountBadge count={count} className="absolute -top-1.5 -right-1.5 md:hidden" /> : null}
</span>
<span className="hidden min-w-0 flex-col items-start text-left md:flex">
<span className="flex items-center gap-2 text-control leading-5 font-semibold">
{open ? "收起" : "智能体"}
{count && !open ? <PulseDot /> : null}
{count && !open ? <CountBadge count={count} /> : null}
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium text-muted-foreground">
{shortcut}
</kbd>
</span>
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
{open ? "Esc 关闭" : (summary ?? "")}
</span>
</span>
</button>,
host,
);
}
/** 侧栏最上方的 Agent 主入口:强调样式(棱镜环),显示进行中会话数 */
export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
const { pathname } = useLocation();
+5 -7
View File
@@ -1,23 +1,21 @@
import { ArrowLeft, PanelRightOpen } from "lucide-react";
import { Link, useNavigate, useOutletContext, useParams } from "react-router";
import { Link, useNavigate, useParams } from "react-router";
import { agentMockEnabled } from "~/api/agent.ts";
import { agentListHref } from "~/components/agent/agent-nav.ts";
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
import { agentReturnPath, showAgentRunInDrawer } from "~/components/agent/agent-store.ts";
import { Button } from "~/components/ui/button.tsx";
import type { AgentOutletContext } from "./agent.tsx";
// 会话页的右栏(AGUI-3):会话列占满(顶部概况条、底部输入区);标题与左栏会话列表在外壳 agent.tsx,切换会话不重挂载。
// 步骤、子会话、预算与模型在概况条里。
// 会话页的右栏:和抽屉里同一个会话组件(顶部状态与统计、会话流、底部输入区);标题与左栏会话列表在外壳 agent.tsx,
// 切换会话不重挂载。子会话在会话流里派出它的位置。
export default function AgentRunPage() {
const { runId = "" } = useParams();
const navigate = useNavigate();
const mock = agentMockEnabled();
const { group } = useOutletContext<AgentOutletContext>();
return (
<div className="pg-slab flex h-[calc(100dvh-10.25rem)] min-h-[28rem] flex-col gap-2 rounded-2xl p-3 md:p-4 lg:h-[calc(100dvh-11.25rem)]">
<Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground lg:hidden">
<Link to={agentListHref(group, mock)}>
<Link to={agentListHref(mock)}>
<ArrowLeft />
全部会话
</Link>
@@ -29,7 +27,7 @@ export default function AgentRunPage() {
actions={
// 整页 → 抽屉:回到进智能体页面之前的页面,会话在抽屉里接着看
<Button
variant="outline"
variant="ghost"
size="icon-sm"
aria-label="在侧边打开"
title="在侧边打开,回到之前的页面"
+3 -2
View File
@@ -6,14 +6,15 @@ import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
import { quickGoalsFor } from "~/components/agent/quick-goals.ts";
import { errorMessage } from "~/lib/api.ts";
// 智能体列表页的右栏:新对话输入框 + 3 个快捷目标(标题与左栏会话列表在外壳 agent.tsx)
// 智能体会话页的右栏:新对话(和抽屉里同一个组件;标题与左栏会话列表在外壳 agent.tsx)
export default function AgentIndexPage() {
const create = useCreateAgentRun();
const navigate = useNavigate();
const mock = agentMockEnabled();
return (
<div className="pg-slab mx-auto w-full max-w-[88ch] rounded-2xl p-4">
<div className="pg-slab flex min-h-[26rem] flex-col rounded-2xl p-3 md:p-4 lg:h-[calc(100dvh-11.25rem)]">
<GoalComposer
className="mx-auto w-full max-w-[88ch]"
pending={create.isPending}
suggestions={quickGoalsFor("/console/agent")}
draftKey={DRAFT_PAGE_GOAL}
+9 -17
View File
@@ -1,27 +1,24 @@
import { FlaskConical } from "lucide-react";
import { Outlet, useParams } from "react-router";
import { agentMockEnabled } from "~/api/agent.ts";
import { agentRunHref, pageListMemory, usePageListGroup, useRunGroup } from "~/components/agent/agent-nav.ts";
import { agentRunHref, pageListMemory } from "~/components/agent/agent-nav.ts";
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
/**
* 智能体页的外壳(AGUI-3):列表页与会话页共用。标题、演示提示与左栏会话列表在这里,只挂载一次;
* 右栏是子路由(新对话输入框 / 某个会话)。切换会话、回到列表都不重挂载左栏,也不重播入场动画,
* 标题与两栏的位置不跳。
* 智能体会话页的外壳:列表页与会话页共用。标题、演示提示与左栏会话列表在这里,只挂载一次;
* 右栏是子路由(新对话 / 某个会话),和抽屉里是同一个组件。切换会话、回到列表都不重挂载左栏,
* 也不重播入场动画,标题与两栏的位置不跳。
*/
export default function AgentLayout() {
const { runId } = useParams();
const mock = agentMockEnabled();
const runGroupOf = useRunGroup(runId);
// 列表页没有 tab 就是「对话」;会话页的左栏分栏跟随当前会话(巡检会话及其子会话在「自动巡检」),也可以手动切换(记在 URL)
const [group, setGroup] = usePageListGroup(runId ? runGroupOf : undefined);
return (
<>
<PageHeader title="智能体" className="mb-4 md:mb-6" />
<PageHeader title="智能体会话" className="mb-4 md:mb-6" />
{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" />
@@ -33,10 +30,9 @@ export default function AgentLayout() {
) : null}
<div
data-agent-page
// 智能体页没有右下角悬浮按钮,收回主内容区为它留的底部空白,两栏正好占满一屏
className="-mb-16 grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] lg:items-start"
className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] lg:items-start"
>
{/* 窄屏:列表页把会话列表排在输入框下面;会话页不显示列表(顶部有「全部会话」返回) */}
{/* 窄屏:列表页把会话列表排在新对话下面;会话页不显示列表(顶部有「全部会话」返回) */}
<aside
className={cn(
"pg-slab order-2 rounded-2xl p-3 lg:order-1 lg:block lg:h-[calc(100dvh-11.25rem)] lg:overflow-hidden",
@@ -46,18 +42,14 @@ export default function AgentLayout() {
<AgentSessionList
activeId={runId}
hrefFor={(id) => agentRunHref(id, mock)}
group={group}
onGroupChange={setGroup}
memory={pageListMemory}
className="h-full"
className="h-full max-lg:max-h-[32rem]"
/>
</aside>
<div className="order-1 min-w-0 lg:order-2">
<Outlet context={{ group } satisfies AgentOutletContext} />
<Outlet />
</div>
</div>
</>
);
}
export type AgentOutletContext = { group: ReturnType<typeof usePageListGroup>[0] };
+4 -5
View File
@@ -6,7 +6,7 @@ import { useSession } from "~/api/auth.ts";
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
import { outletKey } from "~/components/agent/agent-store.ts";
import { AccountMenu } from "~/components/console/account-menu.tsx";
import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
import { AgentHotkey, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
import { ErrorState } from "~/components/console/empty-state.tsx";
import { InboxBell } from "~/components/console/inbox-bell.tsx";
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
@@ -259,7 +259,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
<div className="flex items-center gap-0.5 p-1">
<SearchTrigger className="mr-1" />
<InboxBell bare />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏的智能体按钮只在窄屏保留 */}
{/* 桌面的入口是侧栏最上方的「智能体」;没有侧栏的窄屏用顶栏胶囊里的这颗 */}
<AgentDrawerTrigger className="xl:hidden" />
<ThemeToggle bare />
<AccountMenu session={session} bare />
@@ -268,8 +268,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
</div>
</header>
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-10 md:px-6 md:pt-8")}>
<div
key={outletKey(location.pathname)}
className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}
@@ -278,7 +277,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
</div>
</main>
<AgentDrawer />
<AgentLauncher />
<AgentHotkey />
<CommandPalette />
</div>
</div>
+6 -10
View File
@@ -268,9 +268,9 @@ async function main() {
}
}
// ── 交互:智能体抽屉「历史」弹层(AGUI-3;演示数据,点一条在抽屉内打开、新对话回到输入框、关了再开仍是那条会话)──
// ── 交互:智能体抽屉的最近会话切换(演示数据,点一条在抽屉内打开、新对话回到输入框、关了再开仍是那条会话)──
{
const label = "智能体抽屉 → 历史弹层 → 抽屉内打开会话";
const label = "智能体抽屉 → 最近会话 → 抽屉内打开会话";
problems = [];
const t0 = Date.now();
try {
@@ -280,17 +280,13 @@ async function main() {
const startPath = new URL(page.url()).pathname;
const launcher = page.locator("[data-agent-launcher]:visible").last();
await launcher.click();
await page.getByRole("button", { name: "历史会话" }).click();
await page.getByRole("button", { name: /^最近会话/ }).click();
const list = page.getByRole("navigation", { name: "智能体会话列表" });
await list
.getByRole("button")
.filter({ hasNotText: /^(全部|等你处理|失败)$/ })
.first()
.click();
// 等历史弹层退场后再按 Escape,避免按键被仍在退场的 Popover 消耗。
await list.locator("button[data-run-id]").first().click();
// 等弹层退场后再按 Escape,避免按键被仍在退场的 Popover 消耗。
await list.waitFor({ state: "hidden", timeout: 5_000 });
await page.getByRole("button", { name: "新对话" }).and(page.locator(":enabled")).waitFor({ timeout: 10_000 });
if (new URL(page.url()).pathname !== startPath) problems.push("点历史会话跳走了页面");
if (new URL(page.url()).pathname !== startPath) problems.push("点最近会话跳走了页面");
await page.keyboard.press("Escape");
await page.locator('[data-slot="sheet-content"]').waitFor({ state: "hidden", timeout: 5_000 });
await launcher.click();
+12
View File
@@ -171,3 +171,15 @@
已知限制:幂等键来自审计事件(`/audit-events` 按 `payload.agentRunId` 过滤,最多翻 3 页、按同能力先后与工具调用配对),
较早的运行可能取不到;审批卡片的计划来自工具调用输入/输出里能解析到的 `planId`(摘要被截断时可能漏)与服务端汇报。
## 7. 会话体验改版(2026-10-08)
用户反馈:入口太多、抽屉和会话页是两套界面、列表太乱、看不到子会话在做什么、回答里夹着内部字段、快捷目标和指令绕。改成:
- **一个对话组件,两个入口**。新对话是 `GoalComposer`,会话是 `AgentRunPanel`(`agent-run-panel.tsx`);抽屉(`agent-drawer.tsx`,`compact`)和会话页右栏(`routes/console/agent._index.tsx` / `agent.$runId.tsx`)渲染的是同一套。抽屉头部:标题即最近会话切换(弹层里是 `AgentSessionList`)、新对话、在页面打开、关闭;没有最小化小窗,关上抽屉会话照常在后台跑。会话页头部多一个「在侧边打开」。入口与 IA 见 `console-ia.md` 文末「智能体入口」。
- **一个会话列表**(`agent-session-list.tsx`):搜索 + 一个状态下拉(全部状态 / 等你处理 / 进行中 / 失败),等你处理置顶;对话与巡检混排,巡检 / 事件会话带小标签;只列顶层会话(`GET /agent/runs?roots=true`),子会话不成行。
- **子会话卡片**(`agent-subagents.tsx`):父会话事件流里镜像的 `subagent_spawned / subagent_event / subagent_completed` 在 `foldEvents` 里折成一个 `subagent` 展示项,挂在派出它的那段进度行下面、结论正文之前。卡片显示任务、状态(`RunStatusBadge`,状态色同全站)、正在做什么(最近一次还没结束的工具调用)、用时(运行中每秒走;结束后取 `subagent_completed.durationMs`,旧事件按起止时间算)、调用次数、费用;快照里的子会话(`children`)校正状态与用量。「查看过程」在卡片里读子会话自己的事件流,用 `AgentConversation nested` 渲染;「打开会话」在抽屉里就地打开,在页面里跳会话页。会话顶部的「N 个子会话运行中」点一下滚到卡片。
- **回答不夹内部字段**:汇报规则(`packages/agent/src/prompt/layers/report.ts`)要求转述子会话结论、不照抄键值和「返回字段」表;展示层 `stripInternalFields`(`report-humanize.ts`)把字段行、字段表和行内的「字段: null」从正文拿掉,原文进「技术信息」。
- **开场建议与指令**:开场建议按所在页面给最多 2 条完整问法,点了直接发出,不再有半句话;输入框只剩 `/先给方案`,`/重跑` 改成结束后顶部的「重新运行」按钮。
演示模式(`?mock=1`)里「检查所有站点的登录状态和最近的抓取」是一个一直在跑的会话:一个子会话很快结束、一个停在检测站点上,用来看卡片的实时样子。
+2 -1
View File
@@ -230,7 +230,7 @@
| 订阅 | 订阅 `/console/subscriptions`、收录策略 `/console/rules`、偏好标签 `/console/preferences` | 页名按术语表用"收录策略"(术语表不用"收录规则"),路由仍是 `/rules` |
| 下载 | 下载队列 `/console/downloads`、下载计划 `/console/downloads/plans`、业务任务 `/console/tasks` | — |
| 整理 | 媒体库、整理记录、文件管理 `/console/files`、身份与元数据 | 身份页页签沿用术语表的"待识别",没有改成"未识别" |
| Agent | Agent 会话 `/console/agent` | 侧栏顶部的 Agent 唤出按钮与右下角悬浮按钮保留 |
| Agent | Agent 会话 `/console/agent` | 侧栏顶部的 Agent 唤出按钮保留;右下角悬浮按钮 2026-10-08 去掉(见文末「智能体入口」) |
| 系统 | 设置、定时任务(W7-C)、引擎面板、审计日志 | 多出"定时任务"一项,共 19 项 |
- 当前项按 `findNavItem`(最长前缀)只高亮一项:`/downloads/plans` 不再同时点亮"下载队列"。
@@ -383,5 +383,6 @@ W10 已补(2026-09-28):
## 8. 界面第二轮:系统组只留日志、运行状态、设置(2026-10-06)
- 侧栏 4 组 13 项:概览(首页、智能体会话)/ 获取 / 整理 / 系统(日志、运行状态、设置)。任务、审计、引擎面板、计划不再有入口。
- 智能体入口(2026-10-08):抽屉只有一个按钮入口——桌面是侧栏最上方的「智能体」(Ctrl/⌘+J),没有侧栏的窄屏是顶栏胶囊里的智能体按钮;没有右下角悬浮按钮,也没有最小化小窗。「智能体会话」页 `/console/agent(/:runId)` 用来浏览全部会话,右栏和抽屉是同一个对话组件。页面里的「问智能体」按钮带着当前对象打开同一个抽屉,不算另一个入口。会话列表只有一份(会话页左栏与抽屉的最近会话切换共用):对话与巡检混排、只列顶层会话(`GET /agent/runs?roots=true`),子会话在父会话的会话流里看;旧链接的 `?tab=` 忽略。
- 日志页 `/console/logs`:任务、定时与自动、登录、审计合成一份列表,可实时追加、勾选后问智能体;第二个页签「定时任务」是原计划页。旧地址:`/console/tasks`(含 `?tab=` `?status=` `?kind=&ref=` `?run=`)、`/console/engine(/:id)`、`/console/audit`(`?targetType=&targetId=` → 对象筛选)、`/console/schedules(?job=)` 都跳到日志页对应筛选;`/console/tasks/:id` 独立页保留,侧栏高亮「日志」。
- 运行状态页加「资源」节(CPU / 内存 / 网络 / 磁盘读写与趋势),首页「存储情况」卡换成运行状态简版卡。细节见 `docs/runbook/logs.md`。
@@ -9,6 +9,7 @@ export const REPORT_FORMAT: readonly string[] = [
"不超过 8 行;不复述过程,不写下一步打算,除非需要用户做什么。",
"单位说人话:大小写成 GB / TB(如 79.4 GB),时间写相对时间(3 分钟前、昨天 16:12);不写原始字节数和 ISO 时间。",
"对象用名字:作品名、站点名、下载器名、资源标题、文件名;不写函数名、工具名、字段名、参数、UUID、hash、adapter 或英文状态码,状态用中文(正常、下载中、已整理)。",
"工具和子会话返回的原始字段不照抄:不写「childRunId:…」「decisionId: null」这类键值,不列「返回字段」表,不写 null / true / false;子会话的汇报是给你看的,转述它的结论。",
"异常和需要用户做的事放前面;正常的项合并成一句带过,数量为 0 的不单独列。",
"没做成的动作每个一句:「没做:检测站点,需要你批准」;不展开授权细节。",
"数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。",
+2 -1
View File
@@ -204,7 +204,8 @@ describe("⑤ 汇报格式", () => {
expect(f).toContain("不写函数名、工具名、字段名、参数、UUID、hash、adapter 或英文状态码");
expect(f).toContain("只有列举 3 个以上同类对象");
expect(f).toContain("不写 HTML");
expect(REPORT_FORMAT.length).toBe(9);
expect(f).toContain("工具和子会话返回的原始字段不照抄");
expect(REPORT_FORMAT.length).toBe(10);
});
it("用户汇报不要求引用对象 ID", () => {
+2
View File
@@ -114,6 +114,8 @@ const SubagentCompleted = z.object({
summary: z.string(),
usage: AgentUsageTotals,
toolCalls: NonNegInt,
/** 子会话运行时长(毫秒,开始到结束);旧事件没有 */
durationMs: NonNegInt.optional(),
});
// 自动化优先的会话交互(AG-2):模型用 agent.plan 维护步骤清单,用 agent.propose_options 让用户在方案里选;
+2
View File
@@ -385,6 +385,8 @@ export const AgentRunListQuery = z.object({
group: z.enum(["conversation", "auto"]).optional(),
/** 只列某个会话直接派生的子会话 */
parentRunId: Id.optional(),
/** "true" = 只列顶层会话(不含子会话);子会话在父会话里看 */
roots: z.enum(["true", "false"]).optional(),
});
export type AgentRunListQuery = z.infer<typeof AgentRunListQuery>;