agens-studio 0.1.4 → 0.1.7

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 CHANGED
@@ -26,6 +26,8 @@ import { optimizePrompt, optimizeNegativePrompt, dimensionsToText } from './src/
26
26
  import { describeImage, descriptionToText } from './src/services/mimoVision.js';
27
27
  import * as assetStore from './src/services/assetStore.js';
28
28
  import * as promptMemory from './src/services/promptMemory.js';
29
+ import * as petStore from './src/services/petStore.js';
30
+ import * as petArt from './src/services/petArt.js';
29
31
 
30
32
  // ═══════════════════ ANSI 色彩 ═══════════════════
31
33
 
@@ -170,11 +172,12 @@ async function promptOptimizeFlow(prompt, mode, imageDesc) {
170
172
  const wantOpt = await confirm('是否用 AI 优化提示词');
171
173
  if (!wantOpt) return prompt;
172
174
 
173
- console.log(`${C.yellow}[...] 正在优化提示词(MiMo ${config.mimo.model})...${C.reset}`);
175
+ const stopSpin = startPetSpinner(`正在优化提示词(MiMo ${config.mimo.model})...`);
174
176
  try {
175
177
  const result = await optimizePrompt({
176
178
  text: prompt, mode, imageDescription: imageDesc,
177
179
  });
180
+ stopSpin();
178
181
  const optText = result.dimensions
179
182
  ? dimensionsToText(result.dimensions)
180
183
  : result.optimized;
@@ -191,6 +194,7 @@ async function promptOptimizeFlow(prompt, mode, imageDesc) {
191
194
  const use = await confirm('使用优化后的提示词');
192
195
  return use ? optText : prompt;
193
196
  } catch (e) {
197
+ stopSpin();
194
198
  console.log(`${C.red}优化失败:${e.message}${C.reset}`);
195
199
  return prompt;
196
200
  }
@@ -206,12 +210,14 @@ async function negPromptFlow(positive) {
206
210
  if (!wantNeg) return '';
207
211
  const negInput = await ask('描述不想出现的内容', '模糊, 低质量, 变形, 多余手指');
208
212
  if (!negInput) return '';
209
- console.log(`${C.yellow}[...] 优化反向提示词...${C.reset}`);
213
+ const stopSpin = startPetSpinner('优化反向提示词...');
210
214
  try {
211
215
  const result = await optimizeNegativePrompt({ text: negInput, mode: 'image', positive });
216
+ stopSpin();
212
217
  console.log(`${C.green}反向提示词:${C.reset}${result.optimized || result}`);
213
218
  return result.optimized || result;
214
219
  } catch (e) {
220
+ stopSpin();
215
221
  console.log(`${C.red}反向优化失败:${e.message}${C.reset}`);
216
222
  return negInput;
217
223
  }
@@ -225,31 +231,60 @@ async function negPromptFlow(positive) {
225
231
  async function pollVideo(videoId) {
226
232
  const maxAttempts = config.agens.pollMaxAttempts || 120;
227
233
  const interval = config.agens.pollInterval || 5000;
234
+ const isTTY = !!process.stdout.isTTY;
235
+
236
+ // 单行 \r 重绘(不依赖多行光标回退,不会因为文案换行而错位堆叠)。
237
+ // 网络轮询(每 pollInterval 一次)和动画帧率(120ms 一次)完全独立,
238
+ // 轮询回调只更新 barText。颜文字每 6 帧(约 720ms)轮播一次,比旋转
239
+ // 指示符慢一档,制造"正在想"的层次感,同时不会显得单调。
240
+ const tier = petStore.getTierNow();
241
+ let spinI = 0;
242
+ let barText = `${makeBar(0, 30)} 0%`;
243
+ let animTimer = null;
244
+
245
+ const draw = () => {
246
+ const spin = petArt.SPINNER_FRAMES[spinI % petArt.SPINNER_FRAMES.length];
247
+ const face = petArt.faceAt(tier, Math.floor(spinI / 6));
248
+ process.stdout.write(`\r ${face} ${C.yellow}${spin}${C.reset} ${barText}\x1b[K`);
249
+ spinI++;
250
+ };
251
+
252
+ if (isTTY) {
253
+ draw();
254
+ animTimer = setInterval(draw, 120);
255
+ } else {
256
+ console.log(`${C.yellow}[...] 正在生成视频,请稍候...${C.reset}`);
257
+ }
228
258
 
229
- for (let i = 0; i < maxAttempts; i++) {
230
- await sleep(interval);
231
- let info;
232
- try {
233
- info = await getTaskStatus(videoId);
234
- } catch {
235
- process.stdout.write(`${C.gray}?${C.reset}`);
236
- continue;
237
- }
259
+ const stop = () => {
260
+ if (animTimer) clearInterval(animTimer);
261
+ if (isTTY) process.stdout.write('\r\x1b[K');
262
+ };
238
263
 
239
- const pct = info.progress || Math.min(95, 5 + i * 3);
240
- const bar = makeBar(pct, 30);
241
- process.stdout.write(`\r ${bar} ${String(Math.round(pct)).padStart(3)}% `);
264
+ try {
265
+ for (let i = 0; i < maxAttempts; i++) {
266
+ await sleep(interval);
267
+ let info;
268
+ try {
269
+ info = await getTaskStatus(videoId);
270
+ } catch {
271
+ continue;
272
+ }
242
273
 
243
- if (info.status === 'success') {
244
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
245
- return { status: 'success', videoUrl: info.result?.videoUrl };
246
- }
247
- if (info.status === 'failed') {
248
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
249
- return { status: 'failed', error: info.result?.error || '生成失败' };
274
+ const pct = info.progress || Math.min(95, 5 + i * 3);
275
+ barText = `${makeBar(pct, 30)} ${String(Math.round(pct)).padStart(3)}%`;
276
+
277
+ if (info.status === 'success') {
278
+ return { status: 'success', videoUrl: info.result?.videoUrl };
279
+ }
280
+ if (info.status === 'failed') {
281
+ return { status: 'failed', error: info.result?.error || '生成失败' };
282
+ }
250
283
  }
284
+ return { status: 'failed', error: '轮询超时(约 10 分钟)' };
285
+ } finally {
286
+ stop();
251
287
  }
252
- return { status: 'failed', error: '轮询超时(约 10 分钟)' };
253
288
  }
254
289
 
255
290
  function makeBar(pct, w) {
@@ -257,6 +292,60 @@ function makeBar(pct, w) {
257
292
  return `${C.green}${'█'.repeat(filled)}${C.gray}${'░'.repeat(w - filled)}${C.reset}`;
258
293
  }
259
294
 
295
+ /**
296
+ * 等待期间的颜文字动画(类似 Claude Code 的"思考中"效果)。
297
+ * 单行 \r 重绘(和视频进度条同一种模式),不依赖多行光标回退,
298
+ * 不会因为文案换行而错位堆叠。
299
+ * - TTY 下:旋转指示符每 120ms 切换一次,颜文字每 6 帧(约 720ms)
300
+ * 轮播一次同档位的另一个表情,两个节奏叠在一起,不会显得单调
301
+ * - 非 TTY(管道/重定向):退化为原来的静态文字提示,不写转义序列
302
+ * @param {string} caption 等待文案,如「正在生成图片...」
303
+ * @returns {() => void} stop 函数,调用后清空该行
304
+ */
305
+ function startPetSpinner(caption) {
306
+ if (!process.stdout.isTTY) {
307
+ console.log(`${C.yellow}[...] ${caption}${C.reset}`);
308
+ return () => {};
309
+ }
310
+
311
+ const tier = petStore.getTierNow();
312
+ let i = 0;
313
+ let stopped = false;
314
+
315
+ const draw = () => {
316
+ const spin = petArt.SPINNER_FRAMES[i % petArt.SPINNER_FRAMES.length];
317
+ const face = petArt.faceAt(tier, Math.floor(i / 6));
318
+ process.stdout.write(`\r ${face} ${C.yellow}${spin} ${caption}${C.reset}\x1b[K`);
319
+ i++;
320
+ };
321
+
322
+ draw();
323
+ const timer = setInterval(draw, 120);
324
+
325
+ // stop() 可能在成功/失败两条路径上都被调用(如 try 里调用一次,catch 里再兜底调用一次),
326
+ // 用 stopped 保证只清一次,避免第二次调用把 stop 之后新打印的内容误删。
327
+ return () => {
328
+ if (stopped) return;
329
+ stopped = true;
330
+ clearInterval(timer);
331
+ process.stdout.write('\r\x1b[K');
332
+ };
333
+ }
334
+
335
+ /**
336
+ * 根据 asset.type 用 config.dirs.images/videos 重建绝对路径。
337
+ * 不能用 path.join(config.dirs.root, asset.path):那个写法假设 root 和
338
+ * images/videos 同根,一旦用户自定义了 storage_dir(素材目录可以指到
339
+ * 别的盘/目录),root 和 images/videos 就分家了,会算出错误路径。
340
+ * @param {{type: string, path: string}} asset
341
+ * @returns {string} 绝对路径
342
+ */
343
+ function resolveAssetPath(asset) {
344
+ const subdir = asset.type === 'video' ? 'videos' : 'images';
345
+ const filename = path.basename(asset.path);
346
+ return path.join(config.dirs[subdir], filename);
347
+ }
348
+
260
349
  /**
261
350
  * 下载远程文件到本地 assets
262
351
  * - 支持 data: URI 直接写入(无需网络请求)
@@ -290,7 +379,7 @@ async function downloadResult(url, kind, mime, meta) {
290
379
 
291
380
  try {
292
381
  const asset = await assetStore.downloadAndRegister({ url, kind, mime, meta });
293
- const absPath = path.join(config.dirs.root, asset.path);
382
+ const absPath = resolveAssetPath(asset);
294
383
  return { filePath: absPath, asset };
295
384
  } catch (dlErr) {
296
385
  // 下载失败:尝试直接 fetch 带超时
@@ -336,6 +425,12 @@ async function showResult(filePath, asset, w, h) {
336
425
  console.log(` 文件:${filePath}`);
337
426
  console.log(` 大小:${formatSize(asset.size)}`);
338
427
  if (w && h) console.log(` 尺寸:${w} x ${h}`);
428
+
429
+ try {
430
+ const react = await petStore.onGenerateSuccess(asset.type);
431
+ if (react?.line) console.log(` ${C.cyan}${react.line}${C.reset}`);
432
+ } catch { /* 宠物状态更新失败不影响主流程 */ }
433
+
339
434
  console.log('');
340
435
  if (await confirm('打开文件')) {
341
436
  openFile(filePath);
@@ -403,6 +498,13 @@ async function setupWizard(isFirstRun) {
403
498
  console.log(`\n${C.green}${C.bold}配置已保存到 ${USER_CONFIG_FILE}${C.reset}`);
404
499
 
405
500
  if (isFirstRun) {
501
+ console.log(`\n${C.cyan}── 你的专属创作机器人已激活 ──${C.reset}`);
502
+ console.log(`${C.gray}它会陪着你一起创作,每次生成成功都会给它充电~${C.reset}`);
503
+ const petName = await ask('给它起个名字', '阿吉');
504
+ await petStore.init();
505
+ await petStore.setName(petName);
506
+ console.log(`${C.green}[^_^] ${petName} 已就位!${C.reset}`);
507
+
406
508
  console.log(`\n${C.yellow}请重新启动 CLI 以加载新配置:${C.reset}`);
407
509
  console.log(` ${C.cyan}node cli.js${C.reset}`);
408
510
  process.exit(0);
@@ -412,6 +514,117 @@ async function setupWizard(isFirstRun) {
412
514
  await pause();
413
515
  }
414
516
 
517
+ // ═══════════════════ [9] 修改参数 ═══════════════════
518
+
519
+ /**
520
+ * 修改参数子菜单:挑一项单独改,而不是像 setupWizard 那样每次从头问一遍。
521
+ * 复用现有的 readUserConfig/saveUserConfig 存到 ~/.agens-cli/config.json,
522
+ * 大部分字段需要重启 CLI 才能生效;机器人名字例外(走 pet.json,立即生效)。
523
+ */
524
+ async function menuParams() {
525
+ while (true) {
526
+ console.log(`\n${C.bold}─────── 修改参数 ───────${C.reset}`);
527
+ console.log(` ${C.cyan}[1]${C.reset} Agnes API Key`);
528
+ console.log(` ${C.cyan}[2]${C.reset} Agnes 图片(base_url / model)`);
529
+ console.log(` ${C.cyan}[3]${C.reset} Agnes 视频(base_url / model)`);
530
+ console.log(` ${C.cyan}[4]${C.reset} MiMo API Key`);
531
+ console.log(` ${C.cyan}[5]${C.reset} MiMo(base_url / text model / vision model)`);
532
+ console.log(` ${C.cyan}[6]${C.reset} 存储目录(图片/视频/数据保存位置)`);
533
+ console.log(` ${C.cyan}[7]${C.reset} 机器人名字`);
534
+ console.log(` ${C.gray}[0]${C.reset} 返回主菜单`);
535
+ console.log('');
536
+
537
+ const c = await ask('选择要修改的参数');
538
+ if (c === '0' || c === '') return;
539
+
540
+ const cfg = readUserConfig() || {};
541
+ let saved = false;
542
+ let needRestart = true;
543
+
544
+ switch (c) {
545
+ case '1': {
546
+ console.log(`\n${C.cyan}── Agnes API Key ──${C.reset}`);
547
+ console.log(`${C.gray}图片和视频生成共用同一个 key${C.reset}`);
548
+ const v = await ask('Agnes API Key', cfg.agens_api_key || '');
549
+ if (v) { cfg.agens_api_key = v; saveUserConfig(cfg); saved = true; }
550
+ break;
551
+ }
552
+ case '2': {
553
+ console.log(`\n${C.cyan}── Agnes 图片 ──${C.reset}`);
554
+ cfg.agens_image_base_url = await ask('base_url', cfg.agens_image_base_url || config.agens.image.baseUrl);
555
+ cfg.agens_image_model = await ask('model', cfg.agens_image_model || config.agens.image.model);
556
+ saveUserConfig(cfg);
557
+ saved = true;
558
+ break;
559
+ }
560
+ case '3': {
561
+ console.log(`\n${C.cyan}── Agnes 视频 ──${C.reset}`);
562
+ cfg.agens_video_base_url = await ask('base_url', cfg.agens_video_base_url || config.agens.video.baseUrl);
563
+ cfg.agens_video_model = await ask('model', cfg.agens_video_model || config.agens.video.model);
564
+ saveUserConfig(cfg);
565
+ saved = true;
566
+ break;
567
+ }
568
+ case '4': {
569
+ console.log(`\n${C.cyan}── MiMo API Key ──${C.reset}`);
570
+ const v = await ask('MiMo API Key (tp- 开头)', cfg.mimo_api_key || '');
571
+ if (v) { cfg.mimo_api_key = v; saveUserConfig(cfg); saved = true; }
572
+ break;
573
+ }
574
+ case '5': {
575
+ console.log(`\n${C.cyan}── MiMo ──${C.reset}`);
576
+ cfg.mimo_base_url = await ask('base_url', cfg.mimo_base_url || config.mimo.baseUrl);
577
+ cfg.mimo_model = await ask('text model', cfg.mimo_model || config.mimo.model);
578
+ cfg.mimo_vision_model = await ask('vision model', cfg.mimo_vision_model || config.mimo.visionModel);
579
+ saveUserConfig(cfg);
580
+ saved = true;
581
+ break;
582
+ }
583
+ case '6': {
584
+ console.log(`\n${C.cyan}── 存储目录 ──${C.reset}`);
585
+ console.log(`${C.gray}当前:${config.dirs.storageRoot}${C.reset}`);
586
+ console.log(`${C.gray}只影响以后新生成的文件存到哪,已有素材不会自动搬家${C.reset}`);
587
+ const dir = await ask('新的存储目录(绝对路径)', config.dirs.storageRoot);
588
+ if (dir && path.resolve(dir) !== config.dirs.storageRoot) {
589
+ const resolved = path.resolve(dir);
590
+ try {
591
+ await fs.mkdir(resolved, { recursive: true });
592
+ cfg.storage_dir = resolved;
593
+ saveUserConfig(cfg);
594
+ saved = true;
595
+ } catch (e) {
596
+ console.log(`${C.red}目录无效或无法创建:${e.message}${C.reset}`);
597
+ }
598
+ }
599
+ break;
600
+ }
601
+ case '7': {
602
+ console.log(`\n${C.cyan}── 机器人名字 ──${C.reset}`);
603
+ const pet = petStore.getState();
604
+ const name = await ask('给它起个新名字', pet?.name || '阿吉');
605
+ if (name) {
606
+ await petStore.setName(name);
607
+ console.log(`${C.green}好,以后就叫 ${name} 了~${C.reset}`);
608
+ saved = true;
609
+ needRestart = false;
610
+ }
611
+ break;
612
+ }
613
+ default:
614
+ console.log(`${C.red}无效选择${C.reset}`);
615
+ continue;
616
+ }
617
+
618
+ if (saved) {
619
+ console.log(`${C.green}已保存${C.reset}`);
620
+ if (needRestart) {
621
+ console.log(`${C.gray}提示:这项改动需要重启 CLI 才能生效${C.reset}`);
622
+ }
623
+ }
624
+ await pause();
625
+ }
626
+ }
627
+
415
628
  // ═══════════════════ 初始化 ═══════════════════
416
629
 
417
630
  async function init() {
@@ -434,6 +647,8 @@ async function mainMenu() {
434
647
  console.log(`${C.yellow}==================================================${C.reset}`);
435
648
  console.log(` API:${config.agens.image.apiKey ? C.green + '已配置' + C.reset : C.red + '未配置' + C.reset}`);
436
649
  console.log(` 模型:${config.agens.image.model} / ${config.agens.video.model}`);
650
+ const petLine = petStore.renderBanner();
651
+ if (petLine) console.log(petLine);
437
652
  console.log('');
438
653
  console.log(` ${C.cyan}[1]${C.reset} 文生图`);
439
654
  console.log(` ${C.cyan}[2]${C.reset} 图生图`);
@@ -443,7 +658,7 @@ async function mainMenu() {
443
658
  console.log(` ${C.cyan}[6]${C.reset} 关键帧动画`);
444
659
  console.log(` ${C.cyan}[7]${C.reset} 素材库`);
445
660
  console.log(` ${C.cyan}[8]${C.reset} 查看配置`);
446
- console.log(` ${C.cyan}[9]${C.reset} 配置 API Key`);
661
+ console.log(` ${C.cyan}[9]${C.reset} 修改参数`);
447
662
  console.log(` ${C.gray}[0]${C.reset} 退出`);
448
663
  console.log('');
449
664
 
@@ -458,7 +673,7 @@ async function mainMenu() {
458
673
  case '6': await modeKeyframe(); break;
459
674
  case '7': await menuAssets(); break;
460
675
  case '8': await menuConfig(); break;
461
- case '9': await setupWizard(false); break;
676
+ case '9': await menuParams(); break;
462
677
  case '0':
463
678
  console.log(`\n${C.green}再见!${C.reset}\n`);
464
679
  process.exit(0);
@@ -508,17 +723,20 @@ async function modeText2Image() {
508
723
 
509
724
  if (!(await confirm('开始生成'))) return;
510
725
 
511
- console.log(`\n${C.yellow}[...] 正在生成图片(Agnes ${config.agens.image.model})...${C.reset}`);
726
+ console.log('');
727
+ const stopGen = startPetSpinner(`正在生成图片(Agnes ${config.agens.image.model})...`);
512
728
  try {
513
729
  const { url } = await generateImage({
514
730
  prompt: finalPrompt,
515
731
  params: { ratio, style, negativePrompt: negPrompt },
516
732
  });
733
+ stopGen();
517
734
  const { filePath, asset } = await downloadResult(url, 'image', 'image/png', {
518
735
  prompt: finalPrompt, mode: 'image', params: { ratio, style },
519
736
  });
520
737
  await showResult(filePath, asset);
521
738
  } catch (e) {
739
+ stopGen();
522
740
  console.log(`${C.red}生成失败:${e.message}${C.reset}`);
523
741
  }
524
742
  await pause();
@@ -535,15 +753,17 @@ async function modeImage2Image() {
535
753
  if (!imgPath) { console.log(`${C.yellow}已取消${C.reset}`); return; }
536
754
 
537
755
  // 识图
538
- console.log(`${C.yellow}[...] 正在识别图片内容(MiMo Vision)...${C.reset}`);
756
+ const stopVision = startPetSpinner('正在识别图片内容(MiMo Vision)...');
539
757
  let imageDesc = '';
540
758
  try {
541
759
  const dataUri = await imgToVisionUri(imgPath);
542
760
  // 直接调用 mimo vision 获取描述
543
761
  const descResult = await callVisionDirect(dataUri);
762
+ stopVision();
544
763
  imageDesc = descResult;
545
764
  console.log(`${C.green}识图结果:${C.reset}${descResult.slice(0, 100)}...`);
546
765
  } catch (e) {
766
+ stopVision();
547
767
  console.log(`${C.red}识图失败:${e.message}${C.reset}`);
548
768
  console.log(`${C.gray}将继续使用(提示词优化可能不够精准)${C.reset}`);
549
769
  }
@@ -563,18 +783,21 @@ async function modeImage2Image() {
563
783
 
564
784
  if (!(await confirm('开始生成'))) return;
565
785
 
566
- console.log(`\n${C.yellow}[...] 正在生成图片...${C.reset}`);
786
+ console.log('');
787
+ const stopGen = startPetSpinner('正在生成图片...');
567
788
  try {
568
789
  const dataUri = await imgToDataUri(imgPath);
569
790
  const { url } = await generateImage({
570
791
  prompt: finalPrompt,
571
792
  params: { ratio, style: '', negativePrompt: negPrompt, imageUrls: [dataUri] },
572
793
  });
794
+ stopGen();
573
795
  const { filePath, asset } = await downloadResult(url, 'image', 'image/png', {
574
796
  prompt: finalPrompt, mode: 'img2img', params: { ratio },
575
797
  });
576
798
  await showResult(filePath, asset);
577
799
  } catch (e) {
800
+ stopGen();
578
801
  console.log(`${C.red}生成失败:${e.message}${C.reset}`);
579
802
  }
580
803
  await pause();
@@ -635,12 +858,14 @@ async function modeText2Video() {
635
858
 
636
859
  if (!(await confirm('开始生成'))) return;
637
860
 
638
- console.log(`\n${C.yellow}[...] 正在提交视频任务(Agnes ${config.agens.video.model})...${C.reset}`);
861
+ console.log('');
862
+ const stopSubmit = startPetSpinner(`正在提交视频任务(Agnes ${config.agens.video.model})...`);
639
863
  try {
640
864
  const { videoId } = await generateVideo({
641
865
  mode: 'text2video', prompt: finalPrompt, imageUrls: [],
642
866
  params: { ratio, resolution, duration, negativePrompt: negPrompt },
643
867
  });
868
+ stopSubmit();
644
869
  console.log(` 任务 ID:${videoId}`);
645
870
  console.log(` ${C.yellow}轮询进度(每 ${config.agens.pollInterval / 1000}s 刷新)...${C.reset}\n`);
646
871
 
@@ -654,6 +879,7 @@ async function modeText2Video() {
654
879
  console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
655
880
  }
656
881
  } catch (e) {
882
+ stopSubmit();
657
883
  console.log(`${C.red}生成失败:${e.message}${C.reset}`);
658
884
  }
659
885
  await pause();
@@ -687,13 +913,15 @@ async function modeImage2Video() {
687
913
 
688
914
  if (!(await confirm('开始生成'))) return;
689
915
 
690
- console.log(`\n${C.yellow}[...] 正在提交视频任务...${C.reset}`);
916
+ console.log('');
917
+ const stopSubmit = startPetSpinner('正在提交视频任务...');
691
918
  try {
692
919
  const b64 = await imgToB64(imgPath);
693
920
  const { videoId } = await generateVideo({
694
921
  mode: 'image2video', prompt: finalPrompt, imageUrls: [b64],
695
922
  params: { ratio, resolution, duration, negativePrompt: negPrompt },
696
923
  });
924
+ stopSubmit();
697
925
  console.log(` 任务 ID:${videoId}`);
698
926
  console.log(` ${C.yellow}轮询进度...${C.reset}\n`);
699
927
 
@@ -707,6 +935,7 @@ async function modeImage2Video() {
707
935
  console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
708
936
  }
709
937
  } catch (e) {
938
+ stopSubmit();
710
939
  console.log(`${C.red}生成失败:${e.message}${C.reset}`);
711
940
  }
712
941
  await pause();
@@ -750,13 +979,15 @@ async function modeMulti2Video() {
750
979
 
751
980
  if (!(await confirm('开始生成'))) return;
752
981
 
753
- console.log(`\n${C.yellow}[...] 正在提交视频任务...${C.reset}`);
982
+ console.log('');
983
+ const stopSubmit = startPetSpinner('正在提交视频任务...');
754
984
  try {
755
985
  const b64s = await Promise.all(images.map(imgToB64));
756
986
  const { videoId } = await generateVideo({
757
987
  mode: 'multi2video', prompt: finalPrompt, imageUrls: b64s,
758
988
  params: { ratio, resolution, duration, negativePrompt: negPrompt },
759
989
  });
990
+ stopSubmit();
760
991
  console.log(` 任务 ID:${videoId}`);
761
992
  console.log(` ${C.yellow}轮询进度...${C.reset}\n`);
762
993
 
@@ -770,6 +1001,7 @@ async function modeMulti2Video() {
770
1001
  console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
771
1002
  }
772
1003
  } catch (e) {
1004
+ stopSubmit();
773
1005
  console.log(`${C.red}生成失败:${e.message}${C.reset}`);
774
1006
  }
775
1007
  await pause();
@@ -815,13 +1047,15 @@ async function modeKeyframe() {
815
1047
 
816
1048
  if (!(await confirm('开始生成'))) return;
817
1049
 
818
- console.log(`\n${C.yellow}[...] 正在提交关键帧动画任务...${C.reset}`);
1050
+ console.log('');
1051
+ const stopSubmit = startPetSpinner('正在提交关键帧动画任务...');
819
1052
  try {
820
1053
  const b64s = await Promise.all(keyframes.map(imgToB64));
821
1054
  const { videoId } = await generateVideo({
822
1055
  mode: 'keyframe', prompt: finalPrompt, imageUrls: b64s,
823
1056
  params: { ratio, resolution, duration, negativePrompt: negPrompt },
824
1057
  });
1058
+ stopSubmit();
825
1059
  console.log(` 任务 ID:${videoId}`);
826
1060
  console.log(` ${C.yellow}轮询进度...${C.reset}\n`);
827
1061
 
@@ -835,6 +1069,7 @@ async function modeKeyframe() {
835
1069
  console.log(`${C.red}生成失败:${poll.error}${C.reset}`);
836
1070
  }
837
1071
  } catch (e) {
1072
+ stopSubmit();
838
1073
  console.log(`${C.red}生成失败:${e.message}${C.reset}`);
839
1074
  }
840
1075
  await pause();
@@ -861,59 +1096,88 @@ async function menuAssets() {
861
1096
  else if (c === '4') search = await ask('搜索关键词');
862
1097
  else if (c !== '1') { console.log(`${C.red}无效选择${C.reset}`); continue; }
863
1098
 
864
- const { items, total } = await assetStore.listAssets({ type, search, pageSize: 20 });
865
-
866
- if (items.length === 0) {
867
- console.log(`\n${C.gray}暂无素材${total > 0 ? `(共 ${total} 条,当前页为空)` : ''}${C.reset}`);
868
- continue;
869
- }
1099
+ const pageSize = 20;
1100
+ let page = 1;
870
1101
 
871
- console.log(`\n${C.bold}共 ${total} 条素材:${C.reset}\n`);
872
- items.forEach((a, i) => {
873
- const icon = a.type === 'video' ? '🎬' : '🖼';
874
- const prompt = (a.prompt || '').slice(0, 50);
875
- const note = a.note ? ` | ${a.note}` : '';
876
- console.log(` ${C.cyan}${String(i + 1).padStart(2)}.${C.reset} ${icon} ${a.filename}`);
877
- console.log(` ${C.gray}${prompt}${note}${C.reset}`);
878
- console.log(` ${formatSize(a.size)} | ${fmtDate(a.createdAt)} | ${a.kept ? C.green + '保留' + C.reset : C.gray + '未保留' + C.reset}`);
879
- });
1102
+ while (true) {
1103
+ const { items, total } = await assetStore.listAssets({ type, search, page, pageSize });
1104
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
1105
+ const startIdx = (page - 1) * pageSize;
880
1106
 
881
- // 操作
882
- console.log('');
883
- const op = await ask('输入序号查看详情/操作,或回车返回');
884
- if (!op) continue;
885
- const idx = parseInt(op, 10) - 1;
886
- if (isNaN(idx) || idx < 0 || idx >= items.length) { console.log(`${C.red}无效序号${C.reset}`); continue; }
887
-
888
- const asset = items[idx];
889
- const absPath = path.join(config.dirs.root, asset.path);
890
- console.log(`\n${C.bold}素材详情:${C.reset}`);
891
- console.log(` ID:${asset.id}`);
892
- console.log(` 文件:${absPath}`);
893
- console.log(` 提示词:${asset.prompt || '(无)'}`);
894
- console.log(` 模式:${asset.mode || '(无)'}`);
895
- console.log(` 大小:${formatSize(asset.size)}`);
896
- console.log(` 创建:${fmtDate(asset.createdAt)}`);
897
- console.log(` 保留:${asset.kept ? '是' : '否'}`);
898
- if (asset.note) console.log(` 备注:${asset.note}`);
899
-
900
- const action = await choose('操作', ['打开文件', '切换保留状态', '添加备注', '删除']);
901
- if (action === 0) {
902
- openFile(absPath);
903
- } else if (action === 1) {
904
- await assetStore.updateAsset(asset.id, { kept: !asset.kept });
905
- console.log(`${C.green}已${asset.kept ? '取消保留' : '标记保留'}${C.reset}`);
906
- } else if (action === 2) {
907
- const note = await ask('输入备注');
908
- if (note) {
909
- await assetStore.updateAsset(asset.id, { note });
910
- console.log(`${C.green}备注已保存${C.reset}`);
1107
+ if (items.length === 0) {
1108
+ console.log(`\n${C.gray}暂无素材${total > 0 ? `(共 ${total} 条,当前页为空)` : ''}${C.reset}`);
1109
+ break;
911
1110
  }
912
- } else if (action === 3) {
913
- if (await confirm(`确认删除 ${asset.filename}`)) {
914
- await assetStore.deleteAsset(asset.id);
915
- console.log(`${C.green}已删除${C.reset}`);
1111
+
1112
+ console.log(`\n${C.bold}共 ${total} 条素材(第 ${page}/${totalPages} 页):${C.reset}\n`);
1113
+ items.forEach((a, i) => {
1114
+ const globalIdx = startIdx + i + 1;
1115
+ const icon = a.type === 'video' ? '🎬' : '🖼';
1116
+ const prompt = (a.prompt || '').slice(0, 50);
1117
+ const note = a.note ? ` | ${a.note}` : '';
1118
+ console.log(` ${C.cyan}${String(globalIdx).padStart(3)}.${C.reset} ${icon} ${a.filename}`);
1119
+ console.log(` ${C.gray}${prompt}${note}${C.reset}`);
1120
+ console.log(` ${formatSize(a.size)} | ${fmtDate(a.createdAt)} | ${a.kept ? C.green + '保留' + C.reset : C.gray + '未保留' + C.reset}`);
1121
+ });
1122
+
1123
+ const navHints = [];
1124
+ if (page < totalPages) navHints.push('n=下一页');
1125
+ if (page > 1) navHints.push('p=上一页');
1126
+ const navStr = navHints.length ? `(${navHints.join(',')})` : '';
1127
+
1128
+ console.log('');
1129
+ const op = await ask(`输入序号查看详情${navStr},或回车返回`);
1130
+ if (!op) break;
1131
+
1132
+ if (op.toLowerCase() === 'n' && page < totalPages) { page++; continue; }
1133
+ if (op.toLowerCase() === 'p' && page > 1) { page--; continue; }
1134
+
1135
+ const num = parseInt(op, 10);
1136
+ if (isNaN(num) || num < 1 || num > total) { console.log(`${C.red}无效序号(范围 1-${total})${C.reset}`); continue; }
1137
+
1138
+ const targetPage = Math.ceil(num / pageSize);
1139
+ const localIdx = num - (targetPage - 1) * pageSize - 1;
1140
+ let asset;
1141
+ if (targetPage === page) {
1142
+ asset = items[localIdx];
1143
+ } else {
1144
+ const nextPageData = await assetStore.listAssets({ type, search, page: targetPage, pageSize });
1145
+ asset = nextPageData.items[localIdx];
1146
+ page = targetPage;
916
1147
  }
1148
+ if (asset) await showAssetDetail(asset);
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ async function showAssetDetail(asset) {
1154
+ const absPath = resolveAssetPath(asset);
1155
+ console.log(`\n${C.bold}素材详情:${C.reset}`);
1156
+ console.log(` ID:${asset.id}`);
1157
+ console.log(` 文件:${absPath}`);
1158
+ console.log(` 提示词:${asset.prompt || '(无)'}`);
1159
+ console.log(` 模式:${asset.mode || '(无)'}`);
1160
+ console.log(` 大小:${formatSize(asset.size)}`);
1161
+ console.log(` 创建:${fmtDate(asset.createdAt)}`);
1162
+ console.log(` 保留:${asset.kept ? '是' : '否'}`);
1163
+ if (asset.note) console.log(` 备注:${asset.note}`);
1164
+
1165
+ const action = await choose('操作', ['打开文件', '切换保留状态', '添加备注', '删除']);
1166
+ if (action === 0) {
1167
+ openFile(absPath);
1168
+ } else if (action === 1) {
1169
+ await assetStore.updateAsset(asset.id, { kept: !asset.kept });
1170
+ console.log(`${C.green}已${asset.kept ? '取消保留' : '标记保留'}${C.reset}`);
1171
+ } else if (action === 2) {
1172
+ const note = await ask('输入备注');
1173
+ if (note) {
1174
+ await assetStore.updateAsset(asset.id, { note });
1175
+ console.log(`${C.green}备注已保存${C.reset}`);
1176
+ }
1177
+ } else if (action === 3) {
1178
+ if (await confirm(`确认删除 ${asset.filename}`)) {
1179
+ await assetStore.deleteAsset(asset.id);
1180
+ console.log(`${C.green}已删除${C.reset}`);
917
1181
  }
918
1182
  }
919
1183
  }
@@ -948,6 +1212,7 @@ async function menuConfig() {
948
1212
 
949
1213
  console.log(`\n${C.cyan}── 目录 ──${C.reset}`);
950
1214
  console.log(` 项目根目录:${config.dirs.root}`);
1215
+ console.log(` 素材存储目录:${config.dirs.storageRoot}${config.dirs.storageRoot === config.dirs.root ? C.gray + '(默认,未自定义)' + C.reset : ''}`);
951
1216
  console.log(` 图片素材:${config.dirs.images}`);
952
1217
  console.log(` 视频素材:${config.dirs.videos}`);
953
1218
  console.log(` 数据目录:${config.dirs.data}`);
@@ -965,6 +1230,16 @@ async function menuConfig() {
965
1230
  console.log(`${C.gray} (暂无数据)${C.reset}`);
966
1231
  }
967
1232
 
1233
+ console.log(`\n${C.cyan}── 我的机器人 ──${C.reset}`);
1234
+ const pet = petStore.getState();
1235
+ if (pet) {
1236
+ console.log(` 名字:${pet.name}`);
1237
+ console.log(` 电量:${pet.energy}%(${pet.statusLabel})`);
1238
+ console.log(` 累计陪伴生成:${pet.totalGenerated} 次`);
1239
+ } else {
1240
+ console.log(`${C.gray} (暂无数据)${C.reset}`);
1241
+ }
1242
+
968
1243
  await pause();
969
1244
  }
970
1245
 
@@ -994,6 +1269,10 @@ async function main() {
994
1269
  console.log(`\n${C.green}${C.bold}Agens 创作工作台 CLI 已启动${C.reset}`);
995
1270
  const srcHint = config._configSource ? `(配置来源:${config._configSource})` : '';
996
1271
  console.log(`${C.gray}API 状态:${config.agens.image.apiKey ? '已配置' : '未配置'}${srcHint}${C.reset}\n`);
1272
+
1273
+ const greet = await petStore.init();
1274
+ if (greet) console.log(`${C.cyan}${greet}${C.reset}\n`);
1275
+
997
1276
  await mainMenu();
998
1277
  }
999
1278
 
package/config.js CHANGED
@@ -92,16 +92,23 @@ export const config = {
92
92
  },
93
93
 
94
94
  // 目录
95
- dirs: {
96
- root: __dirname,
97
- public: path.join(__dirname, 'public'),
98
- assets: path.join(__dirname, 'assets'),
99
- images: path.join(__dirname, 'assets', 'images'),
100
- videos: path.join(__dirname, 'assets', 'videos'),
101
- data: path.join(__dirname, 'data'),
102
- indexFile: path.join(__dirname, 'data', 'index.json'),
103
- uploads: path.join(__dirname, 'data', 'uploads'),
104
- },
95
+ // storageRoot:素材/数据实际存放的根目录,支持通过 storage_dir 自定义
96
+ // (默认等于项目代码目录 __dirname,和 root 相同;一旦自定义,root 和
97
+ // storageRoot 会分家 —— root 始终是代码位置,storageRoot 是素材位置)
98
+ dirs: (() => {
99
+ const storageRoot = ext_cfg.storage_dir ? path.resolve(ext_cfg.storage_dir) : __dirname;
100
+ return {
101
+ root: __dirname,
102
+ storageRoot,
103
+ public: path.join(__dirname, 'public'),
104
+ assets: path.join(storageRoot, 'assets'),
105
+ images: path.join(storageRoot, 'assets', 'images'),
106
+ videos: path.join(storageRoot, 'assets', 'videos'),
107
+ data: path.join(storageRoot, 'data'),
108
+ indexFile: path.join(storageRoot, 'data', 'index.json'),
109
+ uploads: path.join(storageRoot, 'data', 'uploads'),
110
+ };
111
+ })(),
105
112
 
106
113
  // 上传参考图限制
107
114
  upload: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agens-studio",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Agens 创作工作台 —— AI 图片/视频生成 CLI + Web 工作台",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -76,7 +76,7 @@ async function localImageToString(localPath, asDataUri) {
76
76
 
77
77
  async function agnesFetch(url, opts, options) {
78
78
  const opts2 = options || {};
79
- const retries = opts2.retries != null ? opts2.retries : 3;
79
+ const retries = opts2.retries != null ? opts2.retries : 5;
80
80
  const timeoutMs = opts2.timeoutMs != null ? opts2.timeoutMs : 120000;
81
81
  let lastErr;
82
82
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -87,8 +87,20 @@ async function agnesFetch(url, opts, options) {
87
87
  clearTimeout(timer);
88
88
  if ((res.status === 429 || res.status === 503) && attempt < retries) {
89
89
  const t = await res.text().catch(function () { return ''; });
90
- console.log('[agnes] ' + res.status + ' busy, retry ' + (attempt + 1) + ' in 5s: ' + t.slice(0, 100));
91
- await new Promise(function (r) { setTimeout(r, 5000); });
90
+ // 429 = 限频(1次/分钟),等 65 秒;503 = 服务忙,等 15
91
+ const waitSec = res.status === 429 ? 65 : 15;
92
+ const reason = res.status === 429 ? '限频' : '服务忙';
93
+ console.log('[agnes] ' + res.status + ' ' + reason + ',' + waitSec + '秒后重试 (' + (attempt + 1) + '/' + retries + ')');
94
+ // 倒计时显示(仅 429 长等待时显示)
95
+ if (waitSec >= 30) {
96
+ for (let s = waitSec; s > 0; s--) {
97
+ process.stdout.write('\r 等待中... ' + s + '秒 ');
98
+ await new Promise(function (r) { setTimeout(r, 1000); });
99
+ }
100
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
101
+ } else {
102
+ await new Promise(function (r) { setTimeout(r, waitSec * 1000); });
103
+ }
92
104
  continue;
93
105
  }
94
106
  return res;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 机器人「阿吉」渲染器(颜文字丰富版)
3
+ * ------------------------------------------------------------------
4
+ * 试过多行像素画(8x6 / 14x14 / 9x9 网格),结论是:不管怎么缩小,
5
+ * "每次回到主菜单都要重新打印一次"的多行画面天生和 CLI 菜单的空间
6
+ * 预算冲突——缩到还能看出天线/眼睛/手臂/腿,就不可能再小了。
7
+ *
8
+ * 改回单行颜文字,但不是回退到单一固定的 [o_o]:每个电量档位配一组
9
+ * 颜文字,随机/轮播挑选 + 配色,解决"太单调"的问题,同时保持单行
10
+ * \r 重绘(不会有多行光标回退错位的风险)。
11
+ * ------------------------------------------------------------------
12
+ */
13
+
14
+ const RESET = '\x1b[0m';
15
+
16
+ // 电量档位 → 前景色
17
+ const COLOR = {
18
+ full: 214, // 满电:明黄橙
19
+ ok: 43, // 元气满满:青绿
20
+ low: 130, // 电量不足:暗橙褐
21
+ empty: 240, // 快没电了:灰色
22
+ };
23
+
24
+ const TIER_LABEL = {
25
+ full: '满电',
26
+ ok: '元气满满',
27
+ low: '电量不足',
28
+ empty: '快没电了',
29
+ };
30
+
31
+ // 每个档位一组颜文字,避免总是同一个表情
32
+ const KAOMOJI = {
33
+ full: ['(≧◡≦)', '(★ω★)', '(๑>ᴗ<๑)', '(*≧▽≦)'],
34
+ ok: ['(´・ω・`)', '(◕‿◕)', '(^‿^)', '( ˘ᴗ˘ )'],
35
+ low: ['( ̄﹃ ̄)', '(¬_¬)', '(-_-)'],
36
+ empty: ['(x_x)', '(-.-)zzZ', '(u_u)'],
37
+ };
38
+
39
+ export function getTier(energy) {
40
+ if (energy >= 80) return 'full';
41
+ if (energy >= 50) return 'ok';
42
+ if (energy >= 20) return 'low';
43
+ return 'empty';
44
+ }
45
+
46
+ export function tierLabel(tier) {
47
+ return TIER_LABEL[tier] || '';
48
+ }
49
+
50
+ function fg(colorCode) {
51
+ return `\x1b[38;5;${colorCode}m`;
52
+ }
53
+
54
+ /**
55
+ * 随机挑一个该档位的颜文字(配色跟着档位走)。主菜单展示用——
56
+ * 每次重绘菜单都随机挑一次,避免每次看到的都是同一个表情。
57
+ * @param {string} tier
58
+ * @returns {string} 带颜色转义码的颜文字
59
+ */
60
+ export function pickFace(tier) {
61
+ const pool = KAOMOJI[tier] || KAOMOJI.ok;
62
+ const face = pool[Math.floor(Math.random() * pool.length)];
63
+ return `${fg(COLOR[tier] ?? COLOR.ok)}${face}${RESET}`;
64
+ }
65
+
66
+ /**
67
+ * 取该档位第 index 个颜文字(按顺序轮播,供等待动画用)。
68
+ * @param {string} tier
69
+ * @param {number} index
70
+ * @returns {string} 带颜色转义码的颜文字
71
+ */
72
+ export function faceAt(tier, index) {
73
+ const pool = KAOMOJI[tier] || KAOMOJI.ok;
74
+ const face = pool[index % pool.length];
75
+ return `${fg(COLOR[tier] ?? COLOR.ok)}${face}${RESET}`;
76
+ }
77
+
78
+ /** 等待动画用的旋转指示符(经典 braille spinner,逐帧切换) */
79
+ export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { USER_CONFIG_DIR } from '../../config.js';
5
+ import { getTier, tierLabel, pickFace } from './petArt.js';
6
+
7
+ /**
8
+ * 机器人宠物「阿吉」
9
+ * ------------------------------------------------------------------
10
+ * 轻量陪伴:靠你的创作「充电」——生成成功涨电量,长期不打开 CLI 会掉电量。
11
+ * 纯 ASCII 颜文字展示,不用 emoji,避免不同终端/字体下宽度错位。
12
+ *
13
+ * 数据结构:~/.agens-cli/pet.json
14
+ * {
15
+ * name, energy(0-100), totalGenerated, createdAt, lastSeenAt
16
+ * }
17
+ * ------------------------------------------------------------------
18
+ */
19
+
20
+ const PET_FILE = path.join(USER_CONFIG_DIR, 'pet.json');
21
+
22
+ const DEFAULT_NAME = '阿吉';
23
+ const MAX_ENERGY = 100;
24
+ const DECAY_PER_DAY = 5; // 每隔一天没打开 CLI,掉多少电量
25
+ const GAIN_PER_SUCCESS = 6; // 每次成功生成,涨多少电量
26
+
27
+ const SUCCESS_LINES = [
28
+ '叮~能量 +{gain},创作真快乐!',
29
+ '又完成一件作品,我悄悄充了点电~',
30
+ '嗡嗡嗡,电路都被这波灵感点亮了!',
31
+ '不错不错,我的电量条又长长了一截。',
32
+ '创作即燃料,我现在浑身是电!',
33
+ ];
34
+
35
+ const WELCOME_BACK_LINES = [
36
+ '你好久没来啦,电量都快耗尽了,快来创作充充电吧!',
37
+ '滴……检测到你离开了 {days} 天,我有点想你了。',
38
+ '电量掉了不少,赶紧生成点作品给我充充电~',
39
+ ];
40
+
41
+ let _state = null;
42
+
43
+ function nowTs() { return Date.now(); }
44
+
45
+ function defaultState() {
46
+ const ts = nowTs();
47
+ return {
48
+ name: DEFAULT_NAME,
49
+ energy: 70,
50
+ totalGenerated: 0,
51
+ createdAt: ts,
52
+ lastSeenAt: ts,
53
+ };
54
+ }
55
+
56
+ function pick(pool) { return pool[Math.floor(Math.random() * pool.length)]; }
57
+
58
+ async function save() {
59
+ if (!_state) return;
60
+ await fs.mkdir(USER_CONFIG_DIR, { recursive: true });
61
+ await fs.writeFile(PET_FILE, JSON.stringify(_state, null, 2), 'utf8');
62
+ }
63
+
64
+ async function load() {
65
+ try {
66
+ const raw = await fs.readFile(PET_FILE, 'utf8');
67
+ const parsed = JSON.parse(raw);
68
+ if (parsed && typeof parsed === 'object' && parsed.name) return parsed;
69
+ } catch { /* 不存在或损坏,走默认值 */ }
70
+ return defaultState();
71
+ }
72
+
73
+ /**
74
+ * 启动时调用:加载状态,按离开天数衰减电量,更新 lastSeenAt。
75
+ * @returns {Promise<string|null>} 若离开 ≥1 天,返回一条「久别重逢」台词;否则 null
76
+ */
77
+ export async function init() {
78
+ _state = await load();
79
+
80
+ const days = Math.floor((nowTs() - _state.lastSeenAt) / 86400000);
81
+ let welcomeLine = null;
82
+ if (days >= 1) {
83
+ _state.energy = Math.max(0, _state.energy - days * DECAY_PER_DAY);
84
+ welcomeLine = pick(WELCOME_BACK_LINES).replace('{days}', String(days));
85
+ }
86
+ _state.lastSeenAt = nowTs();
87
+ await save();
88
+ return welcomeLine;
89
+ }
90
+
91
+ /** 重命名机器人 */
92
+ export async function setName(name) {
93
+ if (!_state) await init();
94
+ const n = (name || '').trim();
95
+ if (n) _state.name = n;
96
+ await save();
97
+ }
98
+
99
+ /**
100
+ * 一次成功生成后调用:涨电量 + 累计计数
101
+ * @param {'image'|'video'} type
102
+ * @returns {Promise<{line: string}>}
103
+ */
104
+ export async function onGenerateSuccess(type) {
105
+ if (!_state) await init();
106
+ _state.energy = Math.min(MAX_ENERGY, _state.energy + GAIN_PER_SUCCESS);
107
+ _state.totalGenerated += 1;
108
+ await save();
109
+ return { line: pick(SUCCESS_LINES).replace('{gain}', String(GAIN_PER_SUCCESS)) };
110
+ }
111
+
112
+ /** 简易电量条 */
113
+ function energyBar(energy, w = 10) {
114
+ const filled = Math.round((w * energy) / 100);
115
+ return '[' + '#'.repeat(filled) + '.'.repeat(w - filled) + ']';
116
+ }
117
+
118
+ /**
119
+ * 主菜单展示用:单行颜文字 + 名字/电量条/状态词。
120
+ * 每次调用随机挑一个该档位的颜文字,同样是"元气满满"也不会总看到
121
+ * 同一张脸,缓解单行方案容易显得单调的问题。
122
+ * @returns {string} 单行文本,直接 console.log 即可
123
+ */
124
+ export function renderBanner() {
125
+ if (!_state) return '';
126
+ const tier = getTier(_state.energy);
127
+ const face = pickFace(tier);
128
+ return ` ${face} ${_state.name} ${energyBar(_state.energy)} ${_state.energy}% ${tierLabel(tier)}`;
129
+ }
130
+
131
+ /** 配置页展示用的详细状态 */
132
+ export function getState() {
133
+ if (!_state) return null;
134
+ return { ..._state, statusLabel: tierLabel(getTier(_state.energy)) };
135
+ }
136
+
137
+ /** 当前电量档位(供 cli.js 的等待动画取用,避免动画循环里重复算电量) */
138
+ export function getTierNow() {
139
+ if (!_state) return 'ok';
140
+ return getTier(_state.energy);
141
+ }