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,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