pixi-board-plugin-mivo 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,463 @@
1
+ // src/index.ts
2
+ import { mkdir as mkdir2 } from "fs/promises";
3
+ import path2 from "path";
4
+
5
+ // src/mivoCli.ts
6
+ import { execFile } from "child_process";
7
+ import { mkdir, mkdtemp, writeFile } from "fs/promises";
8
+ import os from "os";
9
+ import path from "path";
10
+ import { promisify } from "util";
11
+ var execFileAsync = promisify(execFile);
12
+ var DEFAULT_API_BASE = "https://aigc.xindong.com/api/v1";
13
+ var URL_KEYS = ["signUrl", "sign_url", "url", "uri", "displayUrl", "fileUrl", "downloadUrl", "src", "image"];
14
+ var ID_KEYS = ["fileId", "file_id", "object_id", "_id"];
15
+ function createMivoCliClient(env = process.env) {
16
+ const command = env.MIVO_CLI_PATH?.trim() || "mivo-cli";
17
+ let ready = false;
18
+ async function run(args, options) {
19
+ const { stdout } = await execFileAsync(command, args, {
20
+ env: {
21
+ ...process.env,
22
+ ...env
23
+ },
24
+ input: options?.input,
25
+ maxBuffer: 10 * 1024 * 1024
26
+ });
27
+ return parseJson(stdout);
28
+ }
29
+ return {
30
+ async ensureReady() {
31
+ if (ready) return;
32
+ const apiKey = env.MIVO_API_KEY?.trim();
33
+ if (apiKey) {
34
+ await run(["config", "set", "api_base", env.MIVO_API_BASE?.trim() || DEFAULT_API_BASE, "--json"]);
35
+ await run(["login", "--key", apiKey, "--json"]);
36
+ }
37
+ ready = true;
38
+ },
39
+ async getChatSessionId(chatType) {
40
+ await this.ensureReady();
41
+ const value = await run(["message", "chat", "--type", chatType, "--json"]);
42
+ const record = asRecord(value, "message chat output");
43
+ const chatSessionId = readStringFromAny(record, ["chatSessionId", "chat_session_id", "id"]);
44
+ if (!chatSessionId) {
45
+ throw new Error("mivo-cli message chat did not return a chat session id");
46
+ }
47
+ return chatSessionId;
48
+ },
49
+ async submitMessage(payload) {
50
+ await this.ensureReady();
51
+ const dir = await mkdtemp(path.join(os.tmpdir(), "pixi-board-mivo-"));
52
+ const payloadPath = path.join(dir, `message-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
53
+ await writeFile(payloadPath, JSON.stringify(payload), "utf8");
54
+ const value = await run(["message", "submit", "--file", payloadPath, "--no-wait", "--json"]);
55
+ const record = asRecord(value, "message submit output");
56
+ const messageId = readStringFromAny(record, ["messageId", "message_id", "id"]);
57
+ if (!messageId) {
58
+ throw new Error("mivo-cli message submit did not return a message id");
59
+ }
60
+ return messageId;
61
+ },
62
+ async detailBatch(messageIds) {
63
+ await this.ensureReady();
64
+ const value = await run(["message", "detail-batch", ...messageIds, "--json"]);
65
+ if (Array.isArray(value)) {
66
+ return value;
67
+ }
68
+ const record = asRecord(value, "message detail-batch output");
69
+ if (Array.isArray(record.messages)) {
70
+ return record.messages;
71
+ }
72
+ if (Array.isArray(record.results)) {
73
+ return record.results;
74
+ }
75
+ if (Array.isArray(record.data)) {
76
+ return record.data;
77
+ }
78
+ throw new Error("mivo-cli message detail-batch returned an unsupported JSON shape");
79
+ },
80
+ async downloadFile(fileId, savePath) {
81
+ await this.ensureReady();
82
+ await mkdir(path.dirname(savePath), { recursive: true });
83
+ await run(["file", "download", fileId, savePath, "--json"]);
84
+ }
85
+ };
86
+ }
87
+ function extractImageArtifact(message) {
88
+ const image = message.content?.images?.[0];
89
+ if (image === void 0 || image === null) return null;
90
+ if (typeof image === "string") {
91
+ if (/^https?:\/\//i.test(image)) return { url: image };
92
+ return { fileId: image };
93
+ }
94
+ if (!isRecord(image)) return null;
95
+ const url = readStringFromAny(image, URL_KEYS);
96
+ const fileId = readStringFromAny(image, ID_KEYS);
97
+ if (!url && !fileId) return null;
98
+ return { fileId, url };
99
+ }
100
+ function mivoFileUrl(fileId) {
101
+ return `${DEFAULT_API_BASE}/file/image/${encodeURIComponent(fileId)}`;
102
+ }
103
+ async function downloadUrl(url, savePath) {
104
+ await mkdir(path.dirname(savePath), { recursive: true });
105
+ const response = await fetch(url);
106
+ if (!response.ok) {
107
+ throw new Error(`Failed to download Mivo image URL: ${response.status} ${response.statusText}`);
108
+ }
109
+ const buffer = Buffer.from(await response.arrayBuffer());
110
+ await writeFile(savePath, buffer);
111
+ }
112
+ function parseJson(stdout) {
113
+ try {
114
+ return JSON.parse(stdout);
115
+ } catch (error) {
116
+ throw new Error(`mivo-cli returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ }
119
+ function readStringFromAny(record, keys) {
120
+ for (const key of keys) {
121
+ const value = record[key];
122
+ if (typeof value === "string" && value.trim() !== "") {
123
+ return value;
124
+ }
125
+ }
126
+ return void 0;
127
+ }
128
+ function asRecord(value, label) {
129
+ if (!isRecord(value)) {
130
+ throw new Error(`${label} must be an object`);
131
+ }
132
+ return value;
133
+ }
134
+ function isRecord(value) {
135
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
136
+ }
137
+
138
+ // src/index.ts
139
+ var RATIOS = ["1:1", "2:3", "3:2", "9:16", "16:9"];
140
+ var DEFAULT_RATIO = "1:1";
141
+ var DEFAULT_TIMEOUT_MS = 3e5;
142
+ var DEFAULT_POLL_INTERVAL_MS = 2e3;
143
+ var anyOutputSchema = {
144
+ type: "object",
145
+ additionalProperties: true
146
+ };
147
+ var mivoPlugin = {
148
+ name: "pixi-board-plugin-mivo",
149
+ version: "0.1.0",
150
+ dependencies: {
151
+ tools: [
152
+ "canvas.create_nodes",
153
+ "canvas.generating_node_install",
154
+ "canvas.update_nodes",
155
+ "canvas.read_project_info"
156
+ ]
157
+ },
158
+ register(api) {
159
+ api.registerTool(createMivoCreateGptImageTool());
160
+ }
161
+ };
162
+ var plugin = mivoPlugin;
163
+ function createMivoCreateGptImageTool(deps = {}) {
164
+ const cli = deps.cli ?? createMivoCliClient();
165
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
166
+ const downloadRemoteUrl = deps.downloadRemoteUrl ?? downloadUrl;
167
+ return {
168
+ name: "mivo.create_gpt_image",
169
+ description: "Create a generating canvas node, submit a GPT Image 2 Mivo task, poll until the image is ready, then replace the generating node with the downloaded media asset.",
170
+ kind: "plugin",
171
+ inputSchema: createGptImageInputSchema,
172
+ outputSchema: createGptImageOutputSchema,
173
+ async run(input, ctx) {
174
+ const args = readInput(input);
175
+ const projectInfo = await ctx.tools.call("canvas.read_project_info", {
176
+ projectRoot: args.projectRoot
177
+ });
178
+ const projectRoot = projectInfo.projectRoot;
179
+ let nodeId = null;
180
+ let messageId = null;
181
+ try {
182
+ const createResult = await ctx.tools.call("canvas.create_nodes", {
183
+ projectRoot,
184
+ nodes: [
185
+ {
186
+ kind: "generating",
187
+ name: args.name ?? defaultNodeName(args.prompt),
188
+ options: {
189
+ mivo: {
190
+ status: "generating",
191
+ provider: "mivo",
192
+ tool: "mivo.create_gpt_image",
193
+ prompt: args.prompt,
194
+ ratio: args.ratio
195
+ }
196
+ }
197
+ }
198
+ ]
199
+ });
200
+ nodeId = readCreatedNodeId(createResult);
201
+ const chatSessionId = await cli.getChatSessionId("freeform");
202
+ messageId = await cli.submitMessage(buildSubmitPayload(args, chatSessionId));
203
+ const artifact = await pollForImage(cli, messageId, args.timeoutMs, args.pollIntervalMs, sleep);
204
+ const imagePath = await materializeArtifact(cli, artifact, projectRoot, messageId, downloadRemoteUrl);
205
+ const installResult = await ctx.tools.call("canvas.generating_node_install", {
206
+ projectRoot,
207
+ nodeId,
208
+ path: imagePath
209
+ });
210
+ return {
211
+ nodeId,
212
+ messageId,
213
+ imagePath,
214
+ imageUrl: artifact.url ?? (artifact.fileId ? mivoFileUrl(artifact.fileId) : void 0),
215
+ nodes: installResult.nodes,
216
+ assets: installResult.assets ?? []
217
+ };
218
+ } catch (error) {
219
+ if (nodeId) {
220
+ await markNodeFailed(ctx, projectRoot, nodeId, error, messageId);
221
+ }
222
+ throw error;
223
+ }
224
+ }
225
+ };
226
+ }
227
+ var createGptImageInputSchema = objectSchema({
228
+ projectRoot: stringSchema('Canvas project root absolute path, or "active" for the current canvas'),
229
+ prompt: stringSchema("Prompt for GPT Image 2"),
230
+ ratio: {
231
+ type: "string",
232
+ enum: RATIOS,
233
+ description: "Generated image aspect ratio"
234
+ },
235
+ name: stringSchema("Optional display name for the generating node"),
236
+ referenceFileIds: {
237
+ type: "array",
238
+ items: { type: "string" },
239
+ description: "Optional Mivo file ids to use as image references"
240
+ },
241
+ timeoutMs: {
242
+ type: "number",
243
+ description: "Maximum time to wait for the Mivo message result"
244
+ },
245
+ pollIntervalMs: {
246
+ type: "number",
247
+ description: "Delay between Mivo detail-batch polling attempts"
248
+ }
249
+ }, ["projectRoot", "prompt"]);
250
+ var createGptImageOutputSchema = objectSchema({
251
+ nodeId: { type: "string" },
252
+ messageId: { type: "string" },
253
+ imagePath: { type: "string" },
254
+ imageUrl: { type: "string" },
255
+ nodes: {
256
+ type: "array",
257
+ items: anyOutputSchema
258
+ },
259
+ assets: {
260
+ type: "array",
261
+ items: anyOutputSchema
262
+ }
263
+ }, ["nodeId", "messageId", "imagePath", "nodes", "assets"]);
264
+ function buildSubmitPayload(input, chatSessionId) {
265
+ const images = input.referenceFileIds ?? [];
266
+ return {
267
+ chatSessionId,
268
+ title: "\u4F5C\u56FE",
269
+ modelType: "GPT",
270
+ action: images.length > 0 ? "modal" : "imagine",
271
+ messageType: "image",
272
+ modelFormat: {
273
+ version: "gpt-image-2"
274
+ },
275
+ payload: {
276
+ prompt: input.prompt,
277
+ images,
278
+ n: 1,
279
+ imgRatio: input.ratio,
280
+ quality: "auto",
281
+ credentialSource: "personal"
282
+ }
283
+ };
284
+ }
285
+ async function pollForImage(cli, messageId, timeoutMs, pollIntervalMs, sleep) {
286
+ const start = Date.now();
287
+ while (Date.now() - start <= timeoutMs) {
288
+ const item = findDetailItem(await cli.detailBatch([messageId]), messageId);
289
+ const message = readMessageResult(item);
290
+ const artifact = extractImageArtifact(message);
291
+ if (artifact) {
292
+ return artifact;
293
+ }
294
+ const status = normalizeStatus(message.content?.status ?? message.status);
295
+ const error = message.content?.error ?? message.error ?? item.error;
296
+ if (error || isFailedStatus(status)) {
297
+ throw new Error(`Mivo GPT image generation failed for ${messageId}: ${error ?? status}`);
298
+ }
299
+ if (isCompletedStatus(status)) {
300
+ throw new Error(`Mivo GPT image generation completed for ${messageId} but returned no image`);
301
+ }
302
+ await sleep(pollIntervalMs);
303
+ }
304
+ throw new Error(`Mivo GPT image generation timed out for ${messageId} after ${timeoutMs}ms`);
305
+ }
306
+ async function materializeArtifact(cli, artifact, projectRoot, messageId, downloadRemoteUrl) {
307
+ const outputDir = path2.join(projectRoot, ".canvas-mcp", "mivo");
308
+ await mkdir2(outputDir, { recursive: true });
309
+ const imagePath = path2.join(outputDir, `${safeFileName(messageId)}.png`);
310
+ if (artifact.fileId) {
311
+ await cli.downloadFile(artifact.fileId, imagePath);
312
+ return imagePath;
313
+ }
314
+ if (artifact.url) {
315
+ await downloadRemoteUrl(artifact.url, imagePath);
316
+ return imagePath;
317
+ }
318
+ throw new Error(`Mivo GPT image generation returned an image artifact without file id or URL for ${messageId}`);
319
+ }
320
+ async function markNodeFailed(ctx, projectRoot, nodeId, error, messageId) {
321
+ try {
322
+ await ctx.tools.call("canvas.update_nodes", {
323
+ projectRoot,
324
+ updates: [
325
+ {
326
+ id: nodeId,
327
+ name: "Failed: GPT image",
328
+ options: {
329
+ mivo: {
330
+ status: "failed",
331
+ messageId,
332
+ error: errorMessage(error)
333
+ }
334
+ }
335
+ }
336
+ ]
337
+ });
338
+ } catch {
339
+ }
340
+ }
341
+ function findDetailItem(items, messageId) {
342
+ const item = items.find((entry) => entry.message_id === messageId) ?? items[0];
343
+ if (!item) {
344
+ throw new Error(`Mivo detail-batch returned no result for ${messageId}`);
345
+ }
346
+ if (item.ok === false || item.error) {
347
+ throw new Error(`Mivo detail-batch failed for ${messageId}: ${item.error ?? "unknown error"}`);
348
+ }
349
+ return item;
350
+ }
351
+ function readMessageResult(item) {
352
+ if (!item.message || typeof item.message !== "object") {
353
+ throw new Error("Mivo detail-batch result did not include a message object");
354
+ }
355
+ return item.message;
356
+ }
357
+ function normalizeStatus(status) {
358
+ return typeof status === "string" ? status.toLowerCase() : "";
359
+ }
360
+ function isFailedStatus(status) {
361
+ return ["failed", "failure", "error", "errored", "canceled", "cancelled"].includes(status);
362
+ }
363
+ function isCompletedStatus(status) {
364
+ return ["completed", "complete", "success", "succeeded"].includes(status);
365
+ }
366
+ function readCreatedNodeId(result) {
367
+ const nodeId = result.nodes[0]?.id;
368
+ if (typeof nodeId !== "string" || nodeId.trim() === "") {
369
+ throw new Error("canvas.create_nodes did not return a created node id");
370
+ }
371
+ return nodeId;
372
+ }
373
+ function readInput(input) {
374
+ const record = asRecord2(input);
375
+ const projectRoot = readString(record, "projectRoot");
376
+ const prompt = readString(record, "prompt");
377
+ const ratio = readOptionalRatio(record.ratio);
378
+ const referenceFileIds = readOptionalStringArray(record.referenceFileIds, "referenceFileIds");
379
+ const timeoutMs = readOptionalPositiveNumber(record.timeoutMs, "timeoutMs") ?? DEFAULT_TIMEOUT_MS;
380
+ const pollIntervalMs = readOptionalPositiveNumber(record.pollIntervalMs, "pollIntervalMs") ?? DEFAULT_POLL_INTERVAL_MS;
381
+ return {
382
+ projectRoot,
383
+ prompt,
384
+ ratio,
385
+ name: readOptionalString(record.name, "name"),
386
+ referenceFileIds,
387
+ timeoutMs,
388
+ pollIntervalMs
389
+ };
390
+ }
391
+ function readOptionalRatio(value) {
392
+ if (value === void 0) return DEFAULT_RATIO;
393
+ if (typeof value !== "string" || !RATIOS.includes(value)) {
394
+ throw new Error(`ratio must be one of ${RATIOS.join(", ")}`);
395
+ }
396
+ return value;
397
+ }
398
+ function readOptionalStringArray(value, label) {
399
+ if (value === void 0) return void 0;
400
+ if (!Array.isArray(value)) {
401
+ throw new Error(`${label} must be an array`);
402
+ }
403
+ return value.map((entry, index) => {
404
+ if (typeof entry !== "string" || entry.trim() === "") {
405
+ throw new Error(`${label}[${index}] must be a non-empty string`);
406
+ }
407
+ return entry;
408
+ });
409
+ }
410
+ function readOptionalPositiveNumber(value, label) {
411
+ if (value === void 0) return void 0;
412
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
413
+ throw new Error(`${label} must be a positive finite number`);
414
+ }
415
+ return value;
416
+ }
417
+ function readOptionalString(value, label) {
418
+ if (value === void 0) return void 0;
419
+ if (typeof value !== "string" || value.trim() === "") {
420
+ throw new Error(`${label} must be a non-empty string`);
421
+ }
422
+ return value;
423
+ }
424
+ function readString(record, key) {
425
+ const value = record[key];
426
+ if (typeof value !== "string" || value.trim() === "") {
427
+ throw new Error(`${key} must be a non-empty string`);
428
+ }
429
+ return value;
430
+ }
431
+ function defaultNodeName(prompt) {
432
+ const normalized = prompt.replace(/\s+/g, " ").trim();
433
+ const suffix = normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
434
+ return `GPT image: ${suffix}`;
435
+ }
436
+ function safeFileName(value) {
437
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_") || `mivo-${Date.now()}`;
438
+ }
439
+ function objectSchema(properties, required) {
440
+ return {
441
+ type: "object",
442
+ additionalProperties: false,
443
+ required,
444
+ properties
445
+ };
446
+ }
447
+ function stringSchema(description) {
448
+ return { type: "string", description };
449
+ }
450
+ function asRecord2(value) {
451
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
452
+ throw new Error("input must be an object");
453
+ }
454
+ return value;
455
+ }
456
+ function errorMessage(error) {
457
+ return error instanceof Error ? error.message : String(error);
458
+ }
459
+ export {
460
+ createMivoCreateGptImageTool,
461
+ mivoPlugin,
462
+ plugin
463
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "pixi-board-plugin-mivo",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src",
15
+ "package.json"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsup src/index.ts --format esm --platform node --target node20 --out-dir dist --clean",
19
+ "prepack": "pnpm build",
20
+ "test": "tsx --test src/**/*.test.ts"
21
+ },
22
+ "devDependencies": {
23
+ "@canvas/board-plugin-sdk": "workspace:*",
24
+ "@canvas/board-tool-runtime": "workspace:*",
25
+ "@types/node": "^24.10.1",
26
+ "tsup": "^8.5.0",
27
+ "tsx": "^4.21.0",
28
+ "typescript": "^5.9.3"
29
+ }
30
+ }
@@ -0,0 +1,233 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createBoardToolRuntime } from "@canvas/board-tool-runtime";
4
+ import type { BoardPlugin } from "@canvas/board-plugin-sdk";
5
+ import { createMivoCreateGptImageTool, mivoPlugin } from "./index";
6
+ import type { MivoCliClient, MivoMessageDetailBatchItem } from "./mivoCli";
7
+
8
+ test("mivo plugin requires canvas tools before registration", async () => {
9
+ const runtime = createBoardToolRuntime();
10
+
11
+ await assert.rejects(
12
+ () => runtime.registerPlugin(mivoPlugin),
13
+ /requires missing tool/,
14
+ );
15
+ });
16
+
17
+ test("mivo plugin registers after canvas plugin", async () => {
18
+ const runtime = createBoardToolRuntime();
19
+ await runtime.registerPlugin(fakeCanvasPlugin());
20
+ await runtime.registerPlugin(mivoPlugin);
21
+
22
+ assert.ok(runtime.listTools().some((tool) => tool.name === "mivo.create_gpt_image"));
23
+ });
24
+
25
+ test("mivo.create_gpt_image creates placeholder, submits no-wait message, polls, downloads, and installs", async () => {
26
+ const calls: Array<{ name: string; input: unknown }> = [];
27
+ const cli = new MockMivoCli([
28
+ {
29
+ message_id: "message-1",
30
+ ok: true,
31
+ message: {
32
+ status: "processing",
33
+ content: {
34
+ status: "processing",
35
+ images: [],
36
+ },
37
+ },
38
+ },
39
+ {
40
+ message_id: "message-1",
41
+ ok: true,
42
+ message: {
43
+ status: "completed",
44
+ content: {
45
+ status: "completed",
46
+ images: [
47
+ {
48
+ fileId: "file-1",
49
+ },
50
+ ],
51
+ },
52
+ },
53
+ },
54
+ ]);
55
+ const tool = createMivoCreateGptImageTool({
56
+ cli,
57
+ sleep: async () => {},
58
+ });
59
+
60
+ const result = await tool.run({
61
+ projectRoot: "active",
62
+ prompt: "a small red house",
63
+ ratio: "16:9",
64
+ referenceFileIds: ["ref-1"],
65
+ }, {
66
+ tools: {
67
+ async call(name, input) {
68
+ calls.push({ name, input });
69
+ if (name === "canvas.read_project_info") {
70
+ return { projectRoot: "/tmp/canvas-project" };
71
+ }
72
+ if (name === "canvas.create_nodes") {
73
+ return { nodes: [{ id: "node-1" }], assets: [] };
74
+ }
75
+ if (name === "canvas.generating_node_install") {
76
+ return { nodes: [{ id: "node-1", type: "image" }], assets: [{ id: "asset-1" }] };
77
+ }
78
+ throw new Error(`Unexpected tool call: ${name}`);
79
+ },
80
+ },
81
+ });
82
+
83
+ assert.deepEqual(calls.map((call) => call.name), [
84
+ "canvas.read_project_info",
85
+ "canvas.create_nodes",
86
+ "canvas.generating_node_install",
87
+ ]);
88
+ assert.equal(cli.submittedPayload?.chatSessionId, "chat-1");
89
+ assert.equal(cli.submittedPayload?.modelType, "GPT");
90
+ assert.equal(cli.submittedPayload?.action, "modal");
91
+ assert.deepEqual(cli.submittedPayload?.modelFormat, { version: "gpt-image-2" });
92
+ assert.deepEqual(cli.submittedPayload?.payload, {
93
+ prompt: "a small red house",
94
+ images: ["ref-1"],
95
+ n: 1,
96
+ imgRatio: "16:9",
97
+ quality: "auto",
98
+ credentialSource: "personal",
99
+ });
100
+ assert.deepEqual(cli.downloads, [
101
+ {
102
+ fileId: "file-1",
103
+ savePath: "/tmp/canvas-project/.canvas-mcp/mivo/message-1.png",
104
+ },
105
+ ]);
106
+ assert.deepEqual(result, {
107
+ nodeId: "node-1",
108
+ messageId: "message-1",
109
+ imagePath: "/tmp/canvas-project/.canvas-mcp/mivo/message-1.png",
110
+ imageUrl: "https://aigc.xindong.com/api/v1/file/image/file-1",
111
+ nodes: [{ id: "node-1", type: "image" }],
112
+ assets: [{ id: "asset-1" }],
113
+ });
114
+ });
115
+
116
+ test("mivo.create_gpt_image marks placeholder failed when generation fails", async () => {
117
+ const calls: Array<{ name: string; input: unknown }> = [];
118
+ const cli = new MockMivoCli([
119
+ {
120
+ message_id: "message-1",
121
+ ok: true,
122
+ message: {
123
+ status: "failed",
124
+ error: "bad prompt",
125
+ content: {
126
+ status: "failed",
127
+ },
128
+ },
129
+ },
130
+ ]);
131
+ const tool = createMivoCreateGptImageTool({
132
+ cli,
133
+ sleep: async () => {},
134
+ });
135
+
136
+ await assert.rejects(
137
+ () =>
138
+ tool.run({
139
+ projectRoot: "/tmp/canvas-project",
140
+ prompt: "bad prompt",
141
+ }, {
142
+ tools: {
143
+ async call(name, input) {
144
+ calls.push({ name, input });
145
+ if (name === "canvas.read_project_info") {
146
+ return { projectRoot: "/tmp/canvas-project" };
147
+ }
148
+ if (name === "canvas.create_nodes") {
149
+ return { nodes: [{ id: "node-1" }], assets: [] };
150
+ }
151
+ if (name === "canvas.update_nodes") {
152
+ return { nodes: [{ id: "node-1" }] };
153
+ }
154
+ throw new Error(`Unexpected tool call: ${name}`);
155
+ },
156
+ },
157
+ }),
158
+ /bad prompt/,
159
+ );
160
+
161
+ const updateCall = calls.find((call) => call.name === "canvas.update_nodes");
162
+ assert.ok(updateCall);
163
+ assert.deepEqual(updateCall.input, {
164
+ projectRoot: "/tmp/canvas-project",
165
+ updates: [
166
+ {
167
+ id: "node-1",
168
+ name: "Failed: GPT image",
169
+ options: {
170
+ mivo: {
171
+ status: "failed",
172
+ messageId: "message-1",
173
+ error: "Mivo GPT image generation failed for message-1: bad prompt",
174
+ },
175
+ },
176
+ },
177
+ ],
178
+ });
179
+ });
180
+
181
+ class MockMivoCli implements MivoCliClient {
182
+ submittedPayload: Record<string, unknown> | null = null;
183
+ readonly downloads: Array<{ fileId: string; savePath: string }> = [];
184
+ private detailIndex = 0;
185
+
186
+ constructor(private readonly details: MivoMessageDetailBatchItem[]) {}
187
+
188
+ async ensureReady(): Promise<void> {}
189
+
190
+ async getChatSessionId(): Promise<string> {
191
+ return "chat-1";
192
+ }
193
+
194
+ async submitMessage(payload: Record<string, unknown>): Promise<string> {
195
+ this.submittedPayload = payload;
196
+ return "message-1";
197
+ }
198
+
199
+ async detailBatch(): Promise<MivoMessageDetailBatchItem[]> {
200
+ const detail = this.details[Math.min(this.detailIndex, this.details.length - 1)];
201
+ this.detailIndex += 1;
202
+ return [detail];
203
+ }
204
+
205
+ async downloadFile(fileId: string, savePath: string): Promise<void> {
206
+ this.downloads.push({ fileId, savePath });
207
+ }
208
+ }
209
+
210
+ function fakeCanvasPlugin(): BoardPlugin {
211
+ return {
212
+ name: "@test/canvas",
213
+ version: "0.1.0",
214
+ register(api) {
215
+ for (const name of [
216
+ "canvas.create_nodes",
217
+ "canvas.generating_node_install",
218
+ "canvas.update_nodes",
219
+ "canvas.read_project_info",
220
+ ]) {
221
+ api.registerTool({
222
+ name,
223
+ description: name,
224
+ inputSchema: { type: "object", additionalProperties: true },
225
+ outputSchema: { type: "object", additionalProperties: true },
226
+ async run() {
227
+ return {};
228
+ },
229
+ });
230
+ }
231
+ },
232
+ };
233
+ }
package/src/index.ts ADDED
@@ -0,0 +1,418 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { BoardPlugin, BoardTool, BoardToolContext, JSONSchema } from "@canvas/board-plugin-sdk";
4
+ import {
5
+ createMivoCliClient,
6
+ downloadUrl,
7
+ extractImageArtifact,
8
+ mivoFileUrl,
9
+ type MivoCliClient,
10
+ type MivoImageArtifact,
11
+ type MivoMessageDetailBatchItem,
12
+ type MivoMessageResult,
13
+ } from "./mivoCli";
14
+
15
+ const RATIOS = ["1:1", "2:3", "3:2", "9:16", "16:9"] as const;
16
+ const DEFAULT_RATIO = "1:1";
17
+ const DEFAULT_TIMEOUT_MS = 300_000;
18
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
19
+
20
+ type Ratio = (typeof RATIOS)[number];
21
+
22
+ type CreateGptImageInput = {
23
+ projectRoot: string;
24
+ prompt: string;
25
+ ratio?: Ratio;
26
+ name?: string;
27
+ referenceFileIds?: string[];
28
+ timeoutMs?: number;
29
+ pollIntervalMs?: number;
30
+ };
31
+
32
+ type NodeDto = {
33
+ id: string;
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ type NodesAssetsResult = {
38
+ nodes: NodeDto[];
39
+ assets?: unknown[];
40
+ };
41
+
42
+ type ProjectInfoResult = {
43
+ projectRoot: string;
44
+ };
45
+
46
+ type CreateGptImageToolDeps = {
47
+ cli?: MivoCliClient;
48
+ sleep?: (ms: number) => Promise<void>;
49
+ downloadRemoteUrl?: (url: string, savePath: string) => Promise<void>;
50
+ };
51
+
52
+ const anyOutputSchema = {
53
+ type: "object",
54
+ additionalProperties: true,
55
+ };
56
+
57
+ export const mivoPlugin: BoardPlugin = {
58
+ name: "pixi-board-plugin-mivo",
59
+ version: "0.1.0",
60
+ dependencies: {
61
+ tools: [
62
+ "canvas.create_nodes",
63
+ "canvas.generating_node_install",
64
+ "canvas.update_nodes",
65
+ "canvas.read_project_info",
66
+ ],
67
+ },
68
+ register(api) {
69
+ api.registerTool(createMivoCreateGptImageTool());
70
+ },
71
+ };
72
+
73
+ export const plugin = mivoPlugin;
74
+
75
+ export function createMivoCreateGptImageTool(deps: CreateGptImageToolDeps = {}): BoardTool {
76
+ const cli = deps.cli ?? createMivoCliClient();
77
+ const sleep = deps.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
78
+ const downloadRemoteUrl = deps.downloadRemoteUrl ?? downloadUrl;
79
+
80
+ return {
81
+ name: "mivo.create_gpt_image",
82
+ description: "Create a generating canvas node, submit a GPT Image 2 Mivo task, poll until the image is ready, then replace the generating node with the downloaded media asset.",
83
+ kind: "plugin",
84
+ inputSchema: createGptImageInputSchema,
85
+ outputSchema: createGptImageOutputSchema,
86
+ async run(input, ctx) {
87
+ const args = readInput(input);
88
+ const projectInfo = await ctx.tools.call<CreateGptImageInput, ProjectInfoResult>("canvas.read_project_info", {
89
+ projectRoot: args.projectRoot,
90
+ });
91
+ const projectRoot = projectInfo.projectRoot;
92
+ let nodeId: string | null = null;
93
+ let messageId: string | null = null;
94
+
95
+ try {
96
+ const createResult = await ctx.tools.call<unknown, NodesAssetsResult>("canvas.create_nodes", {
97
+ projectRoot,
98
+ nodes: [
99
+ {
100
+ kind: "generating",
101
+ name: args.name ?? defaultNodeName(args.prompt),
102
+ options: {
103
+ mivo: {
104
+ status: "generating",
105
+ provider: "mivo",
106
+ tool: "mivo.create_gpt_image",
107
+ prompt: args.prompt,
108
+ ratio: args.ratio,
109
+ },
110
+ },
111
+ },
112
+ ],
113
+ });
114
+ nodeId = readCreatedNodeId(createResult);
115
+
116
+ const chatSessionId = await cli.getChatSessionId("freeform");
117
+ messageId = await cli.submitMessage(buildSubmitPayload(args, chatSessionId));
118
+ const artifact = await pollForImage(cli, messageId, args.timeoutMs, args.pollIntervalMs, sleep);
119
+ const imagePath = await materializeArtifact(cli, artifact, projectRoot, messageId, downloadRemoteUrl);
120
+ const installResult = await ctx.tools.call<unknown, NodesAssetsResult>("canvas.generating_node_install", {
121
+ projectRoot,
122
+ nodeId,
123
+ path: imagePath,
124
+ });
125
+
126
+ return {
127
+ nodeId,
128
+ messageId,
129
+ imagePath,
130
+ imageUrl: artifact.url ?? (artifact.fileId ? mivoFileUrl(artifact.fileId) : undefined),
131
+ nodes: installResult.nodes,
132
+ assets: installResult.assets ?? [],
133
+ };
134
+ } catch (error) {
135
+ if (nodeId) {
136
+ await markNodeFailed(ctx, projectRoot, nodeId, error, messageId);
137
+ }
138
+ throw error;
139
+ }
140
+ },
141
+ };
142
+ }
143
+
144
+ const createGptImageInputSchema = objectSchema({
145
+ projectRoot: stringSchema('Canvas project root absolute path, or "active" for the current canvas'),
146
+ prompt: stringSchema("Prompt for GPT Image 2"),
147
+ ratio: {
148
+ type: "string",
149
+ enum: RATIOS,
150
+ description: "Generated image aspect ratio",
151
+ },
152
+ name: stringSchema("Optional display name for the generating node"),
153
+ referenceFileIds: {
154
+ type: "array",
155
+ items: { type: "string" },
156
+ description: "Optional Mivo file ids to use as image references",
157
+ },
158
+ timeoutMs: {
159
+ type: "number",
160
+ description: "Maximum time to wait for the Mivo message result",
161
+ },
162
+ pollIntervalMs: {
163
+ type: "number",
164
+ description: "Delay between Mivo detail-batch polling attempts",
165
+ },
166
+ }, ["projectRoot", "prompt"]);
167
+
168
+ const createGptImageOutputSchema = objectSchema({
169
+ nodeId: { type: "string" },
170
+ messageId: { type: "string" },
171
+ imagePath: { type: "string" },
172
+ imageUrl: { type: "string" },
173
+ nodes: {
174
+ type: "array",
175
+ items: anyOutputSchema,
176
+ },
177
+ assets: {
178
+ type: "array",
179
+ items: anyOutputSchema,
180
+ },
181
+ }, ["nodeId", "messageId", "imagePath", "nodes", "assets"]);
182
+
183
+ function buildSubmitPayload(input: CreateGptImageInput, chatSessionId: string): Record<string, unknown> {
184
+ const images = input.referenceFileIds ?? [];
185
+ return {
186
+ chatSessionId,
187
+ title: "作图",
188
+ modelType: "GPT",
189
+ action: images.length > 0 ? "modal" : "imagine",
190
+ messageType: "image",
191
+ modelFormat: {
192
+ version: "gpt-image-2",
193
+ },
194
+ payload: {
195
+ prompt: input.prompt,
196
+ images,
197
+ n: 1,
198
+ imgRatio: input.ratio,
199
+ quality: "auto",
200
+ credentialSource: "personal",
201
+ },
202
+ };
203
+ }
204
+
205
+ async function pollForImage(
206
+ cli: MivoCliClient,
207
+ messageId: string,
208
+ timeoutMs: number,
209
+ pollIntervalMs: number,
210
+ sleep: (ms: number) => Promise<void>,
211
+ ): Promise<MivoImageArtifact> {
212
+ const start = Date.now();
213
+ while (Date.now() - start <= timeoutMs) {
214
+ const item = findDetailItem(await cli.detailBatch([messageId]), messageId);
215
+ const message = readMessageResult(item);
216
+ const artifact = extractImageArtifact(message);
217
+ if (artifact) {
218
+ return artifact;
219
+ }
220
+
221
+ const status = normalizeStatus(message.content?.status ?? message.status);
222
+ const error = message.content?.error ?? message.error ?? item.error;
223
+ if (error || isFailedStatus(status)) {
224
+ throw new Error(`Mivo GPT image generation failed for ${messageId}: ${error ?? status}`);
225
+ }
226
+ if (isCompletedStatus(status)) {
227
+ throw new Error(`Mivo GPT image generation completed for ${messageId} but returned no image`);
228
+ }
229
+ await sleep(pollIntervalMs);
230
+ }
231
+ throw new Error(`Mivo GPT image generation timed out for ${messageId} after ${timeoutMs}ms`);
232
+ }
233
+
234
+ async function materializeArtifact(
235
+ cli: MivoCliClient,
236
+ artifact: MivoImageArtifact,
237
+ projectRoot: string,
238
+ messageId: string,
239
+ downloadRemoteUrl: (url: string, savePath: string) => Promise<void>,
240
+ ): Promise<string> {
241
+ const outputDir = path.join(projectRoot, ".canvas-mcp", "mivo");
242
+ await mkdir(outputDir, { recursive: true });
243
+ const imagePath = path.join(outputDir, `${safeFileName(messageId)}.png`);
244
+ if (artifact.fileId) {
245
+ await cli.downloadFile(artifact.fileId, imagePath);
246
+ return imagePath;
247
+ }
248
+ if (artifact.url) {
249
+ await downloadRemoteUrl(artifact.url, imagePath);
250
+ return imagePath;
251
+ }
252
+ throw new Error(`Mivo GPT image generation returned an image artifact without file id or URL for ${messageId}`);
253
+ }
254
+
255
+ async function markNodeFailed(
256
+ ctx: BoardToolContext,
257
+ projectRoot: string,
258
+ nodeId: string,
259
+ error: unknown,
260
+ messageId: string | null,
261
+ ): Promise<void> {
262
+ try {
263
+ await ctx.tools.call("canvas.update_nodes", {
264
+ projectRoot,
265
+ updates: [
266
+ {
267
+ id: nodeId,
268
+ name: "Failed: GPT image",
269
+ options: {
270
+ mivo: {
271
+ status: "failed",
272
+ messageId,
273
+ error: errorMessage(error),
274
+ },
275
+ },
276
+ },
277
+ ],
278
+ });
279
+ } catch {
280
+ // Keep the original generation error as the caller-facing failure.
281
+ }
282
+ }
283
+
284
+ function findDetailItem(items: MivoMessageDetailBatchItem[], messageId: string): MivoMessageDetailBatchItem {
285
+ const item = items.find((entry) => entry.message_id === messageId) ?? items[0];
286
+ if (!item) {
287
+ throw new Error(`Mivo detail-batch returned no result for ${messageId}`);
288
+ }
289
+ if (item.ok === false || item.error) {
290
+ throw new Error(`Mivo detail-batch failed for ${messageId}: ${item.error ?? "unknown error"}`);
291
+ }
292
+ return item;
293
+ }
294
+
295
+ function readMessageResult(item: MivoMessageDetailBatchItem): MivoMessageResult {
296
+ if (!item.message || typeof item.message !== "object") {
297
+ throw new Error("Mivo detail-batch result did not include a message object");
298
+ }
299
+ return item.message;
300
+ }
301
+
302
+ function normalizeStatus(status: unknown): string {
303
+ return typeof status === "string" ? status.toLowerCase() : "";
304
+ }
305
+
306
+ function isFailedStatus(status: string): boolean {
307
+ return ["failed", "failure", "error", "errored", "canceled", "cancelled"].includes(status);
308
+ }
309
+
310
+ function isCompletedStatus(status: string): boolean {
311
+ return ["completed", "complete", "success", "succeeded"].includes(status);
312
+ }
313
+
314
+ function readCreatedNodeId(result: NodesAssetsResult): string {
315
+ const nodeId = result.nodes[0]?.id;
316
+ if (typeof nodeId !== "string" || nodeId.trim() === "") {
317
+ throw new Error("canvas.create_nodes did not return a created node id");
318
+ }
319
+ return nodeId;
320
+ }
321
+
322
+ function readInput(input: unknown): CreateGptImageInput {
323
+ const record = asRecord(input);
324
+ const projectRoot = readString(record, "projectRoot");
325
+ const prompt = readString(record, "prompt");
326
+ const ratio = readOptionalRatio(record.ratio);
327
+ const referenceFileIds = readOptionalStringArray(record.referenceFileIds, "referenceFileIds");
328
+ const timeoutMs = readOptionalPositiveNumber(record.timeoutMs, "timeoutMs") ?? DEFAULT_TIMEOUT_MS;
329
+ const pollIntervalMs = readOptionalPositiveNumber(record.pollIntervalMs, "pollIntervalMs") ?? DEFAULT_POLL_INTERVAL_MS;
330
+ return {
331
+ projectRoot,
332
+ prompt,
333
+ ratio,
334
+ name: readOptionalString(record.name, "name"),
335
+ referenceFileIds,
336
+ timeoutMs,
337
+ pollIntervalMs,
338
+ };
339
+ }
340
+
341
+ function readOptionalRatio(value: unknown): Ratio {
342
+ if (value === undefined) return DEFAULT_RATIO;
343
+ if (typeof value !== "string" || !RATIOS.includes(value as Ratio)) {
344
+ throw new Error(`ratio must be one of ${RATIOS.join(", ")}`);
345
+ }
346
+ return value as Ratio;
347
+ }
348
+
349
+ function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
350
+ if (value === undefined) return undefined;
351
+ if (!Array.isArray(value)) {
352
+ throw new Error(`${label} must be an array`);
353
+ }
354
+ return value.map((entry, index) => {
355
+ if (typeof entry !== "string" || entry.trim() === "") {
356
+ throw new Error(`${label}[${index}] must be a non-empty string`);
357
+ }
358
+ return entry;
359
+ });
360
+ }
361
+
362
+ function readOptionalPositiveNumber(value: unknown, label: string): number | undefined {
363
+ if (value === undefined) return undefined;
364
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
365
+ throw new Error(`${label} must be a positive finite number`);
366
+ }
367
+ return value;
368
+ }
369
+
370
+ function readOptionalString(value: unknown, label: string): string | undefined {
371
+ if (value === undefined) return undefined;
372
+ if (typeof value !== "string" || value.trim() === "") {
373
+ throw new Error(`${label} must be a non-empty string`);
374
+ }
375
+ return value;
376
+ }
377
+
378
+ function readString(record: Record<string, unknown>, key: string): string {
379
+ const value = record[key];
380
+ if (typeof value !== "string" || value.trim() === "") {
381
+ throw new Error(`${key} must be a non-empty string`);
382
+ }
383
+ return value;
384
+ }
385
+
386
+ function defaultNodeName(prompt: string): string {
387
+ const normalized = prompt.replace(/\s+/g, " ").trim();
388
+ const suffix = normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
389
+ return `GPT image: ${suffix}`;
390
+ }
391
+
392
+ function safeFileName(value: string): string {
393
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_") || `mivo-${Date.now()}`;
394
+ }
395
+
396
+ function objectSchema(properties: Record<string, unknown>, required: string[]): JSONSchema {
397
+ return {
398
+ type: "object",
399
+ additionalProperties: false,
400
+ required,
401
+ properties,
402
+ };
403
+ }
404
+
405
+ function stringSchema(description: string): JSONSchema {
406
+ return { type: "string", description };
407
+ }
408
+
409
+ function asRecord(value: unknown): Record<string, unknown> {
410
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
411
+ throw new Error("input must be an object");
412
+ }
413
+ return value as Record<string, unknown>;
414
+ }
415
+
416
+ function errorMessage(error: unknown): string {
417
+ return error instanceof Error ? error.message : String(error);
418
+ }
package/src/mivoCli.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ const DEFAULT_API_BASE = "https://aigc.xindong.com/api/v1";
10
+ const URL_KEYS = ["signUrl", "sign_url", "url", "uri", "displayUrl", "fileUrl", "downloadUrl", "src", "image"];
11
+ const ID_KEYS = ["fileId", "file_id", "object_id", "_id"];
12
+
13
+ export type MivoCliClient = {
14
+ ensureReady(): Promise<void>;
15
+ getChatSessionId(chatType: string): Promise<string>;
16
+ submitMessage(payload: Record<string, unknown>): Promise<string>;
17
+ detailBatch(messageIds: string[]): Promise<MivoMessageDetailBatchItem[]>;
18
+ downloadFile(fileId: string, savePath: string): Promise<void>;
19
+ };
20
+
21
+ export type MivoMessageDetailBatchItem = {
22
+ message_id?: string;
23
+ ok?: boolean;
24
+ error?: string | null;
25
+ message?: MivoMessageResult;
26
+ };
27
+
28
+ export type MivoMessageResult = {
29
+ status?: string;
30
+ error?: string | null;
31
+ content?: {
32
+ status?: string;
33
+ error?: string | null;
34
+ images?: unknown[];
35
+ };
36
+ };
37
+
38
+ export type MivoImageArtifact = {
39
+ fileId?: string;
40
+ url?: string;
41
+ };
42
+
43
+ export function createMivoCliClient(env: NodeJS.ProcessEnv = process.env): MivoCliClient {
44
+ const command = env.MIVO_CLI_PATH?.trim() || "mivo-cli";
45
+ let ready = false;
46
+
47
+ async function run(args: string[], options?: { input?: string }): Promise<unknown> {
48
+ const { stdout } = await execFileAsync(command, args, {
49
+ env: {
50
+ ...process.env,
51
+ ...env,
52
+ },
53
+ input: options?.input,
54
+ maxBuffer: 10 * 1024 * 1024,
55
+ });
56
+ return parseJson(stdout);
57
+ }
58
+
59
+ return {
60
+ async ensureReady() {
61
+ if (ready) return;
62
+ const apiKey = env.MIVO_API_KEY?.trim();
63
+ if (apiKey) {
64
+ await run(["config", "set", "api_base", env.MIVO_API_BASE?.trim() || DEFAULT_API_BASE, "--json"]);
65
+ await run(["login", "--key", apiKey, "--json"]);
66
+ }
67
+ ready = true;
68
+ },
69
+ async getChatSessionId(chatType) {
70
+ await this.ensureReady();
71
+ const value = await run(["message", "chat", "--type", chatType, "--json"]);
72
+ const record = asRecord(value, "message chat output");
73
+ const chatSessionId = readStringFromAny(record, ["chatSessionId", "chat_session_id", "id"]);
74
+ if (!chatSessionId) {
75
+ throw new Error("mivo-cli message chat did not return a chat session id");
76
+ }
77
+ return chatSessionId;
78
+ },
79
+ async submitMessage(payload) {
80
+ await this.ensureReady();
81
+ const dir = await mkdtemp(path.join(os.tmpdir(), "pixi-board-mivo-"));
82
+ const payloadPath = path.join(dir, `message-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
83
+ await writeFile(payloadPath, JSON.stringify(payload), "utf8");
84
+ const value = await run(["message", "submit", "--file", payloadPath, "--no-wait", "--json"]);
85
+ const record = asRecord(value, "message submit output");
86
+ const messageId = readStringFromAny(record, ["messageId", "message_id", "id"]);
87
+ if (!messageId) {
88
+ throw new Error("mivo-cli message submit did not return a message id");
89
+ }
90
+ return messageId;
91
+ },
92
+ async detailBatch(messageIds) {
93
+ await this.ensureReady();
94
+ const value = await run(["message", "detail-batch", ...messageIds, "--json"]);
95
+ if (Array.isArray(value)) {
96
+ return value as MivoMessageDetailBatchItem[];
97
+ }
98
+ const record = asRecord(value, "message detail-batch output");
99
+ if (Array.isArray(record.messages)) {
100
+ return record.messages as MivoMessageDetailBatchItem[];
101
+ }
102
+ if (Array.isArray(record.results)) {
103
+ return record.results as MivoMessageDetailBatchItem[];
104
+ }
105
+ if (Array.isArray(record.data)) {
106
+ return record.data as MivoMessageDetailBatchItem[];
107
+ }
108
+ throw new Error("mivo-cli message detail-batch returned an unsupported JSON shape");
109
+ },
110
+ async downloadFile(fileId, savePath) {
111
+ await this.ensureReady();
112
+ await mkdir(path.dirname(savePath), { recursive: true });
113
+ await run(["file", "download", fileId, savePath, "--json"]);
114
+ },
115
+ };
116
+ }
117
+
118
+ export function extractImageArtifact(message: MivoMessageResult): MivoImageArtifact | null {
119
+ const image = message.content?.images?.[0];
120
+ if (image === undefined || image === null) return null;
121
+ if (typeof image === "string") {
122
+ if (/^https?:\/\//i.test(image)) return { url: image };
123
+ return { fileId: image };
124
+ }
125
+ if (!isRecord(image)) return null;
126
+ const url = readStringFromAny(image, URL_KEYS);
127
+ const fileId = readStringFromAny(image, ID_KEYS);
128
+ if (!url && !fileId) return null;
129
+ return { fileId, url };
130
+ }
131
+
132
+ export function mivoFileUrl(fileId: string): string {
133
+ return `${DEFAULT_API_BASE}/file/image/${encodeURIComponent(fileId)}`;
134
+ }
135
+
136
+ export async function downloadUrl(url: string, savePath: string): Promise<void> {
137
+ await mkdir(path.dirname(savePath), { recursive: true });
138
+ const response = await fetch(url);
139
+ if (!response.ok) {
140
+ throw new Error(`Failed to download Mivo image URL: ${response.status} ${response.statusText}`);
141
+ }
142
+ const buffer = Buffer.from(await response.arrayBuffer());
143
+ await writeFile(savePath, buffer);
144
+ }
145
+
146
+ function parseJson(stdout: string): unknown {
147
+ try {
148
+ return JSON.parse(stdout);
149
+ } catch (error) {
150
+ throw new Error(`mivo-cli returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
151
+ }
152
+ }
153
+
154
+ function readStringFromAny(record: Record<string, unknown>, keys: string[]): string | undefined {
155
+ for (const key of keys) {
156
+ const value = record[key];
157
+ if (typeof value === "string" && value.trim() !== "") {
158
+ return value;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ function asRecord(value: unknown, label: string): Record<string, unknown> {
165
+ if (!isRecord(value)) {
166
+ throw new Error(`${label} must be an object`);
167
+ }
168
+ return value;
169
+ }
170
+
171
+ function isRecord(value: unknown): value is Record<string, unknown> {
172
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
173
+ }