@playcraft/cli 0.1.85-beta → 0.1.87
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/commands/tools-generation.js +102 -46
- package/dist/commands/tools-request.js +24 -0
- package/dist/commands/tools-utils.js +15 -17
- package/dist/commands/tools.js +2 -0
- package/dist/utils/agent-api-client.js +57 -18
- package/dist/utils/image-asset-context.js +92 -42
- package/dist/utils/tool-operation-journal.js +3 -3
- package/dist/workspace-runtime/codex/config-toml.js +3 -2
- package/dist/workspace-runtime/codex/model-broker-diagnostics.js +149 -0
- package/dist/workspace-runtime/codex/model-broker-request.js +59 -0
- package/dist/workspace-runtime/codex/model-broker.js +221 -84
- package/dist/workspace-runtime/codex/model-capture.js +59 -10
- package/package.json +3 -3
|
@@ -3,8 +3,8 @@ import { writeFileSync, mkdirSync } from 'fs';
|
|
|
3
3
|
import { dirname, parse, join } from 'path';
|
|
4
4
|
import sharp from 'sharp';
|
|
5
5
|
import { captureGeneratedArtifact } from '../workspace-runtime/workspaces/artifact-receipts.js';
|
|
6
|
-
import { resolveImageBackgroundRequirement, validateImageDeliveryOptions } from '@playcraft/common/prefab';
|
|
7
|
-
import { assertImageAssetOutputPaths, readImageAssetContext } from '../utils/image-asset-context.js';
|
|
6
|
+
import { resolveImageBackgroundRequirement, validateImageDeliveryOptions, } from '@playcraft/common/prefab';
|
|
7
|
+
import { assertImageAssetOutputPaths, readImageAssetContext, } from '../utils/image-asset-context.js';
|
|
8
8
|
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
9
9
|
import { isUnsafeImageRetryError } from '../utils/image-generation-retry.js';
|
|
10
10
|
import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, parseImageTargetFieldPath, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, raceWithTimeout, } from './tools-utils.js';
|
|
@@ -73,7 +73,8 @@ async function verifyMasterCompositeStripSize(imgBuf, target) {
|
|
|
73
73
|
}
|
|
74
74
|
export function registerGenerationCommands(tools) {
|
|
75
75
|
// ─── Image Models ────────────────────────────────────────────
|
|
76
|
-
tools
|
|
76
|
+
tools
|
|
77
|
+
.command('list-image-models')
|
|
77
78
|
.description('列出后端已配置的可用生图模型,按模型名分组展示所有 provider(多个 provider 时按优先级自动 fallback)')
|
|
78
79
|
.option('--json', '以 JSON 格式输出(默认为人类可读表格)')
|
|
79
80
|
.action(async (opts) => {
|
|
@@ -150,7 +151,8 @@ export function registerGenerationCommands(tools) {
|
|
|
150
151
|
}
|
|
151
152
|
});
|
|
152
153
|
// ─── Generation ─────────────────────────────────────────────
|
|
153
|
-
tools
|
|
154
|
+
tools
|
|
155
|
+
.command('generate-image')
|
|
154
156
|
.description('AI 生成图片(支持多张参考图图生图;gpt-image-2 支持 --count N 批量出图)')
|
|
155
157
|
.requiredOption('--prompt <text>', '图片描述')
|
|
156
158
|
.option('--aspect-ratio <ratio>', '宽高比 (1:1|45:16|16:9|9:16|3:4|4:3|2:3|3:2|21:9)', '1:1')
|
|
@@ -174,8 +176,12 @@ export function registerGenerationCommands(tools) {
|
|
|
174
176
|
.option('--count <n>', '一次 API 调用生成多少张图片(默认 1,最多 10;仅 gpt-image-2 实际支持批量)', (v) => parseInt(v, 10), 1)
|
|
175
177
|
.action(async (opts) => {
|
|
176
178
|
try {
|
|
177
|
-
const explicitBackground = opts.background === undefined
|
|
178
|
-
|
|
179
|
+
const explicitBackground = opts.background === undefined
|
|
180
|
+
? opts.removeBackground
|
|
181
|
+
: opts.background === 'transparent';
|
|
182
|
+
if (opts.background !== undefined &&
|
|
183
|
+
opts.removeBackground !== undefined &&
|
|
184
|
+
opts.removeBackground !== explicitBackground) {
|
|
179
185
|
throw new Error('Conflicting background options. Use --background transparent or --background opaque; no image request was sent.');
|
|
180
186
|
}
|
|
181
187
|
opts = { ...opts, removeBackground: explicitBackground };
|
|
@@ -184,28 +190,46 @@ export function registerGenerationCommands(tools) {
|
|
|
184
190
|
}
|
|
185
191
|
const explicitOutputFormat = opts.outputFormat;
|
|
186
192
|
const { dir: outputDir, name: outputName, ext: outputExt } = parse(opts.output);
|
|
187
|
-
const outputCandidates = [
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
const outputCandidates = [
|
|
194
|
+
opts.output,
|
|
195
|
+
...Array.from({ length: opts.count > 1 ? Math.min(opts.count, 10) : 0 }, (_, i) => join(outputDir, `${outputName}_${i + 1}${outputExt}`)),
|
|
196
|
+
];
|
|
197
|
+
const assetContext = opts.targetAsset
|
|
198
|
+
? await readImageAssetContext(opts.targetAsset, process.cwd(), {
|
|
199
|
+
referenceImagePaths: opts.referenceImage,
|
|
200
|
+
useTargetReference: opts.targetReference,
|
|
201
|
+
...(opts.targetFieldPath ? { targetFieldPath: opts.targetFieldPath } : {}),
|
|
202
|
+
outputPaths: outputCandidates,
|
|
203
|
+
prompt: opts.prompt,
|
|
204
|
+
removeBackground: opts.removeBackground,
|
|
205
|
+
})
|
|
206
|
+
: undefined;
|
|
207
|
+
opts = {
|
|
208
|
+
...opts,
|
|
195
209
|
aspectRatio: assetContext?.aspectRatio ?? opts.aspectRatio,
|
|
196
|
-
width: assetContext?.width ?? opts.width,
|
|
197
|
-
|
|
210
|
+
width: assetContext?.width ?? opts.width,
|
|
211
|
+
height: assetContext?.height ?? opts.height,
|
|
212
|
+
outputFormat: assetContext?.outputFormatFromSchema
|
|
213
|
+
? assetContext.outputFormat
|
|
214
|
+
: (opts.outputFormat ?? assetContext?.outputFormat),
|
|
198
215
|
fitMode: assetContext?.fitMode ?? opts.fitMode,
|
|
199
|
-
removeBackground: resolveImageBackgroundRequirement({
|
|
200
|
-
|
|
201
|
-
|
|
216
|
+
removeBackground: resolveImageBackgroundRequirement({
|
|
217
|
+
prompt: opts.prompt,
|
|
218
|
+
schemaRemoveBackground: assetContext?.removeBackgroundFromSchema
|
|
219
|
+
? assetContext.removeBackground
|
|
220
|
+
: undefined,
|
|
221
|
+
removeBackground: opts.removeBackground,
|
|
222
|
+
fallbackRemoveBackground: assetContext?.removeBackground,
|
|
223
|
+
}),
|
|
202
224
|
paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
|
|
203
225
|
imageModel: opts.imageModel || assetContext?.imageModelRef,
|
|
204
226
|
};
|
|
205
227
|
const invalidDelivery = validateImageDeliveryOptions(opts);
|
|
206
228
|
if (invalidDelivery)
|
|
207
229
|
throw new Error(invalidDelivery);
|
|
208
|
-
const paths = opts.referenceImage?.length
|
|
230
|
+
const paths = opts.referenceImage?.length
|
|
231
|
+
? opts.referenceImage
|
|
232
|
+
: (assetContext?.referenceImagePaths ?? []);
|
|
209
233
|
if (paths.length > MAX_REFERENCE_IMAGES) {
|
|
210
234
|
console.error(`Error: Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
|
|
211
235
|
process.exit(1);
|
|
@@ -230,14 +254,26 @@ export function registerGenerationCommands(tools) {
|
|
|
230
254
|
width: assetContext?.width ?? opts.width,
|
|
231
255
|
height: assetContext?.height ?? opts.height,
|
|
232
256
|
outputFormat: opts.outputFormat,
|
|
233
|
-
outputFormatSource: assetContext?.outputFormatFromSchema
|
|
234
|
-
|
|
257
|
+
outputFormatSource: assetContext?.outputFormatFromSchema
|
|
258
|
+
? 'schema'
|
|
259
|
+
: explicitOutputFormat
|
|
260
|
+
? 'explicit'
|
|
261
|
+
: assetContext
|
|
262
|
+
? 'inferred'
|
|
263
|
+
: undefined,
|
|
235
264
|
fitMode: assetContext?.fitMode ?? opts.fitMode,
|
|
236
265
|
paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
|
|
237
|
-
background: opts.background === undefined
|
|
266
|
+
background: opts.background === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: opts.removeBackground
|
|
269
|
+
? 'transparent'
|
|
270
|
+
: 'opaque',
|
|
238
271
|
removeBackground: opts.removeBackground,
|
|
239
|
-
backgroundRequirementSource: assetContext?.removeBackgroundFromSchema
|
|
240
|
-
|
|
272
|
+
backgroundRequirementSource: assetContext?.removeBackgroundFromSchema
|
|
273
|
+
? 'schema'
|
|
274
|
+
: explicitBackground !== undefined
|
|
275
|
+
? 'explicit'
|
|
276
|
+
: 'inferred',
|
|
241
277
|
referenceImages,
|
|
242
278
|
...(modelRef ? { imageModelRef: modelRef } : {}),
|
|
243
279
|
...(imageCount > 1 ? { imageCount } : {}),
|
|
@@ -265,7 +301,8 @@ export function registerGenerationCommands(tools) {
|
|
|
265
301
|
lines.push(' → Try --image-size 1K to reduce generation time');
|
|
266
302
|
lines.push(' → Or increase timeout with --timeout <seconds> (current default: 1800s)');
|
|
267
303
|
}
|
|
268
|
-
else if (hasReferenceImages &&
|
|
304
|
+
else if (hasReferenceImages &&
|
|
305
|
+
(msg.includes('reference') || msg.includes('image input'))) {
|
|
269
306
|
lines.push('');
|
|
270
307
|
lines.push('Hint: Reference image error — the selected model may not support --reference-image.');
|
|
271
308
|
lines.push(' → Run: playcraft tools list-image-models');
|
|
@@ -313,7 +350,11 @@ export function registerGenerationCommands(tools) {
|
|
|
313
350
|
}
|
|
314
351
|
// 收集所有待保存图片(第 1 张 + 批量额外图)
|
|
315
352
|
const allImages = [
|
|
316
|
-
{
|
|
353
|
+
{
|
|
354
|
+
imageBase64: result.imageBase64,
|
|
355
|
+
mimeType: result.mimeType,
|
|
356
|
+
previewUrl: result.previewUrl,
|
|
357
|
+
},
|
|
317
358
|
...(result.additionalImages ?? []),
|
|
318
359
|
];
|
|
319
360
|
const isBatch = allImages.length > 1;
|
|
@@ -324,12 +365,14 @@ export function registerGenerationCommands(tools) {
|
|
|
324
365
|
const savedPaths = [];
|
|
325
366
|
if (result.transparencySource)
|
|
326
367
|
console.log(`Transparency source: ${result.transparencySource}`);
|
|
327
|
-
const stripTarget = opts.targetAsset || result.transparentBackground
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
368
|
+
const stripTarget = opts.targetAsset || result.transparentBackground
|
|
369
|
+
? null
|
|
370
|
+
: resolveStripTargetDimensions({
|
|
371
|
+
aspectRatio: opts.aspectRatio,
|
|
372
|
+
width: opts.width,
|
|
373
|
+
height: opts.height,
|
|
374
|
+
imageSize: opts.imageSize,
|
|
375
|
+
});
|
|
333
376
|
for (let i = 0; i < allImages.length; i++) {
|
|
334
377
|
const img = allImages[i];
|
|
335
378
|
let imgBuf = Buffer.from(img.imageBase64, 'base64');
|
|
@@ -347,8 +390,7 @@ export function registerGenerationCommands(tools) {
|
|
|
347
390
|
generatedHeight = metaBefore.height;
|
|
348
391
|
if (metaBefore.width != null &&
|
|
349
392
|
metaBefore.height != null &&
|
|
350
|
-
(metaBefore.width !== stripTarget.width ||
|
|
351
|
-
metaBefore.height !== stripTarget.height)) {
|
|
393
|
+
(metaBefore.width !== stripTarget.width || metaBefore.height !== stripTarget.height)) {
|
|
352
394
|
imgBuf = Buffer.from(await letterboxToTarget(imgBuf, stripTarget.width, stripTarget.height));
|
|
353
395
|
console.log(`↑ Letterbox ${generatedWidth}×${generatedHeight} → ${stripTarget.width}×${stripTarget.height}(等比 + 白边,不拉伸)`);
|
|
354
396
|
}
|
|
@@ -404,10 +446,11 @@ export function registerGenerationCommands(tools) {
|
|
|
404
446
|
referenceImageStatus: result.referenceImageStatus ?? null,
|
|
405
447
|
generatedAt: new Date().toISOString(),
|
|
406
448
|
};
|
|
407
|
-
const sidecarPath = resolvedPath.
|
|
449
|
+
const sidecarPath = `${resolvedPath}.generation.json`;
|
|
408
450
|
writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2));
|
|
409
451
|
await captureGeneratedArtifact(resolvedPath, 'generate-image', {
|
|
410
|
-
...(actualWidth ? { width: actualWidth } : {}),
|
|
452
|
+
...(actualWidth ? { width: actualWidth } : {}),
|
|
453
|
+
...(actualHeight ? { height: actualHeight } : {}),
|
|
411
454
|
});
|
|
412
455
|
console.log(` └─ Generation metadata: ${sidecarPath}`);
|
|
413
456
|
}
|
|
@@ -431,7 +474,8 @@ export function registerGenerationCommands(tools) {
|
|
|
431
474
|
handleError(e);
|
|
432
475
|
}
|
|
433
476
|
});
|
|
434
|
-
tools
|
|
477
|
+
tools
|
|
478
|
+
.command('generate-sfx')
|
|
435
479
|
.description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
|
|
436
480
|
.requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
|
|
437
481
|
.option('--duration <seconds>', '时长(秒,0.5-22;省略时由供应商自动决定)', parseFloat)
|
|
@@ -451,9 +495,14 @@ export function registerGenerationCommands(tools) {
|
|
|
451
495
|
const sizeKB = Math.round(buf.length / 1024);
|
|
452
496
|
console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
|
|
453
497
|
const sfxSidecar = {
|
|
454
|
-
type: 'sfx',
|
|
455
|
-
|
|
456
|
-
|
|
498
|
+
type: 'sfx',
|
|
499
|
+
prompt: opts.prompt,
|
|
500
|
+
duration: opts.duration ?? null,
|
|
501
|
+
loop: opts.loop ?? false,
|
|
502
|
+
provider: result.provider,
|
|
503
|
+
actualDuration: result.duration,
|
|
504
|
+
output: opts.output,
|
|
505
|
+
generatedAt: new Date().toISOString(),
|
|
457
506
|
};
|
|
458
507
|
writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(sfxSidecar, null, 2));
|
|
459
508
|
await captureGeneratedArtifact(opts.output, 'generate-sfx', { duration: result.duration });
|
|
@@ -463,7 +512,8 @@ export function registerGenerationCommands(tools) {
|
|
|
463
512
|
handleError(e);
|
|
464
513
|
}
|
|
465
514
|
});
|
|
466
|
-
tools
|
|
515
|
+
tools
|
|
516
|
+
.command('generate-bgm')
|
|
467
517
|
.description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
|
|
468
518
|
.requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
|
|
469
519
|
.option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
|
|
@@ -484,9 +534,14 @@ export function registerGenerationCommands(tools) {
|
|
|
484
534
|
const sizeKB = Math.round(buf.length / 1024);
|
|
485
535
|
console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
|
|
486
536
|
const bgmSidecar = {
|
|
487
|
-
type: 'bgm',
|
|
488
|
-
|
|
489
|
-
|
|
537
|
+
type: 'bgm',
|
|
538
|
+
prompt: opts.prompt,
|
|
539
|
+
style: opts.style ?? null,
|
|
540
|
+
bpm: opts.bpm ?? null,
|
|
541
|
+
provider: result.provider,
|
|
542
|
+
actualDuration: result.duration,
|
|
543
|
+
output: opts.output,
|
|
544
|
+
generatedAt: new Date().toISOString(),
|
|
490
545
|
};
|
|
491
546
|
writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(bgmSidecar, null, 2));
|
|
492
547
|
await captureGeneratedArtifact(opts.output, 'generate-bgm', { duration: result.duration });
|
|
@@ -517,7 +572,8 @@ export function registerGenerationCommands(tools) {
|
|
|
517
572
|
}
|
|
518
573
|
});
|
|
519
574
|
// ─── Search Image ──────────────────────────────────────────
|
|
520
|
-
tools
|
|
575
|
+
tools
|
|
576
|
+
.command('search-image')
|
|
521
577
|
.description('搜索参考图片(Unsplash / Pexels),结果 URL 可直接用于 --reference-image')
|
|
522
578
|
.requiredOption('--query <query>', '搜索关键词(建议英文)')
|
|
523
579
|
.option('--source <source>', '图源:unsplash | pexels | auto(默认 auto)', 'auto')
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
4
|
+
export async function toolRequestCommand(operation, serializedBody) {
|
|
5
|
+
if (!['generate-image', 'generate-bgm', 'generate-sfx'].includes(operation))
|
|
6
|
+
throw new Error('Unsupported generation operation');
|
|
7
|
+
const body = JSON.parse(serializedBody);
|
|
8
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
9
|
+
throw new Error('Generation request must be a JSON object');
|
|
10
|
+
return new AgentApiClient({
|
|
11
|
+
configFile: process.env.PLAYCRAFT_SANDBOX_CONFIG_PATH || join(process.cwd(), '.playcraft.json'),
|
|
12
|
+
operationNamespace: 'opencode',
|
|
13
|
+
}).post(`/${operation}`, body);
|
|
14
|
+
}
|
|
15
|
+
/** Static sandbox plugins cannot import command-line modules; use this client for transport. */
|
|
16
|
+
export function registerToolRequestCommand(tools) {
|
|
17
|
+
tools
|
|
18
|
+
.command('request <operation>', { hidden: true })
|
|
19
|
+
.description('Send a generation request from JSON on stdin and print its durable result as JSON')
|
|
20
|
+
.action(async (operation) => {
|
|
21
|
+
const result = await toolRequestCommand(operation, readFileSync(0, 'utf8'));
|
|
22
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
|
|
2
|
-
import { join, parse } from 'path';
|
|
2
|
+
import { join, parse, resolve } from 'path';
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { InvalidArgumentError } from 'commander';
|
|
5
5
|
export const TMP_DIR = join(tmpdir(), 'playcraft');
|
|
@@ -9,8 +9,12 @@ export function parseImageTargetFieldPath(value) {
|
|
|
9
9
|
try {
|
|
10
10
|
parsed = JSON.parse(value);
|
|
11
11
|
}
|
|
12
|
-
catch {
|
|
13
|
-
|
|
12
|
+
catch {
|
|
13
|
+
/* Report the expected shape below. */
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(parsed) ||
|
|
16
|
+
!parsed.length ||
|
|
17
|
+
parsed.some((key) => typeof key !== 'string' || !key.length)) {
|
|
14
18
|
throw new InvalidArgumentError('Expected an array of field names, e.g. ["endUI","playButton"].');
|
|
15
19
|
}
|
|
16
20
|
return parsed;
|
|
@@ -74,11 +78,7 @@ export function extensionForImageMime(mimeType) {
|
|
|
74
78
|
* Detect container from decoded bytes (magic). Prefer this over MIME alone.
|
|
75
79
|
*/
|
|
76
80
|
export function sniffImageExtension(buf) {
|
|
77
|
-
if (buf.length >= 8 &&
|
|
78
|
-
buf[0] === 0x89 &&
|
|
79
|
-
buf[1] === 0x50 &&
|
|
80
|
-
buf[2] === 0x4e &&
|
|
81
|
-
buf[3] === 0x47) {
|
|
81
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
82
82
|
return '.png';
|
|
83
83
|
}
|
|
84
84
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
@@ -133,12 +133,14 @@ export function mimeTypeForImagePath(filePath) {
|
|
|
133
133
|
return 'image/png';
|
|
134
134
|
if (ext === 'webp')
|
|
135
135
|
return 'image/webp';
|
|
136
|
+
if (ext === 'gif')
|
|
137
|
+
return 'image/gif';
|
|
136
138
|
if (ext === 'jpg' || ext === 'jpeg')
|
|
137
139
|
return 'image/jpeg';
|
|
138
140
|
if (ext === undefined || ext === '') {
|
|
139
141
|
throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
|
|
140
142
|
}
|
|
141
|
-
throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and
|
|
143
|
+
throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, jpeg, and gif are allowed.`);
|
|
142
144
|
}
|
|
143
145
|
async function downloadImageFromUrl(url) {
|
|
144
146
|
let res;
|
|
@@ -155,7 +157,8 @@ async function downloadImageFromUrl(url) {
|
|
|
155
157
|
const contentType = res.headers.get('content-type') ?? '';
|
|
156
158
|
const mimeType = contentType.split(';')[0].trim() || 'image/png';
|
|
157
159
|
const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
158
|
-
if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) ||
|
|
160
|
+
if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) ||
|
|
161
|
+
!mimeType.startsWith('image/')) {
|
|
159
162
|
throw new Error(`参考图 URL 返回的 Content-Type 不是图片(${mimeType}):${url}\n 只支持 PNG / JPG / WEBP / GIF。`);
|
|
160
163
|
}
|
|
161
164
|
const buf = await res.arrayBuffer();
|
|
@@ -178,18 +181,13 @@ export async function collectReferenceImagePayloads(paths) {
|
|
|
178
181
|
}
|
|
179
182
|
else {
|
|
180
183
|
const mimeType = mimeTypeForImagePath(p);
|
|
181
|
-
out.push({ base64: readFileSync(p).toString('base64'), mimeType });
|
|
184
|
+
out.push({ base64: readFileSync(resolve(process.cwd(), p)).toString('base64'), mimeType });
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
catch (e) {
|
|
185
188
|
const detail = e instanceof Error ? e.message : String(e);
|
|
186
|
-
|
|
187
|
-
const hint = code ? ` (${code})` : '';
|
|
188
|
-
console.warn(`Skipping reference image (load failed): ${p}${hint}\n ${detail}`);
|
|
189
|
+
throw new Error(`Could not load reference image "${p}": ${detail}. No image request was sent.`);
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
|
-
if (paths.length > 0 && out.length === 0) {
|
|
192
|
-
console.warn('All reference images failed to load; continuing with text-only generation.');
|
|
193
|
-
}
|
|
194
192
|
return out.length > 0 ? out : undefined;
|
|
195
193
|
}
|
package/dist/commands/tools.js
CHANGED
|
@@ -6,11 +6,13 @@ import { registerDetectCommand } from './tools-detect.js';
|
|
|
6
6
|
import { registerVideoReviewCommand } from './tools-video-review.js';
|
|
7
7
|
import { registerImageContextCommand } from './tools-image-context.js';
|
|
8
8
|
import { registerContextCommands } from './tools-context.js';
|
|
9
|
+
import { registerToolRequestCommand } from './tools-request.js';
|
|
9
10
|
export function registerToolsCommands(program) {
|
|
10
11
|
const tools = program
|
|
11
12
|
.command('tools')
|
|
12
13
|
.description('后端 /api/agent/tools;运行 playcraft login 后即可直接使用,也支持 .playcraft.json 或 PLAYCRAFT_SANDBOX_TOKEN 环境变量');
|
|
13
14
|
registerGenerationCommands(tools);
|
|
15
|
+
registerToolRequestCommand(tools);
|
|
14
16
|
registerImageContextCommand(tools);
|
|
15
17
|
registerProjectCommands(tools);
|
|
16
18
|
registerBatchCommands(tools);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs';
|
|
2
2
|
import { createWriteStream } from 'node:fs';
|
|
3
3
|
import { link, mkdir, stat, unlink } from 'node:fs/promises';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
|
-
import { dirname, join } from 'path';
|
|
7
|
+
import { dirname, join, resolve } from 'path';
|
|
8
8
|
import { homedir } from 'os';
|
|
9
9
|
import { loadGlobalConfig } from '../config.js';
|
|
10
10
|
import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
@@ -41,20 +41,28 @@ export class AgentApiClient {
|
|
|
41
41
|
credentialFile;
|
|
42
42
|
playcraftEnv;
|
|
43
43
|
cloudRuntimeCredential;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
explicitConfig;
|
|
45
|
+
operationDirectory;
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
const localConfig = options.configFile
|
|
48
|
+
? AgentApiClient.requireLocalConfig(options.configFile)
|
|
49
|
+
: AgentApiClient.loadLocalConfig();
|
|
50
|
+
const globalConfig = options.configFile ? {} : loadGlobalConfig();
|
|
47
51
|
// URL: 环境变量 → 本地 .playcraft.json → 全局 login 配置 → 公有云默认地址
|
|
48
|
-
const apiUrl = (
|
|
49
|
-
localConfig.apiUrl
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const apiUrl = (options.configFile
|
|
53
|
+
? localConfig.apiUrl
|
|
54
|
+
: process.env.PLAYCRAFT_API_URL ||
|
|
55
|
+
localConfig.apiUrl ||
|
|
56
|
+
globalConfig.backendUrl ||
|
|
57
|
+
globalConfig.url ||
|
|
58
|
+
DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
|
|
53
59
|
// Token: 环境变量 → 沙箱全局配置 → 项目配置 → 用户登录配置
|
|
54
|
-
const token =
|
|
55
|
-
localConfig.token
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
const token = options.configFile
|
|
61
|
+
? localConfig.token
|
|
62
|
+
: process.env.PLAYCRAFT_SANDBOX_TOKEN ||
|
|
63
|
+
localConfig.token ||
|
|
64
|
+
globalConfig.token ||
|
|
65
|
+
'';
|
|
58
66
|
if (!token) {
|
|
59
67
|
throw new Error('Missing PlayCraft API credentials. ' +
|
|
60
68
|
'Run `playcraft login` to authenticate, or set PLAYCRAFT_SANDBOX_TOKEN / create a .playcraft.json file.');
|
|
@@ -62,14 +70,29 @@ export class AgentApiClient {
|
|
|
62
70
|
this.apiUrl = apiUrl;
|
|
63
71
|
this.token = token;
|
|
64
72
|
this.credentialFile = localConfig.credentialFile;
|
|
65
|
-
this.
|
|
73
|
+
this.explicitConfig = Boolean(options.configFile);
|
|
74
|
+
this.playcraftEnv = (options.configFile
|
|
75
|
+
? localConfig.env || process.env.PLAYCRAFT_ENV || ''
|
|
76
|
+
: process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
|
|
66
77
|
this.cloudRuntimeCredential = cloudRuntimeCredentialHint(token, token === localConfig.token ? localConfig : undefined);
|
|
78
|
+
if (options.operationNamespace) {
|
|
79
|
+
if (!options.configFile)
|
|
80
|
+
throw new Error('Operation namespace requires an explicit sandbox configuration');
|
|
81
|
+
const scope = JSON.stringify([
|
|
82
|
+
resolve(options.configFile),
|
|
83
|
+
apiUrl,
|
|
84
|
+
this.playcraftEnv,
|
|
85
|
+
localConfig.projectId,
|
|
86
|
+
localConfig.branch,
|
|
87
|
+
]);
|
|
88
|
+
this.operationDirectory = join(process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests'), options.operationNamespace, createHash('sha256').update(scope).digest('hex'));
|
|
89
|
+
}
|
|
67
90
|
}
|
|
68
91
|
/** 根据 token 类型构造鉴权 headers */
|
|
69
92
|
authHeaders() {
|
|
70
93
|
let token = this.token;
|
|
71
94
|
// Polling commands can outlive a credential: read the atomically refreshed file per request.
|
|
72
|
-
if (this.credentialFile && !process.env.PLAYCRAFT_SANDBOX_TOKEN) {
|
|
95
|
+
if (this.credentialFile && (this.explicitConfig || !process.env.PLAYCRAFT_SANDBOX_TOKEN)) {
|
|
73
96
|
try {
|
|
74
97
|
token = JSON.parse(readFileSync(this.credentialFile, 'utf8')).token || token;
|
|
75
98
|
}
|
|
@@ -96,12 +119,12 @@ export class AgentApiClient {
|
|
|
96
119
|
const recoverId = options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID;
|
|
97
120
|
const newRequest = options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1';
|
|
98
121
|
const originalReceipt = recoverId !== undefined && !newRequest && isWorkspaceGenerationPath(path)
|
|
99
|
-
? findToolOperation(url.pathname, originalBody, recoverId) : undefined;
|
|
122
|
+
? findToolOperation(url.pathname, originalBody, recoverId, this.operationDirectory) : undefined;
|
|
100
123
|
let serializedBody = originalReceipt ? originalBody : JSON.stringify(await withGenerationWorkspaceScope(path, body, this.cloudRuntimeCredential));
|
|
101
124
|
if (serializedBody === undefined)
|
|
102
125
|
throw new Error('Tool request body is required');
|
|
103
126
|
if (serializedBody !== originalBody && !newRequest) {
|
|
104
|
-
const previous = findToolOperation(url.pathname, originalBody, recoverId);
|
|
127
|
+
const previous = findToolOperation(url.pathname, originalBody, recoverId, this.operationDirectory);
|
|
105
128
|
if (previous && recoverId !== undefined)
|
|
106
129
|
serializedBody = originalBody;
|
|
107
130
|
else if (previous)
|
|
@@ -110,6 +133,7 @@ export class AgentApiClient {
|
|
|
110
133
|
}
|
|
111
134
|
const operation = beginToolOperation(url.pathname, serializedBody, recoverId, {
|
|
112
135
|
newRequest,
|
|
136
|
+
directory: this.operationDirectory,
|
|
113
137
|
});
|
|
114
138
|
const unknownOutcome = `Tool request ${operation.requestId}: the outcome is unknown. Rerun the same command to ` +
|
|
115
139
|
`continue this request: it returns the result if the request finished, or finishes it if ` +
|
|
@@ -384,6 +408,21 @@ export class AgentApiClient {
|
|
|
384
408
|
}
|
|
385
409
|
return { apiUrl: '', token: '' };
|
|
386
410
|
}
|
|
411
|
+
/** A static plugin's slot must never fall back to another workspace or a global login. */
|
|
412
|
+
static requireLocalConfig(file) {
|
|
413
|
+
try {
|
|
414
|
+
const config = JSON.parse(readFileSync(file, 'utf8'));
|
|
415
|
+
if (typeof config?.apiUrl !== 'string' ||
|
|
416
|
+
!config.apiUrl.trim() ||
|
|
417
|
+
typeof config.token !== 'string' ||
|
|
418
|
+
!config.token.trim())
|
|
419
|
+
throw new Error('invalid');
|
|
420
|
+
return { ...config, credentialFile: file };
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
throw new Error('A valid project .playcraft.json with apiUrl and token is required; no generation request was sent');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
387
426
|
}
|
|
388
427
|
/** A failure whose meaning is settled; no recovery hint applies. */
|
|
389
428
|
class ToolRequestError extends Error {
|