agens-studio 0.1.6 → 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 +283 -33
- package/config.js +17 -10
- package/package.json +1 -1
- package/src/services/petArt.js +79 -0
- package/src/services/petStore.js +141 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
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}
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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();
|
|
@@ -916,7 +1151,7 @@ async function menuAssets() {
|
|
|
916
1151
|
}
|
|
917
1152
|
|
|
918
1153
|
async function showAssetDetail(asset) {
|
|
919
|
-
const absPath =
|
|
1154
|
+
const absPath = resolveAssetPath(asset);
|
|
920
1155
|
console.log(`\n${C.bold}素材详情:${C.reset}`);
|
|
921
1156
|
console.log(` ID:${asset.id}`);
|
|
922
1157
|
console.log(` 文件:${absPath}`);
|
|
@@ -977,6 +1212,7 @@ async function menuConfig() {
|
|
|
977
1212
|
|
|
978
1213
|
console.log(`\n${C.cyan}── 目录 ──${C.reset}`);
|
|
979
1214
|
console.log(` 项目根目录:${config.dirs.root}`);
|
|
1215
|
+
console.log(` 素材存储目录:${config.dirs.storageRoot}${config.dirs.storageRoot === config.dirs.root ? C.gray + '(默认,未自定义)' + C.reset : ''}`);
|
|
980
1216
|
console.log(` 图片素材:${config.dirs.images}`);
|
|
981
1217
|
console.log(` 视频素材:${config.dirs.videos}`);
|
|
982
1218
|
console.log(` 数据目录:${config.dirs.data}`);
|
|
@@ -994,6 +1230,16 @@ async function menuConfig() {
|
|
|
994
1230
|
console.log(`${C.gray} (暂无数据)${C.reset}`);
|
|
995
1231
|
}
|
|
996
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
|
+
|
|
997
1243
|
await pause();
|
|
998
1244
|
}
|
|
999
1245
|
|
|
@@ -1023,6 +1269,10 @@ async function main() {
|
|
|
1023
1269
|
console.log(`\n${C.green}${C.bold}Agens 创作工作台 CLI 已启动${C.reset}`);
|
|
1024
1270
|
const srcHint = config._configSource ? `(配置来源:${config._configSource})` : '';
|
|
1025
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
|
+
|
|
1026
1276
|
await mainMenu();
|
|
1027
1277
|
}
|
|
1028
1278
|
|
package/config.js
CHANGED
|
@@ -92,16 +92,23 @@ export const config = {
|
|
|
92
92
|
},
|
|
93
93
|
|
|
94
94
|
// 目录
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
@@ -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
|
+
}
|