@downcity/plugins 1.0.248 → 1.0.252

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 (76) hide show
  1. package/bin/image/ImagePlugin.d.ts +1 -1
  2. package/bin/image/ImagePlugin.d.ts.map +1 -1
  3. package/bin/image/ImagePlugin.js +23 -8
  4. package/bin/image/ImagePlugin.js.map +1 -1
  5. package/bin/image/runtime/ImageResultStorage.d.ts +14 -0
  6. package/bin/image/runtime/ImageResultStorage.d.ts.map +1 -0
  7. package/bin/image/runtime/ImageResultStorage.js +167 -0
  8. package/bin/image/runtime/ImageResultStorage.js.map +1 -0
  9. package/bin/image/types/ImagePlugin.d.ts +22 -0
  10. package/bin/image/types/ImagePlugin.d.ts.map +1 -1
  11. package/bin/web/Plugin.d.ts +25 -30
  12. package/bin/web/Plugin.d.ts.map +1 -1
  13. package/bin/web/Plugin.js +254 -110
  14. package/bin/web/Plugin.js.map +1 -1
  15. package/bin/web/providers/ComputerUseBrowserProviderAdapter.d.ts +35 -0
  16. package/bin/web/providers/ComputerUseBrowserProviderAdapter.d.ts.map +1 -0
  17. package/bin/web/providers/ComputerUseBrowserProviderAdapter.js +62 -0
  18. package/bin/web/providers/ComputerUseBrowserProviderAdapter.js.map +1 -0
  19. package/bin/web/providers/PlaywrightBrowserProvider.d.ts +43 -0
  20. package/bin/web/providers/PlaywrightBrowserProvider.d.ts.map +1 -0
  21. package/bin/web/providers/PlaywrightBrowserProvider.js +184 -0
  22. package/bin/web/providers/PlaywrightBrowserProvider.js.map +1 -0
  23. package/bin/web/providers/SemanticBrowserProviderAdapter.d.ts +39 -0
  24. package/bin/web/providers/SemanticBrowserProviderAdapter.d.ts.map +1 -0
  25. package/bin/web/providers/SemanticBrowserProviderAdapter.js +61 -0
  26. package/bin/web/providers/SemanticBrowserProviderAdapter.js.map +1 -0
  27. package/bin/web/types/ComputerUseBrowserProviderAdapter.d.ts +43 -0
  28. package/bin/web/types/ComputerUseBrowserProviderAdapter.d.ts.map +1 -0
  29. package/bin/web/types/ComputerUseBrowserProviderAdapter.js +8 -0
  30. package/bin/web/types/ComputerUseBrowserProviderAdapter.js.map +1 -0
  31. package/bin/web/types/PlaywrightBrowserProvider.d.ts +17 -0
  32. package/bin/web/types/PlaywrightBrowserProvider.d.ts.map +1 -0
  33. package/bin/web/types/PlaywrightBrowserProvider.js +7 -0
  34. package/bin/web/types/PlaywrightBrowserProvider.js.map +1 -0
  35. package/bin/web/types/SemanticBrowserProviderAdapter.d.ts +19 -0
  36. package/bin/web/types/SemanticBrowserProviderAdapter.d.ts.map +1 -0
  37. package/bin/web/types/SemanticBrowserProviderAdapter.js +8 -0
  38. package/bin/web/types/SemanticBrowserProviderAdapter.js.map +1 -0
  39. package/bin/web/types/WebPlugin.d.ts +213 -63
  40. package/bin/web/types/WebPlugin.d.ts.map +1 -1
  41. package/bin/web/types/WebPlugin.js +23 -11
  42. package/bin/web/types/WebPlugin.js.map +1 -1
  43. package/bin/web.d.ts +9 -3
  44. package/bin/web.d.ts.map +1 -1
  45. package/bin/web.js +5 -2
  46. package/bin/web.js.map +1 -1
  47. package/package.json +4 -3
  48. package/scripts/web-plugin.test.mjs +195 -57
  49. package/src/image/ImagePlugin.ts +23 -8
  50. package/src/image/runtime/ImageResultStorage.ts +198 -0
  51. package/src/image/types/ImagePlugin.ts +24 -0
  52. package/src/web/Plugin.ts +289 -125
  53. package/src/web/providers/ComputerUseBrowserProviderAdapter.ts +93 -0
  54. package/src/web/providers/PlaywrightBrowserProvider.ts +239 -0
  55. package/src/web/providers/SemanticBrowserProviderAdapter.ts +92 -0
  56. package/src/web/types/ComputerUseBrowserProviderAdapter.ts +58 -0
  57. package/src/web/types/PlaywrightBrowserProvider.ts +17 -0
  58. package/src/web/types/SemanticBrowserProviderAdapter.ts +30 -0
  59. package/src/web/types/WebPlugin.ts +236 -63
  60. package/src/web.ts +30 -8
  61. package/bin/web/PROMPT.d.ts +0 -7
  62. package/bin/web/PROMPT.d.ts.map +0 -1
  63. package/bin/web/PROMPT.js +0 -8
  64. package/bin/web/PROMPT.js.map +0 -1
  65. package/bin/web/WebPromptAssets.d.ts +0 -12
  66. package/bin/web/WebPromptAssets.d.ts.map +0 -1
  67. package/bin/web/WebPromptAssets.js +0 -13
  68. package/bin/web/WebPromptAssets.js.map +0 -1
  69. package/bin/web/runtime/Prompt.d.ts +0 -14
  70. package/bin/web/runtime/Prompt.d.ts.map +0 -1
  71. package/bin/web/runtime/Prompt.js +0 -77
  72. package/bin/web/runtime/Prompt.js.map +0 -1
  73. package/src/web/PROMPT.ts +0 -9
  74. package/src/web/PROMPT.ts.txt +0 -61
  75. package/src/web/WebPromptAssets.ts +0 -14
  76. package/src/web/runtime/Prompt.ts +0 -99
@@ -1,85 +1,223 @@
1
1
  /**
2
- * WebPlugin 纯提示 install action 回归测试。
2
+ * WebPlugin provider-neutral action 回归测试。
3
3
  *
4
4
  * 关键点(中文)
5
- * - install action 只返回 Agent 操作提示,不能执行命令或写入文件。
6
- * - Skill 安装必须委托给 skill.install,避免 WebPlugin 重复维护扫描根规则。
7
- * - agent-browser CLI 提示需要根据 user / project 作用域生成。
5
+ * - Plugin 必须显式注入 provider,不能只注册一段方法论。
6
+ * - 浏览器 action 保持结构化输入输出,并在 lifecycle.stop 时释放 provider。
7
+ * - 缺少某项 provider 时只让对应 action 返回明确失败。
8
8
  */
9
9
 
10
10
  import assert from "node:assert/strict";
11
- import fs from "node:fs";
12
- import path from "node:path";
13
11
  import test from "node:test";
14
- import { WebPlugin } from "../bin/index.js";
12
+ import {
13
+ ComputerUseBrowserProviderAdapter,
14
+ SemanticBrowserProviderAdapter,
15
+ WebPlugin,
16
+ } from "../bin/index.js";
15
17
 
16
- test("WebPlugin 仅暴露纯提示 install action", () => {
17
- const plugin = new WebPlugin();
18
+ function create_browser_provider() {
19
+ const sessions = new Map();
20
+ let disposed = false;
21
+ return {
22
+ name: "mock-browser",
23
+ get disposed() { return disposed; },
24
+ async create_session(input) {
25
+ const session_id = "browser-session-1";
26
+ const state = {
27
+ provider: this.name,
28
+ session_id,
29
+ url: input.url || "about:blank",
30
+ title: "Mock page",
31
+ text: "Initial page",
32
+ };
33
+ sessions.set(session_id, state);
34
+ return state;
35
+ },
36
+ async observe(input) {
37
+ const state = sessions.get(input.session_id);
38
+ if (!state) throw new Error(`Browser session not found: ${input.session_id}`);
39
+ return state;
40
+ },
41
+ async act(input) {
42
+ const state = sessions.get(input.session_id);
43
+ if (!state) throw new Error(`Browser session not found: ${input.session_id}`);
44
+ const next = { ...state, text: `acted:${input.action.type}` };
45
+ sessions.set(input.session_id, next);
46
+ return next;
47
+ },
48
+ async extract(input) {
49
+ const state = sessions.get(input.session_id);
50
+ if (!state) throw new Error(`Browser session not found: ${input.session_id}`);
51
+ return {
52
+ provider: this.name,
53
+ session_id: input.session_id,
54
+ url: state.url,
55
+ content: state.text,
56
+ };
57
+ },
58
+ async close_session(input) {
59
+ sessions.delete(input.session_id);
60
+ },
61
+ async dispose() {
62
+ sessions.clear();
63
+ disposed = true;
64
+ },
65
+ };
66
+ }
18
67
 
19
- assert.deepEqual(Object.keys(plugin.actions), ["install"]);
20
- assert.equal(plugin.setup, undefined);
21
- assert.match(plugin.actions.install.description, /does not install anything/);
68
+ test("WebPlugin 拒绝没有真实能力的空配置", () => {
69
+ assert.throws(() => new WebPlugin({}), /requires at least one provider/);
22
70
  });
23
71
 
24
- test("install action 默认返回 web-access 安装工作流", async () => {
25
- const project_root = path.resolve("fixtures/web-install-instructions");
26
- const plugin = new WebPlugin();
72
+ test("WebPlugin 暴露结构化联网与浏览器 actions", () => {
73
+ const plugin = new WebPlugin({ browser: create_browser_provider() });
74
+ assert.deepEqual(Object.keys(plugin.actions), [
75
+ "search",
76
+ "open",
77
+ "browser_create_session",
78
+ "browser_observe",
79
+ "browser_act",
80
+ "browser_semantic_act",
81
+ "browser_extract",
82
+ "browser_semantic_extract",
83
+ "browser_close_session",
84
+ ]);
85
+ assert.equal(plugin.actions.install, undefined);
86
+ assert.match(plugin.system(), /deterministic CSS-selector operations/);
87
+ });
27
88
 
28
- assert.equal(fs.existsSync(project_root), false);
89
+ test("search 与 open 委托给独立 provider", async () => {
90
+ const plugin = new WebPlugin({
91
+ search: async (input) => ({
92
+ provider: "mock-search",
93
+ items: [{ url: "https://example.com", title: input.query }],
94
+ }),
95
+ open: async (input) => ({
96
+ provider: "mock-reader",
97
+ url: input.url,
98
+ title: "Example",
99
+ content: "Example content",
100
+ }),
101
+ });
29
102
 
30
- const result = await plugin.actions.install.execute({
31
- context: { workspace_path: project_root },
32
- input: {},
103
+ const search_result = await plugin.actions.search.execute({
104
+ context: {},
105
+ input: { query: "official docs" },
106
+ });
107
+ const open_result = await plugin.actions.open.execute({
108
+ context: {},
109
+ input: { url: "https://example.com" },
33
110
  });
34
111
 
35
- assert.equal(result.success, true);
36
- assert.equal(result.data.kind, "instructions");
37
- assert.equal(result.data.target, "web-access");
38
- assert.equal(result.data.scope, "user");
39
- assert.match(result.data.prompt, /plugin: "skill", action: "install"/);
40
- assert.match(result.data.prompt, /spec: "web-access"/);
41
- assert.match(result.data.prompt, /has not executed commands/);
42
- assert.match(result.data.prompt, /action: "list"/);
43
- assert.equal(fs.existsSync(project_root), false);
112
+ assert.equal(search_result.success, true);
113
+ assert.equal(search_result.data.provider, "mock-search");
114
+ assert.equal(open_result.success, true);
115
+ assert.equal(open_result.data.content, "Example content");
44
116
  });
45
117
 
46
- test("agent-browser project 提示包含 Skill 与项目 CLI 安装步骤", async () => {
47
- const plugin = new WebPlugin();
48
- const result = await plugin.actions.install.execute({
49
- context: { workspace_path: path.resolve("fixtures/web-agent-browser-instructions") },
50
- input: { target: "agent-browser", scope: "project" },
118
+ test("浏览器 session 完整执行 create、act、extract close", async () => {
119
+ const browser = create_browser_provider();
120
+ const plugin = new WebPlugin({ browser });
121
+ const created = await plugin.actions.browser_create_session.execute({
122
+ context: {},
123
+ input: { url: "https://example.com" },
51
124
  });
125
+ const session_id = created.data.session_id;
52
126
 
53
- assert.equal(result.success, true);
54
- assert.equal(result.data.target, "agent-browser");
55
- assert.equal(result.data.scope, "project");
56
- assert.match(result.data.prompt, /spec: "agent-browser"/);
57
- assert.match(result.data.prompt, /pnpm add -D agent-browser/);
58
- assert.match(result.data.prompt, /npm install -D agent-browser/);
59
- assert.match(result.data.prompt, /yarn add -D agent-browser/);
60
- assert.match(result.data.prompt, /skill\.lookup/);
127
+ const acted = await plugin.actions.browser_act.execute({
128
+ context: {},
129
+ input: { session_id, action: { type: "click", selector: "a" } },
130
+ });
131
+ const extracted = await plugin.actions.browser_extract.execute({
132
+ context: {},
133
+ input: { session_id },
134
+ });
135
+ const closed = await plugin.actions.browser_close_session.execute({
136
+ context: {},
137
+ input: { session_id },
138
+ });
139
+
140
+ assert.equal(created.success, true);
141
+ assert.equal(acted.data.text, "acted:click");
142
+ assert.equal(extracted.data.content, "acted:click");
143
+ assert.deepEqual(closed.data, { session_id, closed: true });
61
144
  });
62
145
 
63
- test("all 目标返回两个 Skill 和用户级 CLI 提示", async () => {
64
- const plugin = new WebPlugin();
65
- const result = await plugin.actions.install.execute({
66
- context: { workspace_path: path.resolve("fixtures/web-all-instructions") },
67
- input: { target: "all", scope: "user" },
146
+ test("语义 adapter 复用基础 session 并暴露语义 actions", async () => {
147
+ const base_browser = create_browser_provider();
148
+ const browser = new SemanticBrowserProviderAdapter({
149
+ name: "mock-semantic",
150
+ browser: base_browser,
151
+ semantic_act: async (input) => ({
152
+ ...(await base_browser.observe({ session_id: input.session_id })),
153
+ text: `semantic:${input.instruction}`,
154
+ }),
155
+ semantic_extract: async (input) => ({
156
+ provider: "mock-semantic",
157
+ session_id: input.session_id,
158
+ url: "https://example.com",
159
+ content: `semantic-extract:${input.instruction}`,
160
+ }),
161
+ });
162
+ const plugin = new WebPlugin({ browser });
163
+ const created = await plugin.actions.browser_create_session.execute({
164
+ context: {},
165
+ input: { url: "https://example.com" },
166
+ });
167
+ const session_id = created.data.session_id;
168
+ const acted = await plugin.actions.browser_semantic_act.execute({
169
+ context: {},
170
+ input: { session_id, instruction: "click the login button" },
171
+ });
172
+ const extracted = await plugin.actions.browser_semantic_extract.execute({
173
+ context: {},
174
+ input: { session_id, instruction: "extract the account name" },
175
+ });
176
+
177
+ assert.equal(acted.success, true);
178
+ assert.equal(acted.data.text, "semantic:click the login button");
179
+ assert.equal(extracted.data.content, "semantic-extract:extract the account name");
180
+ });
181
+
182
+ test("Computer Use adapter 先获取截图观察再委托模型循环", async () => {
183
+ const base_browser = create_browser_provider();
184
+ const browser = new ComputerUseBrowserProviderAdapter({
185
+ name: "mock-computer-use",
186
+ browser: base_browser,
187
+ run: async ({ instruction, observation }) => ({
188
+ ...observation,
189
+ text: `computer-use:${instruction}`,
190
+ screenshot_data_url: observation.screenshot_data_url ?? "data:image/png;base64,mock",
191
+ }),
192
+ });
193
+ const plugin = new WebPlugin({ browser });
194
+ const created = await plugin.actions.browser_create_session.execute({
195
+ context: {},
196
+ input: { url: "https://example.com" },
197
+ });
198
+ const result = await plugin.actions.browser_semantic_act.execute({
199
+ context: {},
200
+ input: {
201
+ session_id: created.data.session_id,
202
+ instruction: "drag the map",
203
+ },
68
204
  });
69
205
 
70
206
  assert.equal(result.success, true);
71
- assert.match(result.data.prompt, /spec: "web-access"/);
72
- assert.match(result.data.prompt, /spec: "agent-browser"/);
73
- assert.match(result.data.prompt, /npm install -g agent-browser/);
74
- assert.match(result.data.prompt, /no files were changed|changed files/i);
207
+ assert.equal(result.data.text, "computer-use:drag the map");
208
+ assert.match(result.data.screenshot_data_url, /^data:image\/png;base64/);
75
209
  });
76
210
 
77
- test("system prompt 明确 install 只返回提示", () => {
78
- const plugin = new WebPlugin();
79
- const prompt = plugin.system({ workspace_path: process.cwd() });
211
+ test("未配置的 action 返回明确失败,lifecycle.stop 释放浏览器", async () => {
212
+ const browser = create_browser_provider();
213
+ const plugin = new WebPlugin({ browser });
214
+ const search_result = await plugin.actions.search.execute({
215
+ context: {},
216
+ input: { query: "test" },
217
+ });
218
+ assert.equal(search_result.success, false);
219
+ assert.match(search_result.error, /not configured/);
80
220
 
81
- assert.match(prompt, /return installation instructions/);
82
- assert.match(prompt, /never executes commands/);
83
- assert.match(prompt, /skill\.install/);
84
- assert.match(prompt, /skill\.list/);
221
+ await plugin.lifecycle.stop({});
222
+ assert.equal(browser.disposed, true);
85
223
  });
@@ -4,7 +4,7 @@
4
4
  * 关键点(中文)
5
5
  * - 对 Agent 暴露 `image_create` / `image_result` 两步式任务 action。
6
6
  * - City / provider 的图片能力通过 image_create / image_result 任务函数注入。
7
- * - 成功结果返回 provider / City 已解析的 UI Parts,Plugin Action 同时声明结果数据和 Assistant Message。
7
+ * - 成功结果中的远程图片会写入 Workspace,并同时保留本地引用与在线来源地址。
8
8
  */
9
9
 
10
10
  import fs from "node:fs/promises";
@@ -31,6 +31,7 @@ import type {
31
31
  ImagePluginResolvedInput,
32
32
  ImagePluginResult,
33
33
  } from "@/image/types/ImagePlugin.js";
34
+ import { localize_image_result } from "@/image/runtime/ImageResultStorage.js";
34
35
 
35
36
  const DEFAULT_IMAGE_PLUGIN_NAME = "image";
36
37
  const DEFAULT_IMAGE_PLUGIN_TITLE = "Image";
@@ -387,8 +388,8 @@ function normalize_image_result_payload(
387
388
  /**
388
389
  * 校验 image 函数返回的 UIMessage。
389
390
  *
390
- * 图片资源的存储与访问地址由 image provider City Storage 负责;
391
- * ImagePlugin 只校验 UIMessage 结构,不接管远程资源的下载和本地化。
391
+ * 本函数只校验 provider / City 返回的 UIMessage 结构;
392
+ * 远程资源下载由 action 成功分支中的 ImageResultStorage 统一处理。
392
393
  */
393
394
  function normalize_image_result(result: ImagePluginResult): ImagePluginResult {
394
395
  const record = to_record(result);
@@ -791,7 +792,7 @@ export class ImagePlugin extends BasePlugin {
791
792
  },
792
793
  },
793
794
  ],
794
- execute: async ({ input }: { input: JsonValue }) => {
795
+ execute: async ({ context, execution_context, input }) => {
795
796
  try {
796
797
  const normalized_input = normalize_image_result_payload(input);
797
798
  const current = await this.read_image_result(normalized_input);
@@ -803,18 +804,32 @@ export class ImagePlugin extends BasePlugin {
803
804
  message: current.error ?? current.message ?? "image job failed",
804
805
  };
805
806
  }
807
+ const localized = current.status === "succeeded" && current.result
808
+ ? await localize_image_result({
809
+ context,
810
+ job_id: current.job_id,
811
+ result: current.result,
812
+ abort_signal: execution_context?.abort_signal,
813
+ })
814
+ : undefined;
815
+ const output = localized
816
+ ? { ...current, result: localized.result }
817
+ : current;
818
+ const localization_warning = localized?.errors.length
819
+ ? `; ${localized.errors.length} image result(s) kept as remote URLs because local storage failed: ${localized.errors.join("; ")}`
820
+ : "";
806
821
  return {
807
822
  success: true,
808
- data: current as unknown as JsonObject,
823
+ data: output as unknown as JsonObject,
809
824
  message:
810
825
  current.status === "succeeded"
811
- ? "image generated"
826
+ ? `image generated${localization_warning}`
812
827
  : `image job ${current.status}`,
813
- ...(current.status === "succeeded" && current.result
828
+ ...(output.status === "succeeded" && output.result
814
829
  ? {
815
830
  messages: [{
816
831
  role: "assistant" as const,
817
- parts: current.result.parts,
832
+ parts: output.result.parts,
818
833
  }],
819
834
  }
820
835
  : {}),
@@ -0,0 +1,198 @@
1
+ /**
2
+ * ImagePlugin 生成结果本地化。
3
+ *
4
+ * 关键点(中文)
5
+ * - City / provider 返回的远程图片先落到当前 Workspace,再交给 Agent Session。
6
+ * - File Part 的 `url` 使用稳定的本地相对路径,原始在线地址写入 provider metadata。
7
+ * - 单张图片下载失败时保留远程地址,并返回可观察错误,不丢弃已经生成成功的结果。
8
+ */
9
+
10
+ import path from "node:path";
11
+ import type {
12
+ ImagePluginResultStorageInput,
13
+ ImagePluginResultStorageResult,
14
+ } from "@/image/types/ImagePlugin.js";
15
+
16
+ const HTTP_URL_RE = /^https?:\/\//i;
17
+ const MAX_IMAGE_RESULT_BYTES = 50 * 1024 * 1024;
18
+
19
+ const MEDIA_TYPE_EXTENSIONS: Record<string, string> = {
20
+ "image/apng": ".apng",
21
+ "image/avif": ".avif",
22
+ "image/gif": ".gif",
23
+ "image/jpeg": ".jpg",
24
+ "image/png": ".png",
25
+ "image/webp": ".webp",
26
+ };
27
+
28
+ /** 判断未知值是否为普通对象。 */
29
+ function to_record(value: unknown): Record<string, unknown> | null {
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
31
+ return value as Record<string, unknown>;
32
+ }
33
+
34
+ /** 把外部标识压缩为单个安全路径片段。 */
35
+ function to_safe_segment(value: string, fallback: string): string {
36
+ const normalized = String(value || "")
37
+ .trim()
38
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
39
+ .replace(/^-+|-+$/g, "")
40
+ .slice(0, 120);
41
+ return normalized || fallback;
42
+ }
43
+
44
+ /** 从 URL 中提取安全的文件扩展名。 */
45
+ function extension_from_url(url: string): string {
46
+ try {
47
+ const extension = path.extname(new URL(url).pathname).toLowerCase();
48
+ return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : "";
49
+ } catch {
50
+ return "";
51
+ }
52
+ }
53
+
54
+ /** 解析落盘文件扩展名。 */
55
+ function resolve_extension(input: {
56
+ media_type: string;
57
+ filename?: string;
58
+ source_url: string;
59
+ }): string {
60
+ const known = MEDIA_TYPE_EXTENSIONS[input.media_type.toLowerCase()];
61
+ if (known) return known;
62
+ const filename_extension = path.extname(String(input.filename || "")).toLowerCase();
63
+ if (/^\.[a-z0-9]{1,10}$/.test(filename_extension)) return filename_extension;
64
+ return extension_from_url(input.source_url) || ".bin";
65
+ }
66
+
67
+ /** 给 File Part 合并 Downcity 来源元数据。 */
68
+ function with_source_metadata(
69
+ part: Record<string, unknown>,
70
+ source_url: string,
71
+ localization_error?: string,
72
+ ): Record<string, unknown> {
73
+ const provider_metadata = to_record(part.providerMetadata) ?? {};
74
+ const downcity_metadata = to_record(provider_metadata.downcity) ?? {};
75
+ return {
76
+ ...part,
77
+ providerMetadata: {
78
+ ...provider_metadata,
79
+ downcity: {
80
+ ...downcity_metadata,
81
+ source_url,
82
+ ...(localization_error ? { localization_error } : {}),
83
+ },
84
+ },
85
+ };
86
+ }
87
+
88
+ /** 受大小上限保护地读取远程响应。 */
89
+ async function read_response_bytes(response: Response): Promise<Buffer> {
90
+ const declared_length = Number(response.headers.get("content-length") || 0);
91
+ if (declared_length > MAX_IMAGE_RESULT_BYTES) {
92
+ throw new Error(`image result exceeds ${MAX_IMAGE_RESULT_BYTES} bytes`);
93
+ }
94
+ if (!response.body) return Buffer.from(await response.arrayBuffer());
95
+
96
+ const reader = response.body.getReader();
97
+ const chunks: Uint8Array[] = [];
98
+ let total_bytes = 0;
99
+ while (true) {
100
+ const chunk = await reader.read();
101
+ if (chunk.done) break;
102
+ total_bytes += chunk.value.byteLength;
103
+ if (total_bytes > MAX_IMAGE_RESULT_BYTES) {
104
+ await reader.cancel();
105
+ throw new Error(`image result exceeds ${MAX_IMAGE_RESULT_BYTES} bytes`);
106
+ }
107
+ chunks.push(chunk.value);
108
+ }
109
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total_bytes);
110
+ }
111
+
112
+ /** 下载一张远程图片并返回 Workspace 相对路径。 */
113
+ async function persist_remote_image(input: {
114
+ context: ImagePluginResultStorageInput["context"];
115
+ job_id: string;
116
+ part: Record<string, unknown>;
117
+ source_url: string;
118
+ part_index: number;
119
+ abort_signal?: AbortSignal;
120
+ }): Promise<string> {
121
+ const response = await fetch(input.source_url, {
122
+ signal: input.abort_signal,
123
+ });
124
+ if (!response.ok) {
125
+ throw new Error(`image download failed with HTTP ${response.status}`);
126
+ }
127
+
128
+ const declared_media_type = String(input.part.mediaType || "").trim();
129
+ const response_media_type = String(response.headers.get("content-type") || "")
130
+ .split(";", 1)[0]
131
+ .trim();
132
+ const media_type = declared_media_type || response_media_type || "application/octet-stream";
133
+ const extension = resolve_extension({
134
+ media_type,
135
+ filename: typeof input.part.filename === "string" ? input.part.filename : undefined,
136
+ source_url: input.source_url,
137
+ });
138
+ const job_segment = to_safe_segment(input.job_id, "job");
139
+ const filename = `image_${String(input.part_index + 1).padStart(2, "0")}${extension}`;
140
+ const relative_path = path.posix.join(
141
+ ".downcity",
142
+ "image",
143
+ "results",
144
+ job_segment,
145
+ filename,
146
+ );
147
+ const absolute_path = input.context.files.resolve_path(...relative_path.split("/"));
148
+ if (!(await input.context.files.path_exists(absolute_path))) {
149
+ const bytes = await read_response_bytes(response);
150
+ await input.context.files.ensure_directory(path.dirname(absolute_path));
151
+ await input.context.files.write_file_atomically(absolute_path, bytes);
152
+ } else {
153
+ await response.body?.cancel();
154
+ }
155
+ return relative_path;
156
+ }
157
+
158
+ /**
159
+ * 把图片结果中的远程 File Parts 本地化,并保留原始在线地址。
160
+ */
161
+ export async function localize_image_result(
162
+ input: ImagePluginResultStorageInput,
163
+ ): Promise<ImagePluginResultStorageResult> {
164
+ const errors: string[] = [];
165
+ const parts = await Promise.all(input.result.parts.map(async (part, part_index) => {
166
+ const record = to_record(part);
167
+ if (record?.type !== "file") return part;
168
+ const source_url = String(record.url || "").trim();
169
+ if (!HTTP_URL_RE.test(source_url)) return part;
170
+
171
+ try {
172
+ const local_url = await persist_remote_image({
173
+ context: input.context,
174
+ job_id: input.job_id,
175
+ part: record,
176
+ source_url,
177
+ part_index,
178
+ abort_signal: input.abort_signal,
179
+ });
180
+ return {
181
+ ...with_source_metadata(record, source_url),
182
+ url: local_url,
183
+ } as typeof part;
184
+ } catch (error) {
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ errors.push(`part ${part_index + 1}: ${message}`);
187
+ return with_source_metadata(record, source_url, message) as typeof part;
188
+ }
189
+ }));
190
+
191
+ return {
192
+ result: {
193
+ ...input.result,
194
+ parts,
195
+ },
196
+ errors,
197
+ };
198
+ }
@@ -141,6 +141,30 @@ export interface ImagePluginResolvedInput {
141
141
  */
142
142
  export type ImagePluginResult = UIMessage;
143
143
 
144
+ /**
145
+ * 图片结果本地存储输入。
146
+ */
147
+ export interface ImagePluginResultStorageInput {
148
+ /** 当前 Agent Plugin Context,用于访问 Workspace 文件能力。 */
149
+ context: PluginContext;
150
+ /** 当前图片任务 ID,用于划分稳定的本地结果目录。 */
151
+ job_id: string;
152
+ /** City 或 provider 返回的原始图片 UIMessage。 */
153
+ result: ImagePluginResult;
154
+ /** 当前 Session Turn 的可选取消信号。 */
155
+ abort_signal?: AbortSignal;
156
+ }
157
+
158
+ /**
159
+ * 图片结果本地存储输出。
160
+ */
161
+ export interface ImagePluginResultStorageResult {
162
+ /** 已将可下载远程图片替换为本地 Workspace 路径的 UIMessage。 */
163
+ result: ImagePluginResult;
164
+ /** 未能完成本地化的图片错误;对应 File Part 会保留远程 URL。 */
165
+ errors: string[];
166
+ }
167
+
144
168
  /**
145
169
  * 图片生成任务状态。
146
170
  */