@downcity/agent 1.1.86 → 1.1.92
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/config/AgentInitializer.d.ts.map +1 -1
- package/bin/config/AgentInitializer.js +2 -1
- package/bin/config/AgentInitializer.js.map +1 -1
- package/bin/config/Paths.d.ts +8 -0
- package/bin/config/Paths.d.ts.map +1 -1
- package/bin/config/Paths.js +10 -0
- package/bin/config/Paths.js.map +1 -1
- package/bin/executor/Executor.d.ts.map +1 -1
- package/bin/executor/Executor.js +3 -0
- package/bin/executor/Executor.js.map +1 -1
- package/bin/executor/messages/AssistantFileResource.d.ts +31 -0
- package/bin/executor/messages/AssistantFileResource.d.ts.map +1 -0
- package/bin/executor/messages/AssistantFileResource.js +113 -0
- package/bin/executor/messages/AssistantFileResource.js.map +1 -0
- package/bin/executor/messages/SessionAttachmentMapper.d.ts +8 -0
- package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
- package/bin/executor/messages/SessionAttachmentMapper.js +54 -0
- package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
- package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
- package/bin/executor/messages/SessionMessageCodec.js +5 -3
- package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
- package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
- package/bin/executor/tools/plugin/PluginToolBridge.js +9 -2
- package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
- package/bin/index.d.ts +1 -1
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/plugin/core/ImagePlugin.d.ts +62 -0
- package/bin/plugin/core/ImagePlugin.d.ts.map +1 -1
- package/bin/plugin/core/ImagePlugin.js +230 -7
- package/bin/plugin/core/ImagePlugin.js.map +1 -1
- package/bin/sandbox/LinuxBubblewrapSandbox.d.ts +21 -0
- package/bin/sandbox/LinuxBubblewrapSandbox.d.ts.map +1 -0
- package/bin/sandbox/LinuxBubblewrapSandbox.js +184 -0
- package/bin/sandbox/LinuxBubblewrapSandbox.js.map +1 -0
- package/bin/sandbox/SandboxConfigResolver.d.ts +5 -0
- package/bin/sandbox/SandboxConfigResolver.d.ts.map +1 -1
- package/bin/sandbox/SandboxConfigResolver.js +11 -4
- package/bin/sandbox/SandboxConfigResolver.js.map +1 -1
- package/bin/sandbox/SandboxPreflight.d.ts +73 -0
- package/bin/sandbox/SandboxPreflight.d.ts.map +1 -0
- package/bin/sandbox/SandboxPreflight.js +122 -0
- package/bin/sandbox/SandboxPreflight.js.map +1 -0
- package/bin/sandbox/SandboxRunner.d.ts +1 -1
- package/bin/sandbox/SandboxRunner.d.ts.map +1 -1
- package/bin/sandbox/SandboxRunner.js +11 -3
- package/bin/sandbox/SandboxRunner.js.map +1 -1
- package/bin/sandbox/types/SandboxRuntime.d.ts +6 -2
- package/bin/sandbox/types/SandboxRuntime.d.ts.map +1 -1
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +1 -0
- package/bin/session/Session.js.map +1 -1
- package/bin/session/services/SessionTurnService.d.ts +5 -0
- package/bin/session/services/SessionTurnService.d.ts.map +1 -1
- package/bin/session/services/SessionTurnService.js +3 -0
- package/bin/session/services/SessionTurnService.js.map +1 -1
- package/bin/types/executor/SessionRunContext.d.ts +8 -0
- package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
- package/bin/types/plugin/ImagePlugin.d.ts +79 -2
- package/bin/types/plugin/ImagePlugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/scripts/assistant-file-resource.test.mjs +91 -0
- package/scripts/image-plugin-job.test.mjs +155 -0
- package/scripts/linux-bubblewrap-sandbox.test.mjs +142 -0
- package/scripts/shell-sandbox-preflight.test.mjs +88 -0
- package/src/config/AgentInitializer.ts +2 -0
- package/src/config/Paths.ts +11 -0
- package/src/executor/Executor.ts +3 -0
- package/src/executor/messages/AssistantFileResource.ts +155 -0
- package/src/executor/messages/SessionAttachmentMapper.ts +59 -0
- package/src/executor/messages/SessionMessageCodec.ts +9 -3
- package/src/executor/tools/plugin/PluginToolBridge.ts +13 -2
- package/src/index.ts +4 -0
- package/src/plugin/core/ImagePlugin.ts +284 -7
- package/src/sandbox/LinuxBubblewrapSandbox.ts +229 -0
- package/src/sandbox/SandboxConfigResolver.ts +13 -7
- package/src/sandbox/SandboxPreflight.ts +205 -0
- package/src/sandbox/SandboxRunner.ts +11 -3
- package/src/sandbox/types/SandboxRuntime.ts +7 -2
- package/src/session/Session.ts +1 -0
- package/src/session/services/SessionTurnService.ts +8 -0
- package/src/types/executor/SessionRunContext.ts +9 -0
- package/src/types/plugin/ImagePlugin.ts +79 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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 {
|
|
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
|
-
//
|
|
80
|
-
const
|
|
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 {
|
|
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
|
|
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,
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* - action 返回 AI SDK UIMessage,后续由 plugin tool bridge 抽取 file parts 写回 assistant 消息。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import crypto from "node:crypto";
|
|
10
11
|
import type { AgentContext } from "@/types/runtime/agent/AgentContext.js";
|
|
11
12
|
import type { JsonObject, JsonValue } from "@/types/common/Json.js";
|
|
12
13
|
import type {
|
|
13
14
|
ImagePluginInput,
|
|
15
|
+
ImagePluginJobCreateResult,
|
|
16
|
+
ImagePluginJobResult,
|
|
17
|
+
ImagePluginJobStatusResult,
|
|
14
18
|
ImagePluginOptions,
|
|
15
19
|
ImagePluginResult,
|
|
16
20
|
} from "@/types/plugin/ImagePlugin.js";
|
|
@@ -20,6 +24,39 @@ const DEFAULT_IMAGE_PLUGIN_NAME = "image";
|
|
|
20
24
|
const DEFAULT_IMAGE_PLUGIN_TITLE = "Image";
|
|
21
25
|
const DEFAULT_IMAGE_PLUGIN_DESCRIPTION =
|
|
22
26
|
"Generate images and return them as assistant file parts.";
|
|
27
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 60_000;
|
|
28
|
+
const DEFAULT_POLL_INTERVAL_MS = 3_000;
|
|
29
|
+
|
|
30
|
+
type LocalImageJobRecord = {
|
|
31
|
+
/**
|
|
32
|
+
* 图片任务唯一 ID。
|
|
33
|
+
*/
|
|
34
|
+
job_id: string;
|
|
35
|
+
/**
|
|
36
|
+
* 当前任务状态。
|
|
37
|
+
*/
|
|
38
|
+
status: "queued" | "running" | "succeeded" | "failed";
|
|
39
|
+
/**
|
|
40
|
+
* 成功时的图片结果。
|
|
41
|
+
*/
|
|
42
|
+
result?: ImagePluginResult;
|
|
43
|
+
/**
|
|
44
|
+
* 失败时的错误信息。
|
|
45
|
+
*/
|
|
46
|
+
error?: string;
|
|
47
|
+
/**
|
|
48
|
+
* 人类可读状态说明。
|
|
49
|
+
*/
|
|
50
|
+
message?: string;
|
|
51
|
+
/**
|
|
52
|
+
* 任务创建时间。
|
|
53
|
+
*/
|
|
54
|
+
created_at: string;
|
|
55
|
+
/**
|
|
56
|
+
* 任务更新时间。
|
|
57
|
+
*/
|
|
58
|
+
updated_at: string;
|
|
59
|
+
};
|
|
23
60
|
|
|
24
61
|
/**
|
|
25
62
|
* 判断值是否为普通对象。
|
|
@@ -40,6 +77,15 @@ function normalize_image_payload(payload: JsonValue | undefined): ImagePluginInp
|
|
|
40
77
|
return { ...record } as ImagePluginInput;
|
|
41
78
|
}
|
|
42
79
|
|
|
80
|
+
function normalize_job_id_payload(payload: JsonValue | undefined): { job_id: string } {
|
|
81
|
+
const record = to_record(payload ?? {});
|
|
82
|
+
const job_id = String(record?.job_id || "").trim();
|
|
83
|
+
if (!job_id) {
|
|
84
|
+
throw new TypeError("ImagePlugin job action requires job_id");
|
|
85
|
+
}
|
|
86
|
+
return { job_id };
|
|
87
|
+
}
|
|
88
|
+
|
|
43
89
|
/**
|
|
44
90
|
* 校验 image 函数返回的 UIMessage。
|
|
45
91
|
*/
|
|
@@ -51,6 +97,25 @@ function normalize_image_result(result: ImagePluginResult): ImagePluginResult {
|
|
|
51
97
|
return result;
|
|
52
98
|
}
|
|
53
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 归一化任务状态查询结果,确保 status action 不携带图片结果。
|
|
102
|
+
*/
|
|
103
|
+
function normalize_job_status_result(
|
|
104
|
+
result: ImagePluginJobStatusResult,
|
|
105
|
+
): ImagePluginJobStatusResult {
|
|
106
|
+
return {
|
|
107
|
+
job_id: result.job_id,
|
|
108
|
+
status: result.status,
|
|
109
|
+
...(result.message ? { message: result.message } : {}),
|
|
110
|
+
...(result.error ? { error: result.error } : {}),
|
|
111
|
+
...(typeof result.poll_after_ms === "number"
|
|
112
|
+
? { poll_after_ms: result.poll_after_ms }
|
|
113
|
+
: {}),
|
|
114
|
+
...(result.created_at ? { created_at: result.created_at } : {}),
|
|
115
|
+
...(result.updated_at ? { updated_at: result.updated_at } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
54
119
|
/**
|
|
55
120
|
* Agent 图片生成插件。
|
|
56
121
|
*/
|
|
@@ -71,6 +136,12 @@ export class ImagePlugin extends BasePlugin {
|
|
|
71
136
|
readonly description: string;
|
|
72
137
|
|
|
73
138
|
private readonly image: ImagePluginOptions["image"];
|
|
139
|
+
private readonly create_job?: ImagePluginOptions["create"];
|
|
140
|
+
private readonly read_job_status?: ImagePluginOptions["status"];
|
|
141
|
+
private readonly read_job_result?: ImagePluginOptions["result"];
|
|
142
|
+
private readonly wait_timeout_ms: number;
|
|
143
|
+
private readonly poll_interval_ms: number;
|
|
144
|
+
private readonly local_jobs = new Map<string, LocalImageJobRecord>();
|
|
74
145
|
|
|
75
146
|
constructor(options: ImagePluginOptions) {
|
|
76
147
|
super();
|
|
@@ -78,8 +149,23 @@ export class ImagePlugin extends BasePlugin {
|
|
|
78
149
|
if (!name) {
|
|
79
150
|
throw new Error("ImagePlugin requires a non-empty name");
|
|
80
151
|
}
|
|
81
|
-
|
|
82
|
-
|
|
152
|
+
const has_custom_job_api = Boolean(
|
|
153
|
+
options.create || options.status || options.result,
|
|
154
|
+
);
|
|
155
|
+
if (
|
|
156
|
+
has_custom_job_api &&
|
|
157
|
+
(typeof options.create !== "function" ||
|
|
158
|
+
typeof options.status !== "function" ||
|
|
159
|
+
typeof options.result !== "function")
|
|
160
|
+
) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"ImagePlugin custom job API requires create, status, and result functions",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (!has_custom_job_api && typeof options.image !== "function") {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"ImagePlugin requires either image(input) or create/status/result functions",
|
|
168
|
+
);
|
|
83
169
|
}
|
|
84
170
|
this.name = name;
|
|
85
171
|
this.title = String(options.title || DEFAULT_IMAGE_PLUGIN_TITLE).trim();
|
|
@@ -87,6 +173,17 @@ export class ImagePlugin extends BasePlugin {
|
|
|
87
173
|
options.description || DEFAULT_IMAGE_PLUGIN_DESCRIPTION,
|
|
88
174
|
).trim();
|
|
89
175
|
this.image = options.image;
|
|
176
|
+
this.create_job = options.create;
|
|
177
|
+
this.read_job_status = options.status;
|
|
178
|
+
this.read_job_result = options.result;
|
|
179
|
+
this.wait_timeout_ms =
|
|
180
|
+
typeof options.wait_timeout_ms === "number" && options.wait_timeout_ms > 0
|
|
181
|
+
? options.wait_timeout_ms
|
|
182
|
+
: DEFAULT_WAIT_TIMEOUT_MS;
|
|
183
|
+
this.poll_interval_ms =
|
|
184
|
+
typeof options.poll_interval_ms === "number" && options.poll_interval_ms > 0
|
|
185
|
+
? options.poll_interval_ms
|
|
186
|
+
: DEFAULT_POLL_INTERVAL_MS;
|
|
90
187
|
}
|
|
91
188
|
|
|
92
189
|
/**
|
|
@@ -94,22 +191,202 @@ export class ImagePlugin extends BasePlugin {
|
|
|
94
191
|
*/
|
|
95
192
|
system(_context: AgentContext): string {
|
|
96
193
|
return [
|
|
97
|
-
"Image generation is available through the plugin_call tool.",
|
|
98
|
-
`Call plugin "${this.name}" action "
|
|
99
|
-
"
|
|
100
|
-
"
|
|
194
|
+
"Image generation is available through the plugin_call tool as an observable job workflow.",
|
|
195
|
+
`Call plugin "${this.name}" action "create" when the user asks to create, render, draw, or edit an image.`,
|
|
196
|
+
`Then call plugin "${this.name}" action "status" with { job_id } to inspect progress.`,
|
|
197
|
+
`When status is succeeded, call plugin "${this.name}" action "result" with { job_id } to attach the generated files.`,
|
|
198
|
+
"Use action \"generate\" only as a compatibility shortcut when you explicitly need to wait for completion.",
|
|
199
|
+
"Pass a JSON payload with prompt, optional size/aspect_ratio/quality/n, and optional provider_options to create/generate.",
|
|
101
200
|
].join("\n");
|
|
102
201
|
}
|
|
103
202
|
|
|
203
|
+
private create_local_job(input: ImagePluginInput): ImagePluginJobCreateResult {
|
|
204
|
+
if (typeof this.image !== "function") {
|
|
205
|
+
throw new Error("ImagePlugin local image job requires image(input)");
|
|
206
|
+
}
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const job_id = `img_${crypto.randomUUID()}`;
|
|
209
|
+
const record: LocalImageJobRecord = {
|
|
210
|
+
job_id,
|
|
211
|
+
status: "running",
|
|
212
|
+
message: "image job is running",
|
|
213
|
+
created_at: now,
|
|
214
|
+
updated_at: now,
|
|
215
|
+
};
|
|
216
|
+
this.local_jobs.set(job_id, record);
|
|
217
|
+
|
|
218
|
+
void this.run_local_job(record, input, this.image);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
job_id,
|
|
222
|
+
status: record.status,
|
|
223
|
+
message: record.message,
|
|
224
|
+
poll_after_ms: this.poll_interval_ms,
|
|
225
|
+
created_at: record.created_at,
|
|
226
|
+
updated_at: record.updated_at,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async run_local_job(
|
|
231
|
+
record: LocalImageJobRecord,
|
|
232
|
+
input: ImagePluginInput,
|
|
233
|
+
image: NonNullable<ImagePluginOptions["image"]>,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
try {
|
|
236
|
+
const message = normalize_image_result(await image(input));
|
|
237
|
+
record.status = "succeeded";
|
|
238
|
+
record.result = message;
|
|
239
|
+
record.message = "image job succeeded";
|
|
240
|
+
record.updated_at = new Date().toISOString();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
record.status = "failed";
|
|
243
|
+
record.error = String(error);
|
|
244
|
+
record.message = "image job failed";
|
|
245
|
+
record.updated_at = new Date().toISOString();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private read_local_job(job_id: string): LocalImageJobRecord {
|
|
250
|
+
const record = this.local_jobs.get(job_id);
|
|
251
|
+
if (!record) {
|
|
252
|
+
throw new Error(`Unknown image job: ${job_id}`);
|
|
253
|
+
}
|
|
254
|
+
return record;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private serialize_local_status(record: LocalImageJobRecord): ImagePluginJobStatusResult {
|
|
258
|
+
return {
|
|
259
|
+
job_id: record.job_id,
|
|
260
|
+
status: record.status,
|
|
261
|
+
...(record.message ? { message: record.message } : {}),
|
|
262
|
+
...(record.error ? { error: record.error } : {}),
|
|
263
|
+
...(record.status === "running" || record.status === "queued"
|
|
264
|
+
? { poll_after_ms: this.poll_interval_ms }
|
|
265
|
+
: {}),
|
|
266
|
+
created_at: record.created_at,
|
|
267
|
+
updated_at: record.updated_at,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private serialize_local_result(record: LocalImageJobRecord): ImagePluginJobResult {
|
|
272
|
+
return {
|
|
273
|
+
job_id: record.job_id,
|
|
274
|
+
status: record.status,
|
|
275
|
+
...(record.result ? { result: record.result } : {}),
|
|
276
|
+
...(record.error ? { error: record.error } : {}),
|
|
277
|
+
...(record.message ? { message: record.message } : {}),
|
|
278
|
+
created_at: record.created_at,
|
|
279
|
+
updated_at: record.updated_at,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async wait_for_job(job_id: string): Promise<ImagePluginResult> {
|
|
284
|
+
const deadline = Date.now() + this.wait_timeout_ms;
|
|
285
|
+
while (Date.now() <= deadline) {
|
|
286
|
+
const result = this.read_job_result
|
|
287
|
+
? await this.read_job_result({ job_id })
|
|
288
|
+
: this.serialize_local_result(this.read_local_job(job_id));
|
|
289
|
+
if (result.status === "succeeded" && result.result) {
|
|
290
|
+
return normalize_image_result(result.result);
|
|
291
|
+
}
|
|
292
|
+
if (result.status === "failed") {
|
|
293
|
+
throw new Error(result.error || result.message || "image job failed");
|
|
294
|
+
}
|
|
295
|
+
await new Promise((resolve) => setTimeout(resolve, this.poll_interval_ms));
|
|
296
|
+
}
|
|
297
|
+
throw new Error(`image job timed out: ${job_id}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
104
300
|
/**
|
|
105
301
|
* 显式 action 集合。
|
|
106
302
|
*/
|
|
107
303
|
readonly actions = {
|
|
304
|
+
create: {
|
|
305
|
+
execute: async ({ payload }: { payload: JsonValue }) => {
|
|
306
|
+
try {
|
|
307
|
+
const input = normalize_image_payload(payload);
|
|
308
|
+
const result = this.create_job
|
|
309
|
+
? await this.create_job(input)
|
|
310
|
+
: this.create_local_job(input);
|
|
311
|
+
return {
|
|
312
|
+
success: true,
|
|
313
|
+
data: result as unknown as JsonObject,
|
|
314
|
+
message: result.message || "image job created",
|
|
315
|
+
};
|
|
316
|
+
} catch (error) {
|
|
317
|
+
return {
|
|
318
|
+
success: false,
|
|
319
|
+
error: String(error),
|
|
320
|
+
message: String(error),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
status: {
|
|
326
|
+
execute: async ({ payload }: { payload: JsonValue }) => {
|
|
327
|
+
try {
|
|
328
|
+
const input = normalize_job_id_payload(payload);
|
|
329
|
+
const result = this.read_job_status
|
|
330
|
+
? normalize_job_status_result(await this.read_job_status(input))
|
|
331
|
+
: this.serialize_local_status(this.read_local_job(input.job_id));
|
|
332
|
+
return {
|
|
333
|
+
success: true,
|
|
334
|
+
data: result as unknown as JsonObject,
|
|
335
|
+
message: result.message || `image job ${result.status}`,
|
|
336
|
+
};
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return {
|
|
339
|
+
success: false,
|
|
340
|
+
error: String(error),
|
|
341
|
+
message: String(error),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
result: {
|
|
347
|
+
execute: async ({ payload }: { payload: JsonValue }) => {
|
|
348
|
+
try {
|
|
349
|
+
const input = normalize_job_id_payload(payload);
|
|
350
|
+
const result = this.read_job_result
|
|
351
|
+
? await this.read_job_result(input)
|
|
352
|
+
: this.serialize_local_result(this.read_local_job(input.job_id));
|
|
353
|
+
if (result.status === "succeeded" && result.result) {
|
|
354
|
+
return {
|
|
355
|
+
success: true,
|
|
356
|
+
data: result.result as unknown as JsonObject,
|
|
357
|
+
message: result.message || "image job succeeded",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (result.status === "failed") {
|
|
361
|
+
return {
|
|
362
|
+
success: false,
|
|
363
|
+
data: result as unknown as JsonObject,
|
|
364
|
+
error: result.error || result.message || "image job failed",
|
|
365
|
+
message: result.message || "image job failed",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
success: true,
|
|
370
|
+
data: result as unknown as JsonObject,
|
|
371
|
+
message: result.message || `image job ${result.status}`,
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
error: String(error),
|
|
377
|
+
message: String(error),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
},
|
|
108
382
|
generate: {
|
|
109
383
|
execute: async ({ payload }: { payload: JsonValue }) => {
|
|
110
384
|
try {
|
|
111
385
|
const input = normalize_image_payload(payload);
|
|
112
|
-
const
|
|
386
|
+
const job = this.create_job
|
|
387
|
+
? await this.create_job(input)
|
|
388
|
+
: this.create_local_job(input);
|
|
389
|
+
const message = await this.wait_for_job(job.job_id);
|
|
113
390
|
return {
|
|
114
391
|
success: true,
|
|
115
392
|
data: message as unknown as JsonObject,
|