Files
writer-work-flow/apps/web/components/command/CommandPalette.tsx
Yaojia Wang f43ccd293f feat(toolbox): T6 创作工具箱通用生成器框架 — 8 新生成器 + 声明驱动落地页 + P2 收尾
通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
  glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
  GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
  POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
  + LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修

守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:37:55 +02:00

188 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
filterCommands,
globalCommands,
moveHighlight,
projectCommands,
type Command,
type ToolCommandInfo,
} from "@/lib/command/palette";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { api } from "@/lib/api/client";
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/);
return m?.[1] ?? null;
}
interface CommandPaletteProps {
pathname: string;
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=dialog/listbox
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
const toolsLoadedRef = useRef(false);
const projectId = projectIdFromPath(pathname);
const commands = useMemo<Command[]>(
() =>
projectId
? [...projectCommands(projectId, tools), ...globalCommands()]
: globalCommands(),
[projectId, tools],
);
const results = useMemo(
() => filterCommands(commands, query),
[commands, query],
);
const close = useCallback((): void => {
setOpen(false);
setQuery("");
setHighlight(0);
}, []);
const run = useCallback(
(cmd: Command | undefined): void => {
if (!cmd) return;
close();
if (cmd.href) router.push(cmd.href);
},
[close, router],
);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时聚焦输入框;切查询重置高亮。
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
useEffect(() => {
if (!open || !projectId || toolsLoadedRef.current) return;
toolsLoadedRef.current = true;
let cancelled = false;
void (async () => {
try {
const { data } = await api.GET("/skills/toolbox");
if (cancelled || !data?.tools) return;
setTools(
data.tools
.filter((t) => !t.is_legacy)
.map((t) => ({ key: t.key, title: t.title, genre: t.genre })),
);
} catch {
// 静默降级:命令面板照常工作,仅缺生成器动作。
}
})();
return () => {
cancelled = true;
};
}, [open, projectId]);
useEffect(() => {
setHighlight(0);
}, [query]);
if (!open) return null;
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, 1, results.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, -1, results.length));
} else if (e.key === "Enter") {
e.preventDefault();
run(results[highlight]);
}
};
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
onClick={close}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// focus trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}}
>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
aria-label="命令搜索"
aria-controls="command-list"
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
/>
<ul
id="command-list"
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1"
>
{results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li>
) : (
results.map((cmd, i) => (
<li
key={cmd.id}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
onClick={() => run(cmd)}
className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${
i === highlight
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink"
}`}
>
<span>{cmd.title}</span>
<span className="text-xs text-ink-soft">{cmd.group}</span>
</li>
))
)}
</ul>
</div>
</div>
);
}