78 lines
3.8 KiB
TypeScript
78 lines
3.8 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { GenerationToolCard } from "../src/components/ai/generation-tool-card";
|
|
import type { ModelProtocol } from "../src/lib/model-protocols";
|
|
import { requestImageQuestion } from "../src/services/api/image";
|
|
import { createModelChannel, defaultConfig, type AiConfig } from "../src/stores/use-config-store";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
function reasoningConfig(protocol: ModelProtocol | undefined, apiFormat: "openai" | "gemini" = "openai"): AiConfig {
|
|
const channel = createModelChannel({
|
|
id: "reasoning",
|
|
name: "Reasoning",
|
|
baseUrl: "https://reasoning.example/v1",
|
|
apiKey: "synthetic-test-key",
|
|
apiFormat,
|
|
interfaceType: apiFormat === "gemini" ? undefined : protocol,
|
|
models: ["reasoner"],
|
|
modelCosts: [{ model: "reasoner", capability: "text", ...(protocol ? { protocol } : {}), billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
|
|
});
|
|
return { ...defaultConfig, channels: [channel], model: "reasoning::reasoner", textModel: "reasoning::reasoner" };
|
|
}
|
|
|
|
function streamResponse(events: unknown[]) {
|
|
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
|
|
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
|
}
|
|
|
|
async function requestWithReasoning(config: AiConfig, events: unknown[]) {
|
|
globalThis.fetch = (async () => streamResponse(events)) as typeof fetch;
|
|
let content = "";
|
|
let reasoning = "";
|
|
const result = await requestImageQuestion(config, [{ role: "user", content: "请回答" }], (value) => { content = value; }, { onReasoning: (value) => { reasoning = value; } });
|
|
return { result, content, reasoning };
|
|
}
|
|
|
|
describe("text reasoning streams", () => {
|
|
test("separates Responses reasoning summaries from answer text", async () => {
|
|
const output = await requestWithReasoning(reasoningConfig("openai-response"), [
|
|
{ type: "response.reasoning_summary_text.delta", delta: "先分析" },
|
|
{ type: "response.output_text.delta", delta: "最终答案" },
|
|
]);
|
|
|
|
expect(output).toEqual({ result: "最终答案", content: "最终答案", reasoning: "先分析" });
|
|
});
|
|
|
|
test("separates Chat Completions reasoning_content from answer text", async () => {
|
|
const output = await requestWithReasoning(reasoningConfig("chat-completion"), [
|
|
{ choices: [{ delta: { reasoning_content: "推理过程" } }] },
|
|
{ choices: [{ delta: { content: "回答内容" } }] },
|
|
]);
|
|
|
|
expect(output).toEqual({ result: "回答内容", content: "回答内容", reasoning: "推理过程" });
|
|
});
|
|
|
|
test("does not duplicate Gemini thought parts into visible content", async () => {
|
|
const output = await requestWithReasoning(reasoningConfig(undefined, "gemini"), [
|
|
{ candidates: [{ content: { parts: [{ text: "内部思考", thought: true }, { text: "可见回答" }] } }] },
|
|
]);
|
|
|
|
expect(output).toEqual({ result: "可见回答", content: "可见回答", reasoning: "内部思考" });
|
|
});
|
|
|
|
test("keeps single results open and collapses completed bulk results on restore", () => {
|
|
const single = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", heading: "图像生成" }, React.createElement("span", null, "单图结果")));
|
|
const bulk = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", isBulk: true, heading: "图像生成" }, React.createElement("span", null, "批量结果")));
|
|
|
|
expect(single).toContain("单图结果");
|
|
expect(bulk).not.toContain("批量结果");
|
|
});
|
|
});
|