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>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/** 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();}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user