Skip to content

Commit 5c1b1d2

Browse files
committed
✨ 任务工具持久化 & OPFS Blob 跨上下文传输修复
- 任务工具精简为 3 个(create/update/list),新增持久化存储和 UI 实时推送 - 新增 TaskListBlock 组件展示任务进度 - 修复 chrome.runtime sendResponse 不支持 Blob 的问题,改用 blobUrl + CAT_fetchBlob 模式 - 简化 readAttachment API,统一返回 Blob 对象 - 新增 read blob 格式支持 - 更新系统提示词与类型定义
1 parent 7e2b16e commit 5c1b1d2

16 files changed

Lines changed: 417 additions & 168 deletions

File tree

src/app/repo/agent_chat.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { Conversation, ChatMessage } from "@App/app/service/agent/types";
2+
import type { Task } from "@App/app/service/agent/tools/task_tools";
23
import { OPFSRepo } from "./opfs_repo";
34

45
const CONVERSATIONS_FILE = "conversations.json";
56
const MESSAGES_DIR = "data";
67
const ATTACHMENTS_DIR = "attachments";
8+
const TASKS_DIR = "tasks";
79

810
// 目录结构:agents/conversations/
911
// agents/conversations/conversations.json - 会话列表
@@ -66,6 +68,8 @@ export class AgentChatRepo extends OPFSRepo {
6668
// 删除对应消息文件
6769
const messagesDir = await this.getChildDir(MESSAGES_DIR);
6870
await this.deleteFile(`${id}.json`, messagesDir);
71+
// 删除关联的任务数据
72+
await this.deleteTasks(id).catch(() => {});
6973
}
7074

7175
// 获取指定会话的所有消息
@@ -147,6 +151,26 @@ export class AgentChatRepo extends OPFSRepo {
147151
}
148152
}
149153

154+
// ---- 任务 (task_tools) 存储 ----
155+
156+
// 获取会话关联的任务列表
157+
async getTasks(conversationId: string): Promise<Task[]> {
158+
const tasksDir = await this.getChildDir(TASKS_DIR);
159+
return this.readJsonFile<Task[]>(`${conversationId}.json`, [], tasksDir);
160+
}
161+
162+
// 保存会话关联的任务列表
163+
async saveTasks(conversationId: string, tasks: Task[]): Promise<void> {
164+
const tasksDir = await this.getChildDir(TASKS_DIR);
165+
await this.writeJsonFile(`${conversationId}.json`, tasks, tasksDir);
166+
}
167+
168+
// 删除会话关联的任务
169+
async deleteTasks(conversationId: string): Promise<void> {
170+
const tasksDir = await this.getChildDir(TASKS_DIR);
171+
await this.deleteFile(`${conversationId}.json`, tasksDir);
172+
}
173+
150174
// 将 data URL 或纯 base64 转换为 Blob
151175
private dataUrlToBlob(data: string): Blob {
152176
// 匹配 data URL 格式

src/app/service/agent/agent.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,12 @@ describe("callLLMWithToolLoop", () => {
692692

693693
const doneEvent = events.find((e) => e.type === "done");
694694
expect(doneEvent).toBeDefined();
695-
expect(doneEvent!.type === "done" && doneEvent!.usage).toEqual({ inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 });
695+
expect(doneEvent!.type === "done" && doneEvent!.usage).toEqual({
696+
inputTokens: 10,
697+
outputTokens: 5,
698+
cacheCreationInputTokens: 0,
699+
cacheReadInputTokens: 0,
700+
});
696701
});
697702

698703
it("单轮 tool calling", async () => {
@@ -747,7 +752,12 @@ describe("callLLMWithToolLoop", () => {
747752
const doneEvent = events.find((e) => e.type === "done");
748753
expect(doneEvent).toBeDefined();
749754
if (doneEvent?.type === "done") {
750-
expect(doneEvent.usage).toEqual({ inputTokens: 50, outputTokens: 18, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 });
755+
expect(doneEvent.usage).toEqual({
756+
inputTokens: 50,
757+
outputTokens: 18,
758+
cacheCreationInputTokens: 0,
759+
cacheReadInputTokens: 0,
760+
});
751761
}
752762
});
753763

src/app/service/agent/system_prompt.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,17 @@ Detect when you are stuck and stop early:
4848
4949
- **Read page content** → prefer \`get_tab_content\` (structured markdown) over \`execute_script\` (raw JS).
5050
- **Fetch remote data** → \`web_fetch\` for text/HTML/JSON. It does NOT support binary downloads — use a SkillScript with \`fetch()\` + \`CAT.agent.opfs.write(blob)\` for binary files.
51-
- **Ask user** → \`ask_user\` supports text only. To show images to the user, use \`execute_script\` to display them on page.
51+
- **Ask user** → \`ask_user\` for questions. Prefer providing \`options\` for structured choices so the user can select quickly; add \`multiple: true\` for multi-select. The user can also type a custom response even when options are provided. To show images to the user, use \`execute_script\` to display them on page.
52+
53+
## Task Management
54+
55+
For **complex, multi-step tasks**, use task tools to track your progress:
56+
- \`create_task\` — Break the work into individual steps at the start.
57+
- \`update_task\` — Mark each step as \`in_progress\` when you begin it, and \`completed\` when done.
58+
- \`list_tasks\` — Review remaining steps, especially after resuming a conversation.
59+
60+
**When to use:** Tasks that involve 3+ distinct steps (e.g., navigating multiple pages, processing data, multi-stage workflows). Do NOT create tasks for simple, single-step requests.
61+
**Workflow:** Create all tasks first → work through them one by one → update status as you go.
5262
5363
## Binary File Workflow
5464

src/app/service/agent/tools/opfs_tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function setCreateBlobUrlFn(fn: CreateBlobUrlFn): void {
8181
}
8282

8383
/** 根据文件扩展名推断 MIME 类型 */
84-
function guessMimeType(path: string): string {
84+
export function guessMimeType(path: string): string {
8585
const ext = path.split(".").pop()?.toLowerCase() || "";
8686
const map: Record<string, string> = {
8787
jpg: "image/jpeg",

src/app/service/agent/tools/task_tools.test.ts

Lines changed: 62 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { describe, it, expect } from "vitest";
2-
import { createTaskTools } from "./task_tools";
1+
import { describe, it, expect, vi } from "vitest";
2+
import { createTaskTools, type Task } from "./task_tools";
33

44
describe("task_tools", () => {
5-
it("should create 5 tools", () => {
5+
it("应创建 3 个工具", () => {
66
const { tools } = createTaskTools();
7-
expect(tools).toHaveLength(5);
7+
expect(tools).toHaveLength(3);
88
const names = tools.map((t) => t.definition.name);
9-
expect(names).toEqual(["create_task", "get_task", "update_task", "list_tasks", "delete_task"]);
9+
expect(names).toEqual(["create_task", "update_task", "list_tasks"]);
1010
});
1111

12-
it("create_task should create a task with auto-incremented ID", async () => {
12+
it("create_task 应创建自增 ID 的任务", async () => {
1313
const { tools } = createTaskTools();
1414
const create = tools.find((t) => t.definition.name === "create_task")!;
1515

@@ -22,19 +22,7 @@ describe("task_tools", () => {
2222
expect(result2).toEqual({ id: "2", subject: "Task 2", description: "Details", status: "pending" });
2323
});
2424

25-
it("get_task should return task or throw", async () => {
26-
const { tools } = createTaskTools();
27-
const create = tools.find((t) => t.definition.name === "create_task")!;
28-
const get = tools.find((t) => t.definition.name === "get_task")!;
29-
30-
await create.executor.execute({ subject: "Test" });
31-
const result = JSON.parse((await get.executor.execute({ task_id: "1" })) as string);
32-
expect(result.subject).toBe("Test");
33-
34-
await expect(get.executor.execute({ task_id: "999" })).rejects.toThrow('Task "999" not found');
35-
});
36-
37-
it("update_task should update fields", async () => {
25+
it("update_task 应更新任务字段", async () => {
3826
const { tools } = createTaskTools();
3927
const create = tools.find((t) => t.definition.name === "create_task")!;
4028
const update = tools.find((t) => t.definition.name === "update_task")!;
@@ -48,13 +36,13 @@ describe("task_tools", () => {
4836
expect(result.subject).toBe("Updated");
4937
});
5038

51-
it("update_task should throw for non-existent task", async () => {
39+
it("update_task 应对不存在的任务抛错", async () => {
5240
const { tools } = createTaskTools();
5341
const update = tools.find((t) => t.definition.name === "update_task")!;
5442
await expect(update.executor.execute({ task_id: "1" })).rejects.toThrow();
5543
});
5644

57-
it("list_tasks should return all tasks summary", async () => {
45+
it("list_tasks 应返回所有任务摘要", async () => {
5846
const { tools } = createTaskTools();
5947
const create = tools.find((t) => t.definition.name === "create_task")!;
6048
const list = tools.find((t) => t.definition.name === "list_tasks")!;
@@ -68,65 +56,83 @@ describe("task_tools", () => {
6856
expect(result[1]).toEqual({ id: "2", subject: "B", status: "pending" });
6957
});
7058

71-
it("list_tasks should return empty array initially", async () => {
59+
it("list_tasks 初始应返回空数组", async () => {
7260
const { tools } = createTaskTools();
7361
const list = tools.find((t) => t.definition.name === "list_tasks")!;
7462
const result = JSON.parse((await list.executor.execute({})) as string);
7563
expect(result).toEqual([]);
7664
});
7765

78-
it("update_task should allow clearing description with empty string", async () => {
79-
const { tools } = createTaskTools();
66+
it("应从 initialTasks 恢复任务并继续递增 ID", async () => {
67+
const initial: Task[] = [
68+
{ id: "3", subject: "Existing", status: "in_progress" },
69+
{ id: "5", subject: "Another", status: "pending" },
70+
];
71+
const { tools } = createTaskTools({ initialTasks: initial });
8072
const create = tools.find((t) => t.definition.name === "create_task")!;
81-
const update = tools.find((t) => t.definition.name === "update_task")!;
73+
const list = tools.find((t) => t.definition.name === "list_tasks")!;
8274

83-
await create.executor.execute({ subject: "Test", description: "Some desc" });
75+
// 新任务 ID 应从 6 开始(max existing ID 5 + 1)
76+
const result = JSON.parse((await create.executor.execute({ subject: "New" })) as string);
77+
expect(result.id).toBe("6");
8478

85-
const result = JSON.parse((await update.executor.execute({ task_id: "1", description: "" })) as string);
86-
expect(result.description).toBe("");
79+
const all = JSON.parse((await list.executor.execute({})) as string);
80+
expect(all).toHaveLength(3);
8781
});
8882

89-
it("update_task with only task_id should not change anything", async () => {
90-
const { tools } = createTaskTools();
83+
it("create_task 应调用 onSave 和 sendEvent", async () => {
84+
const onSave = vi.fn().mockResolvedValue(undefined);
85+
const sendEvent = vi.fn();
86+
const { tools } = createTaskTools({ onSave, sendEvent });
9187
const create = tools.find((t) => t.definition.name === "create_task")!;
92-
const update = tools.find((t) => t.definition.name === "update_task")!;
9388

94-
await create.executor.execute({ subject: "Original", description: "Desc" });
95-
96-
const result = JSON.parse((await update.executor.execute({ task_id: "1" })) as string);
97-
expect(result.subject).toBe("Original");
98-
expect(result.description).toBe("Desc");
99-
expect(result.status).toBe("pending");
100-
});
89+
await create.executor.execute({ subject: "Test" });
10190

102-
it("create_task without description should not include it in result", async () => {
103-
const { tools } = createTaskTools();
104-
const create = tools.find((t) => t.definition.name === "create_task")!;
91+
expect(onSave).toHaveBeenCalledOnce();
92+
expect(onSave).toHaveBeenCalledWith([{ id: "1", subject: "Test", status: "pending" }]);
10593

106-
const result = JSON.parse((await create.executor.execute({ subject: "No desc" })) as string);
107-
expect(result.description).toBeUndefined();
94+
expect(sendEvent).toHaveBeenCalledOnce();
95+
expect(sendEvent).toHaveBeenCalledWith({
96+
type: "task_update",
97+
tasks: [{ id: "1", subject: "Test", status: "pending" }],
98+
});
10899
});
109100

110-
it("delete_task should remove a task", async () => {
111-
const { tools, tasks } = createTaskTools();
101+
it("update_task 应调用 onSave 和 sendEvent", async () => {
102+
const onSave = vi.fn().mockResolvedValue(undefined);
103+
const sendEvent = vi.fn();
104+
const { tools } = createTaskTools({ onSave, sendEvent });
112105
const create = tools.find((t) => t.definition.name === "create_task")!;
113-
const del = tools.find((t) => t.definition.name === "delete_task")!;
106+
const update = tools.find((t) => t.definition.name === "update_task")!;
114107

115-
await create.executor.execute({ subject: "To delete" });
116-
expect(tasks.size).toBe(1);
108+
await create.executor.execute({ subject: "Task" });
109+
onSave.mockClear();
110+
sendEvent.mockClear();
117111

118-
const result = JSON.parse((await del.executor.execute({ task_id: "1" })) as string);
119-
expect(result).toEqual({ deleted: "1" });
120-
expect(tasks.size).toBe(0);
112+
await update.executor.execute({ task_id: "1", status: "completed" });
113+
114+
expect(onSave).toHaveBeenCalledOnce();
115+
expect(sendEvent).toHaveBeenCalledOnce();
116+
expect(sendEvent).toHaveBeenCalledWith({
117+
type: "task_update",
118+
tasks: [{ id: "1", subject: "Task", status: "completed" }],
119+
});
121120
});
122121

123-
it("delete_task should throw for non-existent task", async () => {
124-
const { tools } = createTaskTools();
125-
const del = tools.find((t) => t.definition.name === "delete_task")!;
126-
await expect(del.executor.execute({ task_id: "999" })).rejects.toThrow('Task "999" not found');
122+
it("list_tasks 不应触发 onSave 或 sendEvent", async () => {
123+
const onSave = vi.fn().mockResolvedValue(undefined);
124+
const sendEvent = vi.fn();
125+
const initial: Task[] = [{ id: "1", subject: "Existing", status: "pending" }];
126+
const { tools } = createTaskTools({ initialTasks: initial, onSave, sendEvent });
127+
const list = tools.find((t) => t.definition.name === "list_tasks")!;
128+
129+
await list.executor.execute({});
130+
131+
expect(onSave).not.toHaveBeenCalled();
132+
expect(sendEvent).not.toHaveBeenCalled();
127133
});
128134

129-
it("tasks map should be independent per createTaskTools call", async () => {
135+
it("多实例应独立", async () => {
130136
const instance1 = createTaskTools();
131137
const instance2 = createTaskTools();
132138

0 commit comments

Comments
 (0)