diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index fa0ea33..081a1e1 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark, Boxes, Settings } from "lucide-react"; import { TopBar } from "./shell/TopBar"; import { LeftNav, type ViewKey } from "./shell/LeftNav"; @@ -10,6 +11,7 @@ import { ReportView } from "./views/ReportView"; import { RunsView } from "./views/RunsView"; import { Home } from "./views/Home"; import { Placeholder } from "./views/Placeholder"; +import { CommandPalette, type Command } from "./components/CommandPalette"; import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api"; import type { TaskDsl } from "./lib/dsl"; import { emptyRun, type RunState } from "./lib/run"; @@ -28,10 +30,40 @@ export default function App() { const [view, setView] = useState("home"); const [identity, setIdentity] = useState({ userId: "wt", sessionId: "sess-ui" }); const [run, setRun] = useState(emptyRun); + const [cmdOpen, setCmdOpen] = useState(false); const closeRef = useRef<(() => void) | null>(null); const execCloseRef = useRef<(() => void) | null>(null); + // 全局 ⌘K / Ctrl+K 唤起命令面板(键盘优先工作站入口)。 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setCmdOpen((o) => !o); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const commands = useMemo(() => { + const go = (key: ViewKey) => () => setView(key); + return [ + { id: "home", label: "前往 · 工作台", icon: LayoutDashboard, group: "页面", keywords: "home dashboard 概览", run: go("home") }, + { id: "studio", label: "前往 · 编排", icon: Workflow, group: "页面", keywords: "studio agent graph 编排 图", run: go("studio") }, + { id: "kb", label: "前往 · 知识库", icon: Database, group: "页面", keywords: "kb rag 检索 图谱 入库", run: go("kb") }, + { id: "report", label: "前往 · 报告生成", icon: FileText, group: "页面", keywords: "report 报告 word docx", run: go("report") }, + { id: "runs", label: "前往 · 运行观测", icon: Activity, group: "页面", keywords: "runs trace 轨迹 观测", run: go("runs") }, + { id: "memory", label: "前往 · 记忆", icon: Bookmark, group: "页面", keywords: "memory 画像 偏好", run: go("memory") }, + { id: "market", label: "前往 · 市场 Packs", icon: Boxes, group: "页面", keywords: "market pack 垂直", run: go("market") }, + { id: "admin", label: "前往 · 管理", icon: Settings, group: "页面", keywords: "admin 租户 模型 设置", run: go("admin") }, + { id: "act-report", label: "生成报告", icon: FileText, group: "动作", keywords: "新建 report 撰写", run: go("report") }, + { id: "act-ingest", label: "入库知识", icon: Database, group: "动作", keywords: "上传 ingest 文件", run: go("kb") }, + { id: "act-studio", label: "新建 Agent 编排", icon: Workflow, group: "动作", keywords: "new flow", run: go("studio") }, + ]; + }, []); + const onRun = useCallback( async (dsl: TaskDsl) => { closeRef.current?.(); @@ -85,7 +117,7 @@ export default function App() { className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60" style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }} /> - + setCmdOpen(true)} />
@@ -107,6 +139,7 @@ export default function App() {
+ setCmdOpen(false)} commands={commands} /> ); diff --git a/sundynix-desktop/frontend/src/components/CommandPalette.tsx b/sundynix-desktop/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..b2a8022 --- /dev/null +++ b/sundynix-desktop/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Search, CornerDownLeft, type LucideIcon } from "lucide-react"; +import { cn } from "../ui"; + +export interface Command { + id: string; + label: string; + icon: LucideIcon; + group?: string; + keywords?: string; + run: () => void; +} + +function match(c: Command, q: string): boolean { + if (!q.trim()) return true; + const hay = (c.label + " " + (c.keywords ?? "") + " " + (c.group ?? "")).toLowerCase(); + return q + .toLowerCase() + .split(/\s+/) + .every((t) => hay.includes(t)); +} + +// CommandPalette 全局命令面板(⌘K):搜索 + 键盘上下选择 + Enter 执行 + Esc 关闭。 +// 纯前端、两种运行模式通用,是"键盘优先工作站"的入口。 +export function CommandPalette({ open, onClose, commands }: { open: boolean; onClose: () => void; commands: Command[] }) { + const [q, setQ] = useState(""); + const [sel, setSel] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const filtered = useMemo(() => commands.filter((c) => match(c, q)), [commands, q]); + + useEffect(() => { + if (open) { + setQ(""); + setSel(0); + const t = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(t); + } + }, [open]); + useEffect(() => setSel(0), [q]); + useEffect(() => { + listRef.current?.querySelector('[data-sel="1"]')?.scrollIntoView({ block: "nearest" }); + }, [sel]); + + if (!open) return null; + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSel((s) => Math.min(s + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSel((s) => Math.max(s - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const c = filtered[sel]; + if (c) { + c.run(); + onClose(); + } + } else if (e.key === "Escape") { + onClose(); + } + }; + + return ( +
+
e.stopPropagation()}> +
+ + setQ(e.target.value)} + onKeyDown={onKey} + placeholder="跳转 · 搜索 · 执行动作…" + className="h-11 w-full bg-transparent text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none" + /> + esc +
+
    + {filtered.length === 0 &&
  • 无匹配命令
  • } + {filtered.map((c, i) => { + const Icon = c.icon; + const on = i === sel; + return ( +
  • setSel(i)} + onClick={() => { + c.run(); + onClose(); + }} + className={cn("flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm", on ? "bg-brand/15 text-brand-400" : "text-slate-300 hover:bg-ink-800")} + > + + {c.label} + {c.group && {c.group}} + {on && } +
  • + ); + })} +
+
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index c45c914..5d1cc18 100644 --- a/sundynix-desktop/frontend/src/shell/TopBar.tsx +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { User, ChevronDown } from "lucide-react"; +import { User, ChevronDown, Search as SearchIcon } from "lucide-react"; import type { Identity } from "../lib/api"; import { useHealth } from "../lib/health"; import { isMacDesktop } from "../lib/desktop"; @@ -20,7 +20,7 @@ function Light({ on, label }: { on: boolean; label: string }) { } // 顶栏:品牌 · 垂直切换 · 健康灯 · 身份/会话(深色 + 毛玻璃)。 -export function TopBar({ identity, setIdentity }: { identity: Identity; setIdentity: (id: Identity) => void }) { +export function TopBar({ identity, setIdentity, onCommand }: { identity: Identity; setIdentity: (id: Identity) => void; onCommand?: () => void }) { const h = useHealth(); return (
+