Skip to content

Commit 250db4f

Browse files
committed
✨ OPFS API 适配 postMessage 通道 & 修复 Blob 传输
- handleOPFSApi 根据 sender 判断通道类型:postMessage 直传 Blob,chrome.runtime 走 blobUrl 中转 - content script middleware 拦截 write Blob 转 blobUrl - offscreen 新增 fetchBlob handler - cat_agent_opfs 客户端兼容两种通道返回 - opfs_read Agent 工具一律返回 blobUrl,避免文件内容进入 LLM 上下文 - 移除 bloburl format,GM API read 只保留 text/blob - saveAttachments 支持无 data 的附件引用(已保存的 imageBlock) - tool_registry 添加错误日志输出
1 parent f14c43b commit 250db4f

19 files changed

Lines changed: 266 additions & 111 deletions

File tree

src/app/repo/skill_repo.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,18 @@ export class SkillRepo extends OPFSRepo {
8282

8383
// 更新 registry
8484
const registry = await this.readRegistry();
85+
const idx = registry.findIndex((s) => s.name === record.name);
8586
const summary: SkillSummary = {
8687
name: record.name,
8788
description: record.description,
8889
toolNames: record.toolNames,
8990
referenceNames: record.referenceNames,
9091
...(record.config && Object.keys(record.config).length > 0 ? { hasConfig: true } : {}),
92+
// 保留已有的 enabled 状态
93+
...(idx >= 0 && registry[idx].enabled !== undefined ? { enabled: registry[idx].enabled } : {}),
9194
installtime: record.installtime,
9295
updatetime: record.updatetime,
9396
};
94-
const idx = registry.findIndex((s) => s.name === record.name);
9597
if (idx >= 0) {
9698
registry[idx] = summary;
9799
} else {
@@ -156,6 +158,15 @@ export class SkillRepo extends OPFSRepo {
156158
}
157159
}
158160

161+
async setSkillEnabled(name: string, enabled: boolean): Promise<boolean> {
162+
const registry = await this.readRegistry();
163+
const idx = registry.findIndex((s) => s.name === name);
164+
if (idx < 0) return false;
165+
registry[idx].enabled = enabled;
166+
await this.writeRegistry(registry);
167+
return true;
168+
}
169+
159170
async getConfigValues(name: string): Promise<Record<string, unknown>> {
160171
try {
161172
const skillDir = await this.getSkillDir(name);

src/app/service/agent/content_utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const MIME_EXT_MAP: Record<string, string> = {
1616
"application/pdf": "pdf",
1717
"application/zip": "zip",
1818
"application/json": "json",
19+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
20+
"application/vnd.ms-excel": "xls",
21+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
22+
"application/msword": "doc",
23+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
24+
"application/vnd.ms-powerpoint": "ppt",
1925
"text/plain": "txt",
2026
"text/html": "html",
2127
"text/csv": "csv",

src/app/service/agent/system_prompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ For **complex, multi-step tasks**, use task tools to track your progress:
6262
6363
## Binary File Workflow
6464
65-
OPFS workspace stores files persistently. Binary files (images, PDFs, etc.) should stay as file references — never put large binary data in your messages.
65+
OPFS workspace stores files persistently. \`opfs_read\` always returns a blob URL — file content is never loaded into the conversation context.
6666
6767
**Save**: screenshot with \`saveTo\` / SkillScript \`fetch()\` → \`CAT.agent.opfs.write(blob)\` → returns path
68-
**Use**: \`opfs_read(path, format='bloburl')\` → returns \`blob:chrome-extension://\` URL → pass to \`execute_script(target='page', world='ISOLATED')\` which can \`fetch()\` the blob URL and manipulate page DOM
68+
**Use**: \`opfs_read(path)\` → returns \`blob:chrome-extension://\` URL → pass to \`execute_script(target='page', world='ISOLATED')\` which can \`fetch()\` the blob URL and manipulate page DOM
6969
**Note**: Blob URLs are scoped to the extension origin. Only ISOLATED world (or Offscreen) can access them — MAIN world cannot.`;
7070

7171
// Skill 摘要提示词模板

src/app/service/agent/tool_registry.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export class ToolRegistry {
9191
return { id: tc.id, result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult) };
9292
}
9393
} catch (e: any) {
94+
console.error(`[ToolRegistry] builtin tool "${tc.name}" execution failed:`, e);
9495
return { id: tc.id, result: JSON.stringify({ error: e.message || "Tool execution failed" }) };
9596
}
9697
})
@@ -132,6 +133,19 @@ export class ToolRegistry {
132133

133134
const attachments: Attachment[] = [];
134135
for (const ad of attachmentDataList) {
136+
if (!ad.data) {
137+
// 无 data 的附件是已保存的引用(如 skill script 返回的 imageBlock),直接透传元数据
138+
if ("attachmentId" in ad && (ad as any).attachmentId) {
139+
attachments.push({
140+
id: (ad as any).attachmentId,
141+
type: ad.type,
142+
name: ad.name,
143+
mimeType: ad.mimeType,
144+
size: (ad as any).size,
145+
});
146+
}
147+
continue;
148+
}
135149
const ext = getExtFromMime(ad.mimeType);
136150
const id = `${uuidv4()}.${ext}`;
137151
const size = await this.chatRepo.saveAttachment(id, ad.data);

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

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,11 @@ const OPFS_WRITE_DEFINITION: ToolDefinition = {
3030
const OPFS_READ_DEFINITION: ToolDefinition = {
3131
name: "opfs_read",
3232
description:
33-
"Read a file from the workspace. For text files returns content. For binary files use format='bloburl' to get a blob URL usable in executeScript (ISOLATED world).",
33+
"Read a file from the workspace. Returns a blob URL (blob:chrome-extension://...) that can be used in executeScript (ISOLATED world) for download, display, or further processing. Never returns file content directly to avoid context overflow.",
3434
parameters: {
3535
type: "object",
3636
properties: {
3737
path: { type: "string", description: "File path relative to workspace root" },
38-
format: {
39-
type: "string",
40-
enum: ["text", "bloburl"],
41-
description:
42-
"Output format: 'text' (default) returns file content as string; 'bloburl' returns a blob:chrome-extension:// URL for binary files (usable in executeScript ISOLATED world)",
43-
},
4438
},
4539
required: ["path"],
4640
},
@@ -119,27 +113,22 @@ export function createOPFSTools(): {
119113
execute: async (args: Record<string, unknown>) => {
120114
const safePath = sanitizePath(args.path as string);
121115
if (!safePath) throw new Error("path is required");
122-
const format = (args.format as string) || "text";
116+
117+
if (!createBlobUrlFn) {
118+
throw new Error("Blob URL creation not available (Offscreen not initialized)");
119+
}
123120

124121
const workspace = await getWorkspaceRoot();
125122
const { dirPath, fileName } = splitPath(safePath);
126123
const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace;
127124
const fileHandle = await dir.getFileHandle(fileName);
128125
const file = await fileHandle.getFile();
129126

130-
if (format === "bloburl") {
131-
if (!createBlobUrlFn) {
132-
throw new Error("Blob URL creation not available (Offscreen not initialized)");
133-
}
134-
const arrayBuffer = await file.arrayBuffer();
135-
const mimeType = guessMimeType(safePath);
136-
const blobUrl = await createBlobUrlFn(arrayBuffer, mimeType);
137-
return JSON.stringify({ path: safePath, blobUrl, size: file.size, mimeType });
138-
}
139-
140-
// 默认 text 模式
141-
const content = await file.text();
142-
return JSON.stringify({ path: safePath, content, size: file.size });
127+
// 一律返回 blob URL,避免文件内容进入 LLM 上下文
128+
const arrayBuffer = await file.arrayBuffer();
129+
const mimeType = guessMimeType(safePath);
130+
const blobUrl = await createBlobUrlFn(arrayBuffer, mimeType);
131+
return JSON.stringify({ path: safePath, blobUrl, size: file.size, mimeType });
143132
},
144133
};
145134

src/app/service/agent/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export type SkillSummary = {
244244
toolNames: string[]; // 随 Skill 打包的脚本名称(scripts/ 目录下)
245245
referenceNames: string[]; // 参考资料名称(references/ 目录下)
246246
hasConfig?: boolean; // 是否有 config 字段声明
247+
enabled?: boolean; // 是否启用,默认 true(undefined 视为 true)
247248
installtime: number;
248249
updatetime: number;
249250
};

src/app/service/content/gm_api/cat_agent_opfs.test.ts

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,56 @@ describe.concurrent("CATAgentOPFSApi", () => {
9494
]);
9595
});
9696

97-
it.concurrent("readAttachment 发送请求并通过 CAT_fetchBlob 将 blobUrl 转换为 Blob", async () => {
97+
// ---- postMessage 通道:SW 直接返回 Blob ----
98+
99+
it.concurrent("readAttachment postMessage 通道直接返回 Blob", async () => {
100+
const testBlob = new Blob(["image data"], { type: "image/png" });
101+
const mockSendMessage = vi.fn().mockResolvedValue({
102+
id: "att-1",
103+
data: testBlob,
104+
size: 10,
105+
mimeType: "image/png",
106+
});
107+
const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } };
108+
109+
const apis = GMContextApiGet("CAT.agent.opfs")!;
110+
const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!;
111+
const result = await readAttachmentApi.api.call(ctx, "att-1");
112+
113+
expect(mockSendMessage).toHaveBeenCalledTimes(1);
114+
expect((result as any).data).toBe(testBlob);
115+
});
116+
117+
it.concurrent("read blob postMessage 通道直接返回 Blob", async () => {
118+
const testBlob = new Blob(["file data"], { type: "image/png" });
119+
const mockSendMessage = vi.fn().mockResolvedValue({
120+
path: "img.png",
121+
data: testBlob,
122+
size: 9,
123+
mimeType: "image/png",
124+
});
125+
const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } };
126+
127+
const apis = GMContextApiGet("CAT.agent.opfs")!;
128+
const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!;
129+
const result = await readApi.api.call(ctx, "img.png", "blob");
130+
131+
expect(mockSendMessage).toHaveBeenCalledTimes(1);
132+
expect((result as any).data).toBe(testBlob);
133+
});
134+
135+
// ---- chrome.runtime 通道:SW 返回 blobUrl,客户端 CAT_fetchBlob 还原 ----
136+
137+
it.concurrent("readAttachment chrome.runtime 通道通过 CAT_fetchBlob 还原 Blob", async () => {
98138
const testBlob = new Blob(["image data"], { type: "image/png" });
99139
const mockSendMessage = vi.fn().mockImplementation((api: string) => {
100140
if (api === "CAT_agentOPFS") {
101-
return Promise.resolve({ id: "att-1", blobUrl: "blob:chrome-extension://test/123", size: 10, mimeType: "image/png" });
141+
return Promise.resolve({
142+
id: "att-1",
143+
blobUrl: "blob:chrome-extension://test/123",
144+
size: 10,
145+
mimeType: "image/png",
146+
});
102147
}
103148
if (api === "CAT_fetchBlob") {
104149
return Promise.resolve(testBlob);
@@ -111,22 +156,21 @@ describe.concurrent("CATAgentOPFSApi", () => {
111156
const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!;
112157
const result = await readAttachmentApi.api.call(ctx, "att-1");
113158

114-
// 第一次调用:请求 readAttachment
115-
expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [
116-
{ action: "readAttachment", id: "att-1", scriptUuid: "test-uuid" },
117-
]);
118-
// 第二次调用:CAT_fetchBlob 转换 blobUrl → Blob
119159
expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/123"]);
120-
// 结果应包含 data(Blob)而非 blobUrl
121160
expect((result as any).data).toBe(testBlob);
122161
expect((result as any).blobUrl).toBeUndefined();
123162
});
124163

125-
it.concurrent("read blob 格式通过 CAT_fetchBlob 将 blobUrl 转换为 Blob", async () => {
164+
it.concurrent("read blob chrome.runtime 通道通过 CAT_fetchBlob 还原 Blob", async () => {
126165
const testBlob = new Blob(["file data"], { type: "image/png" });
127166
const mockSendMessage = vi.fn().mockImplementation((api: string) => {
128167
if (api === "CAT_agentOPFS") {
129-
return Promise.resolve({ path: "img.png", blobUrl: "blob:chrome-extension://test/456", size: 9, mimeType: "image/png" });
168+
return Promise.resolve({
169+
path: "img.png",
170+
blobUrl: "blob:chrome-extension://test/456",
171+
size: 9,
172+
mimeType: "image/png",
173+
});
130174
}
131175
if (api === "CAT_fetchBlob") {
132176
return Promise.resolve(testBlob);
@@ -143,16 +187,4 @@ describe.concurrent("CATAgentOPFSApi", () => {
143187
expect((result as any).data).toBe(testBlob);
144188
expect((result as any).blobUrl).toBeUndefined();
145189
});
146-
147-
it.concurrent("read text 格式不调用 CAT_fetchBlob", async () => {
148-
const mockSendMessage = vi.fn().mockResolvedValue({ path: "f.txt", content: "hello", size: 5 });
149-
const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } };
150-
151-
const apis = GMContextApiGet("CAT.agent.opfs")!;
152-
const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!;
153-
await readApi.api.call(ctx, "f.txt", "text");
154-
155-
expect(mockSendMessage).toHaveBeenCalledTimes(1);
156-
expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", expect.anything());
157-
});
158190
});

src/app/service/content/gm_api/cat_agent_opfs.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import GMContext from "./gm_context";
33

44
// 运行时 this 是 GM_Base 实例
55
interface GMBaseContext {
6-
sendMessage: (
7-
api: string,
8-
params: any[]
9-
) => Promise<any>;
6+
sendMessage: (api: string, params: any[]) => Promise<any>;
107
scriptRes?: { uuid: string };
118
}
129

@@ -30,14 +27,14 @@ export default class CATAgentOPFSApi {
3027
@GMContext.API({ follow: "CAT.agent.opfs" })
3128
public async "CAT.agent.opfs.read"(
3229
path: string,
33-
format?: "text" | "bloburl" | "blob"
34-
): Promise<{ path: string; content?: string; blobUrl?: string; data?: Blob; size: number; mimeType?: string }> {
30+
format?: "text" | "blob"
31+
): Promise<{ path: string; content?: string; data?: Blob; size: number; mimeType?: string }> {
3532
const ctx = this as unknown as GMBaseContext;
3633
const result = await ctx.sendMessage("CAT_agentOPFS", [
3734
{ action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest,
3835
]);
39-
// blob 格式:SW 返回 blobUrl,通过 CAT_fetchBlob 在 extension-origin 上下文中转换为 Blob
40-
if (format === "blob" && result.blobUrl) {
36+
// blob 格式:postMessage 通道直接返回 Blob;chrome.runtime 通道返回 blobUrl 需转换
37+
if (format === "blob" && !(result.data instanceof Blob) && result.blobUrl) {
4138
result.data = await ctx.sendMessage("CAT_fetchBlob", [result.blobUrl]);
4239
delete result.blobUrl;
4340
}
@@ -60,9 +57,8 @@ export default class CATAgentOPFSApi {
6057
const result = await ctx.sendMessage("CAT_agentOPFS", [
6158
{ action: "readAttachment", id, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest,
6259
]);
63-
// SW 返回 blobUrl(chrome.runtime sendResponse 不支持 Blob),
64-
// 通过 CAT_fetchBlob 在 extension-origin 上下文(offscreen/content script)中转换为 Blob
65-
if (result.blobUrl) {
60+
// postMessage 通道直接返回 Blob;chrome.runtime 通道返回 blobUrl 需转换
61+
if (!(result.data instanceof Blob) && result.blobUrl) {
6662
result.data = await ctx.sendMessage("CAT_fetchBlob", [result.blobUrl]);
6763
delete result.blobUrl;
6864
}

src/app/service/content/scripting.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ export default class ScriptingRuntime {
7979
case "CAT_fetchBlob": {
8080
return fetch(data.params[0]).then((res) => res.blob());
8181
}
82+
case "CAT_agentOPFS": {
83+
// chrome.runtime 不支持 Blob,write 操作的 Blob content 需先转为 blob URL
84+
const req = data.params[0];
85+
if (req?.action === "write" && req.content instanceof Blob) {
86+
req.content = makeBlobURL({ blob: req.content, persistence: true }) as string;
87+
}
88+
return false; // 继续转发到 SW
89+
}
8290
case "CAT_fetchDocument": {
8391
const [url, isContent] = data.params;
8492
// 根据来源选择不同的消息桥(content / inject)

src/app/service/offscreen/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,11 @@ export class OffscreenManager {
6969
this.windowServer.on("createObjectURL", async (params: { blob: Blob; persistence: boolean }) => {
7070
return makeBlobURL(params) as string;
7171
});
72+
73+
// fetch blob URL 并返回 Blob(供 SW 在 chrome.runtime 通道下还原 content script 创建的 blob URL)
74+
this.windowServer.on("fetchBlob", async (params: { url: string }) => {
75+
const res = await fetch(params.url);
76+
return await res.blob();
77+
});
7278
}
7379
}

0 commit comments

Comments
 (0)