feat(web): prominent agent launcher (FAB, Ctrl/Cmd+J, sidebar entry, quick goals)
- Bottom-right FAB (56px, prism ring) with active-run count, pulse and latest status summary; turns into "collapse" beside the open drawer; hidden on the Agent page and while the mini dock shows; lifts above bottom floating bars - Ctrl/Cmd+J toggles the drawer, Esc closes; input is focused on open - Emphasized Agent entry at the top of the sidebar (expanded and collapsed) with active-run count; header button kept only below xl - Drawer first screen offers 3 page-aware quick goals that fill the input - Respects reduced motion; AgentShortcutsHelp exported for the settings page Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
16ab2a6c84
commit
39a001c099
7 files changed
+433
-8
No files matched your search
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<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" />
|
||||
@@ -71,6 +94,9 @@ export function AgentDrawer() {
|
||||
</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>
|
||||
@@ -93,6 +119,7 @@ export function AgentDrawer() {
|
||||
<GoalComposer
|
||||
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
|
||||
compact
|
||||
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
|
||||
prefix={prefix || undefined}
|
||||
defaultGoal={context?.goal ?? ""}
|
||||
pending={create.isPending}
|
||||
@@ -200,7 +227,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
|
||||
}
|
||||
|
||||
/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */
|
||||
export function AgentDrawerTrigger() {
|
||||
export function AgentDrawerTrigger({ className }: { className?: string }) {
|
||||
const { minimized, runId } = useAgentDrawer();
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
@@ -208,7 +235,8 @@ export function AgentDrawerTrigger() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="打开 Agent"
|
||||
title="Agent"
|
||||
title={`Agent(${agentShortcutLabel()})`}
|
||||
className={className}
|
||||
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
|
||||
>
|
||||
<Bot />
|
||||
|
||||
@@ -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<AgentMode>("execute");
|
||||
const box = useRef<HTMLDivElement>(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 (
|
||||
<div className="space-y-1.5">
|
||||
<div ref={box} className="space-y-1.5">
|
||||
<PromptInput
|
||||
value={goal}
|
||||
onValueChange={setGoal}
|
||||
@@ -116,6 +129,27 @@ export function GoalComposer({
|
||||
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
|
||||
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。
|
||||
</p>
|
||||
{suggestions?.length ? (
|
||||
<div className="pt-2">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 px-1 text-meta text-muted-foreground">
|
||||
<Sparkles aria-hidden className="size-3.5" />
|
||||
试试这些
|
||||
</p>
|
||||
<ul aria-label="快捷目标" className="flex flex-col gap-1.5">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fill(s)}
|
||||
className="w-full cursor-pointer rounded-xl border border-border/70 px-3 py-2 text-left text-caption text-foreground/90 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{s.endsWith(" ") || s.endsWith(":") ? `${s}…` : s}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
|
||||
): 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)\/([^/?#]+)/);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user