Files
zhuangang/topics/frontend/vue/examples/ai-chat-stream/useChatStream.ts
T

86 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 不自动运行(依赖 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,
};
}