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:
96
apps/web/lib/stream/sse.test.ts
Normal file
96
apps/web/lib/stream/sse.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SseFrameBuffer,
|
||||
initialStreamState,
|
||||
parseSseBlock,
|
||||
reduceStream,
|
||||
type SseEvent,
|
||||
} from "./sse";
|
||||
|
||||
describe("parseSseBlock", () => {
|
||||
it("parses a token frame", () => {
|
||||
const evt = parseSseBlock('event:token\ndata:{"text":"夜"}');
|
||||
expect(evt).toEqual({ event: "token", data: { text: "夜" } });
|
||||
});
|
||||
|
||||
it("parses a done frame", () => {
|
||||
const evt = parseSseBlock('event:done\ndata:{"length":12}');
|
||||
expect(evt).toEqual({ event: "done", data: { length: 12 } });
|
||||
});
|
||||
|
||||
it("parses an error frame with request_id", () => {
|
||||
const evt = parseSseBlock(
|
||||
'event:error\ndata:{"code":"INTERNAL","message":"boom","request_id":"r1"}',
|
||||
);
|
||||
expect(evt).toEqual({
|
||||
event: "error",
|
||||
data: { code: "INTERNAL", message: "boom", request_id: "r1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores comment/heartbeat lines and tolerates leading space after colon", () => {
|
||||
const evt = parseSseBlock(': keep-alive\nevent: token\ndata: {"text":"x"}');
|
||||
expect(evt).toEqual({ event: "token", data: { text: "x" } });
|
||||
});
|
||||
|
||||
it("returns null for unknown event types", () => {
|
||||
expect(parseSseBlock('event:section\ndata:{"x":1}')).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed json", () => {
|
||||
expect(parseSseBlock("event:token\ndata:{not json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SseFrameBuffer", () => {
|
||||
it("emits complete blocks split by blank line and buffers the tail", () => {
|
||||
const buf = new SseFrameBuffer();
|
||||
const first = buf.push('event:token\ndata:{"text":"a"}\n\nevent:tok');
|
||||
expect(first).toEqual([{ event: "token", data: { text: "a" } }]);
|
||||
const second = buf.push('en\ndata:{"text":"b"}\n\n');
|
||||
expect(second).toEqual([{ event: "token", data: { text: "b" } }]);
|
||||
});
|
||||
|
||||
it("handles CRLF boundaries", () => {
|
||||
const buf = new SseFrameBuffer();
|
||||
const out = buf.push('event:token\r\ndata:{"text":"z"}\r\n\r\n');
|
||||
expect(out).toEqual([{ event: "token", data: { text: "z" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceStream", () => {
|
||||
it("accumulates token text and marks streaming", () => {
|
||||
const tokens: SseEvent[] = [
|
||||
{ event: "token", data: { text: "夜色" } },
|
||||
{ event: "token", data: { text: "如墨" } },
|
||||
];
|
||||
const state = tokens.reduce(reduceStream, initialStreamState);
|
||||
expect(state.text).toBe("夜色如墨");
|
||||
expect(state.phase).toBe("streaming");
|
||||
});
|
||||
|
||||
it("marks done on done event preserving text", () => {
|
||||
let s = reduceStream(initialStreamState, {
|
||||
event: "token",
|
||||
data: { text: "abc" },
|
||||
});
|
||||
s = reduceStream(s, { event: "done", data: { length: 3 } });
|
||||
expect(s.phase).toBe("done");
|
||||
expect(s.text).toBe("abc");
|
||||
});
|
||||
|
||||
it("captures error and retains partial text", () => {
|
||||
let s = reduceStream(initialStreamState, {
|
||||
event: "token",
|
||||
data: { text: "partial" },
|
||||
});
|
||||
s = reduceStream(s, {
|
||||
event: "error",
|
||||
data: { code: "LLM_UNAVAILABLE", message: "no key" },
|
||||
});
|
||||
expect(s.phase).toBe("error");
|
||||
expect(s.text).toBe("partial");
|
||||
expect(s.error?.code).toBe("LLM_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user