refactor(core): 任务与模型协议治理 - 拆分服务职责并禁止已发起任务取消

This commit is contained in:
ddCat
2026-08-22 09:52:09 +08:00
parent 7418ddfa9c
commit 633560a5d8
91 changed files with 6380 additions and 4247 deletions

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { ApiError, request } from "../src/services/api/request";
describe("backend API request error semantics", () => {
test("unwraps a successful backend envelope", async () => {
await expect(request(Promise.resolve({ data: { code: 0, data: { id: "task-1" }, msg: "ok" }, status: 200 }))).resolves.toEqual({ id: "task-1" });
});
test("preserves business code from a non-zero envelope", async () => {
const thrown = await request(Promise.resolve({ data: { code: 40901, data: null, msg: "任务状态已变化" }, status: 200 })).catch((error) => error);
expect(thrown).toBeInstanceOf(ApiError);
expect(thrown).toMatchObject({ name: "ApiError", status: 200, code: 40901, message: "任务状态已变化", retryable: false });
});
test("preserves HTTP status, backend code and retryability", async () => {
const axiosError = {
isAxiosError: true,
message: "Request failed with status code 429",
response: {
status: 429,
data: { code: 42901, data: null, msg: "请求过于频繁,请稍后重试" },
},
};
const thrown = await request(Promise.reject(axiosError)).catch((error) => error);
expect(thrown).toBeInstanceOf(ApiError);
expect(thrown).toMatchObject({ status: 429, code: 42901, message: "请求过于频繁,请稍后重试", retryable: true });
expect(thrown.cause).toBe(axiosError);
});
test("converts Axios cancellation to AbortError", async () => {
const thrown = await request(Promise.reject({ __CANCEL__: true, code: "ERR_CANCELED" })).catch((error) => error);
expect(thrown).toMatchObject({ name: "AbortError", message: "请求已取消" });
});
});

View File

@@ -156,7 +156,7 @@ test("Dreamina submission uncertainty is not an accepted background task", () =>
expect(localDreaminaCancellationCopy(uncertain)).toBeUndefined();
});
test("Canvas task surfaces route Dreamina uncertainty through shared display semantics", async () => {
test("Canvas task surfaces route Dreamina uncertainty through shared display semantics without cancellation", async () => {
const [nodeSource, detailSource, scriptSource, taskCenterSource, createSource] = await Promise.all([
Bun.file(new URL("../src/components/canvas/canvas-node-content.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/pages/canvas/canvas-project-status-dialogs.tsx", import.meta.url)).text(),
@@ -166,30 +166,8 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
]);
expect(nodeSource).toContain("generationTaskStageLabel(displayTask)");
expect(nodeSource).toContain("generationTaskShowsProgress(displayTask)");
const loadingContentSource = sourceSection(nodeSource, "function LoadingContent(", "function useTaskElapsed(");
const cancelBranchStart = loadingContentSource.indexOf("{!submissionUncertain ? (");
const cancelBranchEndMarker = ") : null}";
const cancelBranchEnd = loadingContentSource.indexOf(cancelBranchEndMarker, cancelBranchStart);
expect(cancelBranchStart).toBeGreaterThanOrEqual(0);
expect(cancelBranchEnd).toBeGreaterThan(cancelBranchStart);
const cancelBranchSource = loadingContentSource.slice(cancelBranchStart, cancelBranchEnd + cancelBranchEndMarker.length);
const cancelButtonStart = cancelBranchSource.indexOf("<button");
const cancelButtonEndMarker = "</button>";
const cancelButtonEnd = cancelBranchSource.indexOf(cancelButtonEndMarker, cancelButtonStart);
expect(cancelButtonStart).toBeGreaterThan(0);
expect(cancelButtonEnd).toBeGreaterThan(cancelButtonStart);
expect(cancelBranchSource.slice(0, cancelButtonStart)).toBe("{!submissionUncertain ? ( ");
const cancelButtonSource = cancelBranchSource.slice(cancelButtonStart, cancelButtonEnd + cancelButtonEndMarker.length);
const onClickStart = cancelButtonSource.indexOf("onClick={(event) => {");
const onClickEnd = cancelButtonSource.indexOf("}}", onClickStart);
expect(cancelButtonSource).toContain("<button");
expect(onClickStart).toBeGreaterThanOrEqual(0);
expect(onClickEnd).toBeGreaterThan(onClickStart);
expect(cancelButtonSource.slice(onClickStart, onClickEnd)).toContain("onCancelTask?.(node);");
expect(cancelButtonSource).toContain("<Square");
expect(cancelButtonSource).toContain("取消");
expect(nodeSource).not.toContain("onCancelTask");
expect(nodeSource).not.toContain("取消生成");
expect(nodeSource).not.toContain('node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")');
expect(detailSource).toContain("generationTaskStageLabel(task)");
expect(detailSource).toContain("generationTaskShowsProgress(task) ? <TaskDetailItem");
@@ -198,9 +176,9 @@ test("Canvas task surfaces route Dreamina uncertainty through shared display sem
expect(scriptSource).toContain("generationTaskShowsProgress(displayTask)");
expect(scriptSource).not.toContain('node.metadata.taskStage || "正在创建任务"');
expect(nodeSource).toContain("isGenerationTaskSubmissionUncertain(errorDisplayTask)");
expect(taskCenterSource).toContain('if (action === "retry" && currentTask && isGenerationTaskSubmissionUncertain(currentTask))');
expect(taskCenterSource).toContain("if (currentTask && isGenerationTaskSubmissionUncertain(currentTask))");
expect(taskCenterSource).toContain("不能自动重试;请先核对官方状态,避免重复生成");
expect(taskCenterSource).toContain('onRetry={() => void runAction(task.id, "retry")}');
expect(taskCenterSource).toContain('onRetry={() => void runAction(task.id)}');
expect(taskCenterSource).toContain("<TaskListRow");
expect(taskCenterSource).toContain("<TaskGridCard");
expect(createSource).not.toContain('item.generationStage === "submission_unknown"');

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import { consumeChatCompletionStreamText, consumeGeminiStreamText, consumeResponseStreamText, parseChatCompletionPayload, parseGeminiToolResponse, parseImagePayload, parseToolResponse } from "../src/services/api/image-response";
import { normalizeQuality, resolveRequestSize, validateImageSize } from "../src/services/api/image-validation";
describe("image api contracts", () => {
test("规范化质量和尺寸,拒绝超出边界的图像", () => {
expect(normalizeQuality("1k")).toBe("low");
expect(normalizeQuality("2k")).toBe("medium");
expect(normalizeQuality("4k")).toBe("high");
expect(resolveRequestSize("medium", "16:9")).toMatch(/^\d+x\d+$/);
expect(resolveRequestSize("medium", "9:16")).toMatch(/^\d+x\d+$/);
expect(() => validateImageSize(1025, 1024)).toThrow("16 的倍数");
expect(() => validateImageSize(3072, 1008)).toThrow("宽高比");
expect(() => validateImageSize(3840, 2176)).toThrow("总像素");
});
test("解包 b64_json、url 和业务错误", () => {
expect(parseImagePayload({ data: [{ b64_json: "abc" }, { url: "https://example.com/image.png" }] })).toEqual([
{ id: expect.any(String), dataUrl: "data:image/png;base64,abc" },
{ id: expect.any(String), dataUrl: "https://example.com/image.png" },
]);
expect(() => parseImagePayload({ data: [] })).toThrow("接口没有返回图片");
expect(() => parseImagePayload({ code: 1001, msg: "额度不足" })).toThrow("额度不足");
});
test("统一解析 Responses、Chat Completions 和 Gemini 工具调用", () => {
expect(
parseToolResponse({
output: [{ type: "function_call", call_id: "call-1", name: "search", arguments: '{"q":"canvas"}' }],
}),
).toMatchObject({ toolCalls: [{ id: "call-1", function: { name: "search", arguments: '{"q":"canvas"}' } }] });
expect(
parseChatCompletionPayload({
choices: [{ message: { content: "done", tool_calls: [{ id: "call-2", function: { name: "search", arguments: "{}" } }] } }],
}),
).toMatchObject({ content: "done", toolCalls: [{ id: "call-2", function: { name: "search" } }] });
expect(
parseGeminiToolResponse({
candidates: [
{
content: {
parts: [
{ text: "thinking", thought: true },
{ functionCall: { id: "call-3", name: "search", args: { q: "canvas" } }, thoughtSignature: "sig" },
],
},
},
],
}),
).toMatchObject({ reasoning: "thinking", toolCalls: [{ id: "call-3", thoughtSignature: "sig", function: { name: "search", arguments: '{"q":"canvas"}' } }] });
});
test("SSE 增量解析保留正文、reasoning 和工具调用", () => {
const responseState = { buffer: "", text: "", reasoning: "" };
consumeResponseStreamText(responseState, 'data: {"type":"response.output_text.delta","delta":"hel"}\n\n');
consumeResponseStreamText(responseState, 'data: {"type":"response.reasoning.delta","delta":"why"}\n\n');
consumeResponseStreamText(responseState, 'data: {"type":"response.output_text.delta","delta":"lo"}', undefined, undefined, true);
expect(responseState).toMatchObject({ text: "hello", reasoning: "why", buffer: "" });
const chatState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
consumeChatCompletionStreamText(chatState, 'data: {"choices":[{"delta":{"content":"ok","reasoning_content":"r"}}]}\n\n');
expect(chatState).toMatchObject({ text: "ok", reasoning: "r" });
const geminiState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
consumeGeminiStreamText(geminiState, 'data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}\n\n');
expect(geminiState).toMatchObject({ text: "hi" });
});
});

View File

@@ -37,20 +37,26 @@ describe("media fallback", () => {
});
describe("task cancellation policy", () => {
test("offers cancellation only while a task is queued", () => {
const shared = source("../src/pages/tasks/task-shared.tsx");
test("does not expose cancellation after a task is created", () => {
const list = source("../src/pages/tasks/task-list-row.tsx");
const grid = source("../src/pages/tasks/task-grid-card.tsx");
const page = source("../src/pages/tasks/index.tsx");
expect(shared).toContain('return task.status === "queued";');
expect(list).toContain("const isCancellable = isTaskCancellable(task);");
expect(list).toContain("{isCancellable ? (");
expect(grid).toContain("const isCancellable = isTaskCancellable(task);");
expect(grid).toContain("{isCancellable ? (");
expect(page).toContain('if (action === "cancel" && currentTask && !isTaskCancellable(currentTask))');
expect(page).toContain('message.warning("任务已开始生成,无法取消")');
expect(page).toContain('detailTask.provider === "dreamina-cli" && isTaskCancellable(detailTask)');
expect(page).not.toContain('detailTask.status === "queued" || detailTask.status === "running"');
expect(list).not.toContain("isTaskCancellable");
expect(list).not.toContain("取消任务");
expect(grid).not.toContain("isTaskCancellable");
expect(grid).not.toContain("取消任务");
expect(page).not.toContain("cancelGenerationTask");
expect(page).not.toContain('runAction(detailTask.id, "cancel")');
expect(page).toContain('if (task.status === "queued" || task.status === "running")');
expect(page).toContain("任务正在执行,不能删除本机记录");
});
test("batch stop only applies to items still waiting locally", () => {
const batches = source("../src/pages/canvas/use-canvas-generation-batches.ts");
expect(batches).toContain('item.status === "waiting" && !nodeById.get(item.nodeId)?.metadata?.taskId');
expect(batches).toContain('item.status === "waiting" && stoppableItems.some((candidate) => candidate.id === item.id)');
expect(batches).not.toContain('item.status === "waiting" || item.status === "submitting"');
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { unwrapEnvelope, videoTaskId } from "../src/services/api/video-response";
import { isPublicMediaUrl, normalizeVideoResolution, normalizeVideoSeconds, normalizeVideoSize } from "../src/services/api/video-validation";
describe("video API response contracts", () => {
test("解包裸响应和后端 envelope", () => {
expect(unwrapEnvelope({ id: "task-1" }, "缺少任务")).toEqual({ id: "task-1" });
expect(unwrapEnvelope({ code: 0, data: { id: "task-2" }, msg: "ok" }, "缺少任务")).toEqual({ id: "task-2" });
expect(videoTaskId({ request_id: "request-1" })).toBe("request-1");
});
test("业务失败和空数据不会被静默转换为成功", () => {
expect(() => unwrapEnvelope({ code: 401, data: null, msg: "未授权" }, "缺少任务")).toThrow("未授权");
expect(() => unwrapEnvelope({ code: 0, data: null, msg: "ok" }, "缺少任务")).toThrow("缺少任务");
});
});
describe("video request normalization", () => {
test("规范化时长、比例尺寸和分辨率", () => {
expect(normalizeVideoSeconds("0")).toBe("6");
expect(normalizeVideoSeconds("8.9")).toBe("8");
expect(normalizeVideoSize("16:9")).toBe("1280x720");
expect(normalizeVideoSize("auto")).toBeNull();
expect(normalizeVideoResolution("low")).toBe("480p");
expect(normalizeVideoResolution("2k")).toBe("1440p");
});
test("只把 HTTP(S) 地址视为公网媒体地址", () => {
expect(isPublicMediaUrl("https://cdn.example.com/video.mp4")).toBe(true);
expect(isPublicMediaUrl("http://localhost/video.mp4")).toBe(true);
expect(isPublicMediaUrl("asset://resource-1")).toBe(false);
expect(isPublicMediaUrl("data:video/mp4;base64,AAAA")).toBe(false);
expect(isPublicMediaUrl("/resources/video.mp4")).toBe(false);
});
});