feat(web): 工作台 - 优化 Agent 工作区与技能库

This commit is contained in:
ddcat666 committed 2026-09-27 09:45:45 +08:00
1 parent b4111a8721
commit 5df5e09c63
22 files changed
+1681 -196

No files matched your search

+65
View File
@@ -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");
});
+21 -1
View File
@@ -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");
+1
View File
@@ -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();
+13
View File
@@ -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 () => ({
+30 -1
View File
@@ -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;