Files
UHD_Media_Tool/apps/web/app/components/console/agent-launcher.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

253 lines
10 KiB
TypeScript

import { Bot, ChevronsRight, Keyboard } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useLocation } from "react-router";
import { isRunActive, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts";
import { AGENT_LAUNCHER_ATTR } from "~/components/agent/agent-drawer.tsx";
import {
agentShortcutLabel,
isAgentShortcut,
splitContextGoal,
toggleAgentDrawer,
useAgentDrawer,
} from "~/components/agent/agent-store.ts";
import { cn } from "~/lib/utils.ts";
// Agent 的三处醒目入口:右下角悬浮按钮(FAB)、侧栏最上方主入口、全局快捷键 Ctrl/⌘+J。
// 三者都走 toggleAgentDrawer,行为一致:打开中 → 收起;有最小化的运行 → 恢复;否则带当前页面上下文打开。
/** 进行中的会话数与最近一条的状态摘要(列表里有进行中的会话时 5 秒轮询一次) */
export function useActiveAgentRuns() {
const runs = useAgentRuns();
return useMemo(() => {
const active = (runs.data?.pages ?? []).flatMap((p) => p.items).filter((r) => isRunActive(r.status));
const latest = active[0];
return {
count: active.length,
summary: latest ? `${RUN_STATUS_LABEL[latest.status]} · ${splitContextGoal(latest.goal).text}` : null,
};
}, [runs.data]);
}
/** 全局快捷键:Ctrl/⌘+J 切换抽屉(Esc 关闭由抽屉自身处理) */
function useAgentHotkey() {
const { pathname } = useLocation();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.repeat || !isAgentShortcut(e)) return;
e.preventDefault();
toggleAgentDrawer(pathname);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [pathname]);
}
/** 页面里的底部浮动操作栏(文件页批量操作等):FAB 与它横向重叠时抬到它上面 */
const FLOATING_BARS = '[data-floating-bar], [role="toolbar"][aria-label="批量操作"]';
const BASE_BOTTOM = 16;
function useFloatingBarLift(): number {
const [lift, setLift] = useState(0);
useEffect(() => {
let frame = 0;
const measure = () => {
frame = 0;
let next = 0;
for (const bar of document.querySelectorAll<HTMLElement>(FLOATING_BARS)) {
const r = bar.getBoundingClientRect();
// 只看贴底、且伸到屏幕右侧 FAB 所在区域(右 30%)的栏
if (r.height === 0 || r.bottom < window.innerHeight - 160 || r.right < window.innerWidth * 0.7) continue;
next = Math.max(next, window.innerHeight - r.top + 12 - BASE_BOTTOM);
}
setLift((prev) => (prev === next ? prev : next));
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
const mo = new MutationObserver(schedule);
mo.observe(document.body, { childList: true, subtree: true });
window.addEventListener("resize", schedule);
measure();
return () => {
mo.disconnect();
window.removeEventListener("resize", schedule);
if (frame) cancelAnimationFrame(frame);
};
}, []);
return lift;
}
function CountBadge({ count, className }: { count: number; className?: string }) {
if (!count) return null;
return (
<span
className={cn(
"grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1 text-label font-semibold text-primary-foreground tabular-nums",
className,
)}
>
{count > 99 ? "99+" : count}
</span>
);
}
/** 进行中的脉冲点:减少动态效果时只留静态点 */
function PulseDot({ className }: { className?: string }) {
return (
<span aria-hidden className={cn("relative flex size-2.5", className)}>
<span className="absolute inline-flex size-full rounded-full bg-primary/60 motion-safe:animate-ping" />
<span className="relative inline-flex size-2.5 rounded-full bg-primary" />
</span>
);
}
/**
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
* - 抽屉打开时变成"收起",停在抽屉左侧(窄屏抽屉占满全宽,用抽屉自带的关闭钮,FAB 隐藏)。
*/
export function AgentLauncher() {
useAgentHotkey();
const { pathname } = useLocation();
const { open, minimized, runId } = useAgentDrawer();
const { count, summary } = useActiveAgentRuns();
const lift = useFloatingBarLift();
const shortcut = agentShortcutLabel();
// 挂到 body:外壳是 isolate 的层叠上下文,留在里面的话抽屉打开后"收起"会被遮罩盖住
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => setHost(document.body), []);
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
const label = open ? "收起 Agent" : count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent";
return createPortal(
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={() => toggleAgentDrawer(pathname)}
aria-label={label}
aria-keyshortcuts="Control+J Meta+J"
aria-expanded={open}
title={`${open ? "收起" : "打开"} Agent(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
style={{ bottom: `calc(${BASE_BOTTOM + lift}px + env(safe-area-inset-bottom))` }}
className={cn(
"pg-float prism-ring group pointer-events-auto fixed right-4 z-40 flex h-14 min-w-14 cursor-pointer items-center justify-center gap-3 rounded-full text-foreground outline-none select-none",
"transition-[transform,bottom,right] duration-layout ease-spring active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
"md:px-2 md:pr-5",
open && "z-[60] hidden md:right-[calc(36rem+1rem)] md:flex",
)}
>
<span className="relative grid size-10 shrink-0 place-items-center rounded-full pill-selected">
{open ? <ChevronsRight className="size-5" /> : <Bot className="size-5" />}
{count && !open ? <CountBadge count={count} className="absolute -top-1.5 -right-1.5 md:hidden" /> : null}
</span>
<span className="hidden min-w-0 flex-col items-start text-left md:flex">
<span className="flex items-center gap-2 text-control leading-5 font-semibold">
{open ? "收起" : "Agent"}
{count && !open ? <PulseDot /> : null}
{count && !open ? <CountBadge count={count} /> : null}
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium text-muted-foreground">
{shortcut}
</kbd>
</span>
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
{open ? "Esc 关闭" : (summary ?? "交代目标,边做边汇报")}
</span>
</span>
</button>,
host,
);
}
/** 侧栏最上方的 Agent 主入口:强调样式(棱镜环),显示进行中会话数 */
export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
const { pathname } = useLocation();
const { count, summary } = useActiveAgentRuns();
const shortcut = agentShortcutLabel();
const activate = () => {
onNavigate?.();
toggleAgentDrawer(pathname);
};
const title = `Agent(${shortcut})${summary ? `\n${summary}` : ""}`;
if (collapsed) {
return (
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={activate}
title={title}
aria-label={count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent"}
aria-keyshortcuts="Control+J Meta+J"
className="prism-ring relative mb-2 grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-xl text-foreground outline-none transition-transform duration-micro active:scale-[0.95] focus-visible:ring-2 focus-visible:ring-ring"
>
<Bot className="h-[18px] w-[18px]" />
{count ? <CountBadge count={count} className="absolute -top-1.5 -right-1.5" /> : null}
</button>
);
}
return (
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={activate}
title={title}
aria-keyshortcuts="Control+J Meta+J"
className="prism-ring flex w-full cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2 text-left text-foreground outline-none transition-transform duration-micro active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg pill-selected">
<Bot className="h-4 w-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 text-sm font-semibold">
Agent
{count ? <PulseDot /> : null}
</span>
<span className="truncate text-meta text-muted-foreground">
{count ? `${count} 个会话进行中` : "交代目标,边做边汇报"}
</span>
</span>
{count ? (
<CountBadge count={count} />
) : (
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 text-muted-foreground">
{shortcut}
</kbd>
)}
</button>
);
}
/** 快捷键说明(设置页"快捷键"一节用) */
export function AgentShortcutsHelp() {
const shortcut = agentShortcutLabel();
const rows: [string, string][] = [
[shortcut, "打开 / 收起 Agent,附带当前页面"],
["Esc", "关闭 Agent"],
["Enter", "发送目标;Shift+Enter 换行"],
];
return (
<section aria-labelledby="agent-shortcuts-title" className="space-y-2">
<h3 id="agent-shortcuts-title" className="flex items-center gap-2 text-sm font-semibold">
<Keyboard aria-hidden className="size-4 text-muted-foreground" />
快捷键
</h3>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 text-caption">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt>
<kbd className="rounded-tight border border-border/70 px-1.5 py-0.5 font-sans text-label text-foreground">
{k}
</kbd>
</dt>
<dd className="text-muted-foreground">{v}</dd>
</div>
))}
</dl>
</section>
);
}