feat(web): 工作台 - 优化 Agent 工作区与技能库
This commit is contained in:
1 parent
b4111a8721
commit
5df5e09c63
22 files changed
+1681
-196
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AgentChatMessage, rewriteAgentNodeLinks, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
const videoReference = {
|
||||
id: "video-1790385749473-q4157",
|
||||
nodeId: "video-1790385749473-q4157",
|
||||
kind: "video" as const,
|
||||
label: "片头镜头",
|
||||
title: "片头镜头",
|
||||
sourceType: "video" as const,
|
||||
previewUrl: "data:image/png;base64,preview",
|
||||
};
|
||||
|
||||
function assistantMessage(text: string): CloudAgentChatMessage {
|
||||
return { id: "assistant-1", role: "assistant", text };
|
||||
}
|
||||
|
||||
test("rewrites generated node IDs into semantic Markdown links", () => {
|
||||
const text = rewriteAgentNodeLinks("新节点 ID:video-1790385749473-q4157", [videoReference]);
|
||||
|
||||
expect(text).toContain("新节点:[视频节点 · 片头镜头](#agent-node:video-1790385749473-q4157)");
|
||||
expect(text).not.toContain("新节点 ID");
|
||||
expect(text).not.toContain("video-1790385749473-q4157</a>");
|
||||
});
|
||||
|
||||
test("does not mistake Agent task IDs for canvas nodes", () => {
|
||||
const text = "任务 ag293f6d7-55b6-4a25-8c4f-8c6b2c4d9b10 已创建";
|
||||
expect(rewriteAgentNodeLinks(text)).toBe(text);
|
||||
});
|
||||
|
||||
test("falls back to a node type label and URL-encodes exact reference IDs", () => {
|
||||
const nodeId = "video-custom:take-1";
|
||||
const text = rewriteAgentNodeLinks(nodeId, [{ ...videoReference, id: nodeId, nodeId, title: "自定义镜头", label: "自定义镜头" }]);
|
||||
|
||||
expect(text).toContain("[视频节点 · 自定义镜头](#agent-node:video-custom%3Atake-1)");
|
||||
expect(rewriteAgentNodeLinks("video-123")).toContain("[视频节点](#agent-node:video-123)");
|
||||
});
|
||||
|
||||
test("leaves fenced code blocks untouched", () => {
|
||||
const text = "```text\nvideo-1790385749473-q4157\n```\nvideo-1790385749473-q4157";
|
||||
const rewritten = rewriteAgentNodeLinks(text, [videoReference]);
|
||||
|
||||
expect(rewritten).toContain("```text\nvideo-1790385749473-q4157\n```");
|
||||
expect(rewritten).toContain("[视频节点 · 片头镜头](#agent-node:video-1790385749473-q4157)");
|
||||
});
|
||||
|
||||
test("renders an agent node link as a titled inline node card", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentChatMessage
|
||||
item={assistantMessage("新节点 ID:video-1790385749473-q4157")}
|
||||
theme={canvasThemes.dark}
|
||||
references={[videoReference]}
|
||||
onFocusNode={() => {}}
|
||||
/>,
|
||||
);
|
||||
const visibleText = html.replace(/<[^>]+>/gu, "");
|
||||
|
||||
expect(html).toContain('class="agent-message-node-link"');
|
||||
expect(html).toContain('data-agent-node-id="video-1790385749473-q4157"');
|
||||
expect(html).toContain("视频节点");
|
||||
expect(html).toContain("片头镜头");
|
||||
expect(visibleText).not.toContain("video-1790385749473-q4157");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { AgentOperationFeed, AgentReasoningFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
|
||||
import { AgentChatMessage, AgentOperationFeed, AgentReasoningFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
|
||||
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
|
||||
@@ -22,6 +22,9 @@ test("consecutive reasoning messages share one collapsed entry", () => {
|
||||
const html = renderToStaticMarkup(<AgentReasoningFeed items={messages.slice(0, 2)} theme={canvasThemes.light} />);
|
||||
expect(html).toContain("2 段 · 点击查看");
|
||||
expect(html).toContain('class="agent-reasoning-card"');
|
||||
expect(html).toContain('class="agent-reasoning-icon"');
|
||||
expect(html).toContain("lucide-brain-circuit");
|
||||
expect(html).not.toContain("is-live");
|
||||
expect(html).not.toContain(" open");
|
||||
});
|
||||
|
||||
@@ -35,9 +38,25 @@ test("operations fold into one line that reports only the latest action", () =>
|
||||
expect(html).not.toContain("agent-operation-list");
|
||||
expect(html).toContain("2 步");
|
||||
expect(html).toContain("读取信息");
|
||||
expect(html).toContain('class="agent-operation-icon"');
|
||||
expect(html).toContain("lucide-list");
|
||||
}
|
||||
});
|
||||
|
||||
test("messages and live reasoning expose their visual markers", () => {
|
||||
const assistant = renderToStaticMarkup(<AgentChatMessage item={{ id: "a1", role: "assistant", text: "已完成。" }} theme={canvasThemes.light} />);
|
||||
expect(assistant).toContain('class="agent-message-icon agent-message-icon--assistant"');
|
||||
expect(assistant).toContain("lucide-message-circle");
|
||||
|
||||
const user = renderToStaticMarkup(<AgentChatMessage item={{ id: "u1", role: "user", text: "继续处理" }} theme={canvasThemes.light} />);
|
||||
expect(user).toContain('class="agent-message-icon agent-message-icon--user"');
|
||||
expect(user).toContain("lucide-user-round");
|
||||
|
||||
const live = renderToStaticMarkup(<AgentReasoningFeed items={[{ id: "r1", role: "assistant", text: "正在整理", reasoning: true, streaming: true }]} theme={canvasThemes.light} />);
|
||||
expect(live).toContain('class="agent-reasoning-icon is-live"');
|
||||
expect(live).toContain("模型正在思考");
|
||||
});
|
||||
|
||||
test("the folded line names the tier it is reporting", () => {
|
||||
const read = renderToStaticMarkup(<AgentOperationFeed items={[steps[0]]} theme={canvasThemes.light} />);
|
||||
expect(read).toContain("读取清单");
|
||||
@@ -111,6 +130,7 @@ test("style contract: 正文 / 工具调用 / 模型思考三档不再制造左
|
||||
expect(reasoning).not.toContain("margin-left");
|
||||
expect(reasoning).not.toContain("border-left");
|
||||
expect(block(".agent-reasoning-summary")).toContain("padding: 6px 0");
|
||||
expect(block(".agent-reasoning-icon.is-live")).toContain("animation: agent-reasoning-icon-pulse");
|
||||
|
||||
// ④ 操作流是轻量文本收据,不再绘制竖线或额外左缩进。
|
||||
const feed = block(".agent-operation-feed");
|
||||
|
||||
@@ -125,6 +125,7 @@ describe("Agent tool error classification", () => {
|
||||
expect(agentToolErrorClassLabel({ result: { errorClass: "state_conflict" } })).toBe("画布状态已变化");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "permission_violation", errorClassLabel: "超出本轮权限" })).toBe("超出本轮权限");
|
||||
expect(agentToolErrorClassLabel({ result: { errorClass: "upstream_failure" } })).toBe("上游故障");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "admission_failure" })).toBe("媒体生成准入失败");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "invalid_model_output" })).toBe("模型输出问题");
|
||||
expect(agentToolErrorClassLabel({ errorClass: "unknown_new_class" })).toBe("工具执行失败");
|
||||
expect(agentToolErrorClassLabel({ text: "没有归类" })).toBeUndefined();
|
||||
|
||||
@@ -43,6 +43,19 @@ describe("backend API request error semantics", () => {
|
||||
expect(thrown).toMatchObject({ status: 502, message: "后端服务暂时不可用,请稍后重试", retryable: true });
|
||||
});
|
||||
|
||||
test("marks transport failures without an HTTP response as retryable", async () => {
|
||||
const axiosError = {
|
||||
isAxiosError: true,
|
||||
code: "ERR_NETWORK",
|
||||
message: "Network Error",
|
||||
};
|
||||
const thrown = await request(Promise.reject(axiosError)).catch((error) => error);
|
||||
|
||||
expect(thrown).toBeInstanceOf(ApiError);
|
||||
expect(thrown).toMatchObject({ message: "Network Error", retryable: true });
|
||||
expect(thrown.cause).toBe(axiosError);
|
||||
});
|
||||
|
||||
test("http.get unwraps the same backend envelope", async () => {
|
||||
const original = apiClient.request;
|
||||
apiClient.request = (async () => ({
|
||||
|
||||
@@ -23,6 +23,7 @@ let remote = new Map<string, CanvasProject>();
|
||||
let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
|
||||
let beforePut: (() => Promise<void>) | undefined;
|
||||
let deleteFailureId: string | undefined;
|
||||
let temporaryPutFailures = 0;
|
||||
|
||||
function canvas(id = "canvas"): CanvasProject {
|
||||
return {
|
||||
@@ -54,6 +55,7 @@ beforeEach(async () => {
|
||||
autoSave = undefined;
|
||||
beforePut = undefined;
|
||||
deleteFailureId = undefined;
|
||||
temporaryPutFailures = 0;
|
||||
remote = new Map([
|
||||
["canvas", canvas()],
|
||||
["other", canvas("other")],
|
||||
@@ -108,7 +110,10 @@ beforeEach(async () => {
|
||||
await beforePut?.();
|
||||
const project = body.project as CanvasProject;
|
||||
const current = remote.get(id);
|
||||
if (project.revision !== (current?.revision ?? 0)) status = 409;
|
||||
if (temporaryPutFailures > 0) {
|
||||
temporaryPutFailures -= 1;
|
||||
status = 503;
|
||||
} else if (project.revision !== (current?.revision ?? 0)) status = 409;
|
||||
else {
|
||||
const saved = { ...structuredClone(project), revision: project.revision! + 1 };
|
||||
remote.set(id, saved);
|
||||
@@ -123,6 +128,14 @@ beforeEach(async () => {
|
||||
await syncRemoteUserData(scope);
|
||||
});
|
||||
|
||||
async function waitForSyncState(predicate: () => boolean) {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error("同步测试状态等待超时");
|
||||
await new Promise<void>((resolve) => globalThis.setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
resetRemoteUserDataSync();
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
@@ -245,6 +258,22 @@ test("a conflict preserves drafts, stops retries and does not block another canv
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.draftCount).toBe(2);
|
||||
});
|
||||
|
||||
test("transient cloud failures schedule a bounded automatic retry", async () => {
|
||||
temporaryPutFailures = 1;
|
||||
useCanvasStore.getState().renameProject("canvas", "retry me");
|
||||
expect(autoSave).toBeDefined();
|
||||
|
||||
autoSave?.();
|
||||
await waitForSyncState(() => requests.filter((request) => request.method === "put" && request.id === "canvas").length === 1);
|
||||
expect(autoSave).toBeDefined();
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.message).toContain("自动重试");
|
||||
|
||||
autoSave?.();
|
||||
await waitForSyncState(() => remote.get("canvas")?.title === "retry me");
|
||||
expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(2);
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
|
||||
});
|
||||
|
||||
test("edits during a save retain the right ancestor revision and are saved next", async () => {
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
|
||||
Reference in new issue
Block a user