Files
UHD_Media_Tool/apps/web/app/components/agent/agent-drawer.tsx
T
rootandClaude Opus 5.5 31a54186c7 copy: second-round copy review across console, settings, agent and server messages
- Trim descriptions, empty states, confirm buttons and toasts to facts
- Unify terms per glossary: 待处理/待识别/整理记录/运行记录/凭证/子会话/Agent 上游
- Subscription-login termsNote states facts only (borrowed client identity, ToS/ban risk)
- Shorter, concrete goal templates for built-in Agent checks
- Human-readable nouns instead of table names in instance CRUD errors
- Audit labels for pi_upstream / schedule / automation_grant actions
- Glossary: add 上游、订阅登录、凭证、定时任务、巡检、运行记录、子会话、停止、持续授权

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 13:29:42 +08:00

246 lines
9.6 KiB
TypeScript

import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
import { useRef } from "react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
import { Button } from "~/components/ui/button.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 {
type AgentContext,
agentShortcutLabel,
contextFromPath,
contextPrefix,
minimizeAgentDrawer,
openAgentDrawer,
restoreAgentDrawer,
setAgentDrawerOpen,
setAgentDrawerRun,
splitContextGoal,
useAgentDrawer,
} from "./agent-store.ts";
import { quickGoalsFor } from "./quick-goals.ts";
import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
const CONTEXT_KEYS: [keyof AgentContext, string][] = [
["entityId", "作品"],
["releaseId", "资源"],
["downloadTaskId", "下载任务"],
["recordId", "整理记录"],
["siteId", "站点"],
["taskId", "任务"],
];
function ContextChips({ context }: { context: AgentContext }) {
const chips = CONTEXT_KEYS.filter(([k]) => context[k]);
if (!chips.length) return null;
return (
<ul aria-label="当前页面上下文" className="mt-1.5 flex flex-wrap gap-1">
{chips.map(([k, label]) => (
<li
key={k}
className="max-w-full truncate rounded-full border border-border/70 px-2 py-0.5 font-mono text-label text-muted-foreground"
title={String(context[k])}
>
{label} · {String(context[k]).slice(0, 12)}
</li>
))}
</ul>
);
}
/**
* Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀;
* 运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />;顶栏放 <AgentDrawerTrigger />。
* 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。
*/
export function AgentDrawer() {
const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const prefix = contextPrefix(context);
const { pathname } = useLocation();
const content = useRef<HTMLDivElement>(null);
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="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
Agent
</SheetTitle>
<SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭)
</span>
</SheetDescription>
{context ? <ContextChips context={context} /> : null}
</SheetHeader>
{runId ? (
<Button
variant="ghost"
size="icon-sm"
className="absolute top-4 right-14"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
onClick={minimizeAgentDrawer}
>
<Minus />
</Button>
) : null}
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunPanel runId={runId} compact className="flex-1" onRunCreated={setAgentDrawerRun} />
) : (
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
onSubmit={(goal, mode) =>
create.mutate(
{ goal: `${prefix}${goal}`, mode },
{
onSuccess: (run) => setAgentDrawerRun(run.id),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-[var(--shell-border)] px-5 py-3">
{runId ? (
<>
<Button variant="ghost" size="sm" onClick={() => openAgentDrawer(context)}>
<Plus />
新目标
</Button>
<Button asChild variant="outline" size="sm">
<Link to={`/console/agent/${runId}`} onClick={() => setAgentDrawerOpen(false)}>
<ExternalLink />
在会话页打开
</Link>
</Button>
</>
) : (
<Button asChild variant="ghost" size="sm">
<Link to="/console/agent" onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link>
</Button>
)}
</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="Agent 小窗"
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 ? splitContextGoal(run.data.run.goal).text : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开 Agent" 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="打开 Agent"
title={`Agent(${agentShortcutLabel()})`}
className={className}
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
>
<Bot />
</Button>
);
}