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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 13:56:38 +08:00

228 lines
9.9 KiB
TypeScript

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, 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 { 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 { DRAFT_DRAWER_GOAL, writeDraft } from "./agent-nav.ts";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx";
import {
type AgentContext,
agentShortcutLabel,
clearAgentContext,
contextPrefix,
newAgentConversation,
noteAgentReturnPath,
setAgentDrawerOpen,
shortGoalTitle,
showAgentRunInDrawer,
syncAgentContextToPath,
takeReturnFocus,
toggleAgentDrawer,
useAgentDrawer,
} from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagents.tsx";
import { quickGoalsFor } from "./quick-goals.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;
}
/** 抽屉标题 = 最近会话切换:显示当前会话的短标题,点开是和会话页同一份会话列表,点一条在抽屉里打开 */
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
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="start" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
<AgentSessionList
memory={drawerListMemory}
limit={20}
activeId={runId ?? 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>
);
}
/**
* 智能体抽屉:任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。内容和会话页是同一个组件
* (新对话 GoalComposer、会话 AgentRunPanel),这里是紧凑版。
* 头部:标题即最近会话切换、新对话、在页面打开、关闭。关上抽屉会话照常在后台跑,再打开接着看。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
*/
export function AgentDrawer() {
const { open, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const { pathname, search } = useLocation();
const content = useRef<HTMLDivElement>(null);
// 记下最近的非智能体页面:会话页「在侧边打开」回到这里
useEffect(() => noteAgentReturnPath(pathname, search), [pathname, search]);
useEffect(() => syncAgentContextToPath(pathname), [pathname]);
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"
showCloseButton={false}
className="w-full gap-0 p-0 sm:max-w-xl"
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
onOpenAutoFocus={(e) => {
const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])");
if (!ta) return;
e.preventDefault();
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}}
// 关闭后焦点回到打开前的位置(快捷键打开没有触发元素,缺省会落到 body)
onCloseAutoFocus={(e) => {
const el = takeReturnFocus();
if (!el) return;
e.preventDefault();
el.focus();
}}
onInteractOutside={(e) => {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}}
>
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2 pr-3 pl-3">
<SheetTitle className="sr-only">智能体</SheetTitle>
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<RecentSwitcher runId={runId} />
<Button
variant="ghost"
size="icon-sm"
aria-label="新对话"
title="新对话"
onClick={newAgentConversation}
disabled={!runId}
>
<SquarePen />
</Button>
{runId ? (
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
<Maximize2 />
</Link>
</Button>
) : null}
{/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */}
<SheetClose asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label="关闭,会话在后台继续"
title="关闭(Esc),会话在后台继续"
>
<X />
</Button>
</SheetClose>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""}
draftKey={DRAFT_DRAWER_GOAL}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => {
writeDraft(DRAFT_DRAWER_GOAL, "");
showAgentRunInDrawer(run.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
)}
</div>
</SheetContent>
</Sheet>
);
}
/** 顶栏胶囊里的智能体按钮(没有侧栏的窄屏):与快捷键、侧栏入口同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
export function AgentDrawerTrigger({ className }: { className?: string }) {
const { pathname } = useLocation();
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`}
className={className}
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={() => toggleAgentDrawer(pathname)}
>
<Bot />
</Button>
);
}