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

80
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ProjectCard } from "@/components/ProjectCard";
import { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() {
let projects: ProjectResponse[] = [];
let loadError = false;
try {
const data = await fetchProjects();
projects = data.projects ?? [];
} catch {
loadError = true;
}
return (
<AppShell>
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex items-center justify-between">
<h1 className="font-serif text-3xl text-ink"></h1>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
) : projects.length === 0 ? (
<EmptyState />
) : (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
)}
</div>
</AppShell>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
<p className="font-serif text-2xl text-ink"></p>
<p className="text-ink-soft"></p>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
);
}
function NewProjectCard() {
return (
<Link
href="/projects/new"
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
>
<span className="font-serif text-xl text-cinnabar"> </span>
<span className="mt-2 text-sm text-ink-soft"></span>
</Link>
);
}

View File

@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { Workbench } from "@/components/workbench/Workbench";
import { fetchProject } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 写作工作台页UX §6.3。Server Component 取项目Workbench 负责编辑/流式/保存。
export default async function WritePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
return <Workbench project={project} />;
} catch {
notFound();
}
}

View File

@@ -0,0 +1,12 @@
import { AppShell } from "@/components/AppShell";
import { ProjectWizard } from "@/components/ProjectWizard";
export default function NewProjectPage() {
return (
<AppShell>
<div className="px-8 py-10">
<ProjectWizard />
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,29 @@
import { AppShell } from "@/components/AppShell";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types";
// 模型与提供商设置UX §6.10。Server Component 取初始数据。
export default async function ProvidersSettingsPage() {
let initial: ProvidersResponse = { providers: [], tier_routing: [] };
let loadError = false;
try {
initial = await fetchProviders();
} catch {
loadError = true;
}
return (
<AppShell title="设置 模型与提供商">
<div className="mx-auto max-w-3xl px-8 py-10">
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
) : (
<ProvidersSettings initial={initial} />
)}
</div>
</AppShell>
);
}