diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index 880aa412..d6a8b33d 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -1,4 +1,5 @@ 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"; @@ -10,6 +11,7 @@ import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.t import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; import { type AgentContext, + agentShortcutLabel, contextFromPath, contextPrefix, minimizeAgentDrawer, @@ -20,8 +22,12 @@ import { 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", "资源"], @@ -59,11 +65,28 @@ export function AgentDrawer() { const { open, minimized, context, runId } = useAgentDrawer(); const create = useCreateAgentRun(); const prefix = contextPrefix(context); + const { pathname } = useLocation(); + const content = useRef(null); return ( <> - + { + const ta = content.current?.querySelector("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(); + }} + > @@ -71,6 +94,9 @@ export function AgentDrawer() { {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"} + + ({agentShortcutLabel()} 开关,Esc 关闭) + {context ? : null} @@ -93,6 +119,7 @@ export function AgentDrawer() { (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))} > diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index f6c8acce..af08a187 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { EventType } from "@umt/contracts"; -import { FlaskConical, Play, RefreshCw, WifiOff } from "lucide-react"; -import { useState } from "react"; +import { FlaskConical, Play, RefreshCw, Sparkles, WifiOff } from "lucide-react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { type AgentMode, @@ -78,6 +78,7 @@ export function GoalComposer({ prefix, defaultGoal = "", compact = false, + suggestions, }: { onSubmit: (goal: string, mode: AgentMode) => void; pending: boolean; @@ -85,11 +86,23 @@ export function GoalComposer({ prefix?: string; defaultGoal?: string; compact?: boolean; + /** 快捷目标:点一下填入输入框(不直接提交),光标停在末尾 */ + suggestions?: string[]; }) { const [goal, setGoal] = useState(defaultGoal); const [mode, setMode] = useState("execute"); + const box = useRef(null); + const fill = (text: string) => { + setGoal(text); + requestAnimationFrame(() => { + const ta = box.current?.querySelector("textarea"); + if (!ta) return; + ta.focus(); + ta.setSelectionRange(text.length, text.length); + }); + }; return ( -
+
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。

+ {suggestions?.length ? ( +
+

+ + 试试这些 +

+
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+
+ ) : null}
); } diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index e66f8635..7a10eb2b 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -46,6 +46,29 @@ export function restoreAgentDrawer() { emit(); } +/** 快捷键文案:Mac 显示 ⌘J,其余 Ctrl+J */ +export function agentShortcutLabel(): string { + const mac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + return mac ? "⌘J" : "Ctrl+J"; +} + +/** Ctrl/⌘+J(不带 Shift/Alt) */ +export function isAgentShortcut( + e: Pick, +): boolean { + return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "j"; +} + +/** + * 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换: + * 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开新目标。 + */ +export function toggleAgentDrawer(pathname: string) { + if (state.open) setAgentDrawerOpen(false); + else if (state.minimized && state.runId) restoreAgentDrawer(); + else openAgentDrawer(contextFromPath(pathname)); +} + /** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */ export function contextFromPath(pathname: string): AgentContext | null { const m = pathname.match(/^\/console\/(library|sites|tasks|organize|engine)\/([^/?#]+)/); diff --git a/apps/web/app/components/agent/quick-goals.test.ts b/apps/web/app/components/agent/quick-goals.test.ts new file mode 100644 index 00000000..9673cb11 --- /dev/null +++ b/apps/web/app/components/agent/quick-goals.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { quickGoalsFor } from "./quick-goals.ts"; + +describe("抽屉快捷目标", () => { + it("按页面给 3 个相关目标", () => { + expect(quickGoalsFor("/console/library/abc")[0]).toBe("检查这部作品缺什么版本"); + expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站找 "); + expect(quickGoalsFor("/console/tasks/t1")[0]).toBe("看看为什么没进展"); + for (const p of ["/console", "/console/library", "/console/files", "/nope"]) { + expect(quickGoalsFor(p)).toHaveLength(3); + } + }); +}); diff --git a/apps/web/app/components/agent/quick-goals.ts b/apps/web/app/components/agent/quick-goals.ts new file mode 100644 index 00000000..8e5d180e --- /dev/null +++ b/apps/web/app/components/agent/quick-goals.ts @@ -0,0 +1,69 @@ +// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。 +// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站找 ")。 + +type Rule = { match: RegExp; goals: [string, string, string] }; + +const RULES: Rule[] = [ + { + match: /^\/console\/library\/[^/]+/, + goals: ["检查这部作品缺什么版本", "这部作品有没有更好的片源可以升级", "核对这部作品的文件和元数据是否一致"], + }, + { + match: /^\/console\/library\/?$/, + goals: ["盘点媒体库里还缺 4K 版本的作品", "找出最近入库但没识别成功的作品", "列出占用空间最大的 10 部作品"], + }, + { + match: /^\/console\/sites\/[^/]+/, + goals: ["从这个站找 ", "看看这个站最近有哪些新的 4K 资源", "检查这个站的登录和抓取是否正常"], + }, + { + match: /^\/console\/sites\/?$/, + goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"], + }, + { + match: /^\/console\/tasks\/[^/]+/, + goals: ["看看为什么没进展", "下载完成后帮我整理入库", "换一个做种更多的资源重新下载"], + }, + { + match: /^\/console\/tasks\/?$/, + goals: ["看看哪些下载卡住了、为什么没进展", "汇总今天的下载和整理结果", "找出失败的任务并说明原因"], + }, + { + match: /^\/console\/organize/, + goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"], + }, + { + match: /^\/console\/files/, + goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"], + }, + { + match: /^\/console\/inbox/, + goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项", "哪些事项可以直接按建议批量处理"], + }, + { + match: /^\/console\/identity/, + goals: ["帮我识别待识别池里的文件", "找出外部身份映射可能错误的作品", "补全缺少元数据的作品"], + }, + { + match: /^\/console\/subscriptions/, + goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"], + }, + { + match: /^\/console\/sources/, + goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"], + }, + { + match: /^\/console\/engine/, + goals: ["这个任务为什么失败", "哪些工作流一直在重试", "汇总最近一小时失败的运行"], + }, +]; + +const FALLBACK: [string, string, string] = [ + "收一部电影:", + "看看今天有什么需要我处理的", + "检查下载器和媒体服务器是否正常", +]; + +export function quickGoalsFor(pathname: string): string[] { + return RULES.find((r) => r.match.test(pathname))?.goals ?? FALLBACK; +} diff --git a/apps/web/app/components/console/agent-launcher.tsx b/apps/web/app/components/console/agent-launcher.tsx new file mode 100644 index 00000000..ad438085 --- /dev/null +++ b/apps/web/app/components/console/agent-launcher.tsx @@ -0,0 +1,252 @@ +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(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 ( + + {count > 99 ? "99+" : count} + + ); +} + +/** 进行中的脉冲点:减少动态效果时只留静态点 */ +function PulseDot({ className }: { className?: string }) { + return ( + + + + + ); +} + +/** + * 右下角悬浮按钮 + 全局快捷键。挂在 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(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( + , + 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 ( + + ); + } + return ( + + ); +} + +/** 快捷键说明(设置页"快捷键"一节用) */ +export function AgentShortcutsHelp() { + const shortcut = agentShortcutLabel(); + const rows: [string, string][] = [ + [shortcut, "任意页面打开 / 收起 Agent 抽屉(带当前页面上下文)"], + ["Esc", "关闭 Agent 抽屉"], + ["Enter", "发送目标;Shift+Enter 换行"], + ]; + return ( +
+

+ + 快捷键 +

+
+ {rows.map(([k, v]) => ( +
+
+ + {k} + +
+
{v}
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx index 6ab14dce..6d549ed7 100644 --- a/apps/web/app/routes/console/layout.tsx +++ b/apps/web/app/routes/console/layout.tsx @@ -5,6 +5,7 @@ import { Link, NavLink, Outlet, useLocation } from "react-router"; import { useSession } from "~/api/auth.ts"; import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx"; import { AccountMenu } from "~/components/console/account-menu.tsx"; +import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx"; import { ErrorState } from "~/components/console/empty-state.tsx"; import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts"; import PrismLeak from "~/components/prism-leak.tsx"; @@ -54,6 +55,7 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: style={{ transform: `translate(${pill.x}px, ${pill.y}px)`, width: pill.w, height: pill.h }} /> ) : null} + {groups.map((group) => (

@@ -94,6 +96,7 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: function CollapsedNav() { return (

- + {/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */} +
-
+ {/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */} +
+
);