@downcity/agent 1.1.86 → 1.1.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/bin/config/AgentInitializer.d.ts.map +1 -1
  2. package/bin/config/AgentInitializer.js +2 -1
  3. package/bin/config/AgentInitializer.js.map +1 -1
  4. package/bin/config/Paths.d.ts +8 -0
  5. package/bin/config/Paths.d.ts.map +1 -1
  6. package/bin/config/Paths.js +10 -0
  7. package/bin/config/Paths.js.map +1 -1
  8. package/bin/executor/Executor.d.ts.map +1 -1
  9. package/bin/executor/Executor.js +3 -0
  10. package/bin/executor/Executor.js.map +1 -1
  11. package/bin/executor/messages/AssistantFileResource.d.ts +31 -0
  12. package/bin/executor/messages/AssistantFileResource.d.ts.map +1 -0
  13. package/bin/executor/messages/AssistantFileResource.js +113 -0
  14. package/bin/executor/messages/AssistantFileResource.js.map +1 -0
  15. package/bin/executor/messages/SessionAttachmentMapper.d.ts +8 -0
  16. package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
  17. package/bin/executor/messages/SessionAttachmentMapper.js +54 -0
  18. package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
  19. package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
  20. package/bin/executor/messages/SessionMessageCodec.js +5 -3
  21. package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
  22. package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
  23. package/bin/executor/tools/plugin/PluginToolBridge.js +9 -2
  24. package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
  25. package/bin/index.d.ts +1 -1
  26. package/bin/index.d.ts.map +1 -1
  27. package/bin/index.js.map +1 -1
  28. package/bin/plugin/core/ImagePlugin.d.ts +62 -0
  29. package/bin/plugin/core/ImagePlugin.d.ts.map +1 -1
  30. package/bin/plugin/core/ImagePlugin.js +230 -7
  31. package/bin/plugin/core/ImagePlugin.js.map +1 -1
  32. package/bin/sandbox/LinuxBubblewrapSandbox.d.ts +21 -0
  33. package/bin/sandbox/LinuxBubblewrapSandbox.d.ts.map +1 -0
  34. package/bin/sandbox/LinuxBubblewrapSandbox.js +184 -0
  35. package/bin/sandbox/LinuxBubblewrapSandbox.js.map +1 -0
  36. package/bin/sandbox/SandboxConfigResolver.d.ts +5 -0
  37. package/bin/sandbox/SandboxConfigResolver.d.ts.map +1 -1
  38. package/bin/sandbox/SandboxConfigResolver.js +11 -4
  39. package/bin/sandbox/SandboxConfigResolver.js.map +1 -1
  40. package/bin/sandbox/SandboxRunner.d.ts +1 -1
  41. package/bin/sandbox/SandboxRunner.d.ts.map +1 -1
  42. package/bin/sandbox/SandboxRunner.js +11 -3
  43. package/bin/sandbox/SandboxRunner.js.map +1 -1
  44. package/bin/sandbox/types/SandboxRuntime.d.ts +6 -2
  45. package/bin/sandbox/types/SandboxRuntime.d.ts.map +1 -1
  46. package/bin/session/Session.d.ts.map +1 -1
  47. package/bin/session/Session.js +1 -0
  48. package/bin/session/Session.js.map +1 -1
  49. package/bin/session/services/SessionTurnService.d.ts +5 -0
  50. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  51. package/bin/session/services/SessionTurnService.js +3 -0
  52. package/bin/session/services/SessionTurnService.js.map +1 -1
  53. package/bin/types/executor/SessionRunContext.d.ts +8 -0
  54. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  55. package/bin/types/plugin/ImagePlugin.d.ts +79 -2
  56. package/bin/types/plugin/ImagePlugin.d.ts.map +1 -1
  57. package/package.json +2 -2
  58. package/scripts/assistant-file-resource.test.mjs +91 -0
  59. package/scripts/image-plugin-job.test.mjs +155 -0
  60. package/scripts/linux-bubblewrap-sandbox.test.mjs +142 -0
  61. package/src/config/AgentInitializer.ts +2 -0
  62. package/src/config/Paths.ts +11 -0
  63. package/src/executor/Executor.ts +3 -0
  64. package/src/executor/messages/AssistantFileResource.ts +155 -0
  65. package/src/executor/messages/SessionAttachmentMapper.ts +59 -0
  66. package/src/executor/messages/SessionMessageCodec.ts +9 -3
  67. package/src/executor/tools/plugin/PluginToolBridge.ts +13 -2
  68. package/src/index.ts +4 -0
  69. package/src/plugin/core/ImagePlugin.ts +284 -7
  70. package/src/sandbox/LinuxBubblewrapSandbox.ts +229 -0
  71. package/src/sandbox/SandboxConfigResolver.ts +13 -7
  72. package/src/sandbox/SandboxRunner.ts +11 -3
  73. package/src/sandbox/types/SandboxRuntime.ts +7 -2
  74. package/src/session/Session.ts +1 -0
  75. package/src/session/services/SessionTurnService.ts +8 -0
  76. package/src/types/executor/SessionRunContext.ts +9 -0
  77. package/src/types/plugin/ImagePlugin.ts +79 -2
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @file 验证 ImagePlugin 的可观察图片任务协议。
3
+ *
4
+ * 关键点(中文)
5
+ * - `create` 应快速返回 job_id,而不是同步等待图片完成。
6
+ * - `status/result` 可查询同一个任务,成功后 result 返回 UIMessage。
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import { ImagePlugin } from "../bin/index.js";
13
+
14
+ function create_image_message() {
15
+ return {
16
+ id: "msg_image_test",
17
+ role: "assistant",
18
+ parts: [
19
+ {
20
+ type: "file",
21
+ mediaType: "image/png",
22
+ filename: "image.png",
23
+ url: "data:image/png;base64,cG5n",
24
+ },
25
+ ],
26
+ };
27
+ }
28
+
29
+ test("ImagePlugin create/status/result exposes async image jobs", async () => {
30
+ let finish_image;
31
+ const image_promise = new Promise((resolve) => {
32
+ finish_image = () => resolve(create_image_message());
33
+ });
34
+ const plugin = new ImagePlugin({
35
+ image: async () => image_promise,
36
+ poll_interval_ms: 1,
37
+ wait_timeout_ms: 100,
38
+ });
39
+
40
+ const created = await plugin.actions.create.execute({
41
+ context: {},
42
+ payload: { prompt: "draw" },
43
+ pluginName: "image",
44
+ actionName: "create",
45
+ });
46
+
47
+ assert.equal(created.success, true);
48
+ assert.equal(created.data.status, "running");
49
+ assert.match(created.data.job_id, /^img_/);
50
+
51
+ const before = await plugin.actions.status.execute({
52
+ context: {},
53
+ payload: { job_id: created.data.job_id },
54
+ pluginName: "image",
55
+ actionName: "status",
56
+ });
57
+
58
+ assert.equal(before.success, true);
59
+ assert.equal(before.data.status, "running");
60
+
61
+ finish_image();
62
+ await new Promise((resolve) => setTimeout(resolve, 0));
63
+
64
+ const after = await plugin.actions.status.execute({
65
+ context: {},
66
+ payload: { job_id: created.data.job_id },
67
+ pluginName: "image",
68
+ actionName: "status",
69
+ });
70
+
71
+ assert.equal(after.success, true);
72
+ assert.equal(after.data.status, "succeeded");
73
+ assert.equal("result" in after.data, false);
74
+
75
+ const result = await plugin.actions.result.execute({
76
+ context: {},
77
+ payload: { job_id: created.data.job_id },
78
+ pluginName: "image",
79
+ actionName: "result",
80
+ });
81
+
82
+ assert.equal(result.success, true);
83
+ assert.equal(result.data.role, "assistant");
84
+ assert.equal(result.data.parts[0].type, "file");
85
+ });
86
+
87
+ test("ImagePlugin accepts custom job API without synchronous image function", async () => {
88
+ const message = create_image_message();
89
+ const plugin = new ImagePlugin({
90
+ create: () => ({
91
+ job_id: "img_custom",
92
+ status: "running",
93
+ poll_after_ms: 1,
94
+ }),
95
+ status: () => ({
96
+ job_id: "img_custom",
97
+ status: "succeeded",
98
+ }),
99
+ result: () => ({
100
+ job_id: "img_custom",
101
+ status: "succeeded",
102
+ result: message,
103
+ }),
104
+ });
105
+
106
+ const created = await plugin.actions.create.execute({
107
+ context: {},
108
+ payload: { prompt: "draw" },
109
+ pluginName: "image",
110
+ actionName: "create",
111
+ });
112
+
113
+ assert.equal(created.success, true);
114
+ assert.equal(created.data.job_id, "img_custom");
115
+
116
+ const result = await plugin.actions.result.execute({
117
+ context: {},
118
+ payload: { job_id: "img_custom" },
119
+ pluginName: "image",
120
+ actionName: "result",
121
+ });
122
+
123
+ assert.equal(result.success, true);
124
+ assert.equal(result.data.parts[0].type, "file");
125
+ });
126
+
127
+ test("ImagePlugin strips accidental result data from custom status responses", async () => {
128
+ const plugin = new ImagePlugin({
129
+ create: () => ({
130
+ job_id: "img_status_result",
131
+ status: "succeeded",
132
+ }),
133
+ status: () => ({
134
+ job_id: "img_status_result",
135
+ status: "succeeded",
136
+ result: create_image_message(),
137
+ }),
138
+ result: () => ({
139
+ job_id: "img_status_result",
140
+ status: "succeeded",
141
+ result: create_image_message(),
142
+ }),
143
+ });
144
+
145
+ const status = await plugin.actions.status.execute({
146
+ context: {},
147
+ payload: { job_id: "img_status_result" },
148
+ pluginName: "image",
149
+ actionName: "status",
150
+ });
151
+
152
+ assert.equal(status.success, true);
153
+ assert.equal(status.data.status, "succeeded");
154
+ assert.equal("result" in status.data, false);
155
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @file 验证 Linux bubblewrap sandbox 参数生成。
3
+ *
4
+ * 关键点(中文)
5
+ * - 测试编译后的 bin 输出,避免测试文件进入 package 源码导出面。
6
+ * - 不启动真实 `bwrap`,只锁住路径挂载、网络开关与 shell 调用参数。
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import fs from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ import { buildLinuxBubblewrapArgs } from "../bin/sandbox/LinuxBubblewrapSandbox.js";
16
+
17
+ async function createSandboxFixture() {
18
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-bwrap-"));
19
+ const projectRoot = path.join(root, "project");
20
+ const writablePath = path.join(projectRoot, ".downcity");
21
+ const shellDir = path.join(writablePath, "shell", "sh_test");
22
+ const shellHomeDir = path.join(shellDir, "sandbox", "home");
23
+ const shellTmpDir = path.join(shellDir, "sandbox", "tmp");
24
+
25
+ await fs.mkdir(shellHomeDir, { recursive: true });
26
+ await fs.mkdir(shellTmpDir, { recursive: true });
27
+
28
+ return {
29
+ root,
30
+ projectRoot,
31
+ writablePath,
32
+ shellDir,
33
+ shellHomeDir,
34
+ shellTmpDir,
35
+ };
36
+ }
37
+
38
+ function createParams(fixture, overrides = {}) {
39
+ return {
40
+ shellId: "sh_test",
41
+ shellDir: fixture.shellDir,
42
+ cmd: "printf hello",
43
+ cwd: fixture.projectRoot,
44
+ actualCwd: fixture.projectRoot,
45
+ shellPath: "/bin/sh",
46
+ login: true,
47
+ baseEnv: {
48
+ PATH: "/usr/bin:/bin",
49
+ LANG: "C.UTF-8",
50
+ DC_SESSION_ID: "session_test",
51
+ },
52
+ config: {
53
+ backend: "linux-bubblewrap",
54
+ rootPath: fixture.projectRoot,
55
+ envAllowlist: ["PATH", "LANG"],
56
+ writablePaths: [fixture.writablePath],
57
+ networkMode: "off",
58
+ },
59
+ shellHomeDir: fixture.shellHomeDir,
60
+ shellTmpDir: fixture.shellTmpDir,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ function hasArg(args, value) {
66
+ return args.includes(value);
67
+ }
68
+
69
+ function hasOptionPair(args, option, sourcePath, targetPath = sourcePath) {
70
+ for (let index = 0; index < args.length - 2; index += 1) {
71
+ if (
72
+ args[index] === option &&
73
+ args[index + 1] === sourcePath &&
74
+ args[index + 2] === targetPath
75
+ ) {
76
+ return true;
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
82
+ function hasOptionValue(args, option, value) {
83
+ for (let index = 0; index < args.length - 1; index += 1) {
84
+ if (args[index] === option && args[index + 1] === value) {
85
+ return true;
86
+ }
87
+ }
88
+ return false;
89
+ }
90
+
91
+ test("Linux bubblewrap args isolate network and overlay writable project paths", async () => {
92
+ const fixture = await createSandboxFixture();
93
+ try {
94
+ const args = buildLinuxBubblewrapArgs(createParams(fixture));
95
+
96
+ assert.equal(hasArg(args, "--die-with-parent"), true);
97
+ assert.equal(hasArg(args, "--unshare-pid"), true);
98
+ assert.equal(hasArg(args, "--unshare-net"), true);
99
+ assert.equal(hasOptionPair(args, "--ro-bind", fixture.projectRoot), true);
100
+ assert.equal(hasOptionPair(args, "--bind", fixture.writablePath), true);
101
+ assert.equal(hasOptionPair(args, "--bind", fixture.projectRoot), false);
102
+ assert.equal(hasOptionValue(args, "--dir", fixture.writablePath), false);
103
+ assert.deepEqual(args.slice(-5), [
104
+ "--chdir",
105
+ fixture.projectRoot,
106
+ "/bin/sh",
107
+ "-lc",
108
+ "printf hello",
109
+ ]);
110
+ } finally {
111
+ await fs.rm(fixture.root, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("Linux bubblewrap args keep root writable when sandbox writablePaths includes root", async () => {
116
+ const fixture = await createSandboxFixture();
117
+ try {
118
+ const args = buildLinuxBubblewrapArgs(createParams(fixture, {
119
+ config: {
120
+ backend: "linux-bubblewrap",
121
+ rootPath: fixture.projectRoot,
122
+ envAllowlist: ["PATH"],
123
+ writablePaths: [fixture.projectRoot],
124
+ networkMode: "full",
125
+ },
126
+ login: false,
127
+ }));
128
+
129
+ assert.equal(hasArg(args, "--unshare-net"), false);
130
+ assert.equal(hasOptionPair(args, "--bind", fixture.projectRoot), true);
131
+ assert.equal(hasOptionPair(args, "--ro-bind", fixture.projectRoot), false);
132
+ assert.deepEqual(args.slice(-5), [
133
+ "--chdir",
134
+ fixture.projectRoot,
135
+ "/bin/sh",
136
+ "-c",
137
+ "printf hello",
138
+ ]);
139
+ } finally {
140
+ await fs.rm(fixture.root, { recursive: true, force: true });
141
+ }
142
+ });
@@ -27,6 +27,7 @@ import {
27
27
  getDowncityProfileOtherPath,
28
28
  getDowncityProfilePrimaryPath,
29
29
  getDowncityPublicDirPath,
30
+ getDowncityResourcesDirPath,
30
31
  getDowncitySchemaPath,
31
32
  getDowncityTasksDirPath,
32
33
  getSoulMdPath,
@@ -229,6 +230,7 @@ export async function initializeAgentProject(
229
230
  getDowncityDataDirPath(projectRoot),
230
231
  getDowncityAgentsRootDirPath(projectRoot),
231
232
  getDowncityPublicDirPath(projectRoot),
233
+ getDowncityResourcesDirPath(projectRoot),
232
234
  getDowncityConfigDirPath(projectRoot),
233
235
  path.join(projectRoot, ".agents", "skills"),
234
236
  path.join(getDowncityDirPath(projectRoot), "schema"),
@@ -331,6 +331,17 @@ export function getDowncityPublicDirPath(cwd: string): string {
331
331
  return path.join(getDowncityDirPath(cwd), "public");
332
332
  }
333
333
 
334
+ /**
335
+ * 返回项目资源目录路径。
336
+ *
337
+ * 关键点(中文)
338
+ * - 该目录用于存放会话历史引用的二进制资源,例如图片生成结果。
339
+ * - `messages.jsonl` 只保存 `file://` 绝对 URL,避免把大段 base64 长期写入历史。
340
+ */
341
+ export function getDowncityResourcesDirPath(cwd: string): string {
342
+ return path.join(getDowncityDirPath(cwd), "resources");
343
+ }
344
+
334
345
  /**
335
346
  * 返回项目任务目录路径。
336
347
  *
@@ -375,6 +375,9 @@ export class Executor implements SessionExecutor {
375
375
  ): SessionRunContext {
376
376
  return {
377
377
  sessionId: String(input?.sessionId || this.sessionId).trim(),
378
+ ...(typeof input?.projectRoot === "string" && input.projectRoot.trim()
379
+ ? { projectRoot: input.projectRoot.trim() }
380
+ : {}),
378
381
  ...(typeof input?.onStepCallback === "function"
379
382
  ? { onStepCallback: input.onStepCallback }
380
383
  : {}),
@@ -0,0 +1,155 @@
1
+ /**
2
+ * AssistantFileResource:assistant file part 的本地资源落盘工具。
3
+ *
4
+ * 关键点(中文)
5
+ * - 只处理运行期产生的 assistant file part,不参与 user 附件注入。
6
+ * - 将 `data:*;base64,...` 写入 `.downcity/resources`,历史中只保留 `file://` 绝对 URL。
7
+ * - 资源文件按内容 hash 命名,天然去重并避免重复写入大文件。
8
+ */
9
+
10
+ import crypto from "node:crypto";
11
+ import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import fs from "fs-extra";
14
+ import type { FileUIPart } from "ai";
15
+ import { getDowncityResourcesDirPath } from "@/config/Paths.js";
16
+
17
+ type ParsedDataUrl = {
18
+ /**
19
+ * data URL 声明的媒体类型。
20
+ */
21
+ media_type: string;
22
+ /**
23
+ * data URL 解码后的二进制内容。
24
+ */
25
+ bytes: Buffer;
26
+ };
27
+
28
+ /**
29
+ * assistant file part 资源落盘参数。
30
+ */
31
+ export interface MaterializeAssistantFilePartsParams {
32
+ /**
33
+ * 当前项目根目录。
34
+ *
35
+ * 关键点(中文)
36
+ * - 正常 session run 会显式传入 projectRoot。
37
+ * - 旧入口未传时回退到 `process.cwd()`,保证行为可用。
38
+ */
39
+ projectRoot?: string;
40
+
41
+ /**
42
+ * 待处理的 assistant file parts。
43
+ */
44
+ parts: FileUIPart[];
45
+ }
46
+
47
+ function resolve_project_root(projectRoot: string | undefined): string {
48
+ const raw = String(projectRoot || "").trim();
49
+ return path.resolve(raw || process.cwd());
50
+ }
51
+
52
+ function parse_data_url(url: string): ParsedDataUrl | null {
53
+ const raw = String(url || "").trim();
54
+ if (!raw.startsWith("data:")) return null;
55
+ const comma_index = raw.indexOf(",");
56
+ if (comma_index < 0) return null;
57
+
58
+ const header = raw.slice(5, comma_index);
59
+ const body = raw.slice(comma_index + 1);
60
+ const header_parts = header.split(";").filter(Boolean);
61
+ const media_type =
62
+ header_parts.find((item) => item.includes("/")) || "application/octet-stream";
63
+ const is_base64 = header_parts.some((item) => item.toLowerCase() === "base64");
64
+
65
+ try {
66
+ const bytes = is_base64
67
+ ? Buffer.from(body, "base64")
68
+ : Buffer.from(decodeURIComponent(body), "utf8");
69
+ if (bytes.length === 0) return null;
70
+ return {
71
+ media_type,
72
+ bytes,
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function extension_from_media_type(mediaType: string): string {
80
+ const value = String(mediaType || "").toLowerCase();
81
+ if (value === "image/png") return ".png";
82
+ if (value === "image/jpeg" || value === "image/jpg") return ".jpg";
83
+ if (value === "image/webp") return ".webp";
84
+ if (value === "image/gif") return ".gif";
85
+ if (value === "application/pdf") return ".pdf";
86
+ return ".bin";
87
+ }
88
+
89
+ function extension_from_filename(filename: string | undefined): string {
90
+ const ext = path.extname(String(filename || "").trim()).toLowerCase();
91
+ if (!ext || ext.length > 12) return "";
92
+ return /^[.][a-z0-9]+$/u.test(ext) ? ext : "";
93
+ }
94
+
95
+ async function write_resource_file(params: {
96
+ projectRoot: string;
97
+ mediaType: string;
98
+ filename?: string;
99
+ bytes: Buffer;
100
+ }): Promise<string> {
101
+ const hash = crypto.createHash("sha256").update(params.bytes).digest("hex");
102
+ const ext =
103
+ extension_from_filename(params.filename) ||
104
+ extension_from_media_type(params.mediaType);
105
+ const file_name = `${hash}${ext}`;
106
+ const resources_dir = getDowncityResourcesDirPath(params.projectRoot);
107
+ const file_path = path.join(resources_dir, file_name);
108
+
109
+ await fs.ensureDir(resources_dir);
110
+ try {
111
+ await fs.writeFile(file_path, params.bytes, { flag: "wx" });
112
+ } catch (error) {
113
+ const code = (error as NodeJS.ErrnoException).code;
114
+ if (code !== "EEXIST") throw error;
115
+ }
116
+
117
+ return file_path;
118
+ }
119
+
120
+ /**
121
+ * 将 assistant file part 中的 data URL 资源落盘为 `file://` 绝对 URL。
122
+ */
123
+ export async function materializeAssistantFileParts(
124
+ params: MaterializeAssistantFilePartsParams,
125
+ ): Promise<FileUIPart[]> {
126
+ const parts = Array.isArray(params.parts) ? params.parts : [];
127
+ if (parts.length === 0) return [];
128
+
129
+ const project_root = resolve_project_root(params.projectRoot);
130
+ const out: FileUIPart[] = [];
131
+
132
+ for (const part of parts) {
133
+ const parsed = parse_data_url(String(part.url || ""));
134
+ if (!parsed) {
135
+ out.push(part);
136
+ continue;
137
+ }
138
+
139
+ const media_type = String(part.mediaType || parsed.media_type || "").trim();
140
+ const file_path = await write_resource_file({
141
+ projectRoot: project_root,
142
+ mediaType: media_type || parsed.media_type,
143
+ filename: typeof part.filename === "string" ? part.filename : undefined,
144
+ bytes: parsed.bytes,
145
+ });
146
+
147
+ out.push({
148
+ ...part,
149
+ mediaType: media_type || parsed.media_type,
150
+ url: pathToFileURL(file_path).href,
151
+ });
152
+ }
153
+
154
+ return out;
155
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import fs from "fs-extra";
11
11
  import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import {
13
14
  isFileUIPart,
14
15
  isTextUIPart,
@@ -61,6 +62,64 @@ function buildDataUrl(mediaType: string, buffer: Buffer): string {
61
62
  return `data:${safeType};base64,${base64}`;
62
63
  }
63
64
 
65
+ async function hydrateFileUrlPart(part: FileUIPart): Promise<FileUIPart> {
66
+ const url = String(part.url || "").trim();
67
+ if (!url.startsWith("file://")) return part;
68
+ try {
69
+ const absPath = fileURLToPath(url);
70
+ const buffer = await fs.readFile(absPath);
71
+ const mediaType =
72
+ String(part.mediaType || "").trim() ||
73
+ guessAttachmentMediaTypeFromPath(absPath) ||
74
+ "application/octet-stream";
75
+ return {
76
+ ...part,
77
+ mediaType,
78
+ url: buildDataUrl(mediaType, buffer),
79
+ };
80
+ } catch {
81
+ return part;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 将历史中的 `file://` file part 临时转换为模型可消费的 data URL。
87
+ *
88
+ * 关键点(中文)
89
+ * - 该函数只修改本轮内存消息,不回写历史。
90
+ * - 持久化层继续保留轻量 `file://` 绝对 URL,避免 JSONL 存储 base64。
91
+ */
92
+ export async function hydrateFileUrlPartsForModel(
93
+ messages: SessionMessageV1[],
94
+ ): Promise<SessionMessageV1[]> {
95
+ if (!Array.isArray(messages) || messages.length === 0) return messages;
96
+
97
+ const out: SessionMessageV1[] = [];
98
+ for (const message of messages) {
99
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
100
+ if (!parts.some((part) => isFileUIPart(part as FileUIPart))) {
101
+ out.push(message);
102
+ continue;
103
+ }
104
+
105
+ const nextParts: SessionMessageV1["parts"] = [];
106
+ let changed = false;
107
+ for (const part of parts) {
108
+ if (!isFileUIPart(part as FileUIPart)) {
109
+ nextParts.push(part);
110
+ continue;
111
+ }
112
+ const nextPart = await hydrateFileUrlPart(part as FileUIPart);
113
+ if (nextPart !== part) changed = true;
114
+ nextParts.push(nextPart as SessionMessageV1["parts"][number]);
115
+ }
116
+
117
+ out.push(changed ? { ...message, parts: nextParts } : message);
118
+ }
119
+
120
+ return out;
121
+ }
122
+
64
123
  /**
65
124
  * 在 user 消息上注入 FileUIPart,以便多模态模型直接消费本地附件。
66
125
  */
@@ -15,7 +15,10 @@ import {
15
15
  type ToolSet,
16
16
  } from "ai";
17
17
  import type { SessionMessageV1 } from "@/executor/types/SessionMessages.js";
18
- import { injectFilePartsFromAttachments } from "@executor/messages/SessionAttachmentMapper.js";
18
+ import {
19
+ hydrateFileUrlPartsForModel,
20
+ injectFilePartsFromAttachments,
21
+ } from "@executor/messages/SessionAttachmentMapper.js";
19
22
 
20
23
  /**
21
24
  * 过滤回调返回值中的 user 文本消息。
@@ -76,8 +79,11 @@ export async function toModelMessages(
76
79
  // 第一步(中文):在 user 消息上注入 file parts(多模态附件)。
77
80
  const enrichedMessages = await injectFilePartsFromAttachments(messages);
78
81
 
79
- // 第二步(中文):转换前先剔除 UI id 字段,仅保留模型需要的数据结构。
80
- const input = enrichedMessages.map((message) => {
82
+ // 第二步(中文):把历史里的 file:// 资源在内存中 hydrate 成模型可消费的 data URL。
83
+ const hydratedMessages = await hydrateFileUrlPartsForModel(enrichedMessages);
84
+
85
+ // 第三步(中文):转换前先剔除 UI 层 id 字段,仅保留模型需要的数据结构。
86
+ const input = hydratedMessages.map((message) => {
81
87
  // 解构去掉 id。
82
88
  const { id: _id, ...rest } = message;
83
89
 
@@ -14,7 +14,11 @@ import type {
14
14
  PluginCallInput,
15
15
  PluginCallToolResult,
16
16
  } from "@/executor/tools/plugin/types/PluginTool.js";
17
- import { enqueueAssistantFileParts } from "@executor/SessionRunScope.js";
17
+ import { materializeAssistantFileParts } from "@executor/messages/AssistantFileResource.js";
18
+ import {
19
+ enqueueAssistantFileParts,
20
+ getSessionRunContext,
21
+ } from "@executor/SessionRunScope.js";
18
22
 
19
23
  let plugin_tool_runtime: PluginPort | null = null;
20
24
 
@@ -131,9 +135,16 @@ export async function invokePluginCallTool(
131
135
  action,
132
136
  payload,
133
137
  });
134
- const file_parts = result.success
138
+ const raw_file_parts = result.success
135
139
  ? extract_assistant_file_parts(result.data)
136
140
  : [];
141
+ const file_parts =
142
+ raw_file_parts.length > 0
143
+ ? await materializeAssistantFileParts({
144
+ projectRoot: getSessionRunContext()?.projectRoot,
145
+ parts: raw_file_parts,
146
+ })
147
+ : [];
137
148
  if (file_parts.length > 0) {
138
149
  enqueueAssistantFileParts(file_parts);
139
150
  }
package/src/index.ts CHANGED
@@ -229,6 +229,10 @@ export type {
229
229
  ImagePluginContent,
230
230
  ImagePluginFileContent,
231
231
  ImagePluginInput,
232
+ ImagePluginJobCreateResult,
233
+ ImagePluginJobResult,
234
+ ImagePluginJobStatus,
235
+ ImagePluginJobStatusResult,
232
236
  ImagePluginMessage,
233
237
  ImagePluginOptions,
234
238
  ImagePluginResult,