feat(agent): 审美分析 - 接入费用确认与阶段任务恢复

This commit is contained in:
王志伟 committed 2026-09-09 13:01:47 +08:00
1 parent 757d1dd2c7
commit d0024fbbc6
13 files changed
+432 -27

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
import { expect, test } from "bun:test";
import { ApprovedToolExecution, type ApprovedToolExecutionState } from "../src/services/approved-tool-execution";
import { creationRuns, type CreationRun, type CreationSubmission } from "../src/services/api/creation-runs";
import { createModelChannel, defaultConfig } from "../src/stores/use-config-store";
import type { GenerationTask } from "../src/services/api/task-center";
const identity = { kind: "art-critique", nodeId: "node", sourceFingerprint: "image", model: "model" };
const config = { ...defaultConfig, model: "system::model", channels: [createModelChannel({ id: "system", scope: "system", models: ["model"], interfaceType: "chat-completion" })] };
const request = (stage: string) => ({ config, prompt: stage, messages: [{ role: "user" as const, content: "review" }], tools: [], toolChoice: "auto" as const, metadata: { source: "art-critique", stage } });
const quote = (id: string, stage: string, taskId?: string): CreationSubmission => ({ id, runId: "run", itemKey: `stage:${stage}`, requestHash: stage, taskId, quote: { model: "model", billingMode: "fixed_request", quantity: 1, amountMicrocredits: 1_000_000, estimated: false, expiresAt: "2099-01-01", quoteHash: id } });
function harness(submissions: CreationSubmission[] = [], state = identity) {
let run: CreationRun = { id: "run", userId: "user", revision: 1, executionEpoch: 0, executionOwner: "", status: "idle", state, createdAt: "", updatedAt: "" };
let view: ApprovedToolExecutionState = { busy: false };
const listeners: Array<{ matches: (view: ApprovedToolExecutionState) => boolean; resolve: (view: ApprovedToolExecutionState) => void }> = [];
const approvals: string[][] = [], executions: string[] = [], waits: string[] = [];
let creates = 0, refreshes = 0;
const api = { ...creationRuns,
create: async () => { creates++; return { run, submissions }; },
get: async () => ({ run, submissions }),
claim: async (_id: string, input: { owner: string }) => (run = { ...run, executionEpoch: run.executionEpoch + 1, executionOwner: input.owner }),
release: async () => ({ released: true }),
prepare: async (_id: string, input: { itemKey: string }) => {
const existing = submissions.find((item) => item.itemKey === input.itemKey);
if (existing) return existing;
const created = quote(`quote-${submissions.length}`, input.itemKey.replace("stage:", "")); submissions.push(created); return created;
},
approve: async (_id: string, input: { submissionIds: string[] }) => {
approvals.push(input.submissionIds);
submissions = submissions.map((item) => input.submissionIds.includes(item.id) ? { ...item, approvedAt: "2026-01-01" } : item);
return { submissions: submissions.filter((item) => input.submissionIds.includes(item.id)) };
},
refreshQuote: async (_id: string, input: { submissionId: string }) => {
refreshes++;
const item = submissions.find((entry) => entry.id === input.submissionId)!;
item.revokedAt = "2026-01-01";
const fresh = { ...item, id: `fresh-${refreshes}`, itemKey: `requote:${item.id}`, revokedAt: undefined, approvedAt: undefined, quote: { ...item.quote, expiresAt: "2099-01-01" } };
submissions.push(fresh); return fresh;
},
execute: async (_id: string, input: { submissionId: string }) => {
const item = submissions.find((entry) => entry.id === input.submissionId)!;
expect(Boolean(item.taskId || item.approvedAt)).toBe(true);
executions.push(input.submissionId);
return { id: item.taskId || `task-${item.id}` } as GenerationTask;
},
} as typeof creationRuns;
const controller = new AbortController();
const execution = new ApprovedToolExecution({ clientKey: "test", ...(submissions.length ? { runId: "run" } : {}), identity, signal: controller.signal, onRun: () => {}, onState: (next) => {
view = next;
for (const listener of [...listeners]) if (listener.matches(view)) { listeners.splice(listeners.indexOf(listener), 1); listener.resolve(view); }
} }, api, async (id) => { waits.push(id); return { id, status: "succeeded", resultJson: JSON.stringify({ text: "report" }) } as GenerationTask; });
return { execution, controller, api, approvals, executions, waits, counters: () => ({ creates, refreshes }), view: () => view,
until: (matches: (view: ApprovedToolExecutionState) => boolean) => matches(view) ? Promise.resolve(view) : new Promise<ApprovedToolExecutionState>((resolve) => listeners.push({ matches, resolve })),
};
}
test("用户只批准费用卡已展示的项目,后来加入的阶段继续等待", async () => {
const h = harness();
try {
const first = h.execution.execute(request("first"));
const shown = await h.until((view) => view.quote?.items.length === 1);
const second = h.execution.execute(request("second"));
await h.until((view) => view.quote?.items.length === 2);
expect(h.executions).toEqual([]);
await h.execution.approve(shown.quote!.items.map((item) => item.id));
expect((await first).content).toBe("report");
expect(h.executions).toHaveLength(1);
expect(h.view().quote?.items).toHaveLength(1);
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await second;
expect(h.approvals).toEqual([["quote-0"], ["quote-1"]]);
} finally { h.execution.dispose(); }
});
test("恢复已提交阶段沿用原任务,即使原报价过期也不新增批准", async () => {
const item = quote("existing", "stage", "original-task"); item.quote.expiresAt = "2000-01-01";
const h = harness([item]);
try {
await h.execution.execute(request("stage"));
expect(h.waits).toEqual(["original-task"]); expect(h.approvals).toEqual([]);
expect(h.counters()).toEqual({ creates: 0, refreshes: 0 });
} finally { h.execution.dispose(); }
});
test("等待费用确认时取消,不提交模型;迟到按钮调用不抛未处理异常", async () => {
const h = harness();
try {
const result = h.execution.execute(request("stage")).catch((error: Error) => error);
await h.until((view) => Boolean(view.quote)); h.controller.abort();
expect((await result as Error).name).toBe("AbortError");
await h.execution.approve(["quote-0"]); await h.execution.refresh();
expect(h.executions).toEqual([]); expect(h.approvals).toEqual([]);
} finally { h.execution.dispose(); }
});
test("已批准但未提交的过期报价必须刷新并重新确认", async () => {
const item = { ...quote("expired", "stage"), approvedAt: "2000-01-01" }; item.quote.expiresAt = "2000-01-01";
const h = harness([item]);
try {
const result = h.execution.execute(request("stage"));
await h.until((view) => Boolean(view.quote));
expect(h.executions).toEqual([]); expect(h.counters().refreshes).toBe(1);
await h.execution.approve(["expired"]); expect(h.executions).toEqual([]);
await h.execution.approve(h.view().quote!.items.map((entry) => entry.id)); await result;
expect(h.executions).toEqual(["fresh-1"]);
} finally { h.execution.dispose(); }
});
test("刷新后的报价可重新打开恢复,找回未撤销的替代条目", async () => {
const old = { ...quote("old", "stage"), revokedAt: "2026-01-01" };
const fresh = { ...quote("new", "stage", "original-task"), itemKey: "requote:old" };
const h = harness([old, fresh]);
try { await h.execution.execute(request("stage")); expect(h.executions).toEqual(["new"]); expect(h.waits).toEqual(["original-task"]); }
finally { h.execution.dispose(); }
});
test("图片或模型身份改变时拒绝恢复", async () => {
const h = harness([quote("old", "stage")], { ...identity, model: "different" });
try { await expect(h.execution.execute(request("stage"))).rejects.toThrow("已变化"); expect(h.executions).toEqual([]); }
finally { h.execution.dispose(); }
});
test("并行阶段报价失败会停止其他等待中的阶段,不永久挂起", async () => {
const h = harness();
try {
const first = h.execution.execute(request("first")).catch((error: Error) => error);
await h.until((view) => Boolean(view.quote));
h.api.prepare = async () => { throw new Error("报价服务不可用"); };
await expect(h.execution.execute(request("second"))).rejects.toThrow("报价服务不可用");
expect((await first as Error).name).toBe("AbortError"); expect(h.executions).toEqual([]);
} finally { h.execution.dispose(); }
});
test("刷新费用卡后旧按钮不能批准新报价", async () => {
const h = harness();
try {
const result = h.execution.execute(request("analyze_art_scene"));
const shown = await h.until((view) => Boolean(view.quote));
await h.execution.refresh();
expect(h.view().quote?.items[0]?.label).toBe("理解画面");
await h.execution.approve(shown.quote!.items.map((item) => item.id));
expect(h.executions).toEqual([]); expect(h.view().error).toContain("报价已变化");
await h.execution.approve(h.view().quote!.items.map((item) => item.id)); await result;
expect(h.approvals).toEqual([["fresh-1"]]);
} finally { h.execution.dispose(); }
});
+10 -1
View File
@@ -3,7 +3,7 @@ import { executeArtCritique } from "../src/services/art-critique-execution";
import { defaultConfig } from "../src/stores/use-config-store";
import { createCanvasNode } from "../src/lib/canvas/canvas-project-domain";
import { CanvasNodeType } from "../src/types/canvas";
import type { ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
import { runArtCritiquePipeline, type ArtCritiquePipelineOptions } from "../src/lib/art-critique/pipeline";
const source = createCanvasNode(CanvasNodeType.Image, { x: 0, y: 0 }, { storageKey: "resource:original" });
const options = () => ({ nodeId: "analysis", runId: "run", source, config: defaultConfig, signal: new AbortController().signal, onTaskCreated: (_stage: string, _id: string) => {} });
@@ -44,3 +44,12 @@ test("准备素材期间取消不会提交后台模型任务", async () => {
})).rejects.toThrow();
expect(submitted).toBe(false);
});
test("审批执行器中止不能被管线降级为已完成报告", async () => {
await expect(runArtCritiquePipeline({ ...defaultConfig, textModel: "model" }, { dataUrl: "resource:image", title: "image", sourceFingerprint: "image" }, {
requestStage: async ({ toolName }) => {
if (toolName === "analyze_art_scene") throw new DOMException("报价服务不可用", "AbortError");
return { content: "", toolCalls: [{ id: toolName, type: "function", function: { name: toolName, arguments: JSON.stringify({ candidates: [] }) } }] };
},
})).rejects.toThrow("报价服务不可用");
});
+6 -1
View File
@@ -8,7 +8,7 @@ import type { GenerationTask } from "../src/services/api/task-center";
import { CanvasNodeType } from "../src/types/canvas";
function harness(state: CreativeAgentState, status: CreationRun["status"] = "paused", submissions: CreationSubmission[] = [], waitTask?: ConstructorParameters<typeof CreativeAgentController>[0]["waitTask"]) {
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, createdAt: "", updatedAt: "" };
let run: CreationRun = { id: "run", userId: "user", canvasId: "canvas", revision: 1, executionEpoch: 0, executionOwner: "", status, state: structuredClone(state) as unknown as Record<string, unknown>, approvedProposalVersion: state.proposal?.version, approvedProposalHash: state.operations ? "approved-proposal-hash" : undefined, createdAt: "", updatedAt: "" };
let snapshot: CanvasAgentSnapshot = { projectId: "canvas", title: "canvas", nodes: state.media.map((media) => ({ id: media.nodeId, type: CanvasNodeType.Image, title: media.ref, position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} })), connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
let view: CreativeControllerView | undefined;
let commits = 0, prepares = 0, executions = 0;
@@ -68,6 +68,11 @@ describe("创作控制器恢复", () => {
const h = harness({ ...initialCreativeState(), planning: { itemKey: "planning:key", protocol: [], model: "model" } }, "running", [submission("existing", "planning:key")]);
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().state.planning?.submissionId).toBe("existing"); expect(h.view().quote).toBeDefined(); expect(h.counters().prepares).toBe(0); } finally { h.controller.dispose(); }
});
test("未批准的方案恢复时仍等待用户确认,不创建画布节点", async () => {
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: false });
try { await h.controller.load("run"); await h.controller.resume(); expect(h.view().run?.status).toBe("waiting_proposal"); expect(h.snapshot().nodes).toHaveLength(0); expect(h.counters()).toEqual({ commits: 0, prepares: 0, executions: 0 }); }
finally { h.controller.dispose(); }
});
test("第一项失败不阻止同批成功结果回写", async () => {
const media = ["a", "b"].map((ref) => ({ ref, nodeId: ref, attempt: 1, submissionId: ref, taskId: ref, status: "queued" as const }));
const h = harness({ ...initialCreativeState(), proposal, canvasApplied: true, media }, "paused", [submission("a", "a", "a"), submission("b", "b", "b")], async (id) => { if (id === "a") throw new Error("A生成失败"); return { id, status: "succeeded", resultJson: JSON.stringify({ images: [{ storageKey: "resource:output", dataUrl: "/api/resources/output/file" }] }) } as GenerationTask; });