@sunnoy/wecom 2.2.1 → 2.4.0

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.
@@ -0,0 +1,764 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { logger } from "../logger.js";
4
+ import { loadOutboundMediaFromUrl } from "./openclaw-compat.js";
5
+
6
+ const DEFAULT_QWEN_ENDPOINT_PATH = "/services/aigc/multimodal-generation/generation";
7
+ const DEFAULT_WAN_ENDPOINT_PATH = "/services/aigc/image-generation/generation";
8
+ const DEFAULT_WAN_TASK_ENDPOINT = "/tasks/{task_id}";
9
+ const MAX_INPUT_IMAGE_BYTES = 10 * 1024 * 1024;
10
+ const MAX_OUTPUT_IMAGES = 6;
11
+ const ACTIONS = new Set(["generate", "edit"]);
12
+ const ASPECTS = new Set(["landscape", "square", "portrait"]);
13
+ const MODEL_FAMILIES = new Set(["qwen", "wan"]);
14
+ const WAN_GENERATE_MAX_PIXELS = 1280 * 1280;
15
+
16
+ const QWEN_PRIORITY_PATTERNS = [
17
+ /架构图/u,
18
+ /架构/u,
19
+ /流程图/u,
20
+ /时序图/u,
21
+ /拓扑/u,
22
+ /\bdiagram\b/iu,
23
+ /\bworkflow\b/iu,
24
+ /\bflowchart\b/iu,
25
+ /\binfographic\b/iu,
26
+ /\bwireframe\b/iu,
27
+ /\bui\b/iu,
28
+ /\bux\b/iu,
29
+ /海报/u,
30
+ /文字/u,
31
+ /文案/u,
32
+ /标签/u,
33
+ /标题/u,
34
+ /箭头/u,
35
+ /表格/u,
36
+ /\btext\b/iu,
37
+ /\blabel\b/iu,
38
+ /\btitle\b/iu,
39
+ /\bposter\b/iu,
40
+ ];
41
+
42
+ const WAN_PRIORITY_PATTERNS = [
43
+ /写实/u,
44
+ /摄影/u,
45
+ /照片/u,
46
+ /人像/u,
47
+ /棚拍/u,
48
+ /电影感/u,
49
+ /镜头/u,
50
+ /景深/u,
51
+ /胶片/u,
52
+ /光影/u,
53
+ /商品图/u,
54
+ /写真人像/u,
55
+ /\bphoto\b/iu,
56
+ /\bphotography\b/iu,
57
+ /\bphotoreal/i,
58
+ /\brealistic\b/iu,
59
+ /\bcinematic\b/iu,
60
+ /\bportrait\b/iu,
61
+ /\bproduct shot\b/iu,
62
+ ];
63
+
64
+ const LANDSCAPE_PATTERNS = [
65
+ /架构图/u,
66
+ /流程图/u,
67
+ /拓扑/u,
68
+ /时序图/u,
69
+ /横版/u,
70
+ /宽屏/u,
71
+ /\b16:9\b/iu,
72
+ /\blandscape\b/iu,
73
+ /\bbanner\b/iu,
74
+ /\bheader\b/iu,
75
+ ];
76
+
77
+ const PORTRAIT_PATTERNS = [
78
+ /竖版/u,
79
+ /手机壁纸/u,
80
+ /封面/u,
81
+ /海报/u,
82
+ /\b9:16\b/iu,
83
+ /\bportrait\b/iu,
84
+ ];
85
+
86
+ function isPlainObject(value) {
87
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
88
+ }
89
+
90
+ function textResult(text, details) {
91
+ return {
92
+ content: [{ type: "text", text }],
93
+ ...(details !== undefined ? { details } : {}),
94
+ };
95
+ }
96
+
97
+ function errorResult(message, extra = {}) {
98
+ return textResult(JSON.stringify({ error: message, ...extra }, null, 2), {
99
+ error: message,
100
+ ...extra,
101
+ });
102
+ }
103
+
104
+ function normalizeString(value) {
105
+ return typeof value === "string" ? value.trim() : "";
106
+ }
107
+
108
+ function normalizeImageList(value) {
109
+ if (!Array.isArray(value)) {
110
+ return [];
111
+ }
112
+ return value.map((entry) => String(entry ?? "").trim()).filter(Boolean);
113
+ }
114
+
115
+ function isRemoteImageRef(value) {
116
+ return /^https?:\/\//i.test(value) || /^oss:\/\//i.test(value);
117
+ }
118
+
119
+ function toAbsoluteEndpoint(baseUrl, endpoint) {
120
+ const normalizedEndpoint = normalizeString(endpoint) || DEFAULT_QWEN_ENDPOINT_PATH;
121
+ if (/^https?:\/\//i.test(normalizedEndpoint)) {
122
+ return normalizedEndpoint;
123
+ }
124
+
125
+ const normalizedBase = normalizeString(baseUrl);
126
+ if (!normalizedBase) {
127
+ throw new Error("Configured provider is missing baseUrl.");
128
+ }
129
+
130
+ const root = normalizedBase.replace(/\/+$/, "");
131
+ const suffix = normalizedEndpoint.startsWith("/") ? normalizedEndpoint : `/${normalizedEndpoint}`;
132
+ if (/\/services\/aigc\/multimodal-generation\/generation$/u.test(root)) {
133
+ return root;
134
+ }
135
+ return `${root}${suffix}`;
136
+ }
137
+
138
+ function patternMatched(patterns, text) {
139
+ return patterns.some((pattern) => pattern.test(text));
140
+ }
141
+
142
+ function normalizeModelFamily(value) {
143
+ const normalized = normalizeString(value).toLowerCase();
144
+ if (normalized.includes("qwen")) {
145
+ return "qwen";
146
+ }
147
+ if (normalized.includes("wan")) {
148
+ return "wan";
149
+ }
150
+ return "";
151
+ }
152
+
153
+ function normalizeAspect(value, fallback = "square") {
154
+ const normalized = normalizeString(value, fallback).toLowerCase();
155
+ return ASPECTS.has(normalized) ? normalized : fallback;
156
+ }
157
+
158
+ function parseImageSize(size) {
159
+ const match = normalizeString(size).match(/^(\d+)\s*[x*]\s*(\d+)$/i);
160
+ if (!match) {
161
+ return null;
162
+ }
163
+ const width = Number.parseInt(match[1], 10);
164
+ const height = Number.parseInt(match[2], 10);
165
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
166
+ return null;
167
+ }
168
+ return { width, height };
169
+ }
170
+
171
+ function isWanGenerateSizeCompatible(size) {
172
+ const parsed = parseImageSize(size);
173
+ if (!parsed) {
174
+ return true;
175
+ }
176
+ return parsed.width * parsed.height <= WAN_GENERATE_MAX_PIXELS;
177
+ }
178
+
179
+ function resolveAspect(prompt, requestedAspect, fallbackAspect = "square") {
180
+ if (normalizeString(requestedAspect)) {
181
+ return normalizeAspect(requestedAspect, fallbackAspect);
182
+ }
183
+ if (patternMatched(LANDSCAPE_PATTERNS, prompt)) {
184
+ return "landscape";
185
+ }
186
+ if (patternMatched(PORTRAIT_PATTERNS, prompt)) {
187
+ return "portrait";
188
+ }
189
+ return fallbackAspect;
190
+ }
191
+
192
+ function resolveDefaultSize(pluginConfig, family, aspect) {
193
+ const familyDefaults = pluginConfig?.defaults?.[family];
194
+ const requestedAspect = normalizeAspect(aspect, pluginConfig?.defaults?.aspect ?? "square");
195
+ if (familyDefaults?.[requestedAspect]) {
196
+ return familyDefaults[requestedAspect];
197
+ }
198
+ return pluginConfig?.defaults?.size ?? "1024*1024";
199
+ }
200
+
201
+ function resolveRouteFamily({ input, prompt, action, pluginConfig }) {
202
+ const explicit = normalizeModelFamily(input?.model_preference ?? input?.modelPreference);
203
+ if (explicit) {
204
+ return explicit;
205
+ }
206
+
207
+ const configured = normalizeModelFamily(pluginConfig?.route);
208
+ if (configured) {
209
+ return configured;
210
+ }
211
+
212
+ const sourceFamily = normalizeModelFamily(input?.source_model ?? input?.sourceModel);
213
+ const qwenPreferred = patternMatched(QWEN_PRIORITY_PATTERNS, prompt);
214
+ const wanPreferred = patternMatched(WAN_PRIORITY_PATTERNS, prompt);
215
+
216
+ if (qwenPreferred && !wanPreferred) {
217
+ return "qwen";
218
+ }
219
+ if (wanPreferred && !qwenPreferred) {
220
+ return "wan";
221
+ }
222
+ if (action === "edit" && sourceFamily) {
223
+ return sourceFamily;
224
+ }
225
+ return "qwen";
226
+ }
227
+
228
+ function getProviderConfig(openclawConfig, providerAlias) {
229
+ const providers = openclawConfig?.models?.providers;
230
+ if (!isPlainObject(providers)) {
231
+ throw new Error("OpenClaw models.providers is not configured.");
232
+ }
233
+
234
+ const provider = providers[providerAlias];
235
+ if (!isPlainObject(provider)) {
236
+ throw new Error(`OpenClaw provider "${providerAlias}" is not configured.`);
237
+ }
238
+
239
+ const apiKey = normalizeString(provider.apiKey);
240
+ if (!apiKey) {
241
+ throw new Error(`OpenClaw provider "${providerAlias}" is missing apiKey.`);
242
+ }
243
+
244
+ const headers = {};
245
+ if (isPlainObject(provider.headers)) {
246
+ for (const [key, value] of Object.entries(provider.headers)) {
247
+ if (typeof value === "string" && value.trim()) {
248
+ headers[key] = value;
249
+ }
250
+ }
251
+ }
252
+
253
+ return {
254
+ baseUrl: normalizeString(provider.baseUrl),
255
+ apiKey,
256
+ headers,
257
+ authHeader: provider.authHeader !== false,
258
+ };
259
+ }
260
+
261
+ function assertWithinWorkspace(workspaceDir, filePath) {
262
+ const workspaceRoot = resolve(workspaceDir);
263
+ const absolutePath = resolve(filePath);
264
+ const rel = relative(workspaceRoot, absolutePath);
265
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
266
+ return absolutePath;
267
+ }
268
+ throw new Error(`Local image path escapes workspace: ${filePath}`);
269
+ }
270
+
271
+ function resolveWorkspacePath(source, workspaceDir) {
272
+ const trimmed = normalizeString(source);
273
+ if (!trimmed) {
274
+ throw new Error("Image path is empty.");
275
+ }
276
+ if (!workspaceDir) {
277
+ throw new Error("Local image paths require an agent workspace.");
278
+ }
279
+
280
+ if (trimmed.startsWith("file://")) {
281
+ return assertWithinWorkspace(workspaceDir, fileURLToPath(trimmed));
282
+ }
283
+
284
+ if (trimmed === "/workspace") {
285
+ return resolve(workspaceDir);
286
+ }
287
+
288
+ if (trimmed.startsWith("/workspace/")) {
289
+ return assertWithinWorkspace(workspaceDir, resolve(workspaceDir, `.${trimmed.slice("/workspace".length)}`));
290
+ }
291
+
292
+ if (isAbsolute(trimmed)) {
293
+ return assertWithinWorkspace(workspaceDir, trimmed);
294
+ }
295
+
296
+ return assertWithinWorkspace(workspaceDir, resolve(workspaceDir, trimmed));
297
+ }
298
+
299
+ async function normalizeInputImage(source, ctx) {
300
+ if (isRemoteImageRef(source)) {
301
+ return source;
302
+ }
303
+
304
+ const workspaceDir = normalizeString(ctx.workspaceDir);
305
+ const resolvedPath = resolveWorkspacePath(source, workspaceDir);
306
+ const loaded = await loadOutboundMediaFromUrl(resolvedPath, {
307
+ maxBytes: MAX_INPUT_IMAGE_BYTES,
308
+ mediaLocalRoots: [workspaceDir],
309
+ includeDefaultMediaLocalRoots: false,
310
+ });
311
+ const contentType = normalizeString(loaded.contentType);
312
+ if (!contentType.startsWith("image/")) {
313
+ throw new Error(`Local file is not an image: ${source}`);
314
+ }
315
+ return `data:${contentType};base64,${loaded.buffer.toString("base64")}`;
316
+ }
317
+
318
+ function buildQwenRequestBody(params) {
319
+ const content = [];
320
+ for (const image of params.images) {
321
+ content.push({ image });
322
+ }
323
+ content.push({ text: params.prompt });
324
+
325
+ const body = {
326
+ model: params.model,
327
+ input: {
328
+ messages: [
329
+ {
330
+ role: "user",
331
+ content,
332
+ },
333
+ ],
334
+ },
335
+ parameters: {
336
+ n: params.n,
337
+ prompt_extend: params.promptExtend,
338
+ watermark: params.watermark,
339
+ size: params.size,
340
+ },
341
+ };
342
+
343
+ if (params.negativePrompt) {
344
+ body.parameters.negative_prompt = params.negativePrompt;
345
+ }
346
+ if (Number.isInteger(params.seed) && params.seed >= 0) {
347
+ body.parameters.seed = params.seed;
348
+ }
349
+
350
+ return body;
351
+ }
352
+
353
+ function buildWanRequestBody(params) {
354
+ const content = [];
355
+ for (const image of params.images) {
356
+ content.push({ image });
357
+ }
358
+ content.push({ text: params.prompt });
359
+
360
+ const body = {
361
+ model: params.model,
362
+ input: {
363
+ messages: [
364
+ {
365
+ role: "user",
366
+ content,
367
+ },
368
+ ],
369
+ },
370
+ parameters: {
371
+ size: params.size,
372
+ watermark: params.watermark,
373
+ },
374
+ };
375
+
376
+ if (params.action === "generate") {
377
+ body.parameters.enable_interleave = true;
378
+ body.parameters.max_images = params.n;
379
+ } else {
380
+ body.parameters.n = params.n;
381
+ body.parameters.prompt_extend = params.promptExtend;
382
+ }
383
+
384
+ if (params.negativePrompt) {
385
+ body.parameters.negative_prompt = params.negativePrompt;
386
+ }
387
+ if (Number.isInteger(params.seed) && params.seed >= 0) {
388
+ body.parameters.seed = params.seed;
389
+ }
390
+
391
+ return body;
392
+ }
393
+
394
+ function extractImageUrls(payload) {
395
+ const results = [];
396
+ const contents = payload?.output?.choices?.[0]?.message?.content;
397
+ if (Array.isArray(contents)) {
398
+ for (const item of contents) {
399
+ if (typeof item?.image === "string" && item.image.trim()) {
400
+ results.push(item.image.trim());
401
+ }
402
+ }
403
+ }
404
+
405
+ const legacyResults = payload?.output?.results;
406
+ if (Array.isArray(legacyResults)) {
407
+ for (const item of legacyResults) {
408
+ if (typeof item?.url === "string" && item.url.trim()) {
409
+ results.push(item.url.trim());
410
+ } else if (typeof item?.image === "string" && item.image.trim()) {
411
+ results.push(item.image.trim());
412
+ }
413
+ }
414
+ }
415
+
416
+ return [...new Set(results)];
417
+ }
418
+
419
+ async function callDashScope(params, deps = {}) {
420
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
421
+ if (typeof fetchImpl !== "function") {
422
+ throw new Error("fetch is not available in this runtime.");
423
+ }
424
+
425
+ const controller = new AbortController();
426
+ const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
427
+ const headers = {
428
+ "Content-Type": "application/json",
429
+ ...params.headers,
430
+ };
431
+ if (params.authHeader && !headers.Authorization) {
432
+ headers.Authorization = `Bearer ${params.apiKey}`;
433
+ }
434
+
435
+ try {
436
+ const response = await fetchImpl(params.url, {
437
+ method: "POST",
438
+ headers,
439
+ body: JSON.stringify(params.body),
440
+ signal: controller.signal,
441
+ });
442
+ const rawText = await response.text();
443
+ const payload = rawText ? JSON.parse(rawText) : {};
444
+ if (!response.ok) {
445
+ throw new Error(
446
+ payload?.message ||
447
+ payload?.error ||
448
+ `DashScope request failed: ${response.status} ${response.statusText}`,
449
+ );
450
+ }
451
+ return payload;
452
+ } catch (error) {
453
+ if (error instanceof DOMException && error.name === "AbortError") {
454
+ throw new Error(`DashScope image request timed out after ${params.timeoutMs}ms`);
455
+ }
456
+ throw error;
457
+ } finally {
458
+ clearTimeout(timeoutId);
459
+ }
460
+ }
461
+
462
+ async function pollDashScopeTask(params, deps = {}) {
463
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
464
+ if (typeof fetchImpl !== "function") {
465
+ throw new Error("fetch is not available in this runtime.");
466
+ }
467
+
468
+ const headers = {
469
+ ...params.headers,
470
+ };
471
+ if (params.authHeader && !headers.Authorization) {
472
+ headers.Authorization = `Bearer ${params.apiKey}`;
473
+ }
474
+
475
+ const startedAt = Date.now();
476
+ for (;;) {
477
+ if (Date.now() - startedAt > params.timeoutMs) {
478
+ throw new Error(`DashScope task timed out after ${params.timeoutMs}ms`);
479
+ }
480
+
481
+ const response = await fetchImpl(params.url, {
482
+ method: "GET",
483
+ headers,
484
+ });
485
+ const rawText = await response.text();
486
+ const payload = rawText ? JSON.parse(rawText) : {};
487
+ if (!response.ok) {
488
+ throw new Error(
489
+ payload?.message ||
490
+ payload?.error ||
491
+ `DashScope task poll failed: ${response.status} ${response.statusText}`,
492
+ );
493
+ }
494
+
495
+ const status = normalizeString(payload?.output?.task_status ?? payload?.task_status).toUpperCase();
496
+ if (status === "SUCCEEDED") {
497
+ return payload;
498
+ }
499
+ if (status === "FAILED" || status === "CANCELED") {
500
+ throw new Error(payload?.output?.message || payload?.message || `DashScope task ${status.toLowerCase()}.`);
501
+ }
502
+
503
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, params.intervalMs));
504
+ }
505
+ }
506
+
507
+ async function callWanAsync(params, deps = {}) {
508
+ const createPayload = await callDashScope(
509
+ {
510
+ url: params.url,
511
+ apiKey: params.apiKey,
512
+ headers: {
513
+ ...params.headers,
514
+ "X-DashScope-Async": "enable",
515
+ },
516
+ authHeader: params.authHeader,
517
+ timeoutMs: params.timeoutMs,
518
+ body: params.body,
519
+ },
520
+ deps,
521
+ );
522
+
523
+ const taskId = normalizeString(
524
+ createPayload?.output?.task_id ?? createPayload?.task_id ?? createPayload?.output?.id ?? createPayload?.id,
525
+ );
526
+ if (!taskId) {
527
+ throw new Error("DashScope async response did not include task_id.");
528
+ }
529
+
530
+ const taskUrl = toAbsoluteEndpoint(params.baseUrl, params.taskEndpoint.replace("{task_id}", taskId));
531
+ return pollDashScopeTask(
532
+ {
533
+ url: taskUrl,
534
+ apiKey: params.apiKey,
535
+ headers: params.headers,
536
+ authHeader: params.authHeader,
537
+ timeoutMs: params.timeoutMs,
538
+ intervalMs: 1500,
539
+ },
540
+ deps,
541
+ );
542
+ }
543
+
544
+ async function executeImageStudio(input, ctx, pluginConfig, deps = {}) {
545
+ const action = normalizeString(input?.action).toLowerCase();
546
+ if (!ACTIONS.has(action)) {
547
+ return errorResult('action must be "generate" or "edit".', { action });
548
+ }
549
+
550
+ const prompt = normalizeString(input?.prompt);
551
+ if (!prompt) {
552
+ return errorResult("prompt is required.");
553
+ }
554
+
555
+ const images = normalizeImageList(input?.images);
556
+ if (action === "edit" && images.length === 0) {
557
+ return errorResult("images is required for edit.");
558
+ }
559
+
560
+ let family = resolveRouteFamily({ input, prompt, action, pluginConfig });
561
+ const aspect = resolveAspect(prompt, input?.aspect ?? input?.layout, pluginConfig.defaults.aspect);
562
+ const n = Math.max(1, Math.min(MAX_OUTPUT_IMAGES, Math.trunc(Number(input?.n ?? pluginConfig.defaults.n) || 1)));
563
+ const requestedSize = normalizeString(input?.size);
564
+ if (!requestedSize && family === "wan" && action === "generate") {
565
+ family = "wan";
566
+ }
567
+ if (!requestedSize && !MODEL_FAMILIES.has(family)) {
568
+ family = "qwen";
569
+ }
570
+ if (family === "wan" && action === "generate" && requestedSize && !isWanGenerateSizeCompatible(requestedSize)) {
571
+ const explicitFamily = normalizeModelFamily(input?.model_preference ?? input?.modelPreference);
572
+ if (explicitFamily === "wan") {
573
+ return errorResult(`size ${requestedSize} exceeds wan2.6-image generate limits; choose a smaller size or use qwen.`, {
574
+ action,
575
+ modelFamily: family,
576
+ });
577
+ }
578
+ family = "qwen";
579
+ }
580
+
581
+ const size = requestedSize || resolveDefaultSize(pluginConfig, family, aspect);
582
+ const watermark =
583
+ typeof input?.watermark === "boolean" ? input.watermark : pluginConfig.defaults.watermark;
584
+ const promptExtend =
585
+ typeof input?.prompt_extend === "boolean"
586
+ ? input.prompt_extend
587
+ : typeof input?.promptExtend === "boolean"
588
+ ? input.promptExtend
589
+ : pluginConfig.defaults.promptExtend;
590
+ const negativePrompt = normalizeString(input?.negative_prompt ?? input?.negativePrompt);
591
+ const seed = Number.isInteger(input?.seed) ? input.seed : undefined;
592
+ const model = action === "edit" ? pluginConfig.models[family].edit : pluginConfig.models[family].generate;
593
+
594
+ try {
595
+ const openclawConfig = ctx.config;
596
+ const provider = getProviderConfig(openclawConfig, pluginConfig.provider);
597
+ const normalizedImages = [];
598
+ for (const image of images) {
599
+ normalizedImages.push(await normalizeInputImage(image, ctx));
600
+ }
601
+
602
+ const bodyBuilder = family === "wan" ? buildWanRequestBody : buildQwenRequestBody;
603
+ const body = bodyBuilder({
604
+ action,
605
+ model,
606
+ prompt,
607
+ images: normalizedImages,
608
+ n,
609
+ size,
610
+ watermark,
611
+ promptExtend,
612
+ negativePrompt,
613
+ seed,
614
+ });
615
+ const endpoint = family === "wan" ? pluginConfig.endpoints.wan : pluginConfig.endpoints.qwen;
616
+ const url = toAbsoluteEndpoint(provider.baseUrl, endpoint);
617
+ logger.info(`[image_studio] invoking ${action} with model=${model} family=${family} provider=${pluginConfig.provider}`);
618
+ const response =
619
+ family === "wan"
620
+ ? await callWanAsync(
621
+ {
622
+ url,
623
+ baseUrl: provider.baseUrl,
624
+ taskEndpoint: pluginConfig.endpoints.task || DEFAULT_WAN_TASK_ENDPOINT,
625
+ apiKey: provider.apiKey,
626
+ headers: provider.headers,
627
+ authHeader: provider.authHeader,
628
+ timeoutMs: pluginConfig.timeoutMs,
629
+ body,
630
+ },
631
+ deps,
632
+ )
633
+ : await callDashScope(
634
+ {
635
+ url,
636
+ apiKey: provider.apiKey,
637
+ headers: provider.headers,
638
+ authHeader: provider.authHeader,
639
+ timeoutMs: pluginConfig.timeoutMs,
640
+ body,
641
+ },
642
+ deps,
643
+ );
644
+ const mediaUrls = extractImageUrls(response);
645
+ if (mediaUrls.length === 0) {
646
+ return errorResult("DashScope response did not include image URLs.", {
647
+ action,
648
+ model,
649
+ modelFamily: family,
650
+ provider: pluginConfig.provider,
651
+ response,
652
+ });
653
+ }
654
+
655
+ const lines = [`Generated ${mediaUrls.length} image(s) with ${model}.`];
656
+ for (const mediaUrl of mediaUrls) {
657
+ lines.push(`MEDIA:${mediaUrl}`);
658
+ }
659
+
660
+ return textResult(lines.join("\n"), {
661
+ action,
662
+ model,
663
+ modelFamily: family,
664
+ aspect,
665
+ size,
666
+ provider: pluginConfig.provider,
667
+ mediaUrls,
668
+ response,
669
+ });
670
+ } catch (error) {
671
+ return errorResult(error instanceof Error ? error.message : String(error), {
672
+ action,
673
+ model,
674
+ modelFamily: family,
675
+ provider: pluginConfig.provider,
676
+ });
677
+ }
678
+ }
679
+
680
+ export function createImageStudioTool(pluginConfig, deps = {}) {
681
+ return (ctx) => ({
682
+ name: "image_studio",
683
+ label: "Qwen Image Studio",
684
+ description: "Generate or edit images with DashScope Qwen and Wan image models.",
685
+ parameters: {
686
+ type: "object",
687
+ properties: {
688
+ action: {
689
+ type: "string",
690
+ enum: ["generate", "edit"],
691
+ description: "Whether to generate a new image or edit existing image inputs.",
692
+ },
693
+ prompt: {
694
+ type: "string",
695
+ description: "Instruction describing the desired image or edit.",
696
+ },
697
+ images: {
698
+ type: "array",
699
+ items: { type: "string" },
700
+ description: "Input image URLs or workspace-local image paths. Required for edit.",
701
+ },
702
+ aspect: {
703
+ type: "string",
704
+ enum: ["landscape", "square", "portrait"],
705
+ description: "Preferred output framing when size is omitted.",
706
+ },
707
+ size: {
708
+ type: "string",
709
+ description: 'Target output size, for example "1024*1024".',
710
+ },
711
+ model_preference: {
712
+ type: "string",
713
+ enum: ["auto", "qwen", "wan"],
714
+ description: "Optional routing override. qwen is best for text-heavy diagrams, wan for photorealism.",
715
+ },
716
+ source_model: {
717
+ type: "string",
718
+ description: "Optional source image model hint such as qwen-image-2.0-pro or wan2.6-image.",
719
+ },
720
+ n: {
721
+ type: "integer",
722
+ minimum: 1,
723
+ maximum: MAX_OUTPUT_IMAGES,
724
+ description: "Number of images to generate.",
725
+ },
726
+ negative_prompt: {
727
+ type: "string",
728
+ description: "Optional negative prompt.",
729
+ },
730
+ seed: {
731
+ type: "integer",
732
+ minimum: 0,
733
+ description: "Optional random seed.",
734
+ },
735
+ watermark: {
736
+ type: "boolean",
737
+ description: "Whether to keep DashScope watermark enabled.",
738
+ },
739
+ prompt_extend: {
740
+ type: "boolean",
741
+ description: "Whether DashScope may automatically expand the prompt.",
742
+ },
743
+ },
744
+ required: ["action", "prompt"],
745
+ additionalProperties: false,
746
+ },
747
+ async execute(_toolCallId, input) {
748
+ return executeImageStudio(input, ctx, pluginConfig, deps);
749
+ },
750
+ });
751
+ }
752
+
753
+ export const imageStudioToolTesting = {
754
+ executeImageStudio,
755
+ buildQwenRequestBody,
756
+ buildWanRequestBody,
757
+ extractImageUrls,
758
+ toAbsoluteEndpoint,
759
+ normalizeInputImage,
760
+ resolveRouteFamily,
761
+ resolveAspect,
762
+ resolveDefaultSize,
763
+ isWanGenerateSizeCompatible,
764
+ };