Files
UHD_Media_Tool/apps/web/app/components/agent/agent-drawer.tsx
T

297 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Bot, ExternalLink, History, Maximize2, Minus, 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 { 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";
import {
type AgentContext,
agentShortcutLabel,
clearAgentContext,
contextPrefix,
minimizeAgentDrawer,
newAgentConversation,
noteAgentReturnPath,
restoreAgentDrawer,
setAgentDrawerOpen,
shortGoalTitle,
showAgentRunInDrawer,
syncAgentContextToPath,
takeReturnFocus,
toggleAgentDrawer,
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
memory={drawerListMemory}
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, 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.5 pr-3 pl-5">
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
智能体
</SheetTitle>
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<HistoryPopover activeId={runId} />
<Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
<SquarePen />
新对话
</Button>
{runId ? (
<>
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
<Link
to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`}
onClick={() => setAgentDrawerOpen(false)}
>
<ExternalLink />
</Link>
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="最小化,会话继续运行"
title="最小化,会话继续运行"
onClick={minimizeAgentDrawer}
>
<Minus />
</Button>
</>
) : null}
{/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */}
<SheetClose asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭" title="关闭(Esc)">
<X />
</Button>
</SheetClose>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
) : (
<div className="flex min-h-0 flex-1 flex-col justify-end">
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""}
draftKey={DRAFT_DRAWER_GOAL}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => {
writeDraft(DRAFT_DRAWER_GOAL, "");
showAgentRunInDrawer(run.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
)}
</div>
</SheetContent>
</Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
</>
);
}
/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */
function AgentMiniDock({ runId }: { runId: string }) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const last = live.items[live.items.length - 1];
const streaming = active && !live.finished;
const line =
last?.kind === "text"
? last.text.trim().split("\n").pop()
: last?.kind === "tool"
? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}`
: last?.kind === "thinking"
? "思考中…"
: last?.kind === "end"
? last.ok
? "已完成,展开查看结果"
: last.message
: "等待输出…";
const status = run.data
? live.finished && isRunActive(run.data.run.status)
? "running"
: run.data.run.status
: null;
return (
<section
aria-label="智能体小窗"
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
>
<div className="flex items-center gap-2">
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{status ? <RunStatusBadge status={status} /> : null}
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开智能体" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="关闭小窗,会话在后台继续"
title="关闭小窗,会话在后台继续"
onClick={() => setAgentDrawerOpen(false)}
>
<X />
</Button>
</div>
<button
type="button"
onClick={restoreAgentDrawer}
className="mt-2 block w-full cursor-pointer rounded-lg px-1 text-left text-caption leading-5 text-foreground/90 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<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 { pathname } = useLocation();
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`}
className={className}
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={() => toggleAgentDrawer(pathname)}
>
<Bot />
</Button>
);
}