feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
apps/web/components/command/CommandPalette.tsx
Normal file
152
apps/web/components/command/CommandPalette.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
filterCommands,
|
||||
globalCommands,
|
||||
moveHighlight,
|
||||
projectCommands,
|
||||
type Command,
|
||||
} from "@/lib/command/palette";
|
||||
|
||||
// 从 pathname 抽取当前 projectId(/projects/<id>/...)。
|
||||
function projectIdFromPath(pathname: string): string | null {
|
||||
const m = pathname.match(/^\/projects\/([^/]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||||
// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=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 projectId = projectIdFromPath(pathname);
|
||||
const commands = useMemo<Command[]>(
|
||||
() =>
|
||||
projectId
|
||||
? [...projectCommands(projectId), ...globalCommands()]
|
||||
: globalCommands(),
|
||||
[projectId],
|
||||
);
|
||||
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]);
|
||||
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
|
||||
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()}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
12
apps/web/components/command/CommandPaletteMount.tsx
Normal file
12
apps/web/components/command/CommandPaletteMount.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
|
||||
// 全局挂载命令面板(⌘K):读当前 pathname 注入项目上下文。
|
||||
// 放在 RootLayout 内,对所有页面生效。
|
||||
export function CommandPaletteMount() {
|
||||
const pathname = usePathname();
|
||||
return <CommandPalette pathname={pathname ?? "/"} />;
|
||||
}
|
||||
Reference in New Issue
Block a user