Files
mini-openclaw-v2/src/lib/chat-stream.ts
T
qiaoqichaoandClaude Code d56c699ce6 chore(v2): 从兴邦预审v2白名单抽取独立可运行基座
按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:58:26 +08:00

93 lines
4.4 KiB
TypeScript

/** Stateful SSE decoder: TCP chunks are not event boundaries. */
export interface ChatEvent { event: string; data: Record<string, unknown> }
export class ChatEventDecoder {
private buffer = "";
private event = "message";
private data: string[] = [];
push(text: string, flush = false): ChatEvent[] {
this.buffer += text;
const result: ChatEvent[] = [];
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
if (flush) { if (this.buffer) lines.push(this.buffer); lines.push(""); this.buffer = ""; }
for (const raw of lines) {
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (!line) {
if (this.data.length) {
let parsed: unknown;
try { parsed = JSON.parse(this.data.join("\n")); }
catch { throw new Error("回答数据格式异常,请重试;已接收的内容会保留。"); }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("回答事件格式不受支持");
result.push({event: this.event, data: parsed as Record<string, unknown>});
}
this.event = "message"; this.data = [];
} else if (line.startsWith("event:")) {
this.event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
this.data.push(line.slice(5).replace(/^ /, ""));
}
}
return result;
}
}
/** No automatic request replay: a tool may already have executed. */
export async function* readChatStream(url: string, body: unknown, options: {
fetcher?: typeof fetch; idleMs?: number; totalMs?: number;
} = {}): AsyncGenerator<ChatEvent> {
const controller = new AbortController();
const fetcher = options.fetcher || fetch;
const idleMs = options.idleMs ?? 90_000;
let timedOut = false, receivedDone = false, receivedError = false, answerSeen = false;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
const expire = () => { timedOut = true; controller.abort(); };
let idle = setTimeout(expire, idleMs);
const total = setTimeout(expire, options.totalMs ?? 180_000);
const touch = () => { clearTimeout(idle); idle = setTimeout(expire, idleMs); };
const decoder = new TextDecoder();
const parser = new ChatEventDecoder();
const annotate = (events: ChatEvent[]) => {
const normalized: ChatEvent[] = [];
for (const event of events) {
if (event.event === "token" && typeof event.data.content === "string" && event.data.content.trim()) answerSeen = true;
if (event.event === "done") {
if (!answerSeen) {
if (typeof event.data.content !== "string" || !event.data.content.trim()) throw new Error("模型没有返回回答正文,请检查聊天模型配置后重试。");
normalized.push({event:"token",data:{content:event.data.content}});
answerSeen = true;
}
receivedDone = true;
}
if (event.event === "error") receivedError = true;
normalized.push(event);
}
return normalized;
};
try {
const response = await fetcher(url, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body),signal:controller.signal});
if (!response.ok) throw new Error(`聊天服务请求失败(HTTP ${response.status}),请检查当前后端连接。`);
if (!response.headers.get("content-type")?.includes("text/event-stream")) throw new Error("聊天服务未返回事件流,请检查后端地址。");
reader = response.body?.getReader();
if (!reader) throw new Error("聊天服务未返回数据流。");
touch();
while (true) {
const {done,value} = await reader.read();
if (done) break;
for (const event of annotate(parser.push(decoder.decode(value,{stream:true})))) { touch(); yield event; }
if (receivedError) return;
}
for (const event of annotate(parser.push(decoder.decode(),true))) { touch(); yield event; }
if (!receivedDone && !receivedError) throw new Error("回答连接提前中断;已接收的内容已保留,请检查后重试。");
} catch (error) {
if (timedOut) {
if (receivedDone) return; // An optional title request must not invalidate a delivered answer.
throw new Error("回答等待超时;请检查模型服务后重试。不会自动重复执行工具。");
}
throw error;
} finally {
clearTimeout(idle);clearTimeout(total);controller.abort();
if (reader) {try {await reader.cancel();}catch {} reader.releaseLock();}
}
}