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