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,27 @@
// 右栏「本章助手」UX §6.3)。
// M1 无端点暴露注入选择轨迹/四审结果 → 占位说明,不编造 API。
export function ChapterAssistant() {
return (
<aside className="hidden border-l border-line bg-panel p-4 lg:block">
<h2 className="text-sm font-semibold text-ink"></h2>
<section className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
M1 /
</p>
</section>
<section className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
M2 / / /
</p>
</section>
</aside>
);
}

View File

@@ -0,0 +1,24 @@
interface ChapterListProps {
currentChapterNo: number;
}
// 左栏目录UX §6.3。M1 无章节列表端点 → 仅展示当前章占位。
export function ChapterList({ currentChapterNo }: ChapterListProps) {
return (
<aside className="hidden border-r border-line bg-panel py-4 lg:block">
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h2>
<ul className="mt-2">
<li>
<span className="flex items-center gap-2 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm text-cinnabar">
<span aria-hidden="true"></span> {currentChapterNo}
</span>
</li>
</ul>
<p className="mt-4 px-4 text-xs text-ink-soft/70">
</p>
</aside>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
interface EditorProps {
value: string;
onChange: (value: string) => void;
streaming: boolean;
}
// 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。
// 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css
export function Editor({ value, onChange, streaming }: EditorProps) {
return (
<div className="mx-auto max-w-prose">
<label htmlFor="chapter-editor" className="sr-only">
</label>
<textarea
id="chapter-editor"
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={streaming}
aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
className="min-h-[60vh] w-full resize-none bg-transparent font-serif text-[18px] leading-[1.9] text-ink placeholder:text-ink-soft/50 focus:outline-none"
/>
{streaming ? (
<span
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
aria-hidden="true"
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import { ChapterList } from "./ChapterList";
import { ChapterAssistant } from "./ChapterAssistant";
import { Editor } from "./Editor";
interface WorkbenchProps {
project: ProjectResponse;
}
// M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。
const M1_CHAPTER_NO = 1;
export function Workbench({ project }: WorkbenchProps) {
const chapterNo = M1_CHAPTER_NO;
const [text, setText] = useState("");
const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream();
const lastStreamText = useRef("");
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => {
const streamed = stream.state.text;
if (
(stream.state.phase === "streaming" ||
stream.state.phase === "done" ||
stream.state.phase === "aborted") &&
streamed !== lastStreamText.current
) {
lastStreamText.current = streamed;
setText(streamed);
autosave.onChange(streamed);
}
}, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => {
void stream.start(project.id, chapterNo);
};
const onEditorChange = (value: string): void => {
setText(value);
autosave.onChange(value);
};
const wordCount = text.length;
return (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
/>
</div>
<Toolbar
projectId={project.id}
chapterNo={chapterNo}
wordCount={wordCount}
savedLabel={autosave.savedLabel}
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
onWrite={onWrite}
onStop={stream.stop}
/>
</section>
<ChapterAssistant />
</div>
</AppShell>
);
}
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
onWrite: () => void;
onStop: () => void;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
onWrite,
onStop,
}: ToolbarProps) {
return (
<div className="border-t border-line bg-panel px-6 py-3">
{streamError ? (
<p className="mb-2 text-sm text-conflict">
{streamError.code}{streamError.message}
</p>
) : null}
<div className="flex items-center gap-4">
{streaming ? (
<button
type="button"
onClick={onStop}
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
>
</button>
) : (
<button
type="button"
onClick={onWrite}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
>
</button>
)}
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString()}
</span>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
>
稿
</Link>
<span className="ml-auto font-mono text-xs text-ink-soft">
{saveStatus === "saving"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}