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

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