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:
75
apps/web/lib/command/palette.test.ts
Normal file
75
apps/web/lib/command/palette.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
clampHighlight,
|
||||
filterCommands,
|
||||
globalCommands,
|
||||
moveHighlight,
|
||||
projectCommands,
|
||||
} from "./palette";
|
||||
|
||||
describe("projectCommands", () => {
|
||||
it("includes nav + action commands wired to the project id", () => {
|
||||
const cmds = projectCommands("p1");
|
||||
const write = cmds.find((c) => c.id === "nav-write");
|
||||
expect(write?.href).toBe("/projects/p1/write");
|
||||
const genChar = cmds.find((c) => c.id === "action-gen-character");
|
||||
expect(genChar?.kind).toBe("action");
|
||||
expect(genChar?.href).toBe("/projects/p1/codex?gen=character");
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalCommands", () => {
|
||||
it("offers home + settings", () => {
|
||||
expect(globalCommands().map((c) => c.id)).toEqual([
|
||||
"nav-home",
|
||||
"nav-settings",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterCommands", () => {
|
||||
const cmds = projectCommands("p1");
|
||||
|
||||
it("returns all on empty query", () => {
|
||||
expect(filterCommands(cmds, " ")).toHaveLength(cmds.length);
|
||||
});
|
||||
|
||||
it("matches on title", () => {
|
||||
const out = filterCommands(cmds, "审稿");
|
||||
expect(out.map((c) => c.id)).toContain("nav-review");
|
||||
});
|
||||
|
||||
it("matches on keyword case-insensitively", () => {
|
||||
const out = filterCommands(cmds, "FORESHADOW");
|
||||
expect(out.map((c) => c.id)).toContain("nav-foreshadow");
|
||||
});
|
||||
|
||||
it("matches generation actions by 角色/世界观", () => {
|
||||
expect(filterCommands(cmds, "角色").map((c) => c.id)).toContain(
|
||||
"action-gen-character",
|
||||
);
|
||||
expect(filterCommands(cmds, "世界观").map((c) => c.id)).toContain(
|
||||
"action-gen-world",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty when nothing matches", () => {
|
||||
expect(filterCommands(cmds, "zzzz-no-match")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampHighlight / moveHighlight", () => {
|
||||
it("wraps within [0, len)", () => {
|
||||
expect(clampHighlight(0, 3)).toBe(0);
|
||||
expect(clampHighlight(3, 3)).toBe(0);
|
||||
expect(clampHighlight(-1, 3)).toBe(2);
|
||||
expect(clampHighlight(5, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("moves up/down with wrap", () => {
|
||||
expect(moveHighlight(0, 1, 3)).toBe(1);
|
||||
expect(moveHighlight(2, 1, 3)).toBe(0);
|
||||
expect(moveHighlight(0, -1, 3)).toBe(2);
|
||||
});
|
||||
});
|
||||
154
apps/web/lib/command/palette.ts
Normal file
154
apps/web/lib/command/palette.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// 命令面板(⌘K)纯逻辑:命令清单组装 + 模糊过滤 + 键盘选择状态机。
|
||||
// 对齐 UX §7(快速导航/动作)。纯逻辑,便于 node 环境单测(不依赖 DOM)。
|
||||
|
||||
export type CommandKind = "navigate" | "action";
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
// 展示标题(如「写本章」「跳转伏笔」)。
|
||||
title: string;
|
||||
// 用于匹配的关键词(拼音/别名),与 title 一起参与过滤。
|
||||
keywords: string[];
|
||||
kind: CommandKind;
|
||||
// navigate:跳转路径;action:交由调用方按 id 分派。
|
||||
href?: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// 项目内可用命令(projectId 已知)。导航项给 href;动作项(生成角色等)给 id。
|
||||
export function projectCommands(projectId: string): Command[] {
|
||||
const p = `/projects/${projectId}`;
|
||||
return [
|
||||
{
|
||||
id: "nav-write",
|
||||
title: "写本章",
|
||||
keywords: ["write", "写作", "工作台", "起草"],
|
||||
kind: "navigate",
|
||||
href: `${p}/write`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-review",
|
||||
title: "审稿",
|
||||
keywords: ["review", "质检", "冲突", "裁决"],
|
||||
kind: "navigate",
|
||||
href: `${p}/review`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-outline",
|
||||
title: "大纲",
|
||||
keywords: ["outline", "节拍", "beats"],
|
||||
kind: "navigate",
|
||||
href: `${p}/outline`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-foreshadow",
|
||||
title: "跳转伏笔",
|
||||
keywords: ["foreshadow", "伏笔", "看板", "线索"],
|
||||
kind: "navigate",
|
||||
href: `${p}/foreshadow`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-style",
|
||||
title: "文风",
|
||||
keywords: ["style", "文风", "指纹", "漂移"],
|
||||
kind: "navigate",
|
||||
href: `${p}/style`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-codex",
|
||||
title: "搜设定",
|
||||
keywords: ["codex", "设定库", "人物", "世界观", "角色"],
|
||||
kind: "navigate",
|
||||
href: `${p}/codex`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-rules",
|
||||
title: "规则",
|
||||
keywords: ["rules", "规则", "硬规则"],
|
||||
kind: "navigate",
|
||||
href: `${p}/rules`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-skills",
|
||||
title: "技能库",
|
||||
keywords: ["skills", "技能", "注册表"],
|
||||
kind: "navigate",
|
||||
href: `${p}/skills`,
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "action-gen-character",
|
||||
title: "生成角色",
|
||||
keywords: ["character", "角色", "生成器", "群像"],
|
||||
kind: "action",
|
||||
href: `${p}/codex?gen=character`,
|
||||
group: "动作",
|
||||
},
|
||||
{
|
||||
id: "action-gen-world",
|
||||
title: "生成世界观",
|
||||
keywords: ["world", "世界观", "设定", "设计器"],
|
||||
kind: "action",
|
||||
href: `${p}/codex?gen=world`,
|
||||
group: "动作",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 全局命令(无 projectId 时;如作品库/设置)。
|
||||
export function globalCommands(): Command[] {
|
||||
return [
|
||||
{
|
||||
id: "nav-home",
|
||||
title: "作品库",
|
||||
keywords: ["home", "作品", "library", "projects"],
|
||||
kind: "navigate",
|
||||
href: "/",
|
||||
group: "导航",
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
title: "设置",
|
||||
keywords: ["settings", "设置", "凭据", "provider", "档位"],
|
||||
kind: "navigate",
|
||||
href: "/settings/providers",
|
||||
group: "导航",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 模糊过滤:空查询返回全部;否则在 title + keywords 上做大小写无关子串匹配(保持原顺序)。
|
||||
export function filterCommands(
|
||||
commands: readonly Command[],
|
||||
query: string,
|
||||
): Command[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [...commands];
|
||||
return commands.filter((cmd) => {
|
||||
if (cmd.title.toLowerCase().includes(q)) return true;
|
||||
return cmd.keywords.some((kw) => kw.toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
|
||||
// 键盘高亮索引在 [0, len) 内循环(空列表归 0)。
|
||||
export function clampHighlight(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
const wrapped = index % length;
|
||||
return wrapped < 0 ? wrapped + length : wrapped;
|
||||
}
|
||||
|
||||
// 上/下方向移动高亮(循环)。
|
||||
export function moveHighlight(
|
||||
index: number,
|
||||
delta: number,
|
||||
length: number,
|
||||
): number {
|
||||
return clampHighlight(index + delta, length);
|
||||
}
|
||||
Reference in New Issue
Block a user