feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider

- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由
- 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本)
- LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error)
- API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接)
- 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页
- M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent d3dc620a71
commit b523b4fd21
70 changed files with 6642 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
canAdvance,
canSubmit,
clampStep,
emptyWizardForm,
toCreateRequest,
WIZARD_STEPS,
type WizardForm,
} from "./wizard";
describe("wizard step gating", () => {
it("blocks advancing past step 1 without a title", () => {
expect(canAdvance(1, emptyWizardForm)).toBe(false);
});
it("allows advancing step 1 once title is set", () => {
expect(canAdvance(1, { ...emptyWizardForm, title: "逐光而行" })).toBe(true);
});
it("allows advancing later steps even when fields are empty", () => {
expect(canAdvance(3, emptyWizardForm)).toBe(true);
});
it("clamps step within bounds", () => {
expect(clampStep(0)).toBe(1);
expect(clampStep(WIZARD_STEPS + 3)).toBe(WIZARD_STEPS);
expect(clampStep(3)).toBe(3);
});
it("requires a title to submit", () => {
expect(canSubmit(emptyWizardForm)).toBe(false);
expect(canSubmit({ ...emptyWizardForm, title: "x" })).toBe(true);
});
});
describe("toCreateRequest", () => {
it("maps form to snake_case request, nulling empty optionals", () => {
const form: WizardForm = {
title: " 逐光而行 ",
genre: "玄幻",
logline: "",
sellingPoints: ["逆袭", "系统流"],
structure: "三幕",
premise: " ",
theme: "抗争",
};
expect(toCreateRequest(form)).toEqual({
title: "逐光而行",
genre: "玄幻",
logline: null,
premise: null,
theme: "抗争",
selling_points: ["逆袭", "系统流"],
structure: "三幕",
});
});
});
// wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。
describe("wizard create flow (mocked client)", () => {
it("submits the normalized body and resolves a project id", async () => {
const post = async (
_path: string,
opts: { body: ReturnType<typeof toCreateRequest> },
): Promise<{ data: { id: string }; error: null }> => {
expect(opts.body.title).toBe("青冥录");
expect(opts.body.selling_points).toEqual(["群像"]);
return { data: { id: "proj-123" }, error: null };
};
const form: WizardForm = {
...emptyWizardForm,
title: "青冥录",
sellingPoints: ["群像"],
};
const { data } = await post("/projects", { body: toCreateRequest(form) });
expect(data.id).toBe("proj-123");
});
});

View File

@@ -0,0 +1,62 @@
import type { ProjectCreateRequest } from "@/lib/api/types";
// 立项向导 5 步UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。
export interface WizardForm {
title: string;
genre: string;
logline: string;
sellingPoints: string[];
structure: string;
premise: string;
theme: string;
}
export const WIZARD_STEPS = 5;
export const emptyWizardForm: WizardForm = {
title: "",
genre: "",
logline: "",
sellingPoints: [],
structure: "",
premise: "",
theme: "",
};
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
export function canAdvance(step: number, form: WizardForm): boolean {
if (step === 1) return form.title.trim().length > 0;
return true;
}
// 整个向导可否提交:书名必填。
export function canSubmit(form: WizardForm): boolean {
return form.title.trim().length > 0;
}
export function clampStep(step: number): number {
if (step < 1) return 1;
if (step > WIZARD_STEPS) return WIZARD_STEPS;
return step;
}
// 归一为后端请求体snake_case空值落 null/省略)。
export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
const trim = (s: string): string | null => {
const v = s.trim();
return v.length > 0 ? v : null;
};
return {
title: form.title.trim(),
genre: trim(form.genre),
logline: trim(form.logline),
premise: trim(form.premise),
theme: trim(form.theme),
selling_points: form.sellingPoints,
structure: trim(form.structure),
};
}