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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 22:41:34 +08:00

273 lines
11 KiB
TypeScript

import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
import { useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { useDownloadTask } from "~/api/downloads.ts";
import { useLibraryDetail } from "~/api/library.ts";
import { useRecord } from "~/api/records.ts";
import { useSite } from "~/api/sites.ts";
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList } from "./agent-session-list.tsx";
import {
type AgentContext,
agentShortcutLabel,
clearAgentContext,
contextFromPath,
contextPrefix,
minimizeAgentDrawer,
newAgentConversation,
openAgentDrawer,
restoreAgentDrawer,
setAgentDrawerOpen,
shortGoalTitle,
showAgentRunInDrawer,
useAgentDrawer,
} from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
import { quickGoalsFor } from "./quick-goals.ts";
import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
/**
* 上下文的显示名称:页面按钮给的 label(「作品:沙丘2」)去掉类别;按路由推断的只有 ID,按 ID 查名称。
* 查不到时退回类别名(「这部作品」),不显示 ID。
*/
export function useContextName(ctx: AgentContext | null): string | null {
const path = !!ctx?.fromPath;
const entity = useLibraryDetail(path ? ctx?.entityId : undefined);
const site = useSite(path ? ctx?.siteId : undefined);
const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined);
const record = useRecord(path ? ctx?.recordId : undefined);
if (!ctx) return null;
if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null;
if (ctx.entityId) return entity.data?.entity.title ?? "这部作品";
if (ctx.siteId) return site.data?.name ?? ctx.siteId;
if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务";
if (ctx.recordId) return record.data?.media?.title ?? "这条整理记录";
if (ctx.taskId) return "这个后台任务";
return null;
}
/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */
function HistoryPopover({ activeId }: { activeId: string | null }) {
const [open, setOpen] = useState(false);
const mock = agentMockEnabled();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" aria-label="历史会话" title="历史会话">
<History />
历史
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
<AgentSessionList
remember
limit={20}
activeId={activeId ?? undefined}
onOpen={(id) => {
setOpen(false);
showAgentRunInDrawer(id);
}}
className="min-h-0 flex-1"
/>
<Button asChild variant="ghost" size="sm" className="mt-1 shrink-0 text-muted-foreground">
<Link to={`/console/agent${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link>
</Button>
</PopoverContent>
</Popover>
);
}
/**
* 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。
* 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
*/
export function AgentDrawer() {
const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const { pathname } = useLocation();
const content = useRef<HTMLDivElement>(null);
const name = useContextName(context);
const mock = agentMockEnabled();
// 目标前缀带名称和 ID:名称给人看,ID 给模型用
const prefix = contextPrefix(context && name ? { ...context, label: name } : context);
return (
<>
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent
ref={content}
side="right"
className="w-full gap-0 p-0 sm:max-w-xl"
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
onOpenAutoFocus={(e) => {
const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])");
if (!ta) return;
e.preventDefault();
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}}
onInteractOutside={(e) => {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}}
>
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-12 pl-5">
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
智能体
</SheetTitle>
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<HistoryPopover activeId={runId} />
<Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
<SquarePen />
新对话
</Button>
{runId ? (
<>
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
<Link
to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`}
onClick={() => setAgentDrawerOpen(false)}
>
<ExternalLink />
</Link>
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
onClick={minimizeAgentDrawer}
>
<Minus />
</Button>
</>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
) : (
<div className="flex min-h-0 flex-1 flex-col justify-end">
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => showAgentRunInDrawer(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
)}
</div>
</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 传更完整的上下文) */
export function AgentDrawerTrigger({ className }: { className?: string }) {
const { minimized, runId } = useAgentDrawer();
const { pathname } = useLocation();
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`}
className={className}
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
>
<Bot />
</Button>
);
}