feat(agent): Agent 工作台 - 计划插话记忆、统一模型选择器并修复摘要与视频入库

将计划清单、运行中插话和个人记忆接入云端 Agent;全站模型选择器统一为左右二级菜单。画布摘要按预算截断不再整轮拒绝,首页视频未知宽高可入库。README 补充贡献者 morrow。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-17 20:21:25 +08:00
parent b6bae4f957
commit 6892732b06
80 changed files with 7364 additions and 182 deletions

View File

@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
// 自研(2026-09-17):Agent 运行中插话。
//
// 这条功能的核心是**取消一个此前刻意加上的限制**:运行时输入框本来是禁用的
// (旧代码 `disabled={busy || running || …}`),策略提示词也据此写着「运行中用户无法打字」。
// 所以这里的断言要盯住两件事:入口真的打开了,以及旧前提没有残留在代码里。
test("运行中不再锁死输入框,发送走插话而不是新建轮次", async () => {
const panel = await Bun.file(new URL("../src/components/canvas/canvas-cloud-agent-panel.tsx", import.meta.url)).text();
// 运行中走插话分支(不建轮次、不走那套扣费幂等记账)
expect(panel).toContain("await interject(value)");
expect(panel).toContain("sendAgentInterjection(activeRun.id, { text: value, messageId })");
// 乐观落进时间线时带插话标记,与「开新一轮」的消息在界面上区分开
expect(panel).toContain('interjection: "sent"');
// 输入框的禁用条件里不能再有 running —— 只保留「连不上事件流」「历史/待确认未水合」这类真不能发的状态
expect(panel).toContain('disabled={Boolean(run && connectionStatus !== "connected")');
expect(panel).not.toContain("disabled={busy || running ||");
// 停止按钮由 running 驱动,发送按钮由我们自己的请求驱动,两者不再互斥
expect(panel).toContain("sending={busy}");
expect(panel).toContain("running={running}");
// 两个 SSE 事件都要处理:回显(多标签页)与未送达退回
expect(panel).toContain('event.type === "user_interjection"');
expect(panel).toContain('event.type === "user_interjection_dropped"');
// 退回时要让文案能被再次发送
expect(panel).toContain("setPrompt?.((current) => (current.trim() ? current : text))");
});
test("输入框运行中同时给出「发送(插话)」与「停止」", async () => {
const composer = await Bun.file(new URL("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx", import.meta.url)).text();
// 关键改动:canStop 不再等于 sending —— 否则运行中两个动作只能二选一
expect(composer).toContain("const canStop = Boolean(running && onStop);");
expect(composer).toContain("const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);");
expect(composer).not.toContain("const canStop = Boolean(sending && onStop);");
expect(composer).toContain('aria-label="停止本轮"');
// 插话/未送达要有可见标记
expect(composer).toContain('interjection?: "sent" | "undelivered";');
expect(composer).toContain('item.interjection === "undelivered" ? "插话未送达" : "插话"');
});
test("插话走独立接口,不碰轮次接口", async () => {
const api = await Bun.file(new URL("../src/services/api/agent.ts", import.meta.url)).text();
expect(api).toContain("export function sendAgentInterjection(");
expect(api).toContain("/interjections");
expect(api).toContain("messageId: string");
});

View File

@@ -57,3 +57,35 @@ describe("parseAssetRecord", () => {
expect(() => parseAssetRecordList([{ ...completeImage, id: "bad", tags: undefined }])).toThrow(/素材 bad/);
});
});
const completeVideo = {
id: "video-1",
kind: "video",
title: "生成视频",
coverUrl: "https://example.com/a.mp4",
tags: ["生成"],
createdAt: "2026-08-29T00:00:00.000Z",
updatedAt: "2026-08-29T00:00:00.000Z",
data: { url: "https://example.com/a.mp4", width: 720, height: 1280, bytes: 1, mimeType: "video/mp4" },
};
describe("parseAssetRecord 视频尺寸", () => {
test("宽高未知(0)放行,视频照常入库", () => {
const parsed = parseAssetRecord({ ...completeVideo, data: { ...completeVideo.data, width: 0, height: 0 } });
expect(parsed.kind).toBe("video");
if (parsed.kind === "video") {
expect(parsed.data.width).toBe(0);
expect(parsed.data.height).toBe(0);
}
});
test("负数与非法值仍然拒", () => {
expect(() => parseAssetRecord({ ...completeVideo, data: { ...completeVideo.data, width: -1 } })).toThrow(/width/);
expect(() => parseAssetRecord({ ...completeVideo, data: { ...completeVideo.data, height: Number.NaN } })).toThrow(/height/);
expect(() => parseAssetRecord({ ...completeVideo, data: { ...completeVideo.data, width: "720" } })).toThrow(/width/);
});
test("图片不受影响:零尺寸仍然拒", () => {
expect(() => parseAssetRecord({ ...completeImage, data: { ...completeImage.data, width: 0, height: 1 } })).toThrow(/width/);
});
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test";
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
const items = (...statuses: Array<"pending" | "doing" | "done">) => statuses.map((status, index) => ({ id: `${index + 1}`, title: `第 ${index + 1} 项`, status }));
/**
* 回归(2026-09-16):一个对话会跨多轮运行,每轮各有一条 `plan-${runId}` 清单消息,
* 都在同一个 messages 数组里。原来取的是**第一条**,于是清单永远钉在第 1 轮的初始状态 ——
* 后面怎么推进都不刷新、全部做完也不消失(实测反馈:plan 完成了不更新、全完成也不关闭)。
*/
describe("latestAgentPlanItems", () => {
test("跨轮取最新那份,而不是第一条", () => {
const messages = [
{ id: "plan-run-1", planItems: items("pending", "pending") },
{ id: "user-1", text: "..." },
{ id: "plan-run-2", planItems: items("done", "doing") },
];
expect(latestAgentPlanItems(messages).map((entry) => entry.status)).toEqual(["done", "doing"]);
});
test("最新那份全部完成 → 清单收起", () => {
const messages = [
{ id: "plan-run-1", planItems: items("pending") },
{ id: "plan-run-2", planItems: items("done", "done") },
];
const plan = latestAgentPlanItems(messages);
expect(plan.every((entry) => entry.status === "done")).toBe(true);
expect(agentPlanVisible(plan)).toBe(false);
});
test("最新那份还没做完 → 继续显示", () => {
expect(agentPlanVisible(latestAgentPlanItems([{ id: "plan-run-1", planItems: items("done", "doing") }]))).toBe(true);
});
test("没有任何清单(包括带空数组的)→ 空且不显示", () => {
expect(latestAgentPlanItems([])).toEqual([]);
expect(latestAgentPlanItems([{ id: "user-1" }, { id: "plan-run-1", planItems: [] }])).toEqual([]);
expect(agentPlanVisible([])).toBe(false);
});
});
/**
* 回归(2026-09-16):运行中输入框是禁用的,模型在正文里提问用户根本答不了
* (生产实测它连问 4 轮、白烧十几步)。`ask_user` 让本轮收尾并把问题结构化,
* 这个选择器决定「现在该不该把选项面板亮给用户」。
*/
describe("pendingAgentQuestion", () => {
const question = { question: "选哪个模型?", options: [{ label: "H3文生视频" }, { label: "Seedance 2.5" }] };
test("有未作答的提问 → 亮面板", () => {
const messages = [
{ role: "user" },
{ role: "assistant", question },
];
expect(pendingAgentQuestion(messages)?.question).toBe("选哪个模型?");
});
test("提问之后用户已回复 → 收起面板", () => {
const messages = [
{ role: "assistant", question },
{ role: "user" },
];
expect(pendingAgentQuestion(messages)).toBeUndefined();
});
test("多轮提问取最新那道;旧的那道不算数", () => {
const later = { question: "确认开始吗?", options: [{ label: "开始" }, { label: "再改改" }] };
const messages = [
{ role: "assistant", question },
{ role: "user" },
{ role: "assistant", question },
{ role: "assistant", question: later },
];
expect(pendingAgentQuestion(messages)?.question).toBe("确认开始吗?");
});
test("没有提问 → 不亮面板", () => {
expect(pendingAgentQuestion([])).toBeUndefined();
expect(pendingAgentQuestion([{ role: "assistant" }, { role: "user" }])).toBeUndefined();
});
});

View File

@@ -20,6 +20,7 @@ test("welcome contributors parse the README HTML table", () => {
});
expect(people[0]?.signature).toContain("没有任何黑魔法");
expect(people.some((person) => person.name === "爱笑的毛毛虫")).toBe(true);
expect(people.some((person) => person.name === "morrow" && person.email === "3201190041@qq.com" && person.signature === "最难不过坚持")).toBe(true);
expect(people.filter((person) => person.isFounder)).toHaveLength(1);
});