Skip to content

Commit 2a1314e

Browse files
committed
✨ 增强截图和 OPFS 能力:区域截图、saveTo 持久化、bloburl 读取
- screenshot 支持 selector 参数,通过 CDP clip 实现指定元素区域截图 - screenshot 支持 saveTo 参数,将截图二进制直接保存到 OPFS workspace - screenshot 返回类型升级为 ScreenshotResult(dataUrl + path + size) - opfs_read 支持 format: "bloburl",通过 Offscreen 创建 blob URL - 提取 opfs_helpers.ts 公共模块,供 opfs_tools 和 agent_dom 复用 - GM API(CAT.agent.dom.screenshot / CAT.agent.opfs.read)同步更新
1 parent 641c0e3 commit 2a1314e

9 files changed

Lines changed: 249 additions & 92 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// OPFS 工作区公共辅助函数
2+
// 供 opfs_tools、agent_dom 等模块复用
3+
4+
export const WORKSPACE_ROOT = "agents/workspace";
5+
6+
/** Strip leading `/`, reject `..` segments */
7+
export function sanitizePath(raw: string): string {
8+
const stripped = raw.replace(/^\/+/, "");
9+
const segments = stripped.split("/").filter(Boolean);
10+
for (const seg of segments) {
11+
if (seg === "..") {
12+
throw new Error(`Invalid path: ".." is not allowed`);
13+
}
14+
}
15+
return segments.join("/");
16+
}
17+
18+
/** Navigate into nested directories, creating them as needed */
19+
export async function getDirectory(
20+
root: FileSystemDirectoryHandle,
21+
path: string,
22+
create = false
23+
): Promise<FileSystemDirectoryHandle> {
24+
const segments = path.split("/").filter(Boolean);
25+
let dir = root;
26+
for (const seg of segments) {
27+
dir = await dir.getDirectoryHandle(seg, { create });
28+
}
29+
return dir;
30+
}
31+
32+
/** Get the workspace root directory handle */
33+
export async function getWorkspaceRoot(create = false): Promise<FileSystemDirectoryHandle> {
34+
const opfsRoot = await navigator.storage.getDirectory();
35+
return getDirectory(opfsRoot, WORKSPACE_ROOT, create);
36+
}
37+
38+
/** Split a sanitized path into parent directory path and filename */
39+
export function splitPath(sanitized: string): { dirPath: string; fileName: string } {
40+
const lastSlash = sanitized.lastIndexOf("/");
41+
if (lastSlash === -1) {
42+
return { dirPath: "", fileName: sanitized };
43+
}
44+
return {
45+
dirPath: sanitized.substring(0, lastSlash),
46+
fileName: sanitized.substring(lastSlash + 1),
47+
};
48+
}
49+
50+
/** 将 data URL 解码为二进制 Uint8Array */
51+
export function decodeDataUrl(dataUrl: string): { data: Uint8Array; mimeType: string } {
52+
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
53+
if (!match) {
54+
throw new Error("Invalid data URL format");
55+
}
56+
const mimeType = match[1];
57+
const base64 = match[2];
58+
const binaryStr = atob(base64);
59+
const bytes = new Uint8Array(binaryStr.length);
60+
for (let i = 0; i < binaryStr.length; i++) {
61+
bytes[i] = binaryStr.charCodeAt(i);
62+
}
63+
return { data: bytes, mimeType };
64+
}
65+
66+
/** 将二进制数据写入 OPFS workspace 指定路径 */
67+
export async function writeWorkspaceFile(
68+
path: string,
69+
data: Uint8Array | string
70+
): Promise<{ path: string; size: number }> {
71+
const safePath = sanitizePath(path);
72+
if (!safePath) throw new Error("path is required");
73+
74+
const workspace = await getWorkspaceRoot(true);
75+
const { dirPath, fileName } = splitPath(safePath);
76+
const dir = dirPath ? await getDirectory(workspace, dirPath, true) : workspace;
77+
const fileHandle = await dir.getFileHandle(fileName, { create: true });
78+
const writable = await fileHandle.createWritable();
79+
await writable.write(data instanceof Uint8Array ? (data.buffer as ArrayBuffer) : data);
80+
await writable.close();
81+
82+
const size = typeof data === "string" ? new Blob([data]).size : data.byteLength;
83+
return { path: safePath, size };
84+
}

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

Lines changed: 68 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,15 @@
11
import type { ToolDefinition } from "@App/app/service/agent/types";
22
import type { ToolExecutor } from "@App/app/service/agent/tool_registry";
3+
import {
4+
sanitizePath,
5+
getDirectory,
6+
getWorkspaceRoot,
7+
splitPath,
8+
writeWorkspaceFile,
9+
} from "@App/app/service/agent/opfs_helpers";
310

4-
const WORKSPACE_ROOT = "agents/workspace";
5-
6-
/** Strip leading `/`, reject `..` segments */
7-
export function sanitizePath(raw: string): string {
8-
const stripped = raw.replace(/^\/+/, "");
9-
const segments = stripped.split("/").filter(Boolean);
10-
for (const seg of segments) {
11-
if (seg === "..") {
12-
throw new Error(`Invalid path: ".." is not allowed`);
13-
}
14-
}
15-
return segments.join("/");
16-
}
17-
18-
/** Navigate into nested directories, creating them as needed */
19-
async function getDirectory(
20-
root: FileSystemDirectoryHandle,
21-
path: string,
22-
create = false
23-
): Promise<FileSystemDirectoryHandle> {
24-
const segments = path.split("/").filter(Boolean);
25-
let dir = root;
26-
for (const seg of segments) {
27-
dir = await dir.getDirectoryHandle(seg, { create });
28-
}
29-
return dir;
30-
}
31-
32-
/** Get the workspace root directory handle */
33-
async function getWorkspaceRoot(create = false): Promise<FileSystemDirectoryHandle> {
34-
const opfsRoot = await navigator.storage.getDirectory();
35-
return getDirectory(opfsRoot, WORKSPACE_ROOT, create);
36-
}
37-
38-
/** Split a sanitized path into parent directory path and filename */
39-
function splitPath(sanitized: string): { dirPath: string; fileName: string } {
40-
const lastSlash = sanitized.lastIndexOf("/");
41-
if (lastSlash === -1) {
42-
return { dirPath: "", fileName: sanitized };
43-
}
44-
return {
45-
dirPath: sanitized.substring(0, lastSlash),
46-
fileName: sanitized.substring(lastSlash + 1),
47-
};
48-
}
11+
// re-export sanitizePath 供外部使用
12+
export { sanitizePath };
4913

5014
// ---- Tool Definitions ----
5115

@@ -64,11 +28,18 @@ const OPFS_WRITE_DEFINITION: ToolDefinition = {
6428

6529
const OPFS_READ_DEFINITION: ToolDefinition = {
6630
name: "opfs_read",
67-
description: "Read text content from a file in the workspace.",
31+
description:
32+
"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).",
6833
parameters: {
6934
type: "object",
7035
properties: {
7136
path: { type: "string", description: "File path relative to workspace root" },
37+
format: {
38+
type: "string",
39+
enum: ["text", "bloburl"],
40+
description:
41+
"Output format: 'text' (default) returns file content as string; 'bloburl' returns a blob:chrome-extension:// URL for binary files (usable in executeScript ISOLATED world)",
42+
},
7243
},
7344
required: ["path"],
7445
},
@@ -97,41 +68,76 @@ const OPFS_DELETE_DEFINITION: ToolDefinition = {
9768
},
9869
};
9970

71+
// ---- blob URL 创建(通过 Offscreen) ----
72+
73+
// 创建 blob URL 的回调,由外部注入(Offscreen 通道)
74+
type CreateBlobUrlFn = (data: ArrayBuffer, mimeType: string) => Promise<string>;
75+
let createBlobUrlFn: CreateBlobUrlFn | null = null;
76+
77+
/** 注入 Offscreen blob URL 创建函数 */
78+
export function setCreateBlobUrlFn(fn: CreateBlobUrlFn): void {
79+
createBlobUrlFn = fn;
80+
}
81+
82+
/** 根据文件扩展名推断 MIME 类型 */
83+
function guessMimeType(path: string): string {
84+
const ext = path.split(".").pop()?.toLowerCase() || "";
85+
const map: Record<string, string> = {
86+
jpg: "image/jpeg",
87+
jpeg: "image/jpeg",
88+
png: "image/png",
89+
gif: "image/gif",
90+
webp: "image/webp",
91+
svg: "image/svg+xml",
92+
mp3: "audio/mpeg",
93+
wav: "audio/wav",
94+
mp4: "video/mp4",
95+
pdf: "application/pdf",
96+
json: "application/json",
97+
txt: "text/plain",
98+
html: "text/html",
99+
css: "text/css",
100+
js: "application/javascript",
101+
};
102+
return map[ext] || "application/octet-stream";
103+
}
104+
100105
// ---- Factory ----
101106

102107
export function createOPFSTools(): {
103108
tools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>;
104109
} {
105110
const writeExecutor: ToolExecutor = {
106111
execute: async (args: Record<string, unknown>) => {
107-
const safePath = sanitizePath(args.path as string);
108-
const content = args.content as string;
109-
if (!safePath) throw new Error("path is required");
110-
111-
const workspace = await getWorkspaceRoot(true);
112-
const { dirPath, fileName } = splitPath(safePath);
113-
const dir = dirPath ? await getDirectory(workspace, dirPath, true) : workspace;
114-
const fileHandle = await dir.getFileHandle(fileName, { create: true });
115-
const writable = await fileHandle.createWritable();
116-
await writable.write(content);
117-
await writable.close();
118-
119-
return JSON.stringify({ path: safePath, size: new Blob([content]).size });
112+
const result = await writeWorkspaceFile(args.path as string, args.content as string);
113+
return JSON.stringify(result);
120114
},
121115
};
122116

123117
const readExecutor: ToolExecutor = {
124118
execute: async (args: Record<string, unknown>) => {
125119
const safePath = sanitizePath(args.path as string);
126120
if (!safePath) throw new Error("path is required");
121+
const format = (args.format as string) || "text";
127122

128123
const workspace = await getWorkspaceRoot();
129124
const { dirPath, fileName } = splitPath(safePath);
130125
const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace;
131126
const fileHandle = await dir.getFileHandle(fileName);
132127
const file = await fileHandle.getFile();
133-
const content = await file.text();
134128

129+
if (format === "bloburl") {
130+
if (!createBlobUrlFn) {
131+
throw new Error("Blob URL creation not available (Offscreen not initialized)");
132+
}
133+
const arrayBuffer = await file.arrayBuffer();
134+
const mimeType = guessMimeType(safePath);
135+
const blobUrl = await createBlobUrlFn(arrayBuffer, mimeType);
136+
return JSON.stringify({ path: safePath, blobUrl, size: file.size, mimeType });
137+
}
138+
139+
// 默认 text 模式
140+
const content = await file.text();
135141
return JSON.stringify({ path: safePath, content, size: file.size });
136142
},
137143
};
@@ -147,8 +153,8 @@ export function createOPFSTools(): {
147153
const entries: Array<{ name: string; type: "file" | "directory"; size?: number }> = [];
148154
for await (const [name, handle] of dir as unknown as AsyncIterable<[string, FileSystemHandle]>) {
149155
if (handle.kind === "file") {
150-
const file = await (handle as FileSystemFileHandle).getFile();
151-
entries.push({ name, type: "file", size: file.size });
156+
const f = await (handle as FileSystemFileHandle).getFile();
157+
entries.push({ name, type: "file", size: f.size });
152158
} else {
153159
entries.push({ name, type: "directory" });
154160
}

src/app/service/agent/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export type SkillApiRequest =
252252
// CAT.agent.opfs API 请求
253253
export type OPFSApiRequest =
254254
| { action: "write"; path: string; content: string; scriptUuid: string }
255-
| { action: "read"; path: string; scriptUuid: string }
255+
| { action: "read"; path: string; format?: "text" | "bloburl"; scriptUuid: string }
256256
| { action: "list"; path?: string; scriptUuid: string }
257257
| { action: "delete"; path: string; scriptUuid: string };
258258

@@ -339,6 +339,13 @@ export type ScreenshotOptions = {
339339
quality?: number;
340340
fullPage?: boolean;
341341
selector?: string; // CSS 选择器,截取指定元素区域
342+
saveTo?: string; // OPFS workspace 相对路径,截图后保存二进制
343+
};
344+
345+
export type ScreenshotResult = {
346+
dataUrl: string; // 原始 data URL
347+
path?: string; // saveTo 时返回的 OPFS 路径
348+
size?: number; // saveTo 时返回的文件大小(字节)
342349
};
343350

344351
export type NavigateOptions = {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
DomApiRequest,
77
ReadPageOptions,
88
ScreenshotOptions,
9+
ScreenshotResult,
910
DomActionOptions,
1011
NavigateOptions,
1112
ScrollDirection,
@@ -60,7 +61,7 @@ export default class CATAgentDomApi {
6061
}
6162

6263
@GMContext.API({ follow: "CAT.agent.dom" })
63-
public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise<string> {
64+
public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise<ScreenshotResult> {
6465
const ctx = this as unknown as GMBaseContext;
6566
return ctx.sendMessage("CAT_agentDom", [
6667
{ action: "screenshot", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest,

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ export default class CATAgentOPFSApi {
3333
}
3434

3535
@GMContext.API({ follow: "CAT.agent.opfs" })
36-
public "CAT.agent.opfs.read"(path: string): Promise<{ path: string; content: string; size: number }> {
36+
public "CAT.agent.opfs.read"(
37+
path: string,
38+
format?: "text" | "bloburl"
39+
): Promise<{ path: string; content?: string; blobUrl?: string; size: number; mimeType?: string }> {
3740
const ctx = this as unknown as GMBaseContext;
3841
return ctx.sendMessage("CAT_agentOPFS", [
39-
{ action: "read", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest,
40-
]) as Promise<{ path: string; content: string; size: number }>;
42+
{ action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest,
43+
]) as Promise<{ path: string; content?: string; blobUrl?: string; size: number; mimeType?: string }>;
4144
}
4245

4346
@GMContext.API({ follow: "CAT.agent.opfs" })

src/app/service/service_worker/agent.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ import { SearchConfigRepo } from "@App/app/service/agent/tools/search_config";
5757
import { createTaskTools } from "@App/app/service/agent/tools/task_tools";
5858
import { createAskUserTool } from "@App/app/service/agent/tools/ask_user";
5959
import { createSubAgentTool } from "@App/app/service/agent/tools/sub_agent";
60-
import { createOPFSTools } from "@App/app/service/agent/tools/opfs_tools";
60+
import { createOPFSTools, setCreateBlobUrlFn } from "@App/app/service/agent/tools/opfs_tools";
61+
import { createObjectURL } from "@App/app/service/offscreen/client";
6162
import { createExecuteScriptTool } from "@App/app/service/agent/tools/execute_script";
6263
import { executeSkillScript } from "@App/app/service/offscreen/client";
6364
import { createTabTools } from "@App/app/service/agent/tools/tab_tools";
@@ -194,6 +195,11 @@ export class AgentService {
194195
this.toolRegistry.registerBuiltin(WEB_FETCH_DEFINITION, new WebFetchExecutor(this.sender));
195196
this.toolRegistry.registerBuiltin(WEB_SEARCH_DEFINITION, new WebSearchExecutor(this.sender, searchConfigRepo));
196197
// 注册 OPFS 工作区文件工具
198+
// 注入 blob URL 创建函数(通过 Offscreen 的 URL.createObjectURL)
199+
setCreateBlobUrlFn(async (data: ArrayBuffer, mimeType: string) => {
200+
const blob = new Blob([data], { type: mimeType });
201+
return (await createObjectURL(this.sender, { blob, persistence: true })) as string;
202+
});
197203
const opfsTools = createOPFSTools();
198204
for (const t of opfsTools.tools) {
199205
this.toolRegistry.registerBuiltin(t.definition, t.executor);
@@ -429,7 +435,7 @@ export class AgentService {
429435
}
430436
case "read": {
431437
const executor = toolMap.get("opfs_read")!;
432-
return JSON.parse((await executor.execute({ path: request.path })) as string);
438+
return JSON.parse((await executor.execute({ path: request.path, format: request.format })) as string);
433439
}
434440
case "list": {
435441
const executor = toolMap.get("opfs_list")!;

src/app/service/service_worker/agent_dom.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ describe("AgentDomService", () => {
285285

286286
const result = await service.screenshot({ tabId: 1 });
287287

288-
expect(result).toBe("data:image/jpeg;base64,abc123");
288+
expect(result.dataUrl).toBe("data:image/jpeg;base64,abc123");
289289
expect(mockCaptureVisibleTab).toHaveBeenCalledWith(1, { format: "jpeg", quality: 80 });
290290
});
291291
});

0 commit comments

Comments
 (0)