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:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchDraft, fetchOutline } from "./server";
// 纯加载/降级逻辑mock fetch无网络/DOM大纲解包/降级、草稿 404→null。
function mockFetch(status: number, body: unknown): void {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("fetchOutline", () => {
it("returns the persisted chapters on 200", async () => {
mockFetch(200, { chapters: [{ no: 1, volume: 1, beats: ["开场"] }] });
const chapters = await fetchOutline("p1");
expect(chapters).toHaveLength(1);
expect(chapters[0].no).toBe(1);
});
it("returns empty array when the project has no outline", async () => {
mockFetch(200, { chapters: [] });
expect(await fetchOutline("p1")).toEqual([]);
});
it("falls back to empty array on any error (does not throw)", async () => {
mockFetch(404, { error: { code: "NOT_FOUND", message: "no project" } });
expect(await fetchOutline("missing")).toEqual([]);
});
});
describe("fetchDraft", () => {
it("returns the saved draft view on 200", async () => {
mockFetch(200, {
project_id: "p1",
chapter_no: 1,
volume: 1,
status: "draft",
version: 2,
content: "第一章正文",
length: 5,
});
const draft = await fetchDraft("p1", 1);
expect(draft?.content).toBe("第一章正文");
expect(draft?.version).toBe(2);
});
it("returns null when no draft exists (404 → empty editor)", async () => {
mockFetch(404, { error: { code: "NOT_FOUND", message: "no draft" } });
expect(await fetchDraft("p1", 9)).toBeNull();
});
});

View File

@@ -1,10 +1,19 @@
import { serverApiBase } from "./config";
import type {
CharacterListResponse,
DraftView,
ForeshadowBoardResponse,
OutlineChapterView,
OutlineResponse,
ProjectListResponse,
ProjectResponse,
OAuthStatusResponse,
ProvidersResponse,
ReviewHistoryResponse,
RuleListResponse,
SkillListResponse,
StyleFingerprintResponse,
WorldEntityListResponse,
} from "./types";
// 服务端只读取数据Server Components。失败时抛出由页面边界处理。
@@ -16,6 +25,16 @@ async function getJson<T>(path: string): Promise<T> {
return (await res.json()) as T;
}
// 像上面一样读取,但 404无指纹返回 null 而非抛出(首学前页面照常渲染)。
async function getJsonOrNull<T>(path: string): Promise<T | null> {
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`请求失败 ${res.status}: ${path}`);
}
return (await res.json()) as T;
}
export async function fetchProjects(): Promise<ProjectListResponse> {
return getJson<ProjectListResponse>("/projects");
}
@@ -28,6 +47,13 @@ export async function fetchProviders(): Promise<ProvidersResponse> {
return getJson<ProvidersResponse>("/settings/providers");
}
// Kimi Code OAuth 连接态GET .../oauth/status无 token 本体)。
export async function fetchKimiOauthStatus(): Promise<OAuthStatusResponse> {
return getJson<OAuthStatusResponse>(
"/settings/providers/kimi-code/oauth/status",
);
}
export async function fetchReviews(
projectId: string,
chapterNo: number,
@@ -43,3 +69,66 @@ export async function fetchForeshadow(
): Promise<ForeshadowBoardResponse> {
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
}
// 最新文风指纹GET /style无指纹404→ null首学前页面照常渲染
export async function fetchStyleFingerprint(
projectId: string,
): Promise<StyleFingerprintResponse | null> {
return getJsonOrNull<StyleFingerprintResponse>(
`/projects/${projectId}/style`,
);
}
// 规则列表GET .../rules规则页
export async function fetchRules(
projectId: string,
): Promise<RuleListResponse> {
return getJson<RuleListResponse>(`/projects/${projectId}/rules`);
}
// 技能库注册表GET /skills全局只读
export async function fetchSkills(): Promise<SkillListResponse> {
return getJson<SkillListResponse>("/skills");
}
// 设定库 Codex跨会话全量已入库角色GET .../characters无行→空列表非 404
export async function fetchCharacters(
projectId: string,
): Promise<CharacterListResponse> {
return getJson<CharacterListResponse>(`/projects/${projectId}/characters`);
}
// 设定库 Codex跨会话全量已入库世界观实体GET .../world_entities无行→空列表
export async function fetchWorldEntities(
projectId: string,
): Promise<WorldEntityListResponse> {
return getJson<WorldEntityListResponse>(
`/projects/${projectId}/world_entities`,
);
}
// 已存大纲GET .../outline与 POST 同形,按 chapter_no 排序)。
// 无大纲→空列表;任何错误亦降级为空(进页不阻塞,生成流照常工作)。
export async function fetchOutline(
projectId: string,
): Promise<OutlineChapterView[]> {
try {
const res = await getJson<OutlineResponse>(
`/projects/${projectId}/outline`,
);
return res.chapters ?? [];
} catch {
return [];
}
}
// 已存草稿GET .../draft含正文供工作台重访重载编辑器
// 404尚无草稿新章→ null由调用方当空编辑器处理不抛错。
export async function fetchDraft(
projectId: string,
chapterNo: number,
): Promise<DraftView | null> {
return getJsonOrNull<DraftView>(
`/projects/${projectId}/chapters/${chapterNo}/draft`,
);
}

View File

@@ -6,6 +6,8 @@ export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"]
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
export type DraftResponse = components["schemas"]["DraftResponse"];
// GET .../draft 读端点含正文供工作台重访时重载编辑器404→空编辑器
export type DraftView = components["schemas"]["DraftView"];
export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
export type ProviderView = components["schemas"]["ProviderView"];
export type TierRoutingView = components["schemas"]["TierRoutingView"];
@@ -40,3 +42,48 @@ export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
export type OutlineGenerateRequest =
components["schemas"]["OutlineGenerateRequest"];
export type OutlineResponse = components["schemas"]["OutlineResponse"];
// M4 文风学文风jobs 异步)+ 最新指纹 + 回炉C3 扩 T4.3)。
export type StyleLearnRequest = components["schemas"]["StyleLearnRequest"];
export type StyleLearnResponse = components["schemas"]["StyleLearnResponse"];
export type StyleFingerprintResponse =
components["schemas"]["StyleFingerprintResponse"];
export type RefineRequest = components["schemas"]["RefineRequest"];
export type RefineResponse = components["schemas"]["RefineResponse"];
// M5 生成 + 设定库C3 扩 T5.2)。
export type WorldGenerateRequest =
components["schemas"]["WorldGenerateRequest"];
export type WorldGenPreviewResponse =
components["schemas"]["WorldGenPreviewResponse"];
export type WorldEntityCardView =
components["schemas"]["WorldEntityCardView"];
export type CharacterGenerateRequest =
components["schemas"]["CharacterGenerateRequest"];
export type CharacterGenPreviewResponse =
components["schemas"]["CharacterGenPreviewResponse"];
export type CharacterCardView = components["schemas"]["CharacterCardView"];
export type CharacterRelationView =
components["schemas"]["CharacterRelationView"];
export type CharacterIngestRequest =
components["schemas"]["CharacterIngestRequest"];
export type CharacterIngestResponse =
components["schemas"]["CharacterIngestResponse"];
// 设定库 Codex 读端点C3 扩 M5 R1 follow-up跨会话全量已入库角色/世界观。
export type CharacterListResponse =
components["schemas"]["CharacterListResponse"];
export type WorldEntityListResponse =
components["schemas"]["WorldEntityListResponse"];
// K1 Kimi Code OAuth device-flowC3 扩 K1.3)。
export type OAuthStartResponse = components["schemas"]["OAuthStartResponse"];
export type OAuthDisconnectResponse =
components["schemas"]["OAuthDisconnectResponse"];
export type OAuthStatusResponse = components["schemas"]["OAuthStatusResponse"];
// M5 规则 + 技能C3 扩 T5.2 / T5.5)。
export type RuleCreateRequest = components["schemas"]["RuleCreateRequest"];
export type RuleView = components["schemas"]["RuleView"];
export type RuleListResponse = components["schemas"]["RuleListResponse"];
export type SkillView = components["schemas"]["SkillView"];
export type SkillListResponse = components["schemas"]["SkillListResponse"];

View 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);
});
});

View 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);
}

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import type {
CharacterCardView,
WorldEntityCardView,
} from "@/lib/api/types";
import {
buildCharacterGenerateRequest,
buildCharacterIngestRequest,
buildWorldGenerateRequest,
clampCharacterCount,
errorCode,
extractIngestConflicts,
generationErrorMessage,
MAX_CHARACTER_COUNT,
mergeCharacterCards,
mergeWorldEntities,
MIN_CHARACTER_COUNT,
updateCard,
worldEntityRules,
} from "./cards";
const card = (over: Partial<CharacterCardView> = {}): CharacterCardView => ({
name: "甲",
role: "主角",
backstory: "出身寒门",
arc: "由怯转勇",
...over,
});
describe("buildWorldGenerateRequest", () => {
it("trims brief", () => {
expect(buildWorldGenerateRequest(" 修真世界 ")).toEqual({
brief: "修真世界",
});
});
});
describe("clampCharacterCount", () => {
it("clamps to [MIN, MAX] and rounds", () => {
expect(clampCharacterCount(0)).toBe(MIN_CHARACTER_COUNT);
expect(clampCharacterCount(99)).toBe(MAX_CHARACTER_COUNT);
expect(clampCharacterCount(3.6)).toBe(4);
expect(clampCharacterCount(Number.NaN)).toBe(MIN_CHARACTER_COUNT);
});
});
describe("buildCharacterGenerateRequest", () => {
it("trims brief, clamps count, omits empty role", () => {
expect(buildCharacterGenerateRequest(" 剑修 ", 99)).toEqual({
brief: "剑修",
count: MAX_CHARACTER_COUNT,
});
});
it("includes trimmed role when present", () => {
expect(buildCharacterGenerateRequest("剑修", 2, " 对手 ")).toEqual({
brief: "剑修",
count: 2,
role: "对手",
});
});
});
describe("buildCharacterIngestRequest", () => {
it("copies cards and carries acknowledge flag", () => {
const cards = [card()];
const req = buildCharacterIngestRequest(cards, true);
expect(req.acknowledge_conflicts).toBe(true);
expect(req.cards).toEqual(cards);
expect(req.cards[0]).not.toBe(cards[0]); // immutable copy
});
it("defaults acknowledge to false", () => {
expect(buildCharacterIngestRequest([card()]).acknowledge_conflicts).toBe(
false,
);
});
});
describe("updateCard", () => {
it("returns a new card with patch applied (no mutation)", () => {
const original = card();
const next = updateCard(original, { name: "乙" });
expect(next.name).toBe("乙");
expect(original.name).toBe("甲");
expect(next).not.toBe(original);
});
});
describe("extractIngestConflicts", () => {
it("narrows CONFLICT_UNRESOLVED envelope details", () => {
const out = extractIngestConflicts({
error: {
code: "CONFLICT_UNRESOLVED",
details: {
conflicts: [
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
"junk",
],
conflict_count: 2,
},
},
});
expect(out).toEqual({
conflictCount: 2,
conflicts: [
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
{ type: "未分类", where: "", refs: [], suggestion: "" },
],
});
});
it("returns null for non-conflict errors", () => {
expect(extractIngestConflicts({ error: { code: "LLM_UNAVAILABLE" } })).toBeNull();
expect(extractIngestConflicts(null)).toBeNull();
});
it("falls back conflict_count to conflicts length", () => {
const out = extractIngestConflicts({
error: {
code: "CONFLICT_UNRESOLVED",
details: { conflicts: [{ type: "设定违例" }] },
},
});
expect(out?.conflictCount).toBe(1);
});
});
describe("errorCode / generationErrorMessage", () => {
it("extracts code and maps 503 to settings prompt", () => {
expect(errorCode({ error: { code: "LLM_UNAVAILABLE" } })).toBe(
"LLM_UNAVAILABLE",
);
expect(generationErrorMessage({ error: { code: "LLM_UNAVAILABLE" } })).toContain(
"设置",
);
expect(generationErrorMessage({ error: { code: "INTERNAL" } })).toContain(
"重试",
);
});
});
describe("worldEntityRules", () => {
it("returns rules or empty array", () => {
expect(worldEntityRules({ type: "势力", name: "宗门", rules: ["a"] })).toEqual([
"a",
]);
expect(worldEntityRules({ type: "势力", name: "宗门" })).toEqual([]);
});
});
describe("mergeCharacterCards", () => {
it("appends session cards not already in the persisted list", () => {
const persisted = [card({ name: "甲" })];
const session = [card({ name: "乙" })];
const out = mergeCharacterCards(persisted, session);
expect(out.map((c) => c.name)).toEqual(["甲", "乙"]);
});
it("dedups by name (persisted wins, no duplicate on reload echo)", () => {
const persisted = [card({ name: "甲", role: "主角" })];
const session = [card({ name: "甲", role: "对手" })];
const out = mergeCharacterCards(persisted, session);
expect(out).toHaveLength(1);
expect(out[0].role).toBe("主角"); // persisted version kept
});
it("returns persisted list when no session cards", () => {
const persisted = [card({ name: "甲" })];
expect(mergeCharacterCards(persisted, [])).toEqual(persisted);
});
});
describe("mergeWorldEntities", () => {
const entity = (
over: Partial<WorldEntityCardView> = {},
): WorldEntityCardView => ({ type: "势力", name: "宗门", ...over });
it("appends entities not already present (keyed by type+name)", () => {
const persisted = [entity({ name: "天剑宗" })];
const session = [entity({ name: "魔渊" })];
expect(mergeWorldEntities(persisted, session).map((e) => e.name)).toEqual([
"天剑宗",
"魔渊",
]);
});
it("dedups by type+name; same name different type kept separate", () => {
const persisted = [entity({ type: "势力", name: "玄" })];
const session = [
entity({ type: "势力", name: "玄" }),
entity({ type: "地点", name: "玄" }),
];
const out = mergeWorldEntities(persisted, session);
expect(out).toHaveLength(2);
expect(out.map((e) => e.type)).toEqual(["势力", "地点"]);
});
});

Binary file not shown.

View File

@@ -0,0 +1,150 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type {
CharacterCardView,
CharacterIngestResponse,
} from "@/lib/api/types";
import {
buildCharacterGenerateRequest,
buildCharacterIngestRequest,
extractIngestConflicts,
generationErrorMessage,
type IngestConflicts,
} from "./cards";
export type CharacterGenStatus = "idle" | "generating" | "preview" | "error";
export type IngestStatus = "idle" | "ingesting" | "conflict" | "done" | "error";
export interface CharacterGenInput {
projectId: string;
brief: string;
count: number;
role?: string | null;
}
export interface UseCharacterGen {
genStatus: CharacterGenStatus;
cards: CharacterCardView[];
ingestStatus: IngestStatus;
// 409 冲突详情(待作者裁决);裁决后带 acknowledge 重发。
conflicts: IngestConflicts | null;
created: string[];
generate: (input: CharacterGenInput) => Promise<void>;
// 入库选中卡acknowledge 用于过 409已查看冲突后重发
ingest: (
projectId: string,
cards: readonly CharacterCardView[],
acknowledge?: boolean,
) => Promise<boolean>;
reset: () => void;
}
// 角色生成器UX §6.6):生成预览 → 作者选/改 → 入库(处理 409 裁决流)。
export function useCharacterGen(): UseCharacterGen {
const [genStatus, setGenStatus] = useState<CharacterGenStatus>("idle");
const [cards, setCards] = useState<CharacterCardView[]>([]);
const [ingestStatus, setIngestStatus] = useState<IngestStatus>("idle");
const [conflicts, setConflicts] = useState<IngestConflicts | null>(null);
const [created, setCreated] = useState<string[]>([]);
const toast = useToast();
const generate = useCallback<UseCharacterGen["generate"]>(
async ({ projectId, brief, count, role }) => {
setGenStatus("generating");
setCards([]);
setConflicts(null);
setIngestStatus("idle");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/characters/generate",
{
params: { path: { project_id: projectId } },
body: buildCharacterGenerateRequest(brief, count, role),
},
);
if (error || !data) {
setGenStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setCards(data.cards ?? []);
setGenStatus("preview");
} catch {
setGenStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const ingest = useCallback<UseCharacterGen["ingest"]>(
async (projectId, ingestCards, acknowledge = false) => {
if (ingestCards.length === 0) {
toast("请至少选择一张角色卡。", "error");
return false;
}
setIngestStatus("ingesting");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/characters",
{
params: { path: { project_id: projectId } },
body: buildCharacterIngestRequest(ingestCards, acknowledge),
},
);
if (error || !data) {
const conflict = extractIngestConflicts(error);
if (conflict) {
// 409 CONFLICT_UNRESOLVED展示冲突让作者裁决再带 acknowledge 重发。
setConflicts(conflict);
setIngestStatus("conflict");
return false;
}
setIngestStatus("error");
toast(generationErrorMessage(error), "error");
return false;
}
const result = data as CharacterIngestResponse;
setCreated(result.created ?? []);
setConflicts(null);
setIngestStatus("done");
toast(`已入库 ${result.created?.length ?? 0} 个角色`, "success");
if (result.rejected_tables && result.rejected_tables.length > 0) {
toast(
`越权写表被丢弃:${result.rejected_tables.join("、")}`,
"info",
);
}
return true;
} catch {
setIngestStatus("error");
toast("入库请求异常,请检查网络。", "error");
return false;
}
},
[toast],
);
const reset = useCallback((): void => {
setGenStatus("idle");
setCards([]);
setIngestStatus("idle");
setConflicts(null);
setCreated([]);
}, []);
return {
genStatus,
cards,
ingestStatus,
conflicts,
created,
generate,
ingest,
reset,
};
}

View File

@@ -0,0 +1,58 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { WorldEntityCardView } from "@/lib/api/types";
import { buildWorldGenerateRequest, generationErrorMessage } from "./cards";
export type WorldGenStatus = "idle" | "generating" | "done" | "error";
export interface UseWorldGen {
status: WorldGenStatus;
entities: WorldEntityCardView[];
generate: (projectId: string, brief: string) => Promise<void>;
reset: () => void;
}
// 世界观设计器POST /world/generate → 预览实体(不入库)。
export function useWorldGen(): UseWorldGen {
const [status, setStatus] = useState<WorldGenStatus>("idle");
const [entities, setEntities] = useState<WorldEntityCardView[]>([]);
const toast = useToast();
const generate = useCallback<UseWorldGen["generate"]>(
async (projectId, brief) => {
setStatus("generating");
setEntities([]);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/world/generate",
{
params: { path: { project_id: projectId } },
body: buildWorldGenerateRequest(brief),
},
);
if (error || !data) {
setStatus("error");
toast(generationErrorMessage(error), "error");
return;
}
setEntities(data.entities ?? []);
setStatus("done");
} catch {
setStatus("error");
toast("生成请求异常,请检查网络。", "error");
}
},
[toast],
);
const reset = useCallback((): void => {
setStatus("idle");
setEntities([]);
}, []);
return { status, entities, generate, reset };
}

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
initialPollState,
isTerminal,
narrowJob,
reducePoll,
styleLearnResult,
type JobView,
} from "./job";
describe("narrowJob", () => {
it("narrows a well-formed loose dict into JobView", () => {
const job = narrowJob({
id: "j1",
kind: "style_learn",
status: "running",
progress: 42,
result: null,
error: null,
});
expect(job).toEqual({
id: "j1",
kind: "style_learn",
status: "running",
progress: 42,
result: null,
error: null,
});
});
it("falls back safely for missing/wrong-typed fields", () => {
expect(narrowJob({})).toEqual({
id: "",
kind: "",
status: "queued",
progress: 0,
result: null,
error: null,
});
// unknown status → queued; out-of-range progress clamped.
expect(narrowJob({ status: "weird", progress: 250 }).status).toBe("queued");
expect(narrowJob({ progress: 250 }).progress).toBe(100);
expect(narrowJob({ progress: -5 }).progress).toBe(0);
});
it("keeps result only when an object (not array)", () => {
expect(narrowJob({ result: { version: 2 } }).result).toEqual({ version: 2 });
expect(narrowJob({ result: [1, 2] }).result).toBeNull();
});
it("narrows non-object input", () => {
expect(narrowJob(null).status).toBe("queued");
expect(narrowJob("nope").id).toBe("");
});
});
describe("isTerminal", () => {
it("done and failed are terminal", () => {
expect(isTerminal("done")).toBe(true);
expect(isTerminal("failed")).toBe(true);
expect(isTerminal("queued")).toBe(false);
expect(isTerminal("running")).toBe(false);
});
});
describe("reducePoll", () => {
const job = (over: Partial<JobView>): JobView => ({
id: "j1",
kind: "style_learn",
status: "running",
progress: 0,
result: null,
error: null,
...over,
});
it("stays polling for queued/running and tracks progress", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "running", progress: 30 }),
});
expect(out.status).toBe("polling");
expect(out.progress).toBe(30);
});
it("transitions to done with progress 100", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "done", result: { version: 2, dims_count: 16 } }),
});
expect(out.status).toBe("done");
expect(out.progress).toBe(100);
expect(out.job?.result).toEqual({ version: 2, dims_count: 16 });
});
it("transitions to error on failed, using job.error", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "failed", error: "网关爆炸", progress: 20 }),
});
expect(out.status).toBe("error");
expect(out.error).toBe("网关爆炸");
expect(out.progress).toBe(20);
});
it("defaults error text when failed without error string", () => {
const out = reducePoll(initialPollState, {
type: "tick",
job: job({ status: "failed", error: null }),
});
expect(out.error).toBe("任务失败");
});
it("handles a fail action (poll request failure)", () => {
const out = reducePoll(initialPollState, {
type: "fail",
error: "网络断了",
});
expect(out.status).toBe("error");
expect(out.error).toBe("网络断了");
});
});
describe("styleLearnResult", () => {
it("extracts version + dims_count from done result", () => {
const out = styleLearnResult({
id: "j",
kind: "style_learn",
status: "done",
progress: 100,
result: { version: 3, dims_count: 16 },
error: null,
});
expect(out).toEqual({ version: 3, dimsCount: 16 });
});
it("returns nulls for missing result fields", () => {
expect(
styleLearnResult({
id: "j",
kind: "style_learn",
status: "done",
progress: 100,
result: null,
error: null,
}),
).toEqual({ version: null, dimsCount: null });
});
});

111
apps/web/lib/jobs/job.ts Normal file
View File

@@ -0,0 +1,111 @@
// 长任务jobs轮询纯逻辑把松散 `GET /jobs/{id}` 响应安全收窄成 JobView
// 并提供一个 setTimeout 轮询状态机的纯 reducerC3.5 / ARCH §7.4)。
// schema 把 GET /jobs/{id} 标成 {[k]:unknown}T0.3 裸端点这里安全narrow。
// 后端 job 状态机C3.5queued → running → done|failed。
export type JobStatus = "queued" | "running" | "done" | "failed";
export interface JobView {
id: string;
kind: string;
status: JobStatus;
progress: number;
result: Record<string, unknown> | null;
error: string | null;
}
function asString(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function asStatus(v: unknown): JobStatus {
return v === "running" || v === "done" || v === "failed" || v === "queued"
? v
: "queued";
}
function asProgress(v: unknown): number {
if (typeof v !== "number" || !Number.isFinite(v)) return 0;
return Math.min(100, Math.max(0, Math.round(v)));
}
function asResult(v: unknown): Record<string, unknown> | null {
return typeof v === "object" && v !== null && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
// 把松散 GET /jobs/{id} 响应收窄成 JobView缺字段给安全默认
export function narrowJob(raw: unknown): JobView {
const dict =
typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
return {
id: asString(dict["id"]),
kind: asString(dict["kind"]),
status: asStatus(dict["status"]),
progress: asProgress(dict["progress"]),
result: asResult(dict["result"]),
error: typeof dict["error"] === "string" ? (dict["error"] as string) : null,
};
}
// 学文风 job 完成态的 result{version, dims_count}C3 扩 work 摘要)。
export interface StyleLearnResult {
version: number | null;
dimsCount: number | null;
}
export function styleLearnResult(job: JobView): StyleLearnResult {
const r = job.result ?? {};
const version = typeof r["version"] === "number" ? r["version"] : null;
const dimsCount =
typeof r["dims_count"] === "number" ? r["dims_count"] : null;
return { version, dimsCount };
}
// 轮询状态机pollingqueued/running→ done | errordone/failed
export type PollStatus = "polling" | "done" | "error";
export interface PollState {
status: PollStatus;
progress: number;
job: JobView | null;
error: string | null;
}
export const initialPollState: PollState = {
status: "polling",
progress: 0,
job: null,
error: null,
};
export type PollAction =
| { type: "tick"; job: JobView }
| { type: "fail"; error: string };
// 纯 reducer吃一次轮询结果映射成轮询状态。
// done → 终态 donefailed → 终态 error用 job.error 文案);其余 → 继续 polling。
export function reducePoll(state: PollState, action: PollAction): PollState {
if (action.type === "fail") {
return { ...state, status: "error", error: action.error };
}
const { job } = action;
if (job.status === "done") {
return { status: "done", progress: 100, job, error: null };
}
if (job.status === "failed") {
return {
status: "error",
progress: job.progress,
job,
error: job.error ?? "任务失败",
};
}
return { status: "polling", progress: job.progress, job, error: null };
}
// 是否终态(停止轮询)。
export function isTerminal(status: JobStatus): boolean {
return status === "done" || status === "failed";
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { api } from "@/lib/api/client";
import {
initialPollState,
isTerminal,
narrowJob,
reducePoll,
type PollState,
} from "./job";
const POLL_INTERVAL_MS = 1500;
export interface UseJobPoll extends PollState {
// 开始轮询某个 job替换正在轮询的 job
poll: (jobId: string) => void;
// 复位到初始(停止当前轮询)。
reset: () => void;
}
// 轮询 GET /jobs/{job_id}setTimeout 循环done/failed 终态停止,卸载清理。
// 纯状态机在 job.ts可单测本 hook 只负责定时器 + fetch 编排。
export function useJobPoll(): UseJobPoll {
const [state, dispatch] = useReducer(reducePoll, initialPollState);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRef = useRef<string | null>(null);
const clearTimer = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const tick = useCallback(
async (jobId: string): Promise<void> => {
if (activeRef.current !== jobId) return;
try {
const { data, error } = await api.GET("/jobs/{job_id}", {
params: { path: { job_id: jobId } },
});
if (activeRef.current !== jobId) return; // 中途切换/卸载。
if (error || !data) {
dispatch({ type: "fail", error: "轮询任务状态失败" });
return;
}
const job = narrowJob(data);
dispatch({ type: "tick", job });
if (isTerminal(job.status)) {
activeRef.current = null;
return;
}
timerRef.current = setTimeout(() => void tick(jobId), POLL_INTERVAL_MS);
} catch (err: unknown) {
if (activeRef.current !== jobId) return;
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", error: message });
}
},
[],
);
const poll = useCallback(
(jobId: string): void => {
clearTimer();
activeRef.current = jobId;
void tick(jobId);
},
[clearTimer, tick],
);
const reset = useCallback((): void => {
clearTimer();
activeRef.current = null;
}, [clearTimer]);
// 卸载清理。
useEffect(() => {
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
activeRef.current = null;
};
}, []);
return { ...state, poll, reset };
}

View File

@@ -42,6 +42,18 @@ export interface PaceReport {
beat_map: number[];
}
// 文风漂移第四审C4 扩 T4.2):整体相似度 score + 段级漂移。
export interface StyleDriftSegment {
idx: number;
score: number;
label: string | null;
}
export interface StyleDriftReport {
score: number;
segments: StyleDriftSegment[];
}
export interface SectionEvent {
event: "section";
data: { name: string; status: SectionStatus };
@@ -68,6 +80,17 @@ export interface PaceEvent {
beat_map: number[];
};
}
export interface StyleEvent {
event: "style";
data: {
score: number;
segments: {
idx: number;
score: number;
label?: string | null;
}[];
};
}
export interface DoneEvent {
event: "done";
data: { length: number };
@@ -81,6 +104,7 @@ export type ReviewSseEvent =
| ConflictEvent
| ForeshadowEvent
| PaceEvent
| StyleEvent
| DoneEvent
| ErrorEvent;
@@ -89,6 +113,7 @@ const KNOWN_EVENTS = new Set([
"conflict",
"foreshadow",
"pace",
"style",
"done",
"error",
]);
@@ -160,6 +185,7 @@ export interface ReviewStreamState {
conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null;
style: StyleDriftReport | null;
error: { code: string; message: string; request_id?: string | null } | null;
}
@@ -169,6 +195,7 @@ export const initialReviewState: ReviewStreamState = {
conflicts: [],
foreshadow: [],
pace: null,
style: null,
error: null,
};
@@ -202,6 +229,19 @@ export function reduceReview(
phase: "reviewing",
pace: { ...event.data },
};
case "style":
return {
...state,
phase: "reviewing",
style: {
score: event.data.score,
segments: event.data.segments.map((s) => ({
idx: s.idx,
score: s.score,
label: s.label ?? null,
})),
},
};
case "done":
return { ...state, phase: "done" };
case "error":

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
initialReviewState,
parseReviewBlock,
reduceReview,
type ReviewSseEvent,
} from "./sse";
describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
it("parses a style frame", () => {
const evt = parseReviewBlock(
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60,"label":"口语化"}]}',
);
expect(evt).toEqual({
event: "style",
data: {
score: 87,
segments: [{ idx: 3, score: 60, label: "口语化" }],
},
});
});
it("parses a degrade态 style frame (无指纹 score=100/空段)", () => {
expect(parseReviewBlock('event:style\ndata:{"score":100,"segments":[]}')).toEqual(
{ event: "style", data: { score: 100, segments: [] } },
);
});
});
describe("reduceReview — style (replace-style like pace)", () => {
it("sets style report and marks reviewing", () => {
const evt: ReviewSseEvent = {
event: "style",
data: { score: 80, segments: [{ idx: 1, score: 50, label: "突兀" }] },
};
const out = reduceReview(initialReviewState, evt);
expect(out.phase).toBe("reviewing");
expect(out.style).toEqual({
score: 80,
segments: [{ idx: 1, score: 50, label: "突兀" }],
});
});
it("replaces (not accumulates) the style report and defaults label null", () => {
const first = reduceReview(initialReviewState, {
event: "style",
data: { score: 70, segments: [{ idx: 0, score: 40 }] },
});
const second = reduceReview(first, {
event: "style",
data: { score: 95, segments: [] },
});
expect(first.style?.segments[0]).toEqual({ idx: 0, score: 40, label: null });
expect(second.style).toEqual({ score: 95, segments: [] });
});
it("leaves style untouched on a section event", () => {
const seeded = reduceReview(initialReviewState, {
event: "style",
data: { score: 88, segments: [] },
});
const out = reduceReview(seeded, {
event: "section",
data: { name: "style", status: "done" },
});
expect(out.style).toEqual({ score: 88, segments: [] });
expect(out.sections).toEqual([{ name: "style", status: "done" }]);
});
});

View File

@@ -11,12 +11,14 @@ import {
type PaceReport,
type ReviewConflict,
type ReviewStreamState,
type StyleDriftReport,
} from "./sse";
export interface ReviewSeed {
conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null;
style: StyleDriftReport | null;
}
type Action =
@@ -46,6 +48,7 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
conflicts: action.seed.conflicts,
foreshadow: action.seed.foreshadow,
pace: action.seed.pace,
style: action.seed.style,
};
default:
return state;

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { RuleView } from "@/lib/api/types";
import {
buildRuleCreateRequest,
groupByLevel,
hasUsableContent,
isRuleLevel,
RULE_LEVELS,
} from "./rules";
describe("isRuleLevel", () => {
it("accepts the four known levels only", () => {
for (const lv of RULE_LEVELS) expect(isRuleLevel(lv)).toBe(true);
expect(isRuleLevel("bogus")).toBe(false);
});
});
describe("groupByLevel", () => {
it("groups rules by level, unknown level falls to project", () => {
const rules: RuleView[] = [
{ level: "global", content: "g1" },
{ level: "project", content: "p1" },
{ level: "weird", content: "x1" },
];
const groups = groupByLevel(rules);
expect(groups.global).toEqual([{ level: "global", content: "g1" }]);
expect(groups.project).toEqual([
{ level: "project", content: "p1" },
{ level: "weird", content: "x1" },
]);
expect(groups.genre).toEqual([]);
});
it("handles undefined", () => {
expect(groupByLevel(undefined).style).toEqual([]);
});
});
describe("buildRuleCreateRequest", () => {
it("trims content", () => {
expect(buildRuleCreateRequest("project", " 禁现代词 ")).toEqual({
level: "project",
content: "禁现代词",
});
});
});
describe("hasUsableContent", () => {
it("true only when non-blank", () => {
expect(hasUsableContent(" ")).toBe(false);
expect(hasUsableContent(" x ")).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
// 规则页纯逻辑:四级合并展示、请求体组装、级别文案。
// 对齐 C3 扩GET/POST .../rules。纯逻辑便于 node 环境单测。
import type { RuleCreateRequest, RuleView } from "@/lib/api/types";
// 四级规则global→genre→style→project越具体越优先对齐后端 RuleCreateRequest Literal
export type RuleLevel = "global" | "genre" | "style" | "project";
export const RULE_LEVELS: readonly RuleLevel[] = [
"global",
"genre",
"style",
"project",
] as const;
export const RULE_LEVEL_LABELS: Record<RuleLevel, string> = {
global: "全局",
genre: "题材",
style: "文风",
project: "本作",
};
export function isRuleLevel(v: string): v is RuleLevel {
return (
v === "global" || v === "genre" || v === "style" || v === "project"
);
}
export type RuleGroups = Record<RuleLevel, RuleView[]>;
function emptyGroups(): RuleGroups {
return { global: [], genre: [], style: [], project: [] };
}
// 按 level 分组(未知 level 落 project 兜底,保持服务端顺序)。
export function groupByLevel(
rules: readonly RuleView[] | undefined,
): RuleGroups {
const groups = emptyGroups();
for (const rule of rules ?? []) {
const level = isRuleLevel(rule.level) ? rule.level : "project";
groups[level] = [...groups[level], rule];
}
return groups;
}
// 组装新增规则请求体(去空白)。
export function buildRuleCreateRequest(
level: RuleLevel,
content: string,
): RuleCreateRequest {
return { level, content: content.trim() };
}
// 内容是否可提交(非空白)。
export function hasUsableContent(content: string): boolean {
return content.trim().length > 0;
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { RuleView } from "@/lib/api/types";
import { buildRuleCreateRequest, hasUsableContent, type RuleLevel } from "./rules";
export interface UseRules {
items: RuleView[];
busy: boolean;
// 新增一条规则(乐观追加 + 失败回滚 + Toast
add: (
projectId: string,
level: RuleLevel,
content: string,
) => Promise<boolean>;
}
// 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow
export function useRules(initial: RuleView[]): UseRules {
const [items, setItems] = useState<RuleView[]>(initial);
const [busy, setBusy] = useState(false);
const toast = useToast();
const add = useCallback<UseRules["add"]>(
async (projectId, level, content) => {
if (!hasUsableContent(content)) {
toast("请填写规则正文。", "error");
return false;
}
const snapshot = items;
const optimistic: RuleView = { level, content: content.trim() };
setItems((prev) => [...prev, optimistic]);
setBusy(true);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/rules",
{
params: { path: { project_id: projectId } },
body: buildRuleCreateRequest(level, content),
},
);
if (error || !data) {
setItems(snapshot); // 回滚
toast("新增规则失败,请稍后重试。", "error");
return false;
}
// 用服务端权威行替换乐观行(去掉乐观项、追加返回项)。
setItems((prev) => [...prev.slice(0, snapshot.length), data]);
toast("已新增规则", "success");
return true;
} finally {
setBusy(false);
}
},
[items, toast],
);
return { items, busy, add };
}

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import type { JobView, PollState } from "@/lib/jobs/job";
import {
authOpenUrl,
connectPhase,
formatExpiresAt,
jobConnected,
KIMI_CODE_MODEL,
KIMI_CODE_PROVIDER,
toConnectionStatus,
toDeviceDisplay,
} from "./kimiOauth";
const poll = (over: Partial<PollState>): PollState => ({
status: "polling",
progress: 0,
job: null,
error: null,
...over,
});
const job = (over: Partial<JobView>): JobView => ({
id: "j1",
kind: "kimi_oauth",
status: "done",
progress: 100,
result: null,
error: null,
...over,
});
describe("KIMI_CODE constants", () => {
it("uses the OAuth provider + coding model names", () => {
expect(KIMI_CODE_PROVIDER).toBe("kimi-code");
expect(KIMI_CODE_MODEL).toBe("kimi-for-coding");
});
});
describe("toDeviceDisplay", () => {
it("maps start response fields", () => {
const d = toDeviceDisplay({
job_id: "abc",
user_code: "WXYZ-1234",
verification_uri: "https://auth.kimi.com/device",
verification_uri_complete: "https://auth.kimi.com/device?code=WXYZ-1234",
expires_in: 600,
interval: 5,
});
expect(d).toEqual({
userCode: "WXYZ-1234",
verificationUri: "https://auth.kimi.com/device",
verificationUriComplete: "https://auth.kimi.com/device?code=WXYZ-1234",
expiresIn: 600,
interval: 5,
});
});
it("defaults missing complete uri to null", () => {
const d = toDeviceDisplay({
job_id: "abc",
user_code: "CODE",
verification_uri: "https://auth.kimi.com/device",
verification_uri_complete: null,
expires_in: 600,
interval: 5,
});
expect(d.verificationUriComplete).toBeNull();
});
});
describe("authOpenUrl", () => {
it("prefers the complete uri when present", () => {
expect(
authOpenUrl({
userCode: "C",
verificationUri: "https://auth.kimi.com/device",
verificationUriComplete: "https://auth.kimi.com/device?code=C",
expiresIn: 600,
interval: 5,
}),
).toBe("https://auth.kimi.com/device?code=C");
});
it("falls back to the plain uri", () => {
expect(
authOpenUrl({
userCode: "C",
verificationUri: "https://auth.kimi.com/device",
verificationUriComplete: null,
expiresIn: 600,
interval: 5,
}),
).toBe("https://auth.kimi.com/device");
});
});
describe("toConnectionStatus", () => {
it("narrows connected + expires_at", () => {
expect(
toConnectionStatus({ connected: true, expires_at: "2026-07-01T00:00:00Z" }),
).toEqual({ connected: true, expiresAt: "2026-07-01T00:00:00Z" });
});
it("defaults non-string expires_at to null", () => {
expect(toConnectionStatus({ connected: false })).toEqual({
connected: false,
expiresAt: null,
});
});
});
describe("jobConnected", () => {
it("true only when result.connected === true (no token expected)", () => {
expect(jobConnected(job({ result: { connected: true, provider: "kimi-code" } }))).toBe(
true,
);
expect(jobConnected(job({ result: { connected: false } }))).toBe(false);
expect(jobConnected(job({ result: null }))).toBe(false);
});
});
describe("connectPhase", () => {
it("not started + not connected → idle", () => {
expect(
connectPhase({ started: false, connected: false, poll: poll({}) }),
).toBe("idle");
});
it("not started + connected (status query) → connected", () => {
expect(
connectPhase({ started: false, connected: true, poll: poll({}) }),
).toBe("connected");
});
it("started + polling → awaiting", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({ status: "polling" }),
}),
).toBe("awaiting");
});
it("started + done + job.connected → connected", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({
status: "done",
job: job({ result: { connected: true, provider: "kimi-code" } }),
}),
}),
).toBe("connected");
});
it("started + done but job not connected → error", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({ status: "done", job: job({ result: { connected: false } }) }),
}),
).toBe("error");
});
it("started + poll error (expired/denied/network) → error", () => {
expect(
connectPhase({
started: true,
connected: false,
poll: poll({ status: "error", error: "授权已过期" }),
}),
).toBe("error");
});
});
describe("formatExpiresAt", () => {
it("returns null for null/invalid input", () => {
expect(formatExpiresAt(null)).toBeNull();
expect(formatExpiresAt("not-a-date")).toBeNull();
});
it("formats a valid ISO timestamp to a non-empty string", () => {
const out = formatExpiresAt("2026-07-01T08:30:00Z");
expect(typeof out).toBe("string");
expect(out).not.toBe("");
});
});

View File

@@ -0,0 +1,101 @@
// Kimi Code OAuth device-flow 连接态纯逻辑C3 扩 K1.3K1.4 前端)。
// 把「连接状态查询」「device 启动响应」「job 轮询结果」收窄/归一成一个
// 可渲染的连接阶段connect phase组件只读结果、不含分支逻辑。
// 纯函数 + node-env 单测token 永不出现job 结果只 {connected, provider})。
import type { JobView, PollState } from "@/lib/jobs/job";
import type {
OAuthStartResponse,
OAuthStatusResponse,
} from "@/lib/api/types";
// Kimi Code 是 OAuth 订阅 plan 提供商(与 API-key provider `kimi` 分离)。
export const KIMI_CODE_PROVIDER = "kimi-code";
export const KIMI_CODE_MODEL = "kimi-for-coding";
// 连接流阶段:
// - idle未发起且未连接展示「连接」按钮
// - connected状态查询/job 完成显示已连接(展示「断开」)。
// - awaiting已拿 device code正在等待用户在浏览器授权 + 轮询 job。
// - errordevice 启动失败 / 轮询失败 / 过期 / 拒绝(展示错误 + 允许重试)。
export type ConnectPhase = "idle" | "awaiting" | "connected" | "error";
// device 启动后用户面要展示的信息(无 token
export interface DeviceDisplay {
userCode: string;
verificationUri: string;
verificationUriComplete: string | null;
expiresIn: number;
interval: number;
}
// 把 OAuthStartResponse 收窄成展示用 DeviceDisplay缺字段给安全默认
export function toDeviceDisplay(res: OAuthStartResponse): DeviceDisplay {
return {
userCode: res.user_code,
verificationUri: res.verification_uri,
verificationUriComplete: res.verification_uri_complete ?? null,
expiresIn: typeof res.expires_in === "number" ? res.expires_in : 0,
interval: typeof res.interval === "number" ? res.interval : 5,
};
}
// 优先打开的授权 URL有 complete带 user_code 预填)就用它,否则裸 verification_uri。
export function authOpenUrl(device: DeviceDisplay): string {
return device.verificationUriComplete ?? device.verificationUri;
}
// 连接状态GET .../oauth/status收窄。
export interface ConnectionStatus {
connected: boolean;
expiresAt: string | null;
}
export function toConnectionStatus(
res: OAuthStatusResponse,
): ConnectionStatus {
return {
connected: res.connected === true,
expiresAt: typeof res.expires_at === "string" ? res.expires_at : null,
};
}
// kimi_oauth job 完成态的 result{connected, provider}**绝无 token**)。
export function jobConnected(job: JobView): boolean {
return job.result?.["connected"] === true;
}
// 把「是否已发起连接 + 轮询状态」映射成连接阶段。
// - 未发起started=falseconnected ? connected : idle。
// - 已发起done 且 job.connected → connectederror → error否则 awaiting。
export function connectPhase(args: {
started: boolean;
connected: boolean;
poll: PollState;
}): ConnectPhase {
const { started, connected, poll } = args;
if (!started) {
return connected ? "connected" : "idle";
}
if (poll.status === "done") {
return poll.job !== null && jobConnected(poll.job) ? "connected" : "error";
}
if (poll.status === "error") {
return "error";
}
return "awaiting";
}
// 把 ISO8601 过期时刻格式化成可读文案(无效/缺省→null
export function formatExpiresAt(iso: string | null): string | null {
if (!iso) return null;
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return null;
return new Date(ms).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
API_KEY_PROVIDERS,
KNOWN_PROVIDERS,
applyProviderChange,
defaultModelFor,
draftsToRoutingInput,
toRoutingDrafts,
type RoutingDraft,
} from "./providers";
describe("KNOWN_PROVIDERS", () => {
it("includes kimi-code as an OAuth provider with a fixed model", () => {
const kc = KNOWN_PROVIDERS.find((p) => p.id === "kimi-code");
expect(kc).toBeDefined();
expect(kc?.auth).toBe("oauth");
expect(kc?.defaultModel).toBe("kimi-for-coding");
});
it("API_KEY_PROVIDERS excludes the OAuth provider", () => {
expect(API_KEY_PROVIDERS.some((p) => p.id === "kimi-code")).toBe(false);
expect(API_KEY_PROVIDERS.some((p) => p.id === "deepseek")).toBe(true);
});
it("includes kimi-code-key as an api_key provider with a fixed model", () => {
const kck = KNOWN_PROVIDERS.find((p) => p.id === "kimi-code-key");
expect(kck).toBeDefined();
expect(kck?.auth).toBe("api_key");
expect(kck?.defaultModel).toBe("kimi-for-coding");
});
it("API_KEY_PROVIDERS includes the static-key Kimi Code provider", () => {
expect(API_KEY_PROVIDERS.some((p) => p.id === "kimi-code-key")).toBe(true);
});
});
describe("defaultModelFor", () => {
it("returns the OAuth provider's fixed model", () => {
expect(defaultModelFor("kimi-code")).toBe("kimi-for-coding");
});
it("returns the static-key Kimi Code provider's fixed model", () => {
expect(defaultModelFor("kimi-code-key")).toBe("kimi-for-coding");
});
it("returns empty string for api_key providers / unknown", () => {
expect(defaultModelFor("deepseek")).toBe("");
expect(defaultModelFor("nope")).toBe("");
});
});
describe("toRoutingDrafts", () => {
it("expands to all tiers, filling missing ones empty", () => {
const drafts = toRoutingDrafts([
{ tier: "writer", provider: "deepseek", model: "deepseek-chat" },
]);
expect(drafts.map((d) => d.tier)).toEqual(["writer", "analyst", "light"]);
expect(drafts[0]).toEqual({
tier: "writer",
provider: "deepseek",
model: "deepseek-chat",
});
expect(drafts[1]).toEqual({ tier: "analyst", provider: "", model: "" });
});
});
describe("applyProviderChange", () => {
it("auto-fills the OAuth provider's fixed model", () => {
const next = applyProviderChange(
{ tier: "writer", provider: "", model: "" },
"kimi-code",
);
expect(next).toEqual({
tier: "writer",
provider: "kimi-code",
model: "kimi-for-coding",
});
});
it("clears the model when switching to an api_key provider", () => {
const next = applyProviderChange(
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
"deepseek",
);
expect(next).toEqual({ tier: "writer", provider: "deepseek", model: "" });
});
it("does not mutate the input draft", () => {
const draft: RoutingDraft = { tier: "writer", provider: "", model: "" };
applyProviderChange(draft, "kimi-code");
expect(draft).toEqual({ tier: "writer", provider: "", model: "" });
});
});
describe("draftsToRoutingInput", () => {
it("keeps only rows with both provider and model", () => {
const input = draftsToRoutingInput([
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
{ tier: "analyst", provider: "", model: "" },
{ tier: "light", provider: "deepseek", model: "" },
]);
expect(input).toEqual([
{ tier: "writer", provider: "kimi-code", model: "kimi-for-coding" },
]);
});
});

View File

@@ -1,16 +1,103 @@
// 提供商鉴权方式api_key凭据行或 oauthdevice-flow 连接按钮,如 Kimi Code
export type ProviderAuthKind = "api_key" | "oauth";
export interface KnownProvider {
id: string;
label: string;
auth: ProviderAuthKind;
// OAuth 提供商在档位路由里用的默认 modelAPI-key 提供商由用户填写/后端默认)。
defaultModel?: string;
}
// 已知提供商UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。
export const KNOWN_PROVIDERS: { id: string; label: string }[] = [
{ id: "anthropic", label: "Anthropic" },
{ id: "deepseek", label: "DeepSeek" },
{ id: "kimi", label: "Kimi" },
{ id: "openai", label: "OpenAI" },
{ id: "qwen", label: "通义千问" },
{ id: "glm", label: "智谱 GLM" },
{ id: "gemini", label: "Gemini" },
// `kimi-code` 是 OAuth 订阅 plan 提供商device flow伪造客户端头有封号风险与 API-key
// 的 `kimi` 分离K1.4)。`kimi-code-key` 是同一订阅 plan 的**静态 Console Key** 路径ToS 合规、
// 无伪造头)——普通 api_key 提供商,固定 model `kimi-for-coding`。
export const KNOWN_PROVIDERS: KnownProvider[] = [
{ id: "anthropic", label: "Anthropic", auth: "api_key" },
{ id: "deepseek", label: "DeepSeek", auth: "api_key" },
{ id: "kimi", label: "Kimi", auth: "api_key" },
{ id: "openai", label: "OpenAI", auth: "api_key" },
{ id: "qwen", label: "通义千问", auth: "api_key" },
{ id: "glm", label: "智谱 GLM", auth: "api_key" },
{ id: "gemini", label: "Gemini", auth: "api_key" },
{
id: "kimi-code-key",
label: "Kimi Code订阅 Key",
auth: "api_key",
defaultModel: "kimi-for-coding",
},
{
id: "kimi-code",
label: "Kimi CodeOAuth",
auth: "oauth",
defaultModel: "kimi-for-coding",
},
];
// API-key 凭据行只展示 api_key 提供商OAuth 提供商有独立连接区。
export const API_KEY_PROVIDERS: KnownProvider[] = KNOWN_PROVIDERS.filter(
(p) => p.auth === "api_key",
);
export const TIER_LABELS: Record<string, string> = {
writer: "写手档",
analyst: "分析档",
light: "轻量档",
};
// 档位顺序(路由编辑器列出全部档位,缺省的也能配)。
export const TIERS = ["writer", "analyst", "light"] as const;
export type Tier = (typeof TIERS)[number];
// 某 provider 在路由里使用的默认 modelOAuth 提供商有固定 model其余给空串占位。
export function defaultModelFor(providerId: string): string {
return (
KNOWN_PROVIDERS.find((p) => p.id === providerId)?.defaultModel ?? ""
);
}
// 单条档位路由的可编辑草稿(纯数据)。
export interface RoutingDraft {
tier: string;
provider: string;
model: string;
}
export interface TierRouting {
tier: string;
provider: string;
model: string;
fallback?: string[] | null;
}
// 把已存路由(可能缺档位)展开成「全部档位」的可编辑草稿(缺的给空)。
export function toRoutingDrafts(existing: TierRouting[]): RoutingDraft[] {
const byTier = new Map(existing.map((r) => [r.tier, r]));
return TIERS.map((tier) => {
const row = byTier.get(tier);
return {
tier,
provider: row?.provider ?? "",
model: row?.model ?? "",
};
});
}
// 选择 provider 时自动套用该 provider 的默认 modelOAuth 固定 model否则保留已填值或清空
export function applyProviderChange(
draft: RoutingDraft,
providerId: string,
): RoutingDraft {
const def = defaultModelFor(providerId);
return { ...draft, provider: providerId, model: def !== "" ? def : "" };
}
// 草稿 → PUT body 的 tier_routing只取选了 provider+model 的行(不可变)。
export function draftsToRoutingInput(
drafts: RoutingDraft[],
): { tier: string; provider: string; model: string }[] {
return drafts
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import {
authOpenUrl,
connectPhase,
toConnectionStatus,
toDeviceDisplay,
type ConnectPhase,
type DeviceDisplay,
} from "./kimiOauth";
const START = "/settings/providers/kimi-code/oauth/start";
const DISCONNECT = "/settings/providers/kimi-code/oauth/disconnect";
export interface UseKimiOauth {
// 派生连接阶段idle/awaiting/connected/error驱动 UI。
phase: ConnectPhase;
// device 启动后展示给用户的信息user_code + 验证 URL未启动→null。
device: DeviceDisplay | null;
// 已连接时的 access token 过期时刻ISO8601可能为 null
expiresAt: string | null;
// 任一在途请求start/disconnect或正在轮询。
busy: boolean;
// 错误文案device 启动失败 / 轮询失败 / 过期 / 拒绝)。
error: string | null;
// 发起连接POST start → 拿 user_code + job_id → 开浏览器 + 轮询 job。
connect: () => Promise<void>;
// 断开POST disconnect → 复位为未连接。
disconnect: () => Promise<void>;
}
// 在浏览器打开授权页device complete URL 优先。SSR/无 window 时静默跳过。
function openVerification(device: DeviceDisplay): void {
if (typeof window === "undefined") return;
window.open(authOpenUrl(device), "_blank", "noopener,noreferrer");
}
// Kimi Code OAuth device-flow 连接编排K1.4)。
// connectPOST .../oauth/start202→ 展示 user_code + 开浏览器 → 轮询 job 到 done/failed。
// status 进页传入 initialConnected/initialExpiresAtServer Component 取)。
export function useKimiOauth(args: {
initialConnected: boolean;
initialExpiresAt: string | null;
}): UseKimiOauth {
const [connected, setConnected] = useState(args.initialConnected);
const [expiresAt, setExpiresAt] = useState<string | null>(
args.initialExpiresAt,
);
const [device, setDevice] = useState<DeviceDisplay | null>(null);
const [started, setStarted] = useState(false);
const [inFlight, setInFlight] = useState(false);
const poll = useJobPoll();
const toast = useToast();
const startedRef = useRef(false);
const phase = connectPhase({ started, connected, poll });
// 轮询终态done 且 job.connected → 已连接刷新状态error → 提示。
useEffect(() => {
if (!startedRef.current) return;
if (poll.status === "done") {
void refreshStatus().then((ok) => {
if (ok) {
setConnected(true);
toast("已连接 Kimi Code。", "success");
}
});
}
if (poll.status === "error") {
toast(`连接失败:${poll.error ?? "授权未完成或已过期"}`, "error");
}
// 仅在 poll.status 变化时反应。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poll.status]);
const refreshStatus = useCallback(async (): Promise<boolean> => {
const { data, error } = await api.GET(
"/settings/providers/kimi-code/oauth/status",
);
if (error || !data) return false;
const status = toConnectionStatus(data);
setExpiresAt(status.expiresAt);
return status.connected;
}, []);
const connect = useCallback<UseKimiOauth["connect"]>(async () => {
setInFlight(true);
try {
const { data, error } = await api.POST(START, {});
if (error || !data) {
toast("发起 Kimi Code 连接失败,请稍后重试。", "error");
return;
}
const dev = toDeviceDisplay(data);
setDevice(dev);
setStarted(true);
startedRef.current = true;
openVerification(dev);
poll.poll(data.job_id);
} finally {
setInFlight(false);
}
}, [poll, toast]);
const disconnect = useCallback<UseKimiOauth["disconnect"]>(async () => {
setInFlight(true);
try {
const { data, error } = await api.POST(DISCONNECT, {});
if (error || !data) {
toast("断开 Kimi Code 失败,请稍后重试。", "error");
return;
}
poll.reset();
startedRef.current = false;
setStarted(false);
setDevice(null);
setConnected(false);
setExpiresAt(null);
toast("已断开 Kimi Code。", "success");
} finally {
setInFlight(false);
}
}, [poll, toast]);
return {
phase,
device,
expiresAt,
busy: inFlight || (startedRef.current && poll.status === "polling"),
error: poll.status === "error" ? poll.error : null,
connect,
disconnect,
};
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { SkillView } from "@/lib/api/types";
import { groupByScope, scopeLabel, tierLabel } from "./skills";
const skill = (over: Partial<SkillView>): SkillView => ({
name: "x",
scope: "builtin",
tier: "analyst",
...over,
});
describe("scopeLabel / tierLabel", () => {
it("maps known values, passes through unknown", () => {
expect(scopeLabel("builtin")).toBe("内置");
expect(scopeLabel("mystery")).toBe("mystery");
expect(tierLabel("writer")).toBe("写手");
expect(tierLabel("xl")).toBe("xl");
});
});
describe("groupByScope", () => {
it("groups by scope in first-seen order, preserves within-group order", () => {
const skills: SkillView[] = [
skill({ name: "a", scope: "builtin" }),
skill({ name: "b", scope: "custom" }),
skill({ name: "c", scope: "builtin" }),
];
const groups = groupByScope(skills);
expect(groups.map((g) => g.scope)).toEqual(["builtin", "custom"]);
expect(groups[0].skills.map((s) => s.name)).toEqual(["a", "c"]);
expect(groups[1].skills.map((s) => s.name)).toEqual(["b"]);
});
it("handles undefined", () => {
expect(groupByScope(undefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,44 @@
// 技能库纯逻辑:按 scope 分组、档位文案。
// 对齐 C3 扩GET /skills只读注册表视图。纯逻辑便于 node 环境单测。
import type { SkillView } from "@/lib/api/types";
export type SkillScope = "builtin" | "custom" | "community";
export const SCOPE_LABELS: Record<string, string> = {
builtin: "内置",
custom: "自定义",
community: "社区",
};
export const TIER_LABELS: Record<string, string> = {
writer: "写手",
analyst: "分析",
light: "轻量",
};
export function scopeLabel(scope: string): string {
return SCOPE_LABELS[scope] ?? scope;
}
export function tierLabel(tier: string): string {
return TIER_LABELS[tier] ?? tier;
}
export type SkillGroups = { scope: string; skills: SkillView[] }[];
// 按 scope 分组(保持服务端 name 升序scope 顺序按首次出现。
export function groupByScope(
skills: readonly SkillView[] | undefined,
): SkillGroups {
const order: string[] = [];
const map = new Map<string, SkillView[]>();
for (const skill of skills ?? []) {
if (!map.has(skill.scope)) {
order.push(skill.scope);
map.set(skill.scope, []);
}
map.get(skill.scope)?.push(skill);
}
return order.map((scope) => ({ scope, skills: map.get(scope) ?? [] }));
}

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import type {
ReviewHistoryItem,
StyleFingerprintResponse,
} from "@/lib/api/types";
import {
buildLearnRequest,
buildRefineRequest,
hasUsableSamples,
narrowStyleEvent,
normalizeFingerprint,
normalizeStyleDrift,
} from "./style";
const reviewItem = (over: Partial<ReviewHistoryItem>): ReviewHistoryItem => ({
id: "00000000-0000-0000-0000-000000000001",
project_id: "00000000-0000-0000-0000-000000000002",
chapter_no: 1,
...over,
});
describe("normalizeFingerprint", () => {
it("aligns dims with evidence by name, preserving key order", () => {
const resp: StyleFingerprintResponse = {
dimensions: { : "偏短", : "高" },
evidence: { : ["他来了。"], : ["如龙似虎", "若即若离"] },
version: 2,
};
const fp = normalizeFingerprint(resp);
expect(fp).toEqual({
version: 2,
dimensions: [
{ name: "句长", value: "偏短", evidence: ["他来了。"] },
{ name: "比喻密度", value: "高", evidence: ["如龙似虎", "若即若离"] },
],
});
});
it("coerces non-string dim values and missing evidence", () => {
const fp = normalizeFingerprint({
dimensions: { 节奏: 5, 视角: true },
evidence: { : ["x", 1] },
version: 1,
});
expect(fp?.dimensions).toEqual([
{ name: "节奏", value: "5", evidence: ["x"] },
{ name: "视角", value: "true", evidence: [] },
]);
});
it("returns null when fingerprint is undefined", () => {
expect(normalizeFingerprint(undefined)).toBeNull();
});
});
describe("normalizeStyleDrift", () => {
it("tightens style dict into report, filtering bad segments", () => {
const out = normalizeStyleDrift(
reviewItem({
style: {
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 5, score: 72 },
"junk",
],
},
}),
);
expect(out).toEqual({
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 5, score: 72, label: null },
],
});
});
it("defaults score to 100 (degrade态) and returns null when missing", () => {
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
score: 100,
segments: [],
});
expect(normalizeStyleDrift(reviewItem({ style: null }))).toBeNull();
expect(normalizeStyleDrift(undefined)).toBeNull();
});
});
describe("narrowStyleEvent", () => {
it("narrows SSE style event data with label fallback", () => {
expect(
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }),
).toEqual({ score: 90, segments: [{ idx: 1, score: 50, label: null }] });
});
it("returns degrade态 for non-object data", () => {
expect(narrowStyleEvent(null)).toEqual({ score: 100, segments: [] });
});
});
describe("buildLearnRequest", () => {
it("trims and drops empty samples; passes mode through", () => {
expect(buildLearnRequest([" 甲 ", "", "乙"], "update")).toEqual({
samples: ["甲", "乙"],
mode: "update",
});
});
});
describe("hasUsableSamples", () => {
it("true only when at least one non-blank sample", () => {
expect(hasUsableSamples(["", " "])).toBe(false);
expect(hasUsableSamples(["", "正文"])).toBe(true);
});
});
describe("buildRefineRequest", () => {
it("trims segment and omits empty instruction", () => {
expect(buildRefineRequest(" 这一段 ")).toEqual({ segment: "这一段" });
expect(buildRefineRequest("段", " ")).toEqual({ segment: "段" });
expect(buildRefineRequest("段", " 更紧凑 ")).toEqual({
segment: "段",
instruction: "更紧凑",
});
});
});

129
apps/web/lib/style/style.ts Normal file
View File

@@ -0,0 +1,129 @@
// 文风纯逻辑指纹归一dims+evidence、漂移归一从 chapter_reviews.style
// 学文风/回炉请求体组装。纯逻辑,便于 node 环境单测C3 扩 T4.3 / C4 扩 T4.2)。
import type {
ReviewHistoryItem,
StyleFingerprintResponse,
StyleLearnRequest,
RefineRequest,
} from "@/lib/api/types";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
export type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
// ── 指纹16 维 + 证据UX §6.9)─────────────────────────────────
// 后端 dimensions={name:value}、evidence={name:[quotes]}(松散 JSONB
export interface FingerprintDimension {
name: string;
value: string;
evidence: string[];
}
export interface Fingerprint {
dimensions: FingerprintDimension[];
version: number;
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asDisplayValue(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
// 把松散 GET /style 响应收窄成 Fingerprint。
// dimensions 的 key 顺序即维度顺序身份evidence 按维度名对齐。
export function normalizeFingerprint(
resp: StyleFingerprintResponse | undefined,
): Fingerprint | null {
if (!resp) return null;
const dims = resp.dimensions ?? {};
const evid = resp.evidence ?? {};
const names = Object.keys(dims);
const dimensions: FingerprintDimension[] = names.map((name) => ({
name,
value: asDisplayValue(dims[name]),
evidence: asStringArray(evid[name]),
}));
return { dimensions, version: resp.version };
}
// ── 漂移第四审C4 扩 T4.2)────────────────────────────────────
// chapter_reviews.style = {score:int, segments:[{idx,score,label?}]}。
// 类型 StyleDriftReport/StyleDriftSegment 在 lib/review/sse.tsreduce 复用),上面已 re-export。
function asInt(v: unknown, fallback = 0): number {
return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : fallback;
}
function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null不渲染
export function normalizeStyleDrift(
item: ReviewHistoryItem | undefined,
): StyleDriftReport | null {
const raw = item?.style;
if (typeof raw !== "object" || raw === null) return null;
const dict = raw as Record<string, unknown>;
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
const segments: StyleDriftSegment[] = segRaw
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
// score 缺省 100无指纹降级态对齐后端默认
return { score: asInt(dict["score"], 100), segments };
}
// 同 style SSE 事件 data 也用此收窄reduceReview case "style" 复用)。
export function narrowStyleEvent(data: unknown): StyleDriftReport {
if (typeof data !== "object" || data === null) {
return { score: 100, segments: [] };
}
const dict = data as Record<string, unknown>;
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
const segments: StyleDriftSegment[] = segRaw
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
return { score: asInt(dict["score"], 100), segments };
}
// ── 请求体组装 ──────────────────────────────────────────────────
export type StyleLearnMode = "create" | "update";
// 组装学文风请求体去掉空白样本mode 透传(首学/更新仅前端语义)。
export function buildLearnRequest(
samples: readonly string[],
mode: StyleLearnMode,
): StyleLearnRequest {
const cleaned = samples.map((s) => s.trim()).filter((s) => s.length > 0);
return { samples: cleaned, mode };
}
// 至少一条非空样本才可提交(对齐后端 min_length=1
export function hasUsableSamples(samples: readonly string[]): boolean {
return samples.some((s) => s.trim().length > 0);
}
// 组装回炉请求体(段去空白;空指令省略)。
export function buildRefineRequest(
segment: string,
instruction?: string | null,
): RefineRequest {
const body: RefineRequest = { segment: segment.trim() };
const trimmed = instruction?.trim();
if (trimmed) body.instruction = trimmed;
return body;
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { RefineResponse } from "@/lib/api/types";
import { buildRefineRequest } from "./style";
export type RefineStatus = "idle" | "refining" | "done" | "error";
export interface RefineResult {
original: string;
refined: string;
}
export interface UseRefine {
status: RefineStatus;
result: RefineResult | null;
// 回炉重写某段(可选改写指令)→ 返回 {original, refined} 或 null失败
refine: (
projectId: string,
chapterNo: number,
segment: string,
instruction?: string,
) => Promise<RefineResult | null>;
reset: () => void;
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as { error?: { code?: unknown } };
return typeof env.error?.code === "string" ? env.error.code : undefined;
}
// 回炉POST .../refine同步返回新旧 diff不写库不变量#3
// 503 LLM_UNAVAILABLE无凭据优雅提示去设置其余失败 toast。
export function useRefine(): UseRefine {
const [status, setStatus] = useState<RefineStatus>("idle");
const [result, setResult] = useState<RefineResult | null>(null);
const toast = useToast();
const refine = useCallback<UseRefine["refine"]>(
async (projectId, chapterNo, segment, instruction) => {
setStatus("refining");
setResult(null);
try {
const { data, error } = await api.POST(
"/projects/{project_id}/chapters/{chapter_no}/refine",
{
params: {
path: { project_id: projectId, chapter_no: chapterNo },
},
body: buildRefineRequest(segment, instruction),
},
);
if (error || !data) {
setStatus("error");
const code = errorCode(error);
toast(
code === "LLM_UNAVAILABLE"
? "未配置提供商,请先去设置页连一家。"
: "回炉失败,请稍后重试。",
"error",
);
return null;
}
const next = data as RefineResponse;
const outcome: RefineResult = {
original: next.original,
refined: next.refined,
};
setResult(outcome);
setStatus("done");
return outcome;
} catch {
setStatus("error");
toast("回炉请求异常,请检查网络。", "error");
return null;
}
},
[toast],
);
const reset = useCallback((): void => {
setStatus("idle");
setResult(null);
}, []);
return { status, result, refine, reset };
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import type { StyleFingerprintResponse } from "@/lib/api/types";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import { styleLearnResult } from "@/lib/jobs/job";
import {
buildLearnRequest,
normalizeFingerprint,
type Fingerprint,
type StyleLearnMode,
} from "./style";
export interface UseStyleLearn {
// 提交中POST /style 受理)或轮询中。
busy: boolean;
pollStatus: ReturnType<typeof useJobPoll>["status"] | "idle";
progress: number;
fingerprint: Fingerprint | null;
// 学文风POST /style → 拿 job_id → 轮询 → done 后拉最新指纹。
learn: (
projectId: string,
samples: string[],
mode: StyleLearnMode,
) => Promise<boolean>;
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as { error?: { code?: unknown } };
return typeof env.error?.code === "string" ? env.error.code : undefined;
}
// 学文风编排受理202 job_id→ useJobPoll 轮询 → done 拉 GET /style 展示指纹。
export function useStyleLearn(initial: Fingerprint | null): UseStyleLearn {
const [fingerprint, setFingerprint] = useState<Fingerprint | null>(initial);
const [submitting, setSubmitting] = useState(false);
const [pollStatus, setPollStatus] = useState<
ReturnType<typeof useJobPoll>["status"] | "idle"
>("idle");
const [projectId, setProjectId] = useState<string | null>(null);
const poll = useJobPoll();
const toast = useToast();
// 仅在提交过一次学文风后才反映轮询状态initialPollState.status 默认 "polling")。
const startedRef = useRef(false);
// 轮询完成 → 拉最新指纹;失败 → toast。
useEffect(() => {
if (!startedRef.current) return;
setPollStatus(poll.status);
if (poll.status === "done" && projectId) {
void refetchFingerprint(projectId).then((fp) => {
if (fp) setFingerprint(fp);
toast("文风指纹已更新。", "success");
});
}
if (poll.status === "error") {
toast(`学文风失败:${poll.error ?? "未知原因"}`, "error");
}
// 仅在 poll.status 变化时反应。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poll.status]);
const learn = useCallback<UseStyleLearn["learn"]>(
async (pid, samples, mode) => {
setSubmitting(true);
setProjectId(pid);
try {
const { data, error } = await api.POST("/projects/{project_id}/style", {
params: { path: { project_id: pid } },
body: buildLearnRequest(samples, mode),
});
if (error || !data) {
const code = errorCode(error);
toast(
code === "LLM_UNAVAILABLE"
? "未配置提供商,请先去设置页连一家。"
: "学文风受理失败,请稍后重试。",
"error",
);
return false;
}
startedRef.current = true;
poll.poll(data.job_id);
setPollStatus("polling");
return true;
} finally {
setSubmitting(false);
}
},
[poll, toast],
);
return {
busy: submitting || pollStatus === "polling",
pollStatus,
progress: poll.progress,
fingerprint,
learn,
};
}
// 客户端拉最新指纹done 后刷新展示404/失败 → null。
async function refetchFingerprint(
projectId: string,
): Promise<Fingerprint | null> {
const { data, error } = await api.GET("/projects/{project_id}/style", {
params: { path: { project_id: projectId } },
});
if (error || !data) return null;
return normalizeFingerprint(data as StyleFingerprintResponse);
}
// 仅供测试/复用:保证 job done result 的版本回显(不阻断 UI
export function learnSummary(
poll: ReturnType<typeof useJobPoll>,
): { version: number | null; dimsCount: number | null } | null {
if (poll.status !== "done" || !poll.job) return null;
return styleLearnResult(poll.job);
}

View File

@@ -0,0 +1,5 @@
// 工作台当前章号常量(纯模块,**非** "use client")。
// 必须放在非客户端模块Server Componentwrite/page.tsx要在服务端用它拼 GET draft 的 URL
// 若从客户端组件Workbench.tsx"use client"导入Next 会把它替换成客户端引用代理,
// 服务端使用时变成抛错的函数 → draft URL 变成 `/chapters/function(){…}/draft`422
export const WORKBENCH_CHAPTER_NO = 1;