merge: agent-ux(智能体会话与入口精简、子代理实时卡片、回答去内部字段)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
2ebb07dca3
35 files changed
+1249
-932
No files matched your search
@@ -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`。
|
||||
|
||||
@@ -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()) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
@@ -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,7 +116,6 @@ export function AgentDrawer() {
|
||||
const prefix = contextPrefix(context && name ? { ...context, label: name } : context);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
|
||||
<SheetContent
|
||||
ref={content}
|
||||
@@ -138,41 +141,35 @@ export function AgentDrawer() {
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<RecentSwitcher runId={runId} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="最小化,会话继续运行"
|
||||
title="最小化,会话继续运行"
|
||||
onClick={minimizeAgentDrawer}
|
||||
aria-label="新对话"
|
||||
title="新对话"
|
||||
onClick={newAgentConversation}
|
||||
disabled={!runId}
|
||||
>
|
||||
<Minus />
|
||||
<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)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="关闭,会话在后台继续"
|
||||
title="关闭(Esc),会话在后台继续"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
@@ -183,7 +180,6 @@ export function AgentDrawer() {
|
||||
<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)}
|
||||
@@ -205,79 +201,14 @@ export function AgentDrawer() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<span className="line-clamp-2 break-words">
|
||||
{line}
|
||||
{streaming && last?.kind === "text" ? <StreamingCursor /> : null}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶栏按钮(窄屏):与快捷键、悬浮按钮同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
|
||||
/** 顶栏胶囊里的智能体按钮(没有侧栏的窄屏):与快捷键、侧栏入口同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
|
||||
export function AgentDrawerTrigger({ className }: { className?: string }) {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,9 +213,6 @@ 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")}
|
||||
@@ -231,15 +221,8 @@ export function RunOverviewBar({
|
||||
步骤 {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>
|
||||
)}
|
||||
<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(
|
||||
@@ -249,11 +232,9 @@ export function RunOverviewBar({
|
||||
style={{ width: `${progress.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<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 send = (text: string) => {
|
||||
const { command, rest } = parseCommand(text, RUN_COMMANDS);
|
||||
if (command === RERUN.name) {
|
||||
const rerun = () =>
|
||||
create.mutate(
|
||||
{ goal: r.goal, proposeFirst: r.scope.proposeFirst === true },
|
||||
{
|
||||
onSuccess: (n) => {
|
||||
setMsg("");
|
||||
created(n.id);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
{ onSuccess: (n) => created(n.id), onError: (e) => toast.error(errorMessage(e)) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const send = (text: string) => {
|
||||
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,8 +357,13 @@ 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 (
|
||||
<SubagentNodesContext.Provider value={nodes}>
|
||||
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
||||
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||||
{agentMockEnabled() && !compact ? (
|
||||
@@ -395,17 +374,37 @@ export function AgentRunPanel({
|
||||
</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>
|
||||
{run.data.parent ? (
|
||||
<div className={column}>
|
||||
<ParentRunLink parent={run.data.parent} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* 顶部一行:状态 · 统计 · 子会话 · 操作;窄的时候折行 */}
|
||||
<div
|
||||
data-slot="run-header"
|
||||
className={cn(column, "-my-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1.5")}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<RunStatsLine snapshot={run.data} live={live} className={column} />
|
||||
)}
|
||||
<RunOverviewBar
|
||||
snapshot={run.data}
|
||||
status={shownStatus}
|
||||
live={live}
|
||||
active={streaming || r.status === "waiting"}
|
||||
className={column}
|
||||
@@ -464,15 +463,7 @@ export function AgentRunPanel({
|
||||
.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}
|
||||
{finished ? <ResultLine run={r} report={report} live={live} showCost={false} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -491,8 +482,8 @@ export function AgentRunPanel({
|
||||
maxRows={6}
|
||||
maxLength={8000}
|
||||
aria-label="发消息"
|
||||
placeholder={ended ? "继续对话,输入 / 查看指令" : "补充或纠正"}
|
||||
commands={RUN_COMMANDS}
|
||||
placeholder={ended ? "继续对话" : "补充或纠正"}
|
||||
commands={COMMANDS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -514,5 +505,6 @@ export function AgentRunPanel({
|
||||
}
|
||||
/>
|
||||
</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,30 +118,31 @@ 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)}>
|
||||
return (
|
||||
<li>
|
||||
{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>
|
||||
) : (
|
||||
@@ -162,38 +154,14 @@ function Row({
|
||||
>
|
||||
<RunRow r={r} current={current} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<li>
|
||||
{item(row.run)}
|
||||
{row.children.length ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="ml-3 inline-flex cursor-pointer items-center gap-0.5 rounded-md px-1 text-meta text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-90")} />
|
||||
{row.children.length} 个子会话
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="ml-4 border-l border-border/70 pl-1">
|
||||
{row.children.map((c) => (
|
||||
<li key={c.id}>{item(c)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 窄列表用的分页:一行「共 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,8 +329,6 @@ 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("沙丘:第二部");
|
||||
|
||||
@@ -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] : []));
|
||||
}
|
||||
@@ -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(/[::]$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
/** 全局快捷键 Ctrl/⌘+J(没有界面,挂在 console layout 里一次) */
|
||||
export function AgentHotkey() {
|
||||
useAgentHotkey();
|
||||
return null;
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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="在侧边打开,回到之前的页面"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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] };
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`)里「检查所有站点的登录状态和最近的抓取」是一个一直在跑的会话:一个子会话很快结束、一个停在检测站点上,用来看卡片的实时样子。
|
||||
@@ -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 的不单独列。",
|
||||
"没做成的动作每个一句:「没做:检测站点,需要你批准」;不展开授权细节。",
|
||||
"数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 让用户在方案里选;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user