@downcity/plugins 1.0.95 → 1.0.103

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 (56) hide show
  1. package/bin/asr/Plugin.d.ts +1 -15
  2. package/bin/asr/Plugin.d.ts.map +1 -1
  3. package/bin/asr/Plugin.js +36 -5
  4. package/bin/asr/Plugin.js.map +1 -1
  5. package/bin/chat/runtime/ChatAuthorizationRuntime.d.ts.map +1 -1
  6. package/bin/chat/runtime/ChatAuthorizationRuntime.js +53 -12
  7. package/bin/chat/runtime/ChatAuthorizationRuntime.js.map +1 -1
  8. package/bin/chat/runtime/ChatPluginActionRegistry.d.ts.map +1 -1
  9. package/bin/chat/runtime/ChatPluginActionRegistry.js +114 -42
  10. package/bin/chat/runtime/ChatPluginActionRegistry.js.map +1 -1
  11. package/bin/contact/Action.d.ts.map +1 -1
  12. package/bin/contact/Action.js +159 -37
  13. package/bin/contact/Action.js.map +1 -1
  14. package/bin/image/ImagePlugin.d.ts +12 -55
  15. package/bin/image/ImagePlugin.d.ts.map +1 -1
  16. package/bin/image/ImagePlugin.js +374 -138
  17. package/bin/image/ImagePlugin.js.map +1 -1
  18. package/bin/image/types/ImagePlugin.d.ts +94 -31
  19. package/bin/image/types/ImagePlugin.d.ts.map +1 -1
  20. package/bin/image/types/ImagePlugin.js +1 -1
  21. package/bin/index.d.ts +1 -1
  22. package/bin/index.d.ts.map +1 -1
  23. package/bin/memory/MemoryPlugin.d.ts.map +1 -1
  24. package/bin/memory/MemoryPlugin.js +130 -17
  25. package/bin/memory/MemoryPlugin.js.map +1 -1
  26. package/bin/skill/Plugin.d.ts.map +1 -1
  27. package/bin/skill/Plugin.js +90 -11
  28. package/bin/skill/Plugin.js.map +1 -1
  29. package/bin/task/runtime/TaskPluginActionRegistry.d.ts.map +1 -1
  30. package/bin/task/runtime/TaskPluginActionRegistry.js +202 -24
  31. package/bin/task/runtime/TaskPluginActionRegistry.js.map +1 -1
  32. package/bin/tts/Plugin.d.ts +1 -15
  33. package/bin/tts/Plugin.d.ts.map +1 -1
  34. package/bin/tts/Plugin.js +38 -5
  35. package/bin/tts/Plugin.js.map +1 -1
  36. package/bin/web/Plugin.d.ts +2 -19
  37. package/bin/web/Plugin.d.ts.map +1 -1
  38. package/bin/web/Plugin.js +39 -5
  39. package/bin/web/Plugin.js.map +1 -1
  40. package/bin/workboard/Plugin.d.ts.map +1 -1
  41. package/bin/workboard/Plugin.js +10 -2
  42. package/bin/workboard/Plugin.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/asr/Plugin.ts +37 -5
  45. package/src/chat/runtime/ChatAuthorizationRuntime.ts +53 -12
  46. package/src/chat/runtime/ChatPluginActionRegistry.ts +114 -42
  47. package/src/contact/Action.ts +159 -37
  48. package/src/image/ImagePlugin.ts +412 -211
  49. package/src/image/types/ImagePlugin.ts +100 -31
  50. package/src/index.ts +5 -1
  51. package/src/memory/MemoryPlugin.ts +130 -17
  52. package/src/skill/Plugin.ts +101 -21
  53. package/src/task/runtime/TaskPluginActionRegistry.ts +209 -24
  54. package/src/tts/Plugin.ts +39 -5
  55. package/src/web/Plugin.ts +39 -5
  56. package/src/workboard/Plugin.ts +10 -2
@@ -2,11 +2,15 @@
2
2
  * ImagePlugin:图片生成插件。
3
3
  *
4
4
  * 关键点(中文)
5
- * - 对 Agent 只暴露同步体验的 `generate` action。
5
+ * - 对 Agent 暴露 `image_create` / `image_result` 两步式任务 action。
6
6
  * - City / provider 的图片能力通过 image_create / image_result 任务函数注入。
7
- * - action 返回 AI SDK UIMessage,后续由 plugin tool bridge 抽取 file parts 写回 assistant 消息。
7
+ * - 成功结果返回 AI SDK UIMessage,后续由 plugin tool bridge 抽取 file parts 写回 assistant 消息。
8
8
  */
9
9
 
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { z } from "zod";
13
+ import { createAction } from "@downcity/agent/internal/plugin/core/PluginActionFactory.js";
10
14
  import { BasePlugin } from "@downcity/agent/internal/plugin/core/BasePlugin.js";
11
15
  import type { AgentContext } from "@downcity/agent/internal/types/runtime/agent/AgentContext.js";
12
16
  import type {
@@ -18,7 +22,12 @@ import type {
18
22
  ImagePluginJobCreateResult,
19
23
  ImagePluginJobResult,
20
24
  ImagePluginJobResultInput,
25
+ ImagePluginContent,
26
+ ImagePluginModel,
27
+ ImagePluginModelsResult,
21
28
  ImagePluginOptions,
29
+ ImagePluginResolvedContent,
30
+ ImagePluginResolvedInput,
22
31
  ImagePluginResult,
23
32
  } from "@/image/types/ImagePlugin.js";
24
33
 
@@ -26,9 +35,51 @@ const DEFAULT_IMAGE_PLUGIN_NAME = "image";
26
35
  const DEFAULT_IMAGE_PLUGIN_TITLE = "Image";
27
36
  const DEFAULT_IMAGE_PLUGIN_DESCRIPTION =
28
37
  "Generate images and return them as assistant file parts.";
29
- const DEFAULT_TIMEOUT_MS = 300_000;
30
- const DEFAULT_MIN_POLL_INTERVAL_MS = 100;
31
- const DEFAULT_MAX_POLL_INTERVAL_MS = 10_000;
38
+ const HTTP_URL_RE = /^https?:\/\//i;
39
+ const DEFAULT_IMAGE_MEDIA_TYPE = "image/png";
40
+
41
+ const IMAGE_MEDIA_TYPES: Record<string, string> = {
42
+ ".apng": "image/apng",
43
+ ".avif": "image/avif",
44
+ ".gif": "image/gif",
45
+ ".jpg": "image/jpeg",
46
+ ".jpeg": "image/jpeg",
47
+ ".png": "image/png",
48
+ ".webp": "image/webp",
49
+ };
50
+
51
+ const IMAGE_TEXT_CONTENT_SCHEMA = z.object({
52
+ type: z.literal("text"),
53
+ text: z.string(),
54
+ });
55
+
56
+ const IMAGE_FILE_CONTENT_SCHEMA = z.object({
57
+ type: z.literal("image"),
58
+ url: z.string(),
59
+ media_type: z.string().optional(),
60
+ });
61
+
62
+ const IMAGE_CREATE_INPUT_SCHEMA = z.object({
63
+ model: z.string().optional(),
64
+ prompt: z.string().optional(),
65
+ content: z.array(z.union([
66
+ IMAGE_TEXT_CONTENT_SCHEMA,
67
+ IMAGE_FILE_CONTENT_SCHEMA,
68
+ ])).optional(),
69
+ n: z.number().optional(),
70
+ count: z.number().optional(),
71
+ size: z.string().optional(),
72
+ aspect_ratio: z.string().optional(),
73
+ ratio: z.string().optional(),
74
+ quality: z.string().optional(),
75
+ seed: z.number().optional(),
76
+ client_job_id: z.string().optional(),
77
+ provider_options: z.object({}).passthrough().optional(),
78
+ }).passthrough();
79
+
80
+ const IMAGE_RESULT_INPUT_SCHEMA = z.object({
81
+ job_id: z.string(),
82
+ }).passthrough();
32
83
 
33
84
  /**
34
85
  * 判断值是否为普通对象。
@@ -51,6 +102,139 @@ function normalize_image_payload(
51
102
  return { ...record } as ImagePluginInput;
52
103
  }
53
104
 
105
+ /**
106
+ * 根据文件扩展名推断图片 MIME 类型。
107
+ */
108
+ function infer_image_media_type(file_path: string, fallback?: string): string {
109
+ if (fallback && fallback.trim()) return fallback.trim();
110
+ const ext = path.extname(file_path).toLowerCase();
111
+ return IMAGE_MEDIA_TYPES[ext] ?? DEFAULT_IMAGE_MEDIA_TYPE;
112
+ }
113
+
114
+ /**
115
+ * 解析图片本地路径。
116
+ */
117
+ function resolve_image_file_path(root_path: string, image_url: string): string {
118
+ const raw = image_url.trim();
119
+ if (!raw) throw new TypeError("ImagePlugin image content url is required");
120
+ return path.isAbsolute(raw) ? raw : path.resolve(root_path, raw);
121
+ }
122
+
123
+ /**
124
+ * 把本地图片读取为 data URL。
125
+ */
126
+ async function local_image_to_data_url(input: {
127
+ /**
128
+ * 当前 Agent 项目根目录。
129
+ */
130
+ root_path: string;
131
+ /**
132
+ * 本地绝对路径或相对路径。
133
+ */
134
+ image_url: string;
135
+ /**
136
+ * 可选 MIME 类型。
137
+ */
138
+ media_type?: string;
139
+ }): Promise<{ data_url: string; media_type: string }> {
140
+ const file_path = resolve_image_file_path(input.root_path, input.image_url);
141
+ const media_type = infer_image_media_type(file_path, input.media_type);
142
+ const bytes = await fs.readFile(file_path);
143
+ return {
144
+ data_url: `${media_type.includes("/") ? `data:${media_type};base64,` : "data:image/png;base64,"}${bytes.toString("base64")}`,
145
+ media_type,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * 归一化单个图片内容片段。
151
+ */
152
+ async function normalize_image_content_part(
153
+ context: AgentContext,
154
+ part: ImagePluginContent,
155
+ ): Promise<ImagePluginResolvedContent> {
156
+ if (part.type === "text") return part;
157
+ const url = String(part.url || "").trim();
158
+ if (!url) throw new TypeError("ImagePlugin image content url is required");
159
+ if (url.startsWith("data:")) {
160
+ throw new TypeError(
161
+ "ImagePlugin content image url does not accept data URLs; pass an online URL or a local file path",
162
+ );
163
+ }
164
+ if (HTTP_URL_RE.test(url)) {
165
+ return {
166
+ type: "image",
167
+ url,
168
+ ...(part.media_type ? { media_type: part.media_type } : {}),
169
+ };
170
+ }
171
+ const local = await local_image_to_data_url({
172
+ root_path: context.rootPath,
173
+ image_url: url,
174
+ media_type: part.media_type,
175
+ });
176
+ return {
177
+ type: "image",
178
+ data_url: local.data_url,
179
+ media_type: local.media_type,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * 拒绝旧版或内部协议字段,避免 Agent 继续依赖兼容层。
185
+ */
186
+ function assert_public_image_create_input(input: ImagePluginInput): void {
187
+ const record = input as Record<string, unknown>;
188
+ if ("messages" in record) {
189
+ throw new TypeError("ImagePlugin image_create uses prompt or content; messages is not supported");
190
+ }
191
+ const content = record.content;
192
+ if (!Array.isArray(content)) return;
193
+ for (const part of content) {
194
+ const part_record = to_record(part);
195
+ if (part_record && "data_url" in part_record) {
196
+ throw new TypeError(
197
+ "ImagePlugin content image uses url only; data_url is not supported",
198
+ );
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 复制公开输入中的通用字段,剥离 Agent 不应传给下游的公开 content。
205
+ */
206
+ function copy_resolved_image_input(input: ImagePluginInput): ImagePluginResolvedInput {
207
+ const { content: _content, messages: _messages, ...rest } = input as ImagePluginInput & {
208
+ /** 旧版字段,显式丢弃。 */
209
+ messages?: unknown;
210
+ };
211
+ return rest as ImagePluginResolvedInput;
212
+ }
213
+
214
+ /**
215
+ * 把 Agent 友好的公开输入转成 City 图片任务使用的输入。
216
+ */
217
+ async function normalize_image_create_input(
218
+ context: AgentContext,
219
+ input: ImagePluginInput,
220
+ ): Promise<ImagePluginResolvedInput> {
221
+ assert_public_image_create_input(input);
222
+ if (!Array.isArray(input.content)) return copy_resolved_image_input(input);
223
+ const content = await Promise.all(
224
+ input.content.map((part) => normalize_image_content_part(context, part)),
225
+ );
226
+ const { prompt: _prompt, ...rest } = copy_resolved_image_input(input);
227
+ return {
228
+ ...rest,
229
+ messages: [
230
+ {
231
+ role: "user",
232
+ content,
233
+ },
234
+ ],
235
+ };
236
+ }
237
+
54
238
  /**
55
239
  * 归一化图片任务查询 payload。
56
240
  */
@@ -83,38 +267,72 @@ function normalize_image_result(result: ImagePluginResult): ImagePluginResult {
83
267
  }
84
268
 
85
269
  /**
86
- * 等待指定毫秒数。
270
+ * 归一化模型元数据为 JSON 对象。
87
271
  */
88
- function sleep(ms: number): Promise<void> {
89
- return new Promise((resolve) => setTimeout(resolve, ms));
272
+ function normalize_json_object(value: unknown): JsonObject | undefined {
273
+ const record = to_record(value);
274
+ if (!record) return undefined;
275
+ return record as JsonObject;
90
276
  }
91
277
 
92
278
  /**
93
- * 限制轮询间隔,避免服务端异常值导致过快或过慢轮询。
279
+ * 归一化图片模型信息,确保 action 返回纯 JSON。
94
280
  */
95
- function clamp_poll_interval(
96
- value: unknown,
97
- min_ms: number,
98
- max_ms: number,
99
- ): number {
100
- const n = typeof value === "number" && Number.isFinite(value) ? value : min_ms;
101
- return Math.max(min_ms, Math.min(max_ms, n));
102
- }
103
-
104
- /**
105
- * 归一化正数配置。
106
- */
107
- function normalize_positive_number(value: unknown, fallback: number): number {
108
- return typeof value === "number" && Number.isFinite(value) && value > 0
109
- ? value
110
- : fallback;
281
+ function normalize_image_model(value: ImagePluginModel): ImagePluginModel | null {
282
+ const record = to_record(value);
283
+ if (!record) return null;
284
+ const id = typeof record.id === "string" ? record.id.trim() : "";
285
+ if (!id) return null;
286
+ const modalities = Array.isArray(record.modalities)
287
+ ? record.modalities
288
+ .map((item) => String(item || "").trim())
289
+ .filter(Boolean)
290
+ : [];
291
+ if (!modalities.includes("image")) return null;
292
+ const tags = Array.isArray(record.tags)
293
+ ? record.tags.map((item) => String(item || "").trim()).filter(Boolean)
294
+ : undefined;
295
+ const default_modalities = Array.isArray(record.default_modalities)
296
+ ? record.default_modalities
297
+ .map((item) => String(item || "").trim())
298
+ .filter(Boolean)
299
+ : undefined;
300
+ const meta = normalize_json_object(record.meta);
301
+ return {
302
+ id,
303
+ name: typeof record.name === "string" && record.name.trim()
304
+ ? record.name.trim()
305
+ : id,
306
+ ...(typeof record.description === "string"
307
+ ? { description: record.description }
308
+ : {}),
309
+ modalities,
310
+ ...(tags && tags.length > 0 ? { tags } : {}),
311
+ ...(meta ? { meta } : {}),
312
+ ...(typeof record.is_default === "boolean"
313
+ ? { is_default: record.is_default }
314
+ : {}),
315
+ ...(default_modalities && default_modalities.length > 0
316
+ ? { default_modalities }
317
+ : {}),
318
+ };
111
319
  }
112
320
 
113
321
  /**
114
- * 读取可选布尔值。
322
+ * 归一化模型列表结果。
115
323
  */
116
- function normalize_boolean(value: unknown, fallback: boolean): boolean {
117
- return typeof value === "boolean" ? value : fallback;
324
+ function normalize_image_models(values: ImagePluginModel[]): ImagePluginModelsResult {
325
+ const items = values
326
+ .map((item) => normalize_image_model(item))
327
+ .filter((item): item is ImagePluginModel => item !== null);
328
+ const default_model_id =
329
+ items.find((item) => item.default_modalities?.includes("image"))?.id ??
330
+ items.find((item) => item.is_default)?.id ??
331
+ items[0]?.id;
332
+ return {
333
+ items,
334
+ ...(default_model_id ? { default_model_id } : {}),
335
+ };
118
336
  }
119
337
 
120
338
  /**
@@ -167,9 +385,8 @@ export class ImagePlugin extends BasePlugin {
167
385
 
168
386
  private readonly image_create: NonNullable<ImagePluginOptions["image_create"]>;
169
387
  private readonly image_result: NonNullable<ImagePluginOptions["image_result"]>;
170
- private readonly timeout_ms: number;
171
- private readonly min_poll_interval_ms: number;
172
- private readonly max_poll_interval_ms: number;
388
+ private readonly list_models?: ImagePluginOptions["list_models"];
389
+ private default_model_id?: string;
173
390
 
174
391
  constructor(options: ImagePluginOptions) {
175
392
  super();
@@ -190,18 +407,7 @@ export class ImagePlugin extends BasePlugin {
190
407
  ).trim();
191
408
  this.image_create = options.image_create;
192
409
  this.image_result = options.image_result;
193
- this.timeout_ms = normalize_positive_number(
194
- options.timeout_ms,
195
- DEFAULT_TIMEOUT_MS,
196
- );
197
- this.min_poll_interval_ms = normalize_positive_number(
198
- options.min_poll_interval_ms,
199
- DEFAULT_MIN_POLL_INTERVAL_MS,
200
- );
201
- this.max_poll_interval_ms = normalize_positive_number(
202
- options.max_poll_interval_ms,
203
- DEFAULT_MAX_POLL_INTERVAL_MS,
204
- );
410
+ this.list_models = options.list_models;
205
411
  }
206
412
 
207
413
  /**
@@ -211,167 +417,153 @@ export class ImagePlugin extends BasePlugin {
211
417
  return [
212
418
  "# Image Plugin",
213
419
  "",
214
- "Use this plugin only when the user asks to generate, create, draw, render, edit, transform, or stylize an image.",
215
- "Do not call it for ordinary image analysis or questions about an existing image unless the user asks for a new/edited image output.",
216
- "",
217
- "Call through `plugin_call`:",
218
- "",
219
- "```ts",
220
- "plugin_call({",
221
- ` plugin: "${this.name}",`,
222
- ' action: "generate",',
223
- " payload: {",
224
- ' prompt: "...",',
225
- " },",
226
- "});",
227
- "```",
228
- "",
229
- "Payload rules:",
230
- "- `prompt` is required unless `messages` provides the full multimodal image context.",
231
- "- Optional common fields: `messages`, `size`, `aspect_ratio`, `ratio`, `quality`, `n`, `count`, `seed`, `provider_options`.",
232
- "- Preserve the user's creative intent; do not over-rewrite the prompt unless clarification is necessary.",
233
- "- For a two-step flow, call `image_create` first, then call `image_result` with `job_id`; `image_result` waits until the job finishes by default.",
234
- "- `generate` is a convenience action that creates the job and waits for the final image in one call.",
235
- "- Generated image file parts are saved under project `.downcity/resources` and attached to the final assistant message automatically.",
420
+ "Use this plugin only when the user asks to create or edit an image.",
421
+ "Use `prompt` for text-only image generation.",
422
+ "Use `content` for image editing or reference images: `[{ type: \"text\", text }, { type: \"image\", url }]`.",
423
+ "If `content` is present, it is used instead of `prompt`.",
424
+ "Do not pass `messages` or data URLs.",
425
+ "`url` may be an online URL, an absolute local path, or a path relative to the Agent project root.",
426
+ "Flow: call `models` if you need a model id, call `image_create`, then call `image_result` with `job_id`.",
427
+ "If `image_result` returns `queued` or `running`, keep the `job_id` and check again later.",
236
428
  ].join("\n");
237
429
  }
238
430
 
239
- private async generate_image(
240
- input: ImagePluginInput,
241
- ): Promise<ImagePluginResult> {
242
- const created = await this.image_create(input);
243
- validate_created_job(created);
244
- const current = await this.wait_for_image_result({
245
- job_id: created.job_id,
246
- poll_after_ms: created.poll_after_ms,
247
- });
248
- if (!current.result) {
249
- throw new Error(`Image job ${created.job_id} succeeded without result`);
250
- }
251
- return normalize_image_result(current.result);
252
- }
253
-
254
431
  /**
255
- * 查询图片任务,按需等待终态。
432
+ * 当调用方未显式指定模型时,使用模型目录中的图片默认模型。
256
433
  */
257
- private async read_image_result(
258
- input: ImagePluginJobResultInput,
259
- options: {
260
- /**
261
- * 默认是否等待终态。
262
- */
263
- default_until_finish: boolean;
264
- },
265
- ): Promise<ImagePluginJobResult> {
266
- const timeout_ms = normalize_positive_number(input.timeout_ms, this.timeout_ms);
267
- const min_poll_interval_ms = normalize_positive_number(
268
- input.min_poll_interval_ms,
269
- this.min_poll_interval_ms,
270
- );
271
- const max_poll_interval_ms = normalize_positive_number(
272
- input.max_poll_interval_ms,
273
- this.max_poll_interval_ms,
274
- );
275
- const until_finish = normalize_boolean(
276
- input.until_finish,
277
- options.default_until_finish,
278
- );
279
- const first = await this.image_result({ job_id: input.job_id });
280
- validate_job_result(first);
281
- if (!until_finish || first.status === "succeeded") {
282
- if (first.status === "succeeded" && first.result) {
283
- normalize_image_result(first.result);
284
- }
285
- return first;
286
- }
287
- if (first.status === "failed") {
288
- throw new Error(
289
- `Image job failed: ${first.error ?? first.message ?? input.job_id}`,
290
- );
291
- }
292
- return await this.wait_for_image_result({
293
- job_id: input.job_id,
294
- poll_after_ms: first.poll_after_ms,
295
- timeout_ms,
296
- min_poll_interval_ms,
297
- max_poll_interval_ms,
298
- });
434
+ private async with_default_model(
435
+ input: ImagePluginResolvedInput,
436
+ ): Promise<ImagePluginResolvedInput> {
437
+ if (typeof input.model === "string" && input.model.trim()) return input;
438
+ const default_model_id = await this.resolve_default_model_id();
439
+ return default_model_id ? { ...input, model: default_model_id } : input;
440
+ }
441
+
442
+ private async resolve_default_model_id(): Promise<string | undefined> {
443
+ if (this.default_model_id) return this.default_model_id;
444
+ if (!this.list_models) return undefined;
445
+ const result = normalize_image_models(await this.list_models());
446
+ this.default_model_id = result.default_model_id;
447
+ return this.default_model_id;
299
448
  }
300
449
 
301
450
  /**
302
- * 轮询图片任务直到成功或失败。
451
+ * 查询图片任务当前状态。
303
452
  */
304
- private async wait_for_image_result(input: {
305
- /**
306
- * 图片任务 ID。
307
- */
308
- job_id: string;
309
- /**
310
- * 首次建议轮询间隔。
311
- */
312
- poll_after_ms?: number;
313
- /**
314
- * 最大等待时间。
315
- */
316
- timeout_ms?: number;
317
- /**
318
- * 轮询间隔下限。
319
- */
320
- min_poll_interval_ms?: number;
321
- /**
322
- * 轮询间隔上限。
323
- */
324
- max_poll_interval_ms?: number;
325
- }): Promise<ImagePluginJobResult> {
326
- const timeout_ms = normalize_positive_number(input.timeout_ms, this.timeout_ms);
327
- const min_poll_interval_ms = normalize_positive_number(
328
- input.min_poll_interval_ms,
329
- this.min_poll_interval_ms,
330
- );
331
- const max_poll_interval_ms = normalize_positive_number(
332
- input.max_poll_interval_ms,
333
- this.max_poll_interval_ms,
334
- );
335
- const deadline = Date.now() + timeout_ms;
336
- let poll_after_ms = input.poll_after_ms;
337
-
338
- while (Date.now() < deadline) {
339
- await sleep(
340
- clamp_poll_interval(
341
- poll_after_ms,
342
- min_poll_interval_ms,
343
- max_poll_interval_ms,
344
- ),
345
- );
346
- const current = await this.image_result({ job_id: input.job_id });
347
- validate_job_result(current);
348
- poll_after_ms = current.poll_after_ms;
349
- if (current.status === "succeeded") {
350
- if (!current.result) {
351
- throw new Error(`Image job ${input.job_id} succeeded without result`);
352
- }
353
- normalize_image_result(current.result);
354
- return current;
355
- }
356
- if (current.status === "failed") {
357
- throw new Error(
358
- `Image job failed: ${current.error ?? current.message ?? input.job_id}`,
359
- );
360
- }
453
+ private async read_image_result(input: ImagePluginJobResultInput): Promise<ImagePluginJobResult> {
454
+ const current = await this.image_result({ job_id: input.job_id });
455
+ validate_job_result(current);
456
+ if (current.status === "succeeded" && current.result) {
457
+ normalize_image_result(current.result);
361
458
  }
362
-
363
- throw new Error(`Image job timed out: ${input.job_id}`);
459
+ return current;
364
460
  }
365
461
 
366
462
  /**
367
463
  * 显式 action 集合。
368
464
  */
369
465
  readonly actions = {
370
- image_create: {
371
- execute: async ({ payload }: { payload: JsonValue }) => {
466
+ models: createAction({
467
+ description: "List image-capable models available to ImagePlugin.",
468
+ input_schema: z.object({}).passthrough(),
469
+ execute: async () => {
470
+ try {
471
+ if (!this.list_models) {
472
+ return {
473
+ success: false,
474
+ error: "ImagePlugin list_models is not configured",
475
+ message: "ImagePlugin list_models is not configured",
476
+ };
477
+ }
478
+ const models = await this.list_models();
479
+ const result = normalize_image_models(models);
480
+ return {
481
+ success: true,
482
+ data: result as unknown as JsonObject,
483
+ message: "image models listed",
484
+ };
485
+ } catch (error) {
486
+ return {
487
+ success: false,
488
+ error: String(error),
489
+ message: String(error),
490
+ };
491
+ }
492
+ },
493
+ }),
494
+ image_create: createAction({
495
+ description:
496
+ "Create an async image job. Use prompt for text-only generation, or content for reference images and edits.",
497
+ input_schema: {
498
+ zod: IMAGE_CREATE_INPUT_SCHEMA,
499
+ json_schema: {
500
+ type: "object",
501
+ additionalProperties: true,
502
+ properties: {
503
+ model: { type: "string", description: "Image model id." },
504
+ prompt: {
505
+ type: "string",
506
+ description: "Text-only image prompt. Ignored when content is present.",
507
+ },
508
+ content: {
509
+ type: "array",
510
+ description: "Multimodal content for image edits or reference images.",
511
+ items: {
512
+ oneOf: [
513
+ {
514
+ type: "object",
515
+ required: ["type", "text"],
516
+ properties: {
517
+ type: { const: "text" },
518
+ text: { type: "string" },
519
+ },
520
+ },
521
+ {
522
+ type: "object",
523
+ required: ["type", "url"],
524
+ properties: {
525
+ type: { const: "image" },
526
+ url: {
527
+ type: "string",
528
+ description:
529
+ "Online URL, absolute local path, or path relative to the Agent project root.",
530
+ },
531
+ media_type: { type: "string" },
532
+ },
533
+ },
534
+ ],
535
+ },
536
+ },
537
+ aspect_ratio: { type: "string", description: "Aspect ratio, for example 16:9." },
538
+ size: { type: "string", description: "Image size, for example 1024x1024." },
539
+ quality: { type: "string", description: "Image quality." },
540
+ seed: { type: "number", description: "Random seed." },
541
+ },
542
+ },
543
+ },
544
+ examples: [
545
+ {
546
+ title: "Text-only image",
547
+ payload: {
548
+ prompt: "A cinematic illustration of a rainy city corner at night",
549
+ aspect_ratio: "16:9",
550
+ },
551
+ },
552
+ {
553
+ title: "Edit image with local reference",
554
+ payload: {
555
+ content: [
556
+ { type: "text", text: "Change this image to a white studio background" },
557
+ { type: "image", url: "./input.png" },
558
+ ],
559
+ },
560
+ },
561
+ ],
562
+ execute: async ({ context, payload }: { context: AgentContext; payload: JsonValue }) => {
372
563
  try {
373
564
  const input = normalize_image_payload(payload);
374
- const created = await this.image_create(input);
565
+ const normalized_input = await normalize_image_create_input(context, input);
566
+ const created = await this.image_create(await this.with_default_model(normalized_input));
375
567
  validate_created_job(created);
376
568
  return {
377
569
  success: true,
@@ -386,14 +578,42 @@ export class ImagePlugin extends BasePlugin {
386
578
  };
387
579
  }
388
580
  },
389
- },
390
- image_result: {
581
+ }),
582
+ image_result: createAction({
583
+ description: "Read the current state of an async image job once.",
584
+ input_schema: {
585
+ zod: IMAGE_RESULT_INPUT_SCHEMA,
586
+ json_schema: {
587
+ type: "object",
588
+ required: ["job_id"],
589
+ properties: {
590
+ job_id: {
591
+ type: "string",
592
+ description: "Image job id returned by image_create.",
593
+ },
594
+ },
595
+ },
596
+ },
597
+ examples: [
598
+ {
599
+ title: "Read image job",
600
+ payload: {
601
+ job_id: "img_123",
602
+ },
603
+ },
604
+ ],
391
605
  execute: async ({ payload }: { payload: JsonValue }) => {
392
606
  try {
393
607
  const input = normalize_image_result_payload(payload);
394
- const current = await this.read_image_result(input, {
395
- default_until_finish: true,
396
- });
608
+ const current = await this.read_image_result(input);
609
+ if (current.status === "failed") {
610
+ return {
611
+ success: false,
612
+ data: current as unknown as JsonObject,
613
+ error: current.error ?? current.message ?? input.job_id,
614
+ message: current.error ?? current.message ?? "image job failed",
615
+ };
616
+ }
397
617
  const data = current.status === "succeeded" && current.result
398
618
  ? current.result as unknown as JsonObject
399
619
  : current as unknown as JsonObject;
@@ -413,25 +633,6 @@ export class ImagePlugin extends BasePlugin {
413
633
  };
414
634
  }
415
635
  },
416
- },
417
- generate: {
418
- execute: async ({ payload }: { payload: JsonValue }) => {
419
- try {
420
- const input = normalize_image_payload(payload);
421
- const message = await this.generate_image(input);
422
- return {
423
- success: true,
424
- data: message as unknown as JsonObject,
425
- message: "image generated",
426
- };
427
- } catch (error) {
428
- return {
429
- success: false,
430
- error: String(error),
431
- message: String(error),
432
- };
433
- }
434
- },
435
- },
636
+ }),
436
637
  };
437
638
  }