refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证

This commit is contained in:
ddcat666 committed 2026-10-08 19:38:57 +08:00
1 parent 9be3aa8235
commit 87ecb3000c
724 files changed
+3045 -3884

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, spyOn, test } from "bun:test";
import { consumeJsonEvents } from "../src/lib/json-event-stream";
import { consumeChatCompletionStreamText, consumeGeminiStreamText, consumeResponseStreamText, parseGeminiImagePayload, parseGeminiToolResponse, parseToolResponse, readFetchError, readJsonPayload, responseErrorMessage } from "../src/services/api/image-response";
import { blobToDataUrl, delay } from "../src/services/api/video-response";
import type { ChatCompletionStreamState, GeminiStreamState, ResponseStreamState } from "../src/services/api/image-contracts";
const frame = (payload: unknown) => `data: ${JSON.stringify(payload)}\r\n\r\n`;
describe("provider response replacement contracts", () => {
test("JSON event framing survives every chunk boundary, comments, multiple data lines and EOF", () => {
const stream = ': heartbeat\r\nevent: message\r\ndata: {"text":\r\ndata: "你好"}\r\n\r\ndata: [DONE]\r\n\r\ndata: {"last":true}';
for (let split = 0; split <= stream.length; split++) {
const state = { buffer: "" };
const result: unknown[] = [];
consumeJsonEvents(state, stream.slice(0, split), (value) => result.push(value));
consumeJsonEvents(state, stream.slice(split), (value) => result.push(value), true);
expect(result).toEqual([{ text: "你好" }, { last: true }]);
expect(state.buffer).toBe("");
}
});
test("invalid JSON fails without dropping the failed frame or its unprocessed tail", () => {
const state = { buffer: "" };
const result: unknown[] = [];
const invalid = 'data: not-json\n\ndata: {"after":true}\n\n';
expect(() => consumeJsonEvents(state, frame({ before: true }) + invalid, (value) => result.push(value))).toThrow();
expect(result).toEqual([{ before: true }]);
expect(state.buffer).toBe(invalid);
});
test("Responses delta, done and completion preserve separate channels for every split", () => {
const payload = { output_text: "AB" };
const stream = [
{ type: "response.output_text.delta", delta: "A" },
{ type: "response.reasoning_summary_text.delta", delta: "why" },
{ type: "response.output_text.delta", delta: "B" },
{ type: "response.output_text.done", text: "AB" },
{ type: "response.reasoning_summary_text.done", text: "why" },
{ type: "constructor.delta", delta: "ignore" },
{ type: "response.completed", response: payload },
].map(frame).join("");
for (let split = 0; split <= stream.length; split++) {
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
const visible: string[] = [], reasoning: string[] = [];
consumeResponseStreamText(state, stream.slice(0, split), (value) => visible.push(value), (value) => reasoning.push(value));
consumeResponseStreamText(state, stream.slice(split), (value) => visible.push(value), (value) => reasoning.push(value), true);
expect(visible).toEqual(["A", "AB"]);
expect(reasoning).toEqual(["why"]);
expect(state.payload).toEqual(payload);
expect(state.buffer).toBe("");
}
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
consumeResponseStreamText(state, frame({ type: "response.output_text.done", text: "complete" }) + frame({ type: "response.reasoning.done", text: "reason" }));
expect(state).toMatchObject({ text: "complete", reasoning: "reason" });
consumeResponseStreamText(state, frame({ type: "response.failed", response: { error: { message: "quota" } } }));
expect(state.error).toBe("quota");
});
test("Chat Completions interleaves indexed tool fragments and reasoning aliases", () => {
const stream = [
{ choices: [{ delta: { content: "OK", reasoning: "r", tool_calls: [{ index: 3, id: "three", function: { name: "search", arguments: '{"q":' } }, { index: 1, id: "one", function: { name: "read", arguments: '{"id":' } }] } }] },
{ choices: [{ delta: { reasoning_text: "s", tool_calls: [{ index: 1, function: { arguments: '1}' } }, { index: 3, function: { arguments: '"画布"}' } }] } }] },
].map(frame).join("");
for (let split = 0; split <= stream.length; split++) {
const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
consumeChatCompletionStreamText(state, stream.slice(0, split));
consumeChatCompletionStreamText(state, stream.slice(split), undefined, undefined, true);
expect(state.text).toBe("OK");
expect(state.reasoning).toBe("rs");
expect(state.toolCalls.get(1)).toEqual({ id: "one", name: "read", arguments: '{"id":1}' });
expect(state.toolCalls.get(3)).toEqual({ id: "three", name: "search", arguments: '{"q":"画布"}' });
}
});
test("Gemini preserves thought signatures and tool order without mixing thought text", () => {
const payload = { candidates: [{ content: { parts: [{ thought: true, text: "plan" }, { text: "answer" }, { functionCall: { id: "a", name: "read", args: { id: 1 } }, thought_signature: "sig-a" }] } }, { content: { parts: [{ functionCall: { id: "b", name: "search" }, thoughtSignature: "sig-b" }] } }] };
const result = parseGeminiToolResponse(payload);
expect(result).toEqual({ content: "answer", reasoning: "plan", toolCalls: [
{ id: "a", type: "function", function: { name: "read", arguments: '{"id":1}' }, thoughtSignature: "sig-a" },
{ id: "b", type: "function", function: { name: "search", arguments: '{}' }, thoughtSignature: "sig-b" },
] });
const stream = frame(payload);
const state: GeminiStreamState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
for (const character of stream) consumeGeminiStreamText(state, character);
expect(state).toMatchObject({ text: "answer", reasoning: "plan", toolCalls: result.toolCalls });
expect(() => parseGeminiToolResponse({ promptFeedback: { blockReason: "BLOCKED" } })).toThrow("Gemini 拒绝了本次请求:BLOCKED");
});
test("image sources preserve MIME and source order; Responses chooses explicit output text", () => {
expect(parseGeminiImagePayload({ candidates: [{ content: { parts: [
{ inlineData: { mimeType: "image/jpeg", data: "AAA=" } },
{ inline_data: { mime_type: "image/webp", data: "BBB=" } },
{ fileData: { fileUri: "https://example.test/image" } },
{ text: "ignored" },
] } }] }).map((image) => image.dataUrl)).toEqual(["data:image/jpeg;base64,AAA=", "data:image/webp;base64,BBB=", "https://example.test/image"]);
expect(() => parseGeminiImagePayload({ candidates: [] })).toThrow("Gemini 接口没有返回图片");
expect(parseToolResponse({ output_text: "explicit", output: [{ type: "message", content: [{ text: "fallback" }] }, { type: "function_call", id: "item", call_id: "call", name: "read" }] })).toEqual({
content: "explicit", toolCalls: [{ id: "call", type: "function", function: { name: "read", arguments: "{}" } }],
});
});
test("HTTP and JSON errors retain precedence and distinguish proxy HTML", async () => {
expect(responseErrorMessage({ msg: "first", error: { message: "second" }, response: { error: { message: "third" } } })).toBe("first");
expect(await readFetchError(new Response('{"error":{"message":"denied"}}', { status: 403 }), "失败")).toBe("denied");
expect(await readFetchError(new Response('{}', { status: 429 }), "失败")).toBe("请求被限流或额度不足,请稍后重试");
expect(await readFetchError(new Response('x'.repeat(400), { status: 500 }), "失败")).toHaveLength(300);
await expect(readJsonPayload(new Response('<!doctype html><html>'), "失败")).rejects.toThrow("后端代理返回了前端网页");
await expect(readJsonPayload(new Response('not-json'), "失败")).rejects.toThrow("接口没有返回有效 JSON");
});
test("video polling removes abort listeners on completion and cancellation", async () => {
const controller = new AbortController();
const remove = spyOn(controller.signal, "removeEventListener");
try {
await delay(0, controller.signal);
expect(remove).toHaveBeenCalledTimes(1);
const pending = delay(60_000, controller.signal);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(remove).toHaveBeenCalledTimes(2);
await expect(delay(0, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
} finally { remove.mockRestore(); }
});
test("video data URLs preserve raw bytes and MIME without FileReader", async () => {
const data = Uint8Array.from({ length: 256 }, (_, i) => i);
const url = await blobToDataUrl(new Blob([data], { type: "video/webm" }));
expect(url.startsWith("data:video/webm;base64,")).toBe(true);
expect(new Uint8Array(await (await fetch(url)).arrayBuffer())).toEqual(data);
});
});