- 在智能体页面按 Ctrl+J / 点智能体入口聚焦页面输入框,不再在抽屉里叠一份同一会话 - 会话页统计行右侧加「在侧边打开」:回到进智能体页面之前的页面,会话在抽屉里接着看 - 窄屏顶栏按钮与快捷键、悬浮按钮共用同一套切换 - 抽屉关闭钮放进标题栏按钮行(原来绝对定位,比其它按钮低 5px) - 关闭抽屉后焦点回到打开前的元素(快捷键打开没有触发元素,原来落到 body) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
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,
|
||
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]);
|
||
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>
|
||
);
|
||
}
|