phase 5 part 1: typescript, vue, react, ssr(Next.js 16) topics with lessons, scenarios, interviews, verified examples
This commit is contained in:
1 parent
7a32d2c9a6
commit
7b5cae7d24
39 files changed
+2482
-48
No files matched your search
@@ -0,0 +1 @@
|
||||
{"type":"module"}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 运行:node sse-demo.ts —— 验证解析器能处理被切开的分块、多行 data、心跳注释
|
||||
import { createSSEParser, parseFinal, type SSEEvent } from "./sse.ts";
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
const feed = createSSEParser((e) => events.push(e));
|
||||
|
||||
const stream = [
|
||||
": ping\n\n",
|
||||
"id: 1\nevent: progress\ndata: {\"step\":\"检索资料\"}\n\n",
|
||||
"id: 2\nevent: token\ndata: 签收 7 天内",
|
||||
"可以无理由退货\n\nid: 3\nevent: tok",
|
||||
"en\ndata: 。\n\n",
|
||||
"id: 4\nevent: final\ndata: {\"answer\":\"签收 7 天内可以无理由退货。\",\n",
|
||||
"data: \"citations\":[{\"id\":1,\"title\":\"退货政策\"}]}\n\n",
|
||||
];
|
||||
for (const chunk of stream) feed(chunk);
|
||||
|
||||
console.log(events.map((e) => `${e.id}:${e.event}`).join(" "));
|
||||
const text = events.filter((e) => e.event === "token").map((e) => e.data).join("");
|
||||
const last = events.at(-1);
|
||||
const final = last ? parseFinal(last.data) : null;
|
||||
console.log("拼接文本:", text, "引用:", final?.citations);
|
||||
if (text !== "签收 7 天内可以无理由退货。" || events.length !== 4 || final?.citations[0]?.title !== "退货政策") {
|
||||
throw new Error("断言失败");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 增量 SSE 解析器:网络分块可能把一个事件切成两半,必须缓冲到空行再解析。
|
||||
// 浏览器的 EventSource 只支持 GET 且不能带自定义请求头,AI 对话通常用 fetch + POST,再自己解析 SSE。
|
||||
// 格式参考 HTML 标准:https://html.spec.whatwg.org/multipage/server-sent-events.html
|
||||
|
||||
export interface SSEEvent {
|
||||
event: string; // 默认 "message"
|
||||
data: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function createSSEParser(onEvent: (e: SSEEvent) => void) {
|
||||
let buffer = "";
|
||||
return (chunk: string): void => {
|
||||
buffer += chunk.replace(/\r\n?/g, "\n");
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) >= 0) {
|
||||
const block = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const ev: SSEEvent = { event: "message", data: "" };
|
||||
const data: string[] = [];
|
||||
for (const line of block.split("\n")) {
|
||||
if (!line || line.startsWith(":")) continue; // 空行、注释(常用作心跳)
|
||||
const i = line.indexOf(":");
|
||||
const field = i < 0 ? line : line.slice(0, i);
|
||||
const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
|
||||
if (field === "event") ev.event = value;
|
||||
else if (field === "data") data.push(value);
|
||||
else if (field === "id") ev.id = value;
|
||||
}
|
||||
if (data.length) {
|
||||
ev.data = data.join("\n");
|
||||
onEvent(ev);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 对话任务的状态:前端据此显示“连接中 / 生成中 / 完成 / 失败(可重试)/ 已停止”
|
||||
export type ChatStatus = "idle" | "connecting" | "streaming" | "done" | "error" | "aborted";
|
||||
|
||||
// 服务端约定的事件:token(增量文本)、progress(任务进度)、final(结构化结果)、error
|
||||
export interface FinalPayload {
|
||||
answer: string;
|
||||
citations: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
export function parseFinal(data: string): FinalPayload {
|
||||
const v = JSON.parse(data) as Partial<FinalPayload>;
|
||||
if (typeof v.answer !== "string" || !Array.isArray(v.citations)) throw new Error("final 事件格式错误");
|
||||
return { answer: v.answer, citations: v.citations };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 不自动运行(需要 npm i -D vitest vue 后执行 npx vitest run)
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectScope } from "vue";
|
||||
import { useChatStream } from "./useChatStream.ts";
|
||||
|
||||
function sseResponse(chunks: string[], failAfter?: number): Response {
|
||||
let i = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(ctrl) {
|
||||
if (failAfter !== undefined && i === failAfter) return ctrl.error(new Error("network down"));
|
||||
if (i >= chunks.length) return ctrl.close();
|
||||
ctrl.enqueue(new TextEncoder().encode(chunks[i++]));
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
const FINAL = 'id: 3\nevent: final\ndata: {"answer":"好","citations":[{"id":1,"title":"政策"}]}\n\n';
|
||||
|
||||
describe("useChatStream", () => {
|
||||
it("拼接 token 并解析 final", async () => {
|
||||
const scope = effectScope();
|
||||
const chat = scope.run(() =>
|
||||
useChatStream({ url: "/chat", fetchImpl: async () => sseResponse(["id: 1\nevent: token\ndata: 你", "好\n\n", FINAL]) }),
|
||||
)!;
|
||||
await chat.send("hi");
|
||||
expect(chat.status.value).toBe("done");
|
||||
expect(chat.text.value).toBe("你好");
|
||||
expect(chat.final.value?.citations[0]?.title).toBe("政策");
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("断线后带 Last-Event-ID 重试", async () => {
|
||||
const seen: (string | null)[] = [];
|
||||
let n = 0;
|
||||
const fetchImpl = async (_: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push(new Headers(init?.headers).get("Last-Event-ID"));
|
||||
return n++ === 0 ? sseResponse(["id: 1\nevent: token\ndata: 你\n\n", "x"], 1) : sseResponse([FINAL]);
|
||||
};
|
||||
const chat = useChatStream({ url: "/chat", fetchImpl, maxRetries: 1 });
|
||||
await chat.send("hi");
|
||||
expect(seen).toEqual([null, "1"]);
|
||||
expect(chat.status.value).toBe("done");
|
||||
});
|
||||
|
||||
it("stop 之后状态为 aborted", async () => {
|
||||
const fetchImpl = (_: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) =>
|
||||
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))),
|
||||
);
|
||||
const chat = useChatStream({ url: "/chat", fetchImpl });
|
||||
const p = chat.send("hi");
|
||||
chat.stop();
|
||||
await p;
|
||||
expect(chat.status.value).toBe("aborted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// 不自动运行(依赖 vue,需要 npm install 后用 vitest 运行 useChatStream.test.ts)
|
||||
// Vue 3 组合式函数:POST + 流式读取、状态机、停止生成、断线后带 Last-Event-ID 重试。
|
||||
import { ref, shallowRef, readonly, onScopeDispose } from "vue";
|
||||
import { createSSEParser, parseFinal, type ChatStatus, type FinalPayload } from "./sse.ts";
|
||||
|
||||
export interface ChatOptions {
|
||||
url: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export function useChatStream(opts: ChatOptions) {
|
||||
const status = ref<ChatStatus>("idle");
|
||||
const text = ref("");
|
||||
const progress = ref("");
|
||||
const final = shallowRef<FinalPayload | null>(null);
|
||||
const error = ref("");
|
||||
let controller: AbortController | null = null;
|
||||
let lastEventId: string | undefined;
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
|
||||
async function send(question: string, attempt = 0): Promise<void> {
|
||||
if (attempt === 0) {
|
||||
controller?.abort(); // 新问题:取消上一次的生成
|
||||
text.value = "";
|
||||
final.value = null;
|
||||
error.value = "";
|
||||
lastEventId = undefined;
|
||||
}
|
||||
controller = new AbortController();
|
||||
status.value = "connecting";
|
||||
try {
|
||||
const resp = await doFetch(opts.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
|
||||
body: JSON.stringify({ question }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||||
status.value = "streaming";
|
||||
const feed = createSSEParser((e) => {
|
||||
if (e.id) lastEventId = e.id; // 记录进度,断线重连时服务端从这里继续
|
||||
if (e.event === "token") text.value += e.data;
|
||||
else if (e.event === "progress") progress.value = JSON.parse(e.data).step;
|
||||
else if (e.event === "final") final.value = parseFinal(e.data);
|
||||
else if (e.event === "error") throw new Error(e.data);
|
||||
});
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
feed(value);
|
||||
}
|
||||
status.value = final.value ? "done" : "error";
|
||||
if (!final.value) error.value = "连接提前结束";
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
status.value = "aborted";
|
||||
return;
|
||||
}
|
||||
if (attempt < (opts.maxRetries ?? 2)) {
|
||||
await new Promise((r) => setTimeout(r, 300 * 2 ** attempt));
|
||||
return send(question, attempt + 1);
|
||||
}
|
||||
status.value = "error";
|
||||
error.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
controller?.abort();
|
||||
}
|
||||
|
||||
onScopeDispose(stop); // 组件卸载时停止,避免泄漏和继续计费
|
||||
|
||||
return {
|
||||
status: readonly(status),
|
||||
text: readonly(text),
|
||||
progress: readonly(progress),
|
||||
final,
|
||||
error: readonly(error),
|
||||
send,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user