agens-studio 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/cli.js ADDED
@@ -0,0 +1,978 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Agens 创作工作台 CLI v1.0
5
+ * =========================================
6
+ * 复用现有 service 层(agensClient / mimoClient / mimoVision / assetStore / promptMemory)
7
+ * 提供纯终端交互:菜单选择 → 参数输入 → AI 生成 → 下载结果 → 自动打开
8
+ *
9
+ * 启动:node cli.js 或 npm run cli
10
+ */
11
+
12
+ import readline from 'node:readline';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { exec } from 'node:child_process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { config, USER_CONFIG_FILE, saveUserConfig, readUserConfig } from './config.js';
19
+ import { generateImage, generateVideo, getTaskStatus } from './src/services/agensClient.js';
20
+ import { optimizePrompt, optimizeNegativePrompt, dimensionsToText } from './src/services/mimoClient.js';
21
+ import { describeImage, descriptionToText } from './src/services/mimoVision.js';
22
+ import * as assetStore from './src/services/assetStore.js';
23
+ import * as promptMemory from './src/services/promptMemory.js';
24
+
25
+ // ═══════════════════ ANSI 色彩 ═══════════════════
26
+
27
+ const C = {
28
+ reset: '\x1b[0m', bold: '\x1b[1m',
29
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m',
30
+ blue: '\x1b[34m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m',
31
+ };
32
+
33
+ // ═══════════════════ 终端 I/O 工具 ═══════════════════
34
+
35
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
36
+ let _rlClosed = false;
37
+ rl.on('close', () => { _rlClosed = true; });
38
+
39
+ /**
40
+ * 交互式提问,支持默认值
41
+ * @param {string} q 提示文字
42
+ * @param {string} [def] 默认值(回车时使用)
43
+ * @returns {Promise<string>}
44
+ */
45
+ function ask(q, def) {
46
+ if (_rlClosed) {
47
+ process.exit(0);
48
+ }
49
+ const suffix = def != null && def !== '' ? ` (${def})` : '';
50
+ return new Promise((resolve) => {
51
+ rl.question(`${C.cyan}${q}${suffix}:${C.reset}`, (a) => {
52
+ resolve((a || '').trim() || (def != null ? def : ''));
53
+ });
54
+ });
55
+ }
56
+
57
+ /** 按回车继续 */
58
+ async function pause() {
59
+ return new Promise((resolve) => {
60
+ rl.question(`\n${C.gray}按回车继续...${C.reset}`, () => resolve());
61
+ });
62
+ }
63
+
64
+ /** 确认(默认 Y) */
65
+ async function confirm(q) {
66
+ const a = await ask(q + ' (Y/n)', 'Y');
67
+ return a.toLowerCase() !== 'n';
68
+ }
69
+
70
+ /**
71
+ * 编号选择菜单
72
+ * @param {string} title 标题
73
+ * @param {string[]} opts 选项文字
74
+ * @returns {Promise<number>} 选中的索引(0-based),取消返回 -1
75
+ */
76
+ async function choose(title, opts) {
77
+ console.log(`\n${C.yellow}${title}${C.reset}`);
78
+ opts.forEach((o, i) => console.log(` ${C.cyan}[${i + 1}]${C.reset} ${o}`));
79
+ console.log(` ${C.gray}[0] 取消${C.reset}`);
80
+ const c = await ask('选择');
81
+ const n = parseInt(c, 10);
82
+ if (isNaN(n) || n < 0 || n > opts.length) return -1;
83
+ return n - 1; // 0 = 取消 → -1
84
+ }
85
+
86
+ // ═══════════════════ 通用工具函数 ═══════════════════
87
+
88
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
89
+
90
+ function formatSize(bytes) {
91
+ if (bytes < 1024) return bytes + ' B';
92
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
93
+ return (bytes / 1048576).toFixed(1) + ' MB';
94
+ }
95
+
96
+ function fmtDate(ts) {
97
+ return new Date(ts).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
98
+ }
99
+
100
+ /** 去掉用户拖拽文件路径时自带的引号 */
101
+ function cleanPath(input) {
102
+ let s = (input || '').trim();
103
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
104
+ s = s.slice(1, -1).trim();
105
+ }
106
+ return s;
107
+ }
108
+
109
+ /** 读取本地图片 → base64 data URI(用于生图 API) */
110
+ async function imgToDataUri(input) {
111
+ const s = cleanPath(input);
112
+ if (!s) return '';
113
+ if (s.startsWith('data:')) return s;
114
+ try {
115
+ const abs = path.isAbsolute(s) ? s : path.resolve(s);
116
+ const buf = await fs.readFile(abs);
117
+ const ext = path.extname(abs).slice(1).toLowerCase();
118
+ const mime = 'image/' + (ext === 'jpg' ? 'jpeg' : ext || 'png');
119
+ return 'data:' + mime + ';base64,' + buf.toString('base64');
120
+ } catch {
121
+ return s; // 可能是 URL
122
+ }
123
+ }
124
+
125
+ /** 读取本地图片 → raw base64(用于视频 API) */
126
+ async function imgToB64(input) {
127
+ const s = cleanPath(input);
128
+ if (!s) return '';
129
+ if (s.startsWith('data:')) {
130
+ const i = s.indexOf(',');
131
+ return i >= 0 ? s.slice(i + 1) : s;
132
+ }
133
+ try {
134
+ const abs = path.isAbsolute(s) ? s : path.resolve(s);
135
+ const buf = await fs.readFile(abs);
136
+ return buf.toString('base64');
137
+ } catch {
138
+ return s;
139
+ }
140
+ }
141
+
142
+ /** 读取本地图片 → base64 data URI(用于 vision 识图 API) */
143
+ async function imgToVisionUri(input) {
144
+ const s = cleanPath(input);
145
+ if (!s) throw new Error('图片路径为空');
146
+ if (s.startsWith('data:')) return s;
147
+ const abs = path.isAbsolute(s) ? s : path.resolve(s);
148
+ const buf = await fs.readFile(abs);
149
+ const ext = path.extname(abs).slice(1).toLowerCase();
150
+ const mime = 'image/' + (ext === 'jpg' ? 'jpeg' : ext || 'png');
151
+ return 'data:' + mime + ';base64,' + buf.toString('base64');
152
+ }
153
+
154
+ // ═══════════════════ 公共流程 ═══════════════════
155
+
156
+ /**
157
+ * 提示词优化交互流程
158
+ * @param {string} prompt 原始提示词
159
+ * @param {string} mode 生成模式(image/img2img/text2video 等)
160
+ * @param {string} [imageDesc] 图生图时的识图结果
161
+ * @returns {Promise<string>} 最终使用的提示词
162
+ */
163
+ async function promptOptimizeFlow(prompt, mode, imageDesc) {
164
+ if (!prompt) return prompt;
165
+ const wantOpt = await confirm('是否用 AI 优化提示词');
166
+ if (!wantOpt) return prompt;
167
+
168
+ console.log(`${C.yellow}[...] 正在优化提示词(MiMo ${config.mimo.model})...${C.reset}`);
169
+ try {
170
+ const result = await optimizePrompt({
171
+ text: prompt, mode, imageDescription: imageDesc,
172
+ });
173
+ const optText = result.dimensions
174
+ ? dimensionsToText(result.dimensions)
175
+ : result.optimized;
176
+
177
+ console.log(`\n${C.green}═══ 优化结果 ═══${C.reset}`);
178
+ if (result.dimensions) {
179
+ for (const [k, v] of Object.entries(result.dimensions)) {
180
+ console.log(` ${C.cyan}[${k}]${C.reset} ${v}`);
181
+ }
182
+ console.log('');
183
+ }
184
+ console.log(` ${optText}`);
185
+
186
+ const use = await confirm('使用优化后的提示词');
187
+ return use ? optText : prompt;
188
+ } catch (e) {
189
+ console.log(`${C.red}优化失败:${e.message}${C.reset}`);
190
+ return prompt;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 反向提示词交互
196
+ * @param {string} positive 正向提示词(供参考)
197
+ * @returns {Promise<string>}
198
+ */
199
+ async function negPromptFlow(positive) {
200
+ const wantNeg = await confirm('是否添加反向提示词(排除不想要的元素)');
201
+ if (!wantNeg) return '';
202
+ const negInput = await ask('描述不想出现的内容', '模糊, 低质量, 变形, 多余手指');
203
+ if (!negInput) return '';
204
+ console.log(`${C.yellow}[...] 优化反向提示词...${C.reset}`);
205
+ try {
206
+ const result = await optimizeNegativePrompt({ text: negInput, mode: 'image', positive });
207
+ console.log(`${C.green}反向提示词:${C.reset}${result.optimized || result}`);
208
+ return result.optimized || result;
209
+ } catch (e) {
210
+ console.log(`${C.red}反向优化失败:${e.message}${C.reset}`);
211
+ return negInput;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 视频轮询(显示进度条)
217
+ * @param {string} videoId
218
+ * @returns {Promise<{status, videoUrl, error}>}
219
+ */
220
+ async function pollVideo(videoId) {
221
+ const maxAttempts = config.agens.pollMaxAttempts || 120;
222
+ const interval = config.agens.pollInterval || 5000;
223
+
224
+ for (let i = 0; i < maxAttempts; i++) {
225
+ await sleep(interval);
226
+ let info;
227
+ try {
228
+ info = await getTaskStatus(videoId);
229
+ } catch {
230
+ process.stdout.write(`${C.gray}?${C.reset}`);
231
+ continue;
232
+ }
233
+
234
+ const pct = info.progress || Math.min(95, 5 + i * 3);
235
+ const bar = makeBar(pct, 30);
236
+ process.stdout.write(`\r ${bar} ${String(Math.round(pct)).padStart(3)}% `);
237
+
238
+ if (info.status === 'success') {
239
+ process.stdout.write('\r' + ' '.repeat(50) + '\r');
240
+ return { status: 'success', videoUrl: info.result?.videoUrl };
241
+ }
242
+ if (info.status === 'failed') {
243
+ process.stdout.write('\r' + ' '.repeat(50) + '\r');
244
+ return { status: 'failed', error: info.result?.error || '生成失败' };
245
+ }
246
+ }
247
+ return { status: 'failed', error: '轮询超时(约 10 分钟)' };
248
+ }
249
+
250
+ function makeBar(pct, w) {
251
+ const filled = Math.round(w * pct / 100);
252
+ return `${C.green}${'█'.repeat(filled)}${C.gray}${'░'.repeat(w - filled)}${C.reset}`;
253
+ }
254
+
255
+ /**
256
+ * 下载远程文件到本地 assets
257
+ * - 支持 data: URI 直接写入(无需网络请求)
258
+ * - 远程 URL 带 60s 超时 + 调试日志
259
+ * @returns {Promise<{filePath, asset}>}
260
+ */
261
+ async function downloadResult(url, kind, mime, meta) {
262
+ // data URI:直接走本地保存,不下载
263
+ if (url.startsWith('data:')) {
264
+ console.log(`${C.yellow}[...] 正在保存(base64 数据)...${C.reset}`);
265
+ const b64 = url.split(',')[1];
266
+ if (!b64) throw new Error('data URI 格式异常');
267
+ const buf = Buffer.from(b64, 'base64');
268
+ const ext = mime === 'video/mp4' ? '.mp4' : '.png';
269
+ const subdir = kind === 'video' ? 'videos' : 'images';
270
+ // randomUUID already imported at top
271
+ const id = randomUUID();
272
+ const filename = `${id}${ext}`;
273
+ const absPath = path.join(config.dirs[subdir], filename);
274
+ await fs.mkdir(config.dirs[subdir], { recursive: true });
275
+ await fs.writeFile(absPath, buf);
276
+ // 登记到 assetStore
277
+ const asset = await assetStore.registerLocal({ absPath, kind, meta });
278
+ return { filePath: absPath, asset };
279
+ }
280
+
281
+ // 远程 URL
282
+ const preview = url.length > 80 ? url.slice(0, 80) + '...' : url;
283
+ console.log(`${C.yellow}[...] 正在保存到本地...${C.reset}`);
284
+ console.log(` ${C.gray}URL: ${preview}${C.reset}`);
285
+
286
+ try {
287
+ const asset = await assetStore.downloadAndRegister({ url, kind, mime, meta });
288
+ const absPath = path.join(config.dirs.root, asset.path);
289
+ return { filePath: absPath, asset };
290
+ } catch (dlErr) {
291
+ // 下载失败:尝试直接 fetch 带超时
292
+ console.log(` ${C.red}assetStore 下载失败:${dlErr.message}${C.reset}`);
293
+ console.log(`${C.yellow}[...] 重试下载(60s 超时)...${C.reset}`);
294
+
295
+ const controller = new AbortController();
296
+ const timer = setTimeout(() => controller.abort(), 60000);
297
+ try {
298
+ const res = await fetch(url, { signal: controller.signal });
299
+ clearTimeout(timer);
300
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
301
+ const buf = Buffer.from(await res.arrayBuffer());
302
+ const ext = mime === 'video/mp4' ? '.mp4' : '.png';
303
+ const subdir = kind === 'video' ? 'videos' : 'images';
304
+ // randomUUID already imported at top
305
+ const id = randomUUID();
306
+ const filename = `${id}${ext}`;
307
+ const absPath = path.join(config.dirs[subdir], filename);
308
+ await fs.mkdir(config.dirs[subdir], { recursive: true });
309
+ await fs.writeFile(absPath, buf);
310
+ const asset = await assetStore.registerLocal({ absPath, kind, meta });
311
+ console.log(` ${C.green}重试下载成功!${C.reset}`);
312
+ return { filePath: absPath, asset };
313
+ } catch (e2) {
314
+ clearTimeout(timer);
315
+ const reason = e2.name === 'AbortError' ? '超时(60s)' : e2.message;
316
+ throw new Error(`下载失败:${reason}\n原始 URL: ${url.slice(0, 200)}`);
317
+ }
318
+ }
319
+ }
320
+
321
+ /** Windows 下用 start 命令打开文件 */
322
+ function openFile(filePath) {
323
+ exec(`start "" "${filePath}"`, (err) => {
324
+ if (err) console.log(`${C.gray}(自动打开失败,请手动查看文件)${C.reset}`);
325
+ });
326
+ }
327
+
328
+ /** 生成完成后展示结果 + 打开 */
329
+ async function showResult(filePath, asset, w, h) {
330
+ console.log(`\n${C.green}${C.bold}═══ 生成完成!═══${C.reset}`);
331
+ console.log(` 文件:${filePath}`);
332
+ console.log(` 大小:${formatSize(asset.size)}`);
333
+ if (w && h) console.log(` 尺寸:${w} x ${h}`);
334
+ console.log('');
335
+ if (await confirm('打开文件')) {
336
+ openFile(filePath);
337
+ }
338
+ }
339
+
340
+ // ═══════════════════ 首次运行 Setup Wizard ═══════════════════
341
+
342
+ /**
343
+ * 首次运行引导:配置 API Key
344
+ * 保存到 ~/.agens-cli/config.json,后续启动自动加载
345
+ */
346
+ async function setupWizard(isFirstRun) {
347
+ if (isFirstRun) {
348
+ console.log('');
349
+ console.log(`${C.yellow}╔══════════════════════════════════════════╗${C.reset}`);
350
+ console.log(`${C.yellow}║ 欢迎使用 Agens 创作工作台 CLI! ║${C.reset}`);
351
+ console.log(`${C.yellow}║ 首次运行需要配置 API Key ║${C.reset}`);
352
+ console.log(`${C.yellow}╚══════════════════════════════════════════╝${C.reset}`);
353
+ console.log('');
354
+ console.log(`${C.gray}配置文件将保存到:${USER_CONFIG_FILE}${C.reset}`);
355
+ console.log(`${C.gray}你可以随时用菜单 [9] 重新配置${C.reset}`);
356
+ console.log('');
357
+ } else {
358
+ console.log(`\n${C.bold}─────── 配置管理 ───────${C.reset}`);
359
+ const existing = readUserConfig();
360
+ if (existing) {
361
+ const mask = (s) => s ? s.slice(0, 8) + '···' + s.slice(-4) : '(未设置)';
362
+ console.log(` 当前 Agnes Key:${mask(existing.agens_api_key)}`);
363
+ console.log(` 当前 MiMo Key:${mask(existing.mimo_api_key)}`);
364
+ console.log('');
365
+ if (!(await confirm('重新配置'))) return;
366
+ }
367
+ }
368
+
369
+ const cfg = readUserConfig() || {};
370
+
371
+ // Agnes API Key
372
+ console.log(`${C.cyan}── Agnes AI API Key ──${C.reset}`);
373
+ console.log(`${C.gray}用于图片生成和视频生成${C.reset}`);
374
+ console.log(`${C.gray}获取方式:https://agnes-ai.com 注册后在控制台获取${C.reset}`);
375
+ const agnesKey = await ask('Agnes API Key', cfg.agens_api_key || '');
376
+ if (agnesKey) cfg.agens_api_key = agnesKey;
377
+
378
+ // MiMo API Key
379
+ console.log(`\n${C.cyan}── MiMo 大模型 API Key ──${C.reset}`);
380
+ console.log(`${C.gray}用于 AI 提示词优化和识图(可选,不填则跳过优化功能)${C.reset}`);
381
+ console.log(`${C.gray}获取方式:https://xiaomimimo.com Token Plan 获取${C.reset}`);
382
+ const mimoKey = await ask('MiMo API Key (tp- 开头)', cfg.mimo_api_key || '');
383
+ if (mimoKey) cfg.mimo_api_key = mimoKey;
384
+
385
+ // 高级设置(可选)
386
+ console.log('');
387
+ const wantAdvanced = await confirm('是否配置高级选项(自定义 Base URL 等)');
388
+ if (wantAdvanced) {
389
+ const agnesBaseUrl = await ask('Agnes Base URL', cfg.agens_image_base_url || 'https://apihub.agnes-ai.com/v1');
390
+ if (agnesBaseUrl !== 'https://apihub.agnes-ai.com/v1') cfg.agens_image_base_url = agnesBaseUrl;
391
+
392
+ const mimoBaseUrl = await ask('MiMo Base URL', cfg.mimo_base_url || 'https://token-plan-cn.xiaomimimo.com/v1');
393
+ if (mimoBaseUrl !== 'https://token-plan-cn.xiaomimimo.com/v1') cfg.mimo_base_url = mimoBaseUrl;
394
+ }
395
+
396
+ // 保存
397
+ saveUserConfig(cfg);
398
+ console.log(`\n${C.green}${C.bold}配置已保存到 ${USER_CONFIG_FILE}${C.reset}`);
399
+
400
+ if (isFirstRun) {
401
+ console.log(`\n${C.yellow}请重新启动 CLI 以加载新配置:${C.reset}`);
402
+ console.log(` ${C.cyan}node cli.js${C.reset}`);
403
+ process.exit(0);
404
+ }
405
+
406
+ console.log(`${C.gray}提示:部分配置需要重启 CLI 才能生效${C.reset}`);
407
+ await pause();
408
+ }
409
+
410
+ // ═══════════════════ 初始化 ═══════════════════
411
+
412
+ async function init() {
413
+ await assetStore.init();
414
+ await promptMemory.init();
415
+ // 确保目录就绪
416
+ for (const dir of [config.dirs.images, config.dirs.videos, config.dirs.data, config.dirs.uploads]) {
417
+ await fs.mkdir(dir, { recursive: true });
418
+ }
419
+ }
420
+
421
+ // ═══════════════════ 主菜单 ═══════════════════
422
+
423
+ async function mainMenu() {
424
+ while (true) {
425
+ console.log('');
426
+ console.log(`${C.yellow}==================================================${C.reset}`);
427
+ console.log(`${C.yellow}${C.bold} Agens 创作工作台 CLI v1.0${C.reset}`);
428
+ console.log(`${C.yellow} 图片 · 视频 · AI 提示词优化 · 素材库${C.reset}`);
429
+ console.log(`${C.yellow}==================================================${C.reset}`);
430
+ console.log(` API:${config.agens.image.apiKey ? C.green + '已配置' + C.reset : C.red + '未配置' + C.reset}`);
431
+ console.log(` 模型:${config.agens.image.model} / ${config.agens.video.model}`);
432
+ console.log('');
433
+ console.log(` ${C.cyan}[1]${C.reset} 文生图`);
434
+ console.log(` ${C.cyan}[2]${C.reset} 图生图`);
435
+ console.log(` ${C.cyan}[3]${C.reset} 文生视频`);
436
+ console.log(` ${C.cyan}[4]${C.reset} 图生视频`);
437
+ console.log(` ${C.cyan}[5]${C.reset} 多图生视频`);
438
+ console.log(` ${C.cyan}[6]${C.reset} 关键帧动画`);
439
+ console.log(` ${C.cyan}[7]${C.reset} 素材库`);
440
+ console.log(` ${C.cyan}[8]${C.reset} 查看配置`);
441
+ console.log(` ${C.cyan}[9]${C.reset} 配置 API Key`);
442
+ console.log(` ${C.gray}[0]${C.reset} 退出`);
443
+ console.log('');
444
+
445
+ const c = await ask('请选择功能');
446
+
447
+ switch (c) {
448
+ case '1': await modeText2Image(); break;
449
+ case '2': await modeImage2Image(); break;
450
+ case '3': await modeText2Video(); break;
451
+ case '4': await modeImage2Video(); break;
452
+ case '5': await modeMulti2Video(); break;
453
+ case '6': await modeKeyframe(); break;
454
+ case '7': await menuAssets(); break;
455
+ case '8': await menuConfig(); break;
456
+ case '9': await setupWizard(false); break;
457
+ case '0':
458
+ console.log(`\n${C.green}再见!${C.reset}\n`);
459
+ process.exit(0);
460
+ break;
461
+ default:
462
+ console.log(`${C.red}无效选择,请输入 0-9 的数字${C.reset}`);
463
+ }
464
+ }
465
+ }
466
+
467
+ // ═══════════════════ [1] 文生图 ═══════════════════
468
+
469
+ async function modeText2Image() {
470
+ console.log(`\n${C.bold}─────── 文生图 ───────${C.reset}`);
471
+
472
+ const rawPrompt = await ask('描述你想生成的画面');
473
+ if (!rawPrompt) { console.log(`${C.yellow}已取消${C.reset}`); return; }
474
+
475
+ const finalPrompt = await promptOptimizeFlow(rawPrompt, 'image');
476
+
477
+ const ratio = await ask('比例 (1:1 / 3:4 / 4:3 / 16:9 / 9:16)', '16:9');
478
+ const styleIdx = await choose('风格', ['写实摄影', '动漫插画', '3D渲染', '数字艺术', '不指定']);
479
+ const styleMap = ['photographic', 'anime', '3d', 'art', ''];
480
+ const style = styleMap[styleIdx] || '';
481
+ const negPrompt = await negPromptFlow(finalPrompt);
482
+
483
+ console.log(`\n${C.bold}── 生成配置 ──${C.reset}`);
484
+ console.log(` 提示词:${finalPrompt}`);
485
+ if (negPrompt) console.log(` 反向:${negPrompt}`);
486
+ console.log(` 比例:${ratio} 风格:${style || '默认'}`);
487
+
488
+ if (!(await confirm('开始生成'))) return;
489
+
490
+ console.log(`\n${C.yellow}[...] 正在生成图片(Agnes ${config.agens.image.model})...${C.reset}`);
491
+ try {
492
+ const { url } = await generateImage({
493
+ prompt: finalPrompt,
494
+ params: { ratio, style, negativePrompt: negPrompt },
495
+ });
496
+ const { filePath, asset } = await downloadResult(url, 'image', 'image/png', {
497
+ prompt: finalPrompt, mode: 'image', params: { ratio, style },
498
+ });
499
+ await showResult(filePath, asset);
500
+ } catch (e) {
501
+ console.log(`${C.red}生成失败:${e.message}${C.reset}`);
502
+ }
503
+ await pause();
504
+ }
505
+
506
+ // ═══════════════════ [2] 图生图 ═══════════════════
507
+
508
+ async function modeImage2Image() {
509
+ console.log(`\n${C.bold}─────── 图生图 ───────${C.reset}`);
510
+ console.log(`${C.gray}输入参考图片的本地文件路径(支持拖拽文件到窗口)${C.reset}`);
511
+
512
+ const imgPath = cleanPath(await ask('参考图片路径'));
513
+ if (!imgPath) { console.log(`${C.yellow}已取消${C.reset}`); return; }
514
+
515
+ // 识图
516
+ console.log(`${C.yellow}[...] 正在识别图片内容(MiMo Vision)...${C.reset}`);
517
+ let imageDesc = '';
518
+ try {
519
+ const dataUri = await imgToVisionUri(imgPath);
520
+ // 直接调用 mimo vision 获取描述
521
+ const descResult = await callVisionDirect(dataUri);
522
+ imageDesc = descResult;
523
+ console.log(`${C.green}识图结果:${C.reset}${descResult.slice(0, 100)}...`);
524
+ } catch (e) {
525
+ console.log(`${C.red}识图失败:${e.message}${C.reset}`);
526
+ console.log(`${C.gray}将继续使用(提示词优化可能不够精准)${C.reset}`);
527
+ }
528
+
529
+ const rawPrompt = await ask('描述你想要的修改', '转为赛博朋克风格夜景');
530
+ if (!rawPrompt) { console.log(`${C.yellow}已取消${C.reset}`); return; }
531
+
532
+ const finalPrompt = await promptOptimizeFlow(rawPrompt, 'img2img', imageDesc);
533
+ const ratio = await ask('比例 (1:1 / 3:4 / 4:3 / 16:9 / 9:16)', '1:1');
534
+ const negPrompt = await negPromptFlow(finalPrompt);
535
+
536
+ console.log(`\n${C.bold}── 生成配置 ──${C.reset}`);
537
+ console.log(` 参考图:${imgPath}`);
538
+ console.log(` 提示词:${finalPrompt}`);
539
+ if (negPrompt) console.log(` 反向:${negPrompt}`);
540
+ console.log(` 比例:${ratio}`);
541
+
542
+ if (!(await confirm('开始生成'))) return;
543
+
544
+ console.log(`\n${C.yellow}[...] 正在生成图片...${C.reset}`);
545
+ try {
546
+ const dataUri = await imgToDataUri(imgPath);
547
+ const { url } = await generateImage({
548
+ prompt: finalPrompt,
549
+ params: { ratio, style: '', negativePrompt: negPrompt, imageUrls: [dataUri] },
550
+ });
551
+ const { filePath, asset } = await downloadResult(url, 'image', 'image/png', {
552
+ prompt: finalPrompt, mode: 'img2img', params: { ratio },
553
+ });
554
+ await showResult(filePath, asset);
555
+ } catch (e) {
556
+ console.log(`${C.red}生成失败:${e.message}${C.reset}`);
557
+ }
558
+ await pause();
559
+ }
560
+
561
+ /** 直接调用 mimo vision 识图(不依赖 mimoVision.js 的本地路径逻辑) */
562
+ async function callVisionDirect(dataUri) {
563
+ const cfg = config.mimo;
564
+ const system = [
565
+ '你是图像分析专家。请分析图片,严格按以下 6 个维度输出,每个维度一行,格式为「维度名:内容」。',
566
+ '', '维度:', '主体:画面核心对象是谁/是什么,什么状态/动作', '场景:发生在哪里,背景环境',
567
+ '构图:画面布局,主体位置', '色调:主色调、光线氛围', '风格:写实/动漫/3D/艺术/摄影等',
568
+ '可保留元素:图生图时应该保持不变的元素',
569
+ '', '要求:1. 必须输出全部 6 行 2. 每行简洁(5-30字) 3. 不要输出其它内容',
570
+ ].join('\n');
571
+
572
+ const res = await fetch(`${cfg.baseUrl}${cfg.endpoint}`, {
573
+ method: 'POST',
574
+ headers: { 'Content-Type': 'application/json', 'api-key': cfg.apiKey },
575
+ body: JSON.stringify({
576
+ model: cfg.visionModel,
577
+ messages: [
578
+ { role: 'system', content: system },
579
+ { role: 'user', content: [
580
+ { type: 'text', text: '请分析这张图片' },
581
+ { type: 'image_url', image_url: { url: dataUri } },
582
+ ]},
583
+ ],
584
+ max_completion_tokens: 800, temperature: 0.3, top_p: 0.9, stream: false,
585
+ }),
586
+ });
587
+ const text = await res.text();
588
+ if (!res.ok) throw new Error(`识图失败 (${res.status})`);
589
+ const data = JSON.parse(text);
590
+ return data.choices?.[0]?.message?.content?.trim() || '';
591
+ }
592
+
593
+ // ═══════════════════ [3] 文生视频 ═══════════════════
594
+
595
+ async function modeText2Video() {
596
+ console.log(`\n${C.bold}─────── 文生视频 ───────${C.reset}`);
597
+
598
+ const rawPrompt = await ask('描述你想生成的视频场景');
599
+ if (!rawPrompt) { console.log(`${C.yellow}已取消${C.reset}`); return; }
600
+
601
+ const finalPrompt = await promptOptimizeFlow(rawPrompt, 'text2video');
602
+
603
+ const ratio = await ask('比例 (16:9 / 9:16 / 1:1 / 4:3 / 3:4)', '16:9');
604
+ const resolution = await ask('分辨率 (720p / 1080p)', '720p');
605
+ const duration = await ask('时长(秒,3-10)', '5');
606
+ const negPrompt = await negPromptFlow(finalPrompt);
607
+
608
+ console.log(`\n${C.bold}── 生成配置 ──${C.reset}`);
609
+ console.log(` 提示词:${finalPrompt}`);
610
+ if (negPrompt) console.log(` 反向:${negPrompt}`);
611
+ console.log(` 分辨率:${resolution} 比例:${ratio} 时长:${duration}s`);
612
+
613
+ if (!(await confirm('开始生成'))) return;
614
+
615
+ console.log(`\n${C.yellow}[...] 正在提交视频任务(Agnes ${config.agens.video.model})...${C.reset}`);
616
+ try {
617
+ const { videoId } = await generateVideo({
618
+ mode: 'text2video', prompt: finalPrompt, imageUrls: [],
619
+ params: { ratio, resolution, duration, negativePrompt: negPrompt },
620
+ });
621
+ console.log(` 任务 ID:${videoId}`);
622
+ console.log(` ${C.yellow}轮询进度(每 ${config.agens.pollInterval / 1000}s 刷新)...${C.reset}\n`);
623
+
624
+ const poll = await pollVideo(videoId);
625
+ if (poll.status === 'success' && poll.videoUrl) {
626
+ const { filePath, asset } = await downloadResult(poll.videoUrl, 'video', 'video/mp4', {
627
+ prompt: finalPrompt, mode: 'text2video', params: { ratio, resolution, duration },
628
+ });
629
+ await showResult(filePath, asset);
630
+ } else {
631
+ console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
632
+ }
633
+ } catch (e) {
634
+ console.log(`${C.red}生成失败:${e.message}${C.reset}`);
635
+ }
636
+ await pause();
637
+ }
638
+
639
+ // ═══════════════════ [4] 图生视频 ═══════════════════
640
+
641
+ async function modeImage2Video() {
642
+ console.log(`\n${C.bold}─────── 图生视频 ───────${C.reset}`);
643
+ console.log(`${C.gray}输入参考图片路径(支持拖拽)${C.reset}`);
644
+
645
+ const imgPath = cleanPath(await ask('参考图片路径'));
646
+ if (!imgPath) { console.log(`${C.yellow}已取消${C.reset}`); return; }
647
+
648
+ const rawPrompt = await ask('描述动画效果', '人物轻微呼吸,头发随风轻摆,背景灯光闪烁');
649
+ if (!rawPrompt) { console.log(`${C.yellow}已取消${C.reset}`); return; }
650
+
651
+ const finalPrompt = await promptOptimizeFlow(rawPrompt, 'image2video');
652
+
653
+ const ratio = await ask('比例 (16:9 / 9:16 / 1:1)', '16:9');
654
+ const resolution = await ask('分辨率 (720p / 1080p)', '720p');
655
+ const duration = await ask('时长(秒,3-10)', '5');
656
+ const negPrompt = await negPromptFlow(finalPrompt);
657
+
658
+ console.log(`\n${C.bold}── 生成配置 ──${C.reset}`);
659
+ console.log(` 参考图:${imgPath}`);
660
+ console.log(` 提示词:${finalPrompt}`);
661
+ if (negPrompt) console.log(` 反向:${negPrompt}`);
662
+ console.log(` 分辨率:${resolution} 比例:${ratio} 时长:${duration}s`);
663
+
664
+ if (!(await confirm('开始生成'))) return;
665
+
666
+ console.log(`\n${C.yellow}[...] 正在提交视频任务...${C.reset}`);
667
+ try {
668
+ const b64 = await imgToB64(imgPath);
669
+ const { videoId } = await generateVideo({
670
+ mode: 'image2video', prompt: finalPrompt, imageUrls: [b64],
671
+ params: { ratio, resolution, duration, negativePrompt: negPrompt },
672
+ });
673
+ console.log(` 任务 ID:${videoId}`);
674
+ console.log(` ${C.yellow}轮询进度...${C.reset}\n`);
675
+
676
+ const poll = await pollVideo(videoId);
677
+ if (poll.status === 'success' && poll.videoUrl) {
678
+ const { filePath, asset } = await downloadResult(poll.videoUrl, 'video', 'video/mp4', {
679
+ prompt: finalPrompt, mode: 'image2video', params: { ratio, resolution, duration },
680
+ });
681
+ await showResult(filePath, asset);
682
+ } else {
683
+ console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
684
+ }
685
+ } catch (e) {
686
+ console.log(`${C.red}生成失败:${e.message}${C.reset}`);
687
+ }
688
+ await pause();
689
+ }
690
+
691
+ // ═══════════════════ [5] 多图生视频 ═══════════════════
692
+
693
+ async function modeMulti2Video() {
694
+ console.log(`\n${C.bold}─────── 多图生视频 ───────${C.reset}`);
695
+ console.log(`${C.gray}输入多张图片路径,生成过渡视频(2-5 张)${C.reset}`);
696
+
697
+ const images = [];
698
+ for (let i = 0; i < 5; i++) {
699
+ const p = cleanPath(await ask(`图片 ${i + 1} 路径${i >= 2 ? '(留空=完成)' : ''}`));
700
+ if (!p) {
701
+ if (i < 2) { console.log(`${C.red}至少需要 2 张图片${C.reset}`); return; }
702
+ break;
703
+ }
704
+ images.push(p);
705
+ }
706
+
707
+ console.log(`${C.green}已选择 ${images.length} 张图片${C.reset}`);
708
+
709
+ const rawPrompt = await ask('描述过渡效果', '图片之间平滑过渡,镜头缓慢推进');
710
+ if (!rawPrompt) { console.log(`${C.yellow}已取消${C.reset}`); return; }
711
+
712
+ const finalPrompt = await promptOptimizeFlow(rawPrompt, 'multi2video');
713
+
714
+ const ratio = await ask('比例 (16:9 / 9:16 / 1:1)', '16:9');
715
+ const resolution = await ask('分辨率 (720p / 1080p)', '720p');
716
+ const duration = await ask('时长(秒,5-15)', '5');
717
+ const negPrompt = await negPromptFlow(finalPrompt);
718
+
719
+ console.log(`\n${C.bold}── 生成配置 ──${C.reset}`);
720
+ console.log(` 图片数量:${images.length}`);
721
+ images.forEach((img, i) => console.log(` [${i + 1}] ${img}`));
722
+ console.log(` 提示词:${finalPrompt}`);
723
+ if (negPrompt) console.log(` 反向:${negPrompt}`);
724
+ console.log(` 分辨率:${resolution} 比例:${ratio} 时长:${duration}s`);
725
+
726
+ if (!(await confirm('开始生成'))) return;
727
+
728
+ console.log(`\n${C.yellow}[...] 正在提交视频任务...${C.reset}`);
729
+ try {
730
+ const b64s = await Promise.all(images.map(imgToB64));
731
+ const { videoId } = await generateVideo({
732
+ mode: 'multi2video', prompt: finalPrompt, imageUrls: b64s,
733
+ params: { ratio, resolution, duration, negativePrompt: negPrompt },
734
+ });
735
+ console.log(` 任务 ID:${videoId}`);
736
+ console.log(` ${C.yellow}轮询进度...${C.reset}\n`);
737
+
738
+ const poll = await pollVideo(videoId);
739
+ if (poll.status === 'success' && poll.videoUrl) {
740
+ const { filePath, asset } = await downloadResult(poll.videoUrl, 'video', 'video/mp4', {
741
+ prompt: finalPrompt, mode: 'multi2video', params: { ratio, resolution, duration },
742
+ });
743
+ await showResult(filePath, asset);
744
+ } else {
745
+ console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
746
+ }
747
+ } catch (e) {
748
+ console.log(`${C.red}生成失败:${e.message}${C.reset}`);
749
+ }
750
+ await pause();
751
+ }
752
+
753
+ // ═══════════════════ [6] 关键帧动画 ═══════════════════
754
+
755
+ async function modeKeyframe() {
756
+ console.log(`\n${C.bold}─────── 关键帧动画 ───────${C.reset}`);
757
+ console.log(`${C.gray}至少输入首帧和尾帧图片(2-5 张关键帧)${C.reset}`);
758
+
759
+ const keyframes = [];
760
+ const labels = ['首帧', '尾帧', '中间帧1', '中间帧2', '中间帧3'];
761
+ for (let i = 0; i < 5; i++) {
762
+ const hint = i < 2 ? `(必填)` : `(可选,留空跳过)`;
763
+ const p = cleanPath(await ask(`${labels[i]} 图片路径 ${hint}`));
764
+ if (!p) {
765
+ if (i < 2) { console.log(`${C.red}首帧和尾帧为必填${C.reset}`); return; }
766
+ break;
767
+ }
768
+ keyframes.push(p);
769
+ }
770
+
771
+ console.log(`${C.green}已选择 ${keyframes.length} 个关键帧${C.reset}`);
772
+
773
+ const rawPrompt = await ask('描述过渡和运动', '从首帧平滑过渡到尾帧,人物缓慢转头');
774
+ if (!rawPrompt) { console.log(`${C.yellow}已取消${C.reset}`); return; }
775
+
776
+ const finalPrompt = await promptOptimizeFlow(rawPrompt, 'keyframe');
777
+
778
+ const ratio = await ask('比例 (16:9 / 9:16 / 1:1)', '16:9');
779
+ const resolution = await ask('分辨率 (720p / 1080p)', '720p');
780
+ const duration = await ask('时长(秒,5-15)', '5');
781
+ const negPrompt = await negPromptFlow(finalPrompt);
782
+
783
+ console.log(`\n${C.bold}── 生成配置 ──${C.reset}`);
784
+ console.log(` 关键帧:${keyframes.length} 个`);
785
+ keyframes.forEach((kf, i) => console.log(` [${labels[i]}] ${kf}`));
786
+ console.log(` 提示词:${finalPrompt}`);
787
+ if (negPrompt) console.log(` 反向:${negPrompt}`);
788
+ console.log(` 分辨率:${resolution} 比例:${ratio} 时长:${duration}s`);
789
+
790
+ if (!(await confirm('开始生成'))) return;
791
+
792
+ console.log(`\n${C.yellow}[...] 正在提交关键帧动画任务...${C.reset}`);
793
+ try {
794
+ const b64s = await Promise.all(keyframes.map(imgToB64));
795
+ const { videoId } = await generateVideo({
796
+ mode: 'keyframe', prompt: finalPrompt, imageUrls: b64s,
797
+ params: { ratio, resolution, duration, negativePrompt: negPrompt },
798
+ });
799
+ console.log(` 任务 ID:${videoId}`);
800
+ console.log(` ${C.yellow}轮询进度...${C.reset}\n`);
801
+
802
+ const poll = await pollVideo(videoId);
803
+ if (poll.status === 'success' && poll.videoUrl) {
804
+ const { filePath, asset } = await downloadResult(poll.videoUrl, 'video', 'video/mp4', {
805
+ prompt: finalPrompt, mode: 'keyframe', params: { ratio, resolution, duration },
806
+ });
807
+ await showResult(filePath, asset);
808
+ } else {
809
+ console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
810
+ }
811
+ } catch (e) {
812
+ console.log(`${C.red}生成失败:${e.message}${C.reset}`);
813
+ }
814
+ await pause();
815
+ }
816
+
817
+ // ═══════════════════ [7] 素材库 ═══════════════════
818
+
819
+ async function menuAssets() {
820
+ while (true) {
821
+ console.log(`\n${C.bold}─────── 素材库 ───────${C.reset}`);
822
+ console.log(` ${C.cyan}[1]${C.reset} 全部素材`);
823
+ console.log(` ${C.cyan}[2]${C.reset} 只看图片`);
824
+ console.log(` ${C.cyan}[3]${C.reset} 只看视频`);
825
+ console.log(` ${C.cyan}[4]${C.reset} 搜索素材`);
826
+ console.log(` ${C.gray}[0]${C.reset} 返回主菜单`);
827
+ console.log('');
828
+
829
+ const c = await ask('选择');
830
+ let type = 'all', search = '';
831
+
832
+ if (c === '0' || c === '') return;
833
+ if (c === '2') type = 'image';
834
+ else if (c === '3') type = 'video';
835
+ else if (c === '4') search = await ask('搜索关键词');
836
+ else if (c !== '1') { console.log(`${C.red}无效选择${C.reset}`); continue; }
837
+
838
+ const { items, total } = await assetStore.listAssets({ type, search, pageSize: 20 });
839
+
840
+ if (items.length === 0) {
841
+ console.log(`\n${C.gray}暂无素材${total > 0 ? `(共 ${total} 条,当前页为空)` : ''}${C.reset}`);
842
+ continue;
843
+ }
844
+
845
+ console.log(`\n${C.bold}共 ${total} 条素材:${C.reset}\n`);
846
+ items.forEach((a, i) => {
847
+ const icon = a.type === 'video' ? '🎬' : '🖼';
848
+ const prompt = (a.prompt || '').slice(0, 50);
849
+ const note = a.note ? ` | ${a.note}` : '';
850
+ console.log(` ${C.cyan}${String(i + 1).padStart(2)}.${C.reset} ${icon} ${a.filename}`);
851
+ console.log(` ${C.gray}${prompt}${note}${C.reset}`);
852
+ console.log(` ${formatSize(a.size)} | ${fmtDate(a.createdAt)} | ${a.kept ? C.green + '保留' + C.reset : C.gray + '未保留' + C.reset}`);
853
+ });
854
+
855
+ // 操作
856
+ console.log('');
857
+ const op = await ask('输入序号查看详情/操作,或回车返回');
858
+ if (!op) continue;
859
+ const idx = parseInt(op, 10) - 1;
860
+ if (isNaN(idx) || idx < 0 || idx >= items.length) { console.log(`${C.red}无效序号${C.reset}`); continue; }
861
+
862
+ const asset = items[idx];
863
+ const absPath = path.join(config.dirs.root, asset.path);
864
+ console.log(`\n${C.bold}素材详情:${C.reset}`);
865
+ console.log(` ID:${asset.id}`);
866
+ console.log(` 文件:${absPath}`);
867
+ console.log(` 提示词:${asset.prompt || '(无)'}`);
868
+ console.log(` 模式:${asset.mode || '(无)'}`);
869
+ console.log(` 大小:${formatSize(asset.size)}`);
870
+ console.log(` 创建:${fmtDate(asset.createdAt)}`);
871
+ console.log(` 保留:${asset.kept ? '是' : '否'}`);
872
+ if (asset.note) console.log(` 备注:${asset.note}`);
873
+
874
+ const action = await choose('操作', ['打开文件', '切换保留状态', '添加备注', '删除']);
875
+ if (action === 0) {
876
+ openFile(absPath);
877
+ } else if (action === 1) {
878
+ await assetStore.updateAsset(asset.id, { kept: !asset.kept });
879
+ console.log(`${C.green}已${asset.kept ? '取消保留' : '标记保留'}${C.reset}`);
880
+ } else if (action === 2) {
881
+ const note = await ask('输入备注');
882
+ if (note) {
883
+ await assetStore.updateAsset(asset.id, { note });
884
+ console.log(`${C.green}备注已保存${C.reset}`);
885
+ }
886
+ } else if (action === 3) {
887
+ if (await confirm(`确认删除 ${asset.filename}`)) {
888
+ await assetStore.deleteAsset(asset.id);
889
+ console.log(`${C.green}已删除${C.reset}`);
890
+ }
891
+ }
892
+ }
893
+ }
894
+
895
+ // ═══════════════════ [8] 查看配置 ═══════════════════
896
+
897
+ async function menuConfig() {
898
+ console.log(`\n${C.bold}─────── 当前配置 ───────${C.reset}\n`);
899
+
900
+ const mask = (s) => s ? s.slice(0, 10) + '···' + s.slice(-4) : '(未设置)';
901
+
902
+ console.log(`${C.cyan}── Agnes 图片 API ──${C.reset}`);
903
+ console.log(` base_url:${config.agens.image.baseUrl}`);
904
+ console.log(` api_key:${mask(config.agens.image.apiKey)}`);
905
+ console.log(` model:${config.agens.image.model}`);
906
+
907
+ console.log(`\n${C.cyan}── Agnes 视频 API ──${C.reset}`);
908
+ console.log(` base_url:${config.agens.video.baseUrl}`);
909
+ console.log(` api_key:${mask(config.agens.video.apiKey)}`);
910
+ console.log(` model:${config.agens.video.model}`);
911
+ console.log(` query:${config.agens.video.queryBaseUrl}${config.agens.video.queryPath}`);
912
+
913
+ console.log(`\n${C.cyan}── MiMo 大模型 ──${C.reset}`);
914
+ console.log(` base_url:${config.mimo.baseUrl}`);
915
+ console.log(` api_key:${mask(config.mimo.apiKey)}`);
916
+ console.log(` text model:${config.mimo.model}`);
917
+ console.log(` vision model:${config.mimo.visionModel}`);
918
+
919
+ console.log(`\n${C.cyan}── 轮询配置 ──${C.reset}`);
920
+ console.log(` 间隔:${config.agens.pollInterval / 1000}s`);
921
+ console.log(` 超时:~${Math.round(config.agens.pollMaxAttempts * config.agens.pollInterval / 60000)} 分钟`);
922
+
923
+ console.log(`\n${C.cyan}── 目录 ──${C.reset}`);
924
+ console.log(` 项目根目录:${config.dirs.root}`);
925
+ console.log(` 图片素材:${config.dirs.images}`);
926
+ console.log(` 视频素材:${config.dirs.videos}`);
927
+ console.log(` 数据目录:${config.dirs.data}`);
928
+
929
+ console.log(`\n${C.cyan}── 提示词记忆 ──${C.reset}`);
930
+ try {
931
+ const s = await promptMemory.stats();
932
+ console.log(` 总样本:${s.total}`);
933
+ console.log(` 正向:${s.positive.total}(点赞 ${s.positive.liked},采纳 ${s.positive.adopted})`);
934
+ console.log(` 反向:${s.negative.total}(点赞 ${s.negative.liked},采纳 ${s.negative.adopted})`);
935
+ if (s.prefs?.positive?.favoriteStyle) {
936
+ console.log(` 正向偏好风格:${s.prefs.positive.favoriteStyle}`);
937
+ }
938
+ } catch {
939
+ console.log(`${C.gray} (暂无数据)${C.reset}`);
940
+ }
941
+
942
+ await pause();
943
+ }
944
+
945
+ // ═══════════════════ 入口 ═══════════════════
946
+
947
+ async function main() {
948
+ // Windows 终端 UTF-8 提示
949
+ if (process.platform === 'win32') {
950
+ console.log(`${C.gray}提示:如果中文显示异常,请在 cmd 中先执行 chcp 65001${C.reset}`);
951
+ }
952
+
953
+ // 首次运行检测:
954
+ // - 有 ~/.agens-cli/config.json → 用户已配置过,跳过
955
+ // - 有 config.local.json → 开发者模式,跳过
956
+ // - 都没有 + 代码里没硬编码 key → 分发版新用户,显示 wizard
957
+ const hasUserConfig = !!readUserConfig();
958
+ const hasDevConfig = !!config._configSource;
959
+ const hasHardcodedKeys = !!(config.agens.image.apiKey && config.agens.image.apiKey.length > 10);
960
+ const isFirstRun = !hasUserConfig && !hasDevConfig && !hasHardcodedKeys;
961
+
962
+ if (isFirstRun) {
963
+ await setupWizard(true);
964
+ return; // setupWizard 会 process.exit(0) 提示重启
965
+ }
966
+
967
+ await init();
968
+ console.log(`\n${C.green}${C.bold}Agens 创作工作台 CLI 已启动${C.reset}`);
969
+ const srcHint = config._configSource ? `(配置来源:${config._configSource})` : '';
970
+ console.log(`${C.gray}API 状态:${config.agens.image.apiKey ? '已配置' : '未配置'}${srcHint}${C.reset}\n`);
971
+ await mainMenu();
972
+ }
973
+
974
+ main().catch((e) => {
975
+ console.error(`${C.red}启动失败:${e.message}${C.reset}`);
976
+ console.error(e.stack);
977
+ process.exit(1);
978
+ });