@downcity/plugins 1.0.246 → 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.
- package/bin/image/ImagePlugin.d.ts +1 -1
- package/bin/image/ImagePlugin.d.ts.map +1 -1
- package/bin/image/ImagePlugin.js +26 -10
- package/bin/image/ImagePlugin.js.map +1 -1
- package/bin/image/runtime/ImageResultStorage.d.ts +14 -0
- package/bin/image/runtime/ImageResultStorage.d.ts.map +1 -0
- package/bin/image/runtime/ImageResultStorage.js +167 -0
- package/bin/image/runtime/ImageResultStorage.js.map +1 -0
- package/bin/image/types/ImagePlugin.d.ts +22 -0
- package/bin/image/types/ImagePlugin.d.ts.map +1 -1
- package/bin/web/Plugin.d.ts +25 -30
- package/bin/web/Plugin.d.ts.map +1 -1
- package/bin/web/Plugin.js +254 -110
- package/bin/web/Plugin.js.map +1 -1
- package/bin/web/providers/ComputerUseBrowserProviderAdapter.d.ts +35 -0
- package/bin/web/providers/ComputerUseBrowserProviderAdapter.d.ts.map +1 -0
- package/bin/web/providers/ComputerUseBrowserProviderAdapter.js +62 -0
- package/bin/web/providers/ComputerUseBrowserProviderAdapter.js.map +1 -0
- package/bin/web/providers/PlaywrightBrowserProvider.d.ts +43 -0
- package/bin/web/providers/PlaywrightBrowserProvider.d.ts.map +1 -0
- package/bin/web/providers/PlaywrightBrowserProvider.js +184 -0
- package/bin/web/providers/PlaywrightBrowserProvider.js.map +1 -0
- package/bin/web/providers/SemanticBrowserProviderAdapter.d.ts +39 -0
- package/bin/web/providers/SemanticBrowserProviderAdapter.d.ts.map +1 -0
- package/bin/web/providers/SemanticBrowserProviderAdapter.js +61 -0
- package/bin/web/providers/SemanticBrowserProviderAdapter.js.map +1 -0
- package/bin/web/types/ComputerUseBrowserProviderAdapter.d.ts +43 -0
- package/bin/web/types/ComputerUseBrowserProviderAdapter.d.ts.map +1 -0
- package/bin/web/types/ComputerUseBrowserProviderAdapter.js +8 -0
- package/bin/web/types/ComputerUseBrowserProviderAdapter.js.map +1 -0
- package/bin/web/types/PlaywrightBrowserProvider.d.ts +17 -0
- package/bin/web/types/PlaywrightBrowserProvider.d.ts.map +1 -0
- package/bin/web/types/PlaywrightBrowserProvider.js +7 -0
- package/bin/web/types/PlaywrightBrowserProvider.js.map +1 -0
- package/bin/web/types/SemanticBrowserProviderAdapter.d.ts +19 -0
- package/bin/web/types/SemanticBrowserProviderAdapter.d.ts.map +1 -0
- package/bin/web/types/SemanticBrowserProviderAdapter.js +8 -0
- package/bin/web/types/SemanticBrowserProviderAdapter.js.map +1 -0
- package/bin/web/types/WebPlugin.d.ts +213 -63
- package/bin/web/types/WebPlugin.d.ts.map +1 -1
- package/bin/web/types/WebPlugin.js +23 -11
- package/bin/web/types/WebPlugin.js.map +1 -1
- package/bin/web.d.ts +9 -3
- package/bin/web.d.ts.map +1 -1
- package/bin/web.js +5 -2
- package/bin/web.js.map +1 -1
- package/package.json +4 -3
- package/scripts/web-plugin.test.mjs +195 -57
- package/src/image/ImagePlugin.ts +25 -12
- package/src/image/runtime/ImageResultStorage.ts +198 -0
- package/src/image/types/ImagePlugin.ts +24 -0
- package/src/web/Plugin.ts +289 -125
- package/src/web/providers/ComputerUseBrowserProviderAdapter.ts +93 -0
- package/src/web/providers/PlaywrightBrowserProvider.ts +239 -0
- package/src/web/providers/SemanticBrowserProviderAdapter.ts +92 -0
- package/src/web/types/ComputerUseBrowserProviderAdapter.ts +58 -0
- package/src/web/types/PlaywrightBrowserProvider.ts +17 -0
- package/src/web/types/SemanticBrowserProviderAdapter.ts +30 -0
- package/src/web/types/WebPlugin.ts +236 -63
- package/src/web.ts +30 -8
- package/bin/web/PROMPT.d.ts +0 -7
- package/bin/web/PROMPT.d.ts.map +0 -1
- package/bin/web/PROMPT.js +0 -8
- package/bin/web/PROMPT.js.map +0 -1
- package/bin/web/WebPromptAssets.d.ts +0 -12
- package/bin/web/WebPromptAssets.d.ts.map +0 -1
- package/bin/web/WebPromptAssets.js +0 -13
- package/bin/web/WebPromptAssets.js.map +0 -1
- package/bin/web/runtime/Prompt.d.ts +0 -14
- package/bin/web/runtime/Prompt.d.ts.map +0 -1
- package/bin/web/runtime/Prompt.js +0 -77
- package/bin/web/runtime/Prompt.js.map +0 -1
- package/src/web/PROMPT.ts +0 -9
- package/src/web/PROMPT.ts.txt +0 -61
- package/src/web/WebPromptAssets.ts +0 -14
- package/src/web/runtime/Prompt.ts +0 -99
|
@@ -1,85 +1,223 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WebPlugin
|
|
2
|
+
* WebPlugin provider-neutral action 回归测试。
|
|
3
3
|
*
|
|
4
4
|
* 关键点(中文)
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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 {
|
|
12
|
+
import {
|
|
13
|
+
ComputerUseBrowserProviderAdapter,
|
|
14
|
+
SemanticBrowserProviderAdapter,
|
|
15
|
+
WebPlugin,
|
|
16
|
+
} from "../bin/index.js";
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
const
|
|
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
|
-
|
|
20
|
-
assert.
|
|
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("
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
31
|
-
context: {
|
|
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(
|
|
36
|
-
assert.equal(
|
|
37
|
-
assert.equal(
|
|
38
|
-
assert.equal(
|
|
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("
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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("
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
72
|
-
assert.match(result.data.
|
|
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("
|
|
78
|
-
const
|
|
79
|
-
const
|
|
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
|
-
|
|
82
|
-
assert.
|
|
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
|
});
|
package/src/image/ImagePlugin.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 关键点(中文)
|
|
5
5
|
* - 对 Agent 暴露 `image_create` / `image_result` 两步式任务 action。
|
|
6
6
|
* - City / provider 的图片能力通过 image_create / image_result 任务函数注入。
|
|
7
|
-
* -
|
|
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";
|
|
@@ -386,6 +387,9 @@ function normalize_image_result_payload(
|
|
|
386
387
|
|
|
387
388
|
/**
|
|
388
389
|
* 校验 image 函数返回的 UIMessage。
|
|
390
|
+
*
|
|
391
|
+
* 本函数只校验 provider / City 返回的 UIMessage 结构;
|
|
392
|
+
* 远程资源下载由 action 成功分支中的 ImageResultStorage 统一处理。
|
|
389
393
|
*/
|
|
390
394
|
function normalize_image_result(result: ImagePluginResult): ImagePluginResult {
|
|
391
395
|
const record = to_record(result);
|
|
@@ -396,11 +400,7 @@ function normalize_image_result(result: ImagePluginResult): ImagePluginResult {
|
|
|
396
400
|
const part_record = to_record(part);
|
|
397
401
|
if (part_record?.type !== "file") continue;
|
|
398
402
|
const url = String(part_record.url || "").trim();
|
|
399
|
-
if (!url
|
|
400
|
-
throw new TypeError(
|
|
401
|
-
"ImagePlugin result file parts must be saved locally before returning",
|
|
402
|
-
);
|
|
403
|
-
}
|
|
403
|
+
if (!url) throw new TypeError("ImagePlugin result file parts must include a url");
|
|
404
404
|
}
|
|
405
405
|
return result;
|
|
406
406
|
}
|
|
@@ -792,7 +792,7 @@ export class ImagePlugin extends BasePlugin {
|
|
|
792
792
|
},
|
|
793
793
|
},
|
|
794
794
|
],
|
|
795
|
-
execute: async ({
|
|
795
|
+
execute: async ({ context, execution_context, input }) => {
|
|
796
796
|
try {
|
|
797
797
|
const normalized_input = normalize_image_result_payload(input);
|
|
798
798
|
const current = await this.read_image_result(normalized_input);
|
|
@@ -804,19 +804,32 @@ export class ImagePlugin extends BasePlugin {
|
|
|
804
804
|
message: current.error ?? current.message ?? "image job failed",
|
|
805
805
|
};
|
|
806
806
|
}
|
|
807
|
-
const
|
|
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
|
+
: "";
|
|
808
821
|
return {
|
|
809
822
|
success: true,
|
|
810
|
-
data:
|
|
823
|
+
data: output as unknown as JsonObject,
|
|
811
824
|
message:
|
|
812
825
|
current.status === "succeeded"
|
|
813
|
-
?
|
|
826
|
+
? `image generated${localization_warning}`
|
|
814
827
|
: `image job ${current.status}`,
|
|
815
|
-
...(
|
|
828
|
+
...(output.status === "succeeded" && output.result
|
|
816
829
|
? {
|
|
817
830
|
messages: [{
|
|
818
831
|
role: "assistant" as const,
|
|
819
|
-
parts: result.parts,
|
|
832
|
+
parts: output.result.parts,
|
|
820
833
|
}],
|
|
821
834
|
}
|
|
822
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
|
*/
|