yuangs 1.3.34 → 1.3.37
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/.weaver/evidence.log +0 -0
- package/cli.js +229 -22
- package/index.js +145 -16
- package/package.json +5 -2
- package/test/macros.test.js +91 -0
- package/todo.md +273 -0
- package/yuangs.config.json +9 -0
|
File without changes
|
package/cli.js
CHANGED
|
@@ -2,11 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
const yuangs = require('./index.js');
|
|
4
4
|
const chalk = require('chalk');
|
|
5
|
-
const { version } = require('./package.json');
|
|
5
|
+
const { version } = require('./package.json');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const ora = require('ora').default;
|
|
9
|
+
const { marked } = require('marked');
|
|
10
|
+
const TerminalRenderer = require('marked-terminal').default;
|
|
11
|
+
const { exec } = require('child_process');
|
|
12
|
+
|
|
13
|
+
marked.setOptions({
|
|
14
|
+
renderer: new TerminalRenderer({
|
|
15
|
+
code: chalk.yellow,
|
|
16
|
+
heading: chalk.magenta.bold,
|
|
17
|
+
firstHeading: chalk.magenta.underline.bold,
|
|
18
|
+
listitem: chalk.cyan,
|
|
19
|
+
table: chalk.white,
|
|
20
|
+
strong: chalk.bold.red,
|
|
21
|
+
em: chalk.italic
|
|
22
|
+
})
|
|
23
|
+
});
|
|
6
24
|
|
|
7
25
|
const args = process.argv.slice(2);
|
|
8
26
|
const command = args[0];
|
|
9
27
|
|
|
28
|
+
async function readStdin() {
|
|
29
|
+
if (process.stdin.isTTY) return '';
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
let data = '';
|
|
32
|
+
process.stdin.setEncoding('utf8');
|
|
33
|
+
process.stdin.on('data', (chunk) => {
|
|
34
|
+
data += chunk;
|
|
35
|
+
});
|
|
36
|
+
process.stdin.on('end', () => {
|
|
37
|
+
resolve(data);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
function printHelp() {
|
|
11
43
|
console.log(chalk.bold.cyan('\n🎨 苑广山的个人应用启动器\n'));
|
|
12
44
|
console.log(chalk.yellow(`当前版本: ${version}`)); // 显示版本号
|
|
@@ -35,6 +67,11 @@ function printHelp() {
|
|
|
35
67
|
console.log(` ${chalk.gray('--model, -m <模型名称>')} 指定 AI 模型 (可选)`);
|
|
36
68
|
console.log(` ${chalk.gray('-p -f -l')} 指定 pro,flash,lite 模型 (可选)`);
|
|
37
69
|
console.log(` ${chalk.gray('-e <描述>')} 生成 Linux 命令`);
|
|
70
|
+
console.log(` ${chalk.green('config')} <key> <value> 设置配置项`);
|
|
71
|
+
console.log(` ${chalk.green('history')} 查看命令历史`);
|
|
72
|
+
console.log(` ${chalk.green('save')} <名称> 创建快捷指令`);
|
|
73
|
+
console.log(` ${chalk.green('run')} <名称> 执行快捷指令`);
|
|
74
|
+
console.log(` ${chalk.green('macros')} 查看所有快捷指令`);
|
|
38
75
|
console.log(` ${chalk.green('help')} 显示帮助信息\n`);
|
|
39
76
|
console.log(chalk.bold('AI 交互模式命令:'));
|
|
40
77
|
console.log(` ${chalk.gray('/clear')} 清空对话历史`);
|
|
@@ -51,50 +88,83 @@ function printSuccess(app, url) {
|
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
async function askOnce(question, model) {
|
|
54
|
-
const startTime = Date.now();
|
|
91
|
+
const startTime = Date.now();
|
|
55
92
|
let i = 0;
|
|
56
93
|
const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
57
94
|
const interval = setInterval(() => {
|
|
58
|
-
const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
|
|
95
|
+
const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
|
|
59
96
|
process.stdout.write(chalk.cyan(`\r${spinner[i++ % spinner.length]} 正在请求 AI,请稍候... (${elapsedTime}s}`));
|
|
60
97
|
}, 100);
|
|
61
98
|
|
|
62
99
|
try {
|
|
63
|
-
// For single requests (non-interactive mode), we may want to include history
|
|
64
|
-
// For now, use history for all requests, but we could make this configurable
|
|
65
100
|
const answer = await yuangs.getAIAnswer(question, model, true);
|
|
66
101
|
clearInterval(interval);
|
|
67
102
|
|
|
68
|
-
// Clear the spinner line if possible
|
|
69
103
|
if (process.stdout.clearLine) {
|
|
70
104
|
process.stdout.clearLine(0);
|
|
71
105
|
process.stdout.cursorTo(0);
|
|
72
106
|
} else {
|
|
73
|
-
process.stdout.write('\r');
|
|
107
|
+
process.stdout.write('\r');
|
|
74
108
|
}
|
|
75
109
|
|
|
76
|
-
const totalElapsedTime = (Date.now() - startTime) / 1000;
|
|
110
|
+
const totalElapsedTime = (Date.now() - startTime) / 1000;
|
|
77
111
|
if (answer && answer.explanation) {
|
|
78
|
-
console.log(
|
|
79
|
-
console.log(answer.explanation);
|
|
112
|
+
console.log(marked(answer.explanation));
|
|
80
113
|
} else {
|
|
81
114
|
console.log(chalk.yellow('AI 未返回有效内容。'));
|
|
82
115
|
}
|
|
83
|
-
console.log(chalk.gray(`\n请求耗时: ${totalElapsedTime.toFixed(2)}s\n`));
|
|
116
|
+
console.log(chalk.gray(`\n请求耗时: ${totalElapsedTime.toFixed(2)}s\n`));
|
|
84
117
|
} catch (error) {
|
|
85
118
|
clearInterval(interval);
|
|
86
119
|
|
|
87
|
-
// Clear the spinner line if possible
|
|
88
120
|
if (process.stdout.clearLine) {
|
|
89
121
|
process.stdout.clearLine(0);
|
|
90
122
|
process.stdout.cursorTo(0);
|
|
91
123
|
} else {
|
|
92
|
-
process.stdout.write('\r');
|
|
124
|
+
process.stdout.write('\r');
|
|
93
125
|
}
|
|
94
126
|
|
|
95
|
-
const totalElapsedTime = (Date.now() - startTime) / 1000;
|
|
127
|
+
const totalElapsedTime = (Date.now() - startTime) / 1000;
|
|
96
128
|
console.error(chalk.red('处理 AI 请求时出错:'), error.message || error);
|
|
97
|
-
console.log(chalk.gray(`\n请求耗时: ${totalElapsedTime.toFixed(2)}s\n`));
|
|
129
|
+
console.log(chalk.gray(`\n请求耗时: ${totalElapsedTime.toFixed(2)}s\n`));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function askOnceStream(question, model) {
|
|
134
|
+
const startTime = Date.now();
|
|
135
|
+
let messages = [...yuangs.getConversationHistory()];
|
|
136
|
+
messages.push({ role: 'user', content: question });
|
|
137
|
+
|
|
138
|
+
const spinner = ora(chalk.cyan('AI 正在思考...')).start();
|
|
139
|
+
let fullResponse = '';
|
|
140
|
+
let firstChunk = true;
|
|
141
|
+
|
|
142
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await yuangs.callAI_Stream(messages, model, (chunk) => {
|
|
146
|
+
if (spinner.isSpinning) {
|
|
147
|
+
spinner.stop();
|
|
148
|
+
}
|
|
149
|
+
fullResponse += chunk;
|
|
150
|
+
process.stdout.write(chunk);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
yuangs.addToConversationHistory('user', question);
|
|
154
|
+
yuangs.addToConversationHistory('assistant', fullResponse);
|
|
155
|
+
|
|
156
|
+
// 打印优美的分割线和 Markdown 渲染结果
|
|
157
|
+
console.log('');
|
|
158
|
+
console.log(chalk.gray('─'.repeat(Math.min(80, process.stdout.columns || 80))));
|
|
159
|
+
console.log(marked(fullResponse));
|
|
160
|
+
|
|
161
|
+
const totalElapsedTime = (Date.now() - startTime) / 1000;
|
|
162
|
+
console.log(chalk.gray(`\n\n请求耗时: ${totalElapsedTime.toFixed(2)}s\n`));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (spinner.isSpinning) {
|
|
165
|
+
spinner.fail(chalk.red('AI 响应出错'));
|
|
166
|
+
}
|
|
167
|
+
console.error(error.message);
|
|
98
168
|
}
|
|
99
169
|
}
|
|
100
170
|
|
|
@@ -105,6 +175,12 @@ async function handleAICommand() {
|
|
|
105
175
|
let questionParts = commandArgs;
|
|
106
176
|
let isExecMode = false;
|
|
107
177
|
|
|
178
|
+
const stdinContent = await readStdin();
|
|
179
|
+
|
|
180
|
+
if (stdinContent) {
|
|
181
|
+
questionParts.unshift(stdinContent);
|
|
182
|
+
}
|
|
183
|
+
|
|
108
184
|
// Check for -e flag
|
|
109
185
|
const execIndex = commandArgs.indexOf('-e');
|
|
110
186
|
if (execIndex !== -1) {
|
|
@@ -218,11 +294,13 @@ async function handleAICommand() {
|
|
|
218
294
|
|
|
219
295
|
const { spawn } = require('child_process');
|
|
220
296
|
console.log(chalk.gray('正在执行...'));
|
|
221
|
-
// Use shell: true to support pipes, redirects, etc.
|
|
222
297
|
const child = spawn(finalCommand, [], { shell: true, stdio: 'inherit' });
|
|
223
298
|
|
|
224
299
|
child.on('close', (code) => {
|
|
225
|
-
if (code
|
|
300
|
+
if (code === 0) {
|
|
301
|
+
yuangs.saveSuccessfulCommand(question, finalCommand);
|
|
302
|
+
console.log(chalk.green('\n✓ 执行成功并已存入历史库'));
|
|
303
|
+
} else {
|
|
226
304
|
console.log(chalk.red(`\n命令执行失败 (退出码: ${code})`));
|
|
227
305
|
}
|
|
228
306
|
process.exit(code);
|
|
@@ -288,11 +366,10 @@ async function handleAICommand() {
|
|
|
288
366
|
}
|
|
289
367
|
|
|
290
368
|
if (!trimmed) {
|
|
291
|
-
return askLoop();
|
|
369
|
+
return askLoop();
|
|
292
370
|
}
|
|
293
371
|
|
|
294
|
-
|
|
295
|
-
await askOnce(trimmed, model);
|
|
372
|
+
await askOnceStream(trimmed, model);
|
|
296
373
|
askLoop();
|
|
297
374
|
});
|
|
298
375
|
};
|
|
@@ -303,7 +380,7 @@ async function handleAICommand() {
|
|
|
303
380
|
}
|
|
304
381
|
|
|
305
382
|
// 有问题时,直接请求一次
|
|
306
|
-
await
|
|
383
|
+
await askOnceStream(question, model);
|
|
307
384
|
}
|
|
308
385
|
|
|
309
386
|
// Check if the command matches one of the configured apps
|
|
@@ -333,13 +410,143 @@ switch (command) {
|
|
|
333
410
|
case 'ai':
|
|
334
411
|
handleAICommand();
|
|
335
412
|
break;
|
|
413
|
+
case 'config':
|
|
414
|
+
const key = args[1];
|
|
415
|
+
const value = args[2];
|
|
416
|
+
if (!key || !value) {
|
|
417
|
+
console.log(chalk.cyan('\n⚙️ 配置帮助: yuangs config <key> <value>'));
|
|
418
|
+
console.log(chalk.gray('当前配置:'), yuangs.getUserConfig());
|
|
419
|
+
console.log(chalk.gray('\n可用配置:'));
|
|
420
|
+
console.log(chalk.gray(' defaultModel 默认AI模型 (如: Assistant, gemini-pro-latest)'));
|
|
421
|
+
console.log(chalk.gray(' aiProxyUrl AI代理地址'));
|
|
422
|
+
console.log(chalk.gray(' accountType 账户类型 (如: free, pro)\n'));
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
const config = yuangs.getUserConfig();
|
|
426
|
+
config[key] = value;
|
|
427
|
+
fs.writeFileSync(path.join(require('os').homedir(), '.yuangs.json'), JSON.stringify(config, null, 2));
|
|
428
|
+
console.log(chalk.green(`✓ 已更新 ${key}`));
|
|
429
|
+
break;
|
|
430
|
+
case 'history':
|
|
431
|
+
const history = yuangs.getCommandHistory();
|
|
432
|
+
if (history.length === 0) {
|
|
433
|
+
console.log(chalk.gray('暂无命令历史\n'));
|
|
434
|
+
} else {
|
|
435
|
+
console.log(chalk.bold.cyan('\n📋 命令历史\n'));
|
|
436
|
+
console.log(chalk.gray('─────────────────────────────────────────────────'));
|
|
437
|
+
history.forEach((item, index) => {
|
|
438
|
+
console.log(chalk.white(`${index + 1}. ${item.command}`));
|
|
439
|
+
console.log(chalk.gray(` 问题: ${item.question}`));
|
|
440
|
+
console.log(chalk.gray(` 时间: ${item.time}\n`));
|
|
441
|
+
});
|
|
442
|
+
console.log(chalk.gray('─────────────────────────────────────────────────\n'));
|
|
443
|
+
}
|
|
444
|
+
break;
|
|
445
|
+
case 'save':
|
|
446
|
+
const macroName = args[1];
|
|
447
|
+
if (!macroName) {
|
|
448
|
+
console.log(chalk.red('\n错误: 请指定快捷指令名称'));
|
|
449
|
+
console.log(chalk.gray('用法: yuangs save <名称>\n'));
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const readline = require('readline');
|
|
454
|
+
const saveRl = readline.createInterface({
|
|
455
|
+
input: process.stdin,
|
|
456
|
+
output: process.stdout
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
console.log(chalk.cyan(`\n正在创建快捷指令 "${macroName}"...`));
|
|
460
|
+
console.log(chalk.gray('请输入要保存的命令(多行命令用 && 或 ; 分隔,输入空行结束):\n'));
|
|
461
|
+
|
|
462
|
+
let commandLines = [];
|
|
463
|
+
const askCommand = () => {
|
|
464
|
+
saveRl.question(chalk.green('> '), (line) => {
|
|
465
|
+
if (line.trim() === '') {
|
|
466
|
+
if (commandLines.length === 0) {
|
|
467
|
+
console.log(chalk.yellow('\n未输入命令,已取消'));
|
|
468
|
+
saveRl.close();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
saveRl.question(chalk.cyan('请输入描述(可选): '), (desc) => {
|
|
473
|
+
const commands = commandLines.map(cmd => cmd.trim()).join(' && ');
|
|
474
|
+
yuangs.saveMacro(macroName, commands, desc.trim());
|
|
475
|
+
console.log(chalk.green(`\n✓ 快捷指令 "${macroName}" 已保存\n`));
|
|
476
|
+
saveRl.close();
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
commandLines.push(line);
|
|
481
|
+
askCommand();
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
askCommand();
|
|
485
|
+
break;
|
|
486
|
+
case 'run':
|
|
487
|
+
const runMacroName = args[1];
|
|
488
|
+
if (!runMacroName) {
|
|
489
|
+
console.log(chalk.red('\n错误: 请指定快捷指令名称'));
|
|
490
|
+
console.log(chalk.gray('用法: yuangs run <名称>\n'));
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const macros = yuangs.getMacros();
|
|
495
|
+
if (!macros[runMacroName]) {
|
|
496
|
+
console.log(chalk.red(`\n错误: 快捷指令 "${runMacroName}" 不存在`));
|
|
497
|
+
console.log(chalk.gray('使用 "yuangs macros" 查看所有快捷指令\n'));
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const macro = macros[runMacroName];
|
|
502
|
+
console.log(chalk.cyan(`\n执行快捷指令: ${runMacroName}`));
|
|
503
|
+
if (macro.description) {
|
|
504
|
+
console.log(chalk.gray(`描述: ${macro.description}`));
|
|
505
|
+
}
|
|
506
|
+
console.log(chalk.gray(`命令: ${macro.commands}\n`));
|
|
507
|
+
|
|
508
|
+
const { spawn } = require('child_process');
|
|
509
|
+
const child = spawn(macro.commands, [], { shell: true, stdio: 'inherit' });
|
|
510
|
+
|
|
511
|
+
child.on('close', (code) => {
|
|
512
|
+
if (code !== 0) {
|
|
513
|
+
console.error(chalk.red(`\n快捷指令执行失败 (退出码: ${code})`));
|
|
514
|
+
process.exit(code);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
break;
|
|
518
|
+
case 'macros':
|
|
519
|
+
const allMacros = yuangs.getMacros();
|
|
520
|
+
const macroNames = Object.keys(allMacros);
|
|
521
|
+
|
|
522
|
+
if (macroNames.length === 0) {
|
|
523
|
+
console.log(chalk.gray('暂无快捷指令\n'));
|
|
524
|
+
console.log(chalk.gray('使用 "yuangs save <名称>" 创建快捷指令\n'));
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
console.log(chalk.bold.cyan('\n🚀 快捷指令列表\n'));
|
|
529
|
+
console.log(chalk.gray('─────────────────────────────────────────────────'));
|
|
530
|
+
macroNames.forEach(name => {
|
|
531
|
+
const m = allMacros[name];
|
|
532
|
+
console.log(chalk.white(` ${name}`));
|
|
533
|
+
if (m.description) {
|
|
534
|
+
console.log(chalk.gray(` 描述: ${m.description}`));
|
|
535
|
+
}
|
|
536
|
+
console.log(chalk.gray(` 命令: ${m.commands}`));
|
|
537
|
+
console.log('');
|
|
538
|
+
});
|
|
539
|
+
console.log(chalk.gray('─────────────────────────────────────────────────\n'));
|
|
540
|
+
console.log(chalk.gray('使用:'));
|
|
541
|
+
console.log(chalk.gray(' yuangs run <名称> 执行快捷指令'));
|
|
542
|
+
console.log(chalk.gray(' yuangs save <名称> 创建新快捷指令\n'));
|
|
543
|
+
break;
|
|
336
544
|
case 'help':
|
|
337
545
|
case '--help':
|
|
338
546
|
case '-h':
|
|
339
547
|
printHelp();
|
|
340
548
|
break;
|
|
341
549
|
default:
|
|
342
|
-
// If it's an app command but not one of the named ones, handle it with the dynamic function
|
|
343
550
|
if (isAppCommand) {
|
|
344
551
|
printSuccess(command, yuangs.urls[command]);
|
|
345
552
|
yuangs.openApp(command);
|
package/index.js
CHANGED
|
@@ -2,11 +2,72 @@ const { exec } = require('child_process');
|
|
|
2
2
|
const axios = require('axios');
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
|
|
6
7
|
// Store conversation history
|
|
7
8
|
// 存储结构标准为: [{ role: 'user', content: '...' }, { role: 'assistant', content: '...' }]
|
|
8
9
|
let conversationHistory = [];
|
|
9
10
|
|
|
11
|
+
const HISTORY_FILE = path.join(os.homedir(), '.yuangs_cmd_history.json');
|
|
12
|
+
const CONFIG_FILE = path.join(os.homedir(), '.yuangs.json');
|
|
13
|
+
const MACROS_FILE = path.join(os.homedir(), '.yuangs_macros.json');
|
|
14
|
+
|
|
15
|
+
function getUserConfig() {
|
|
16
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
19
|
+
} catch (e) {}
|
|
20
|
+
}
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getCommandHistory() {
|
|
25
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8'));
|
|
28
|
+
} catch (e) {}
|
|
29
|
+
}
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveSuccessfulCommand(question, command) {
|
|
34
|
+
if (!command) return;
|
|
35
|
+
let history = getCommandHistory();
|
|
36
|
+
const newEntry = { question, command, time: new Date().toLocaleString() };
|
|
37
|
+
history = [newEntry, ...history.filter(item => item.command !== command)].slice(0, 5);
|
|
38
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getMacros() {
|
|
42
|
+
if (fs.existsSync(MACROS_FILE)) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(MACROS_FILE, 'utf8'));
|
|
45
|
+
} catch (e) {}
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveMacro(name, commands, description = '') {
|
|
51
|
+
const macros = getMacros();
|
|
52
|
+
macros[name] = {
|
|
53
|
+
commands,
|
|
54
|
+
description,
|
|
55
|
+
createdAt: new Date().toISOString()
|
|
56
|
+
};
|
|
57
|
+
fs.writeFileSync(MACROS_FILE, JSON.stringify(macros, null, 2));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deleteMacro(name) {
|
|
62
|
+
const macros = getMacros();
|
|
63
|
+
if (macros[name]) {
|
|
64
|
+
delete macros[name];
|
|
65
|
+
fs.writeFileSync(MACROS_FILE, JSON.stringify(macros, null, 2));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
10
71
|
// Default apps (fallback if no config file exists)
|
|
11
72
|
const DEFAULT_APPS = {
|
|
12
73
|
shici: 'https://wealth.want.biz/shici/index.html',
|
|
@@ -103,21 +164,90 @@ function getConversationHistory() {
|
|
|
103
164
|
/**
|
|
104
165
|
* 通用 AI 调用函数 (OpenAI 兼容接口)
|
|
105
166
|
*/
|
|
167
|
+
async function callAI_Stream(messages, model, onChunk) {
|
|
168
|
+
const config = getUserConfig();
|
|
169
|
+
const url = config.aiProxyUrl || 'https://aiproxy.want.biz/v1/chat/completions';
|
|
170
|
+
|
|
171
|
+
const response = await axios({
|
|
172
|
+
method: 'post',
|
|
173
|
+
url: url,
|
|
174
|
+
data: {
|
|
175
|
+
model: model || config.defaultModel || 'Assistant',
|
|
176
|
+
messages: messages,
|
|
177
|
+
stream: true
|
|
178
|
+
},
|
|
179
|
+
responseType: 'stream',
|
|
180
|
+
headers: {
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
'X-Client-ID': 'npm_yuangs',
|
|
183
|
+
'Origin': 'https://cli.want.biz',
|
|
184
|
+
'Referer': 'https://cli.want.biz/',
|
|
185
|
+
'account': config.accountType || 'free',
|
|
186
|
+
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
|
187
|
+
'Accept': 'application/json'
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
let buffer = '';
|
|
193
|
+
response.data.on('data', chunk => {
|
|
194
|
+
buffer += chunk.toString();
|
|
195
|
+
let lines = buffer.split('\n');
|
|
196
|
+
// 数组的最后一个元素可能是不完整的行,留到下一次处理
|
|
197
|
+
buffer = lines.pop();
|
|
198
|
+
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
const trimmedLine = line.trim();
|
|
201
|
+
if (trimmedLine.startsWith('data: ')) {
|
|
202
|
+
const data = trimmedLine.slice(6);
|
|
203
|
+
if (data === '[DONE]') {
|
|
204
|
+
resolve();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(data);
|
|
209
|
+
const content = parsed.choices[0]?.delta?.content || '';
|
|
210
|
+
if (content) onChunk(content);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
// 如果这一行真的有问题,忽略它
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
response.data.on('error', reject);
|
|
218
|
+
response.data.on('end', () => {
|
|
219
|
+
// 处理缓冲区中剩余的内容(如果有)
|
|
220
|
+
if (buffer.trim().startsWith('data: ')) {
|
|
221
|
+
try {
|
|
222
|
+
const data = buffer.trim().slice(6);
|
|
223
|
+
if (data !== '[DONE]') {
|
|
224
|
+
const parsed = JSON.parse(data);
|
|
225
|
+
const content = parsed.choices[0]?.delta?.content || '';
|
|
226
|
+
if (content) onChunk(content);
|
|
227
|
+
}
|
|
228
|
+
} catch (e) {}
|
|
229
|
+
}
|
|
230
|
+
resolve();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
106
235
|
async function callAI_OpenAI(messages, model) {
|
|
107
|
-
const
|
|
236
|
+
const config = getUserConfig();
|
|
237
|
+
const url = config.aiProxyUrl || 'https://aiproxy.want.biz/v1/chat/completions';
|
|
108
238
|
|
|
109
239
|
const headers = {
|
|
110
240
|
'Content-Type': 'application/json',
|
|
111
|
-
'X-Client-ID': 'npm_yuangs',
|
|
112
|
-
'Origin': 'https://cli.want.biz',
|
|
241
|
+
'X-Client-ID': 'npm_yuangs',
|
|
242
|
+
'Origin': 'https://cli.want.biz',
|
|
113
243
|
'Referer': 'https://cli.want.biz/',
|
|
114
|
-
|
|
244
|
+
'account': config.accountType || 'free',
|
|
115
245
|
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
|
116
246
|
'Accept': 'application/json'
|
|
117
247
|
};
|
|
118
248
|
|
|
119
249
|
const data = {
|
|
120
|
-
model: model ||
|
|
250
|
+
model: model || config.defaultModel || 'Assistant',
|
|
121
251
|
messages: messages,
|
|
122
252
|
stream: false
|
|
123
253
|
};
|
|
@@ -162,8 +292,7 @@ async function getAIAnswer(question, model, includeHistory = true) {
|
|
|
162
292
|
}
|
|
163
293
|
|
|
164
294
|
async function generateCommand(instruction, model) {
|
|
165
|
-
|
|
166
|
-
// Gemini 有时对 system role 支持不同,但在 OpenAI 兼容接口下通常支持 system
|
|
295
|
+
const config = getUserConfig();
|
|
167
296
|
const messages = [
|
|
168
297
|
{
|
|
169
298
|
role: 'system',
|
|
@@ -177,20 +306,17 @@ async function generateCommand(instruction, model) {
|
|
|
177
306
|
];
|
|
178
307
|
|
|
179
308
|
try {
|
|
180
|
-
const response = await callAI_OpenAI(messages, model);
|
|
309
|
+
const response = await callAI_OpenAI(messages, model || config.defaultModel);
|
|
181
310
|
const aiContent = response.data?.choices?.[0]?.message?.content;
|
|
182
311
|
|
|
183
312
|
if (aiContent) {
|
|
184
|
-
// Clean up the output just in case the AI adds markdown or whitespace
|
|
185
313
|
let command = aiContent.trim();
|
|
186
|
-
// Remove wrapping backticks if present
|
|
187
314
|
if (command.startsWith('`') && command.endsWith('`')) {
|
|
188
315
|
command = command.slice(1, -1);
|
|
189
316
|
}
|
|
190
317
|
if (command.startsWith('```') && command.endsWith('```')) {
|
|
191
318
|
command = command.split('\n').filter(line => !line.startsWith('```')).join('\n').trim();
|
|
192
319
|
}
|
|
193
|
-
// If it starts with a shell prefix like "$ " or "> ", remove it
|
|
194
320
|
if (command.startsWith('$ ')) command = command.slice(2);
|
|
195
321
|
if (command.startsWith('> ')) command = command.slice(2);
|
|
196
322
|
|
|
@@ -198,15 +324,12 @@ async function generateCommand(instruction, model) {
|
|
|
198
324
|
}
|
|
199
325
|
return null;
|
|
200
326
|
} catch (error) {
|
|
201
|
-
// 命令生成失败不打印过多错误,返回 null 即可
|
|
202
|
-
// console.error('AI 生成命令失败:', error.message);
|
|
203
327
|
return null;
|
|
204
328
|
}
|
|
205
329
|
}
|
|
206
330
|
|
|
207
331
|
module.exports = {
|
|
208
332
|
urls: APPS,
|
|
209
|
-
// Dynamic function to open any app by key
|
|
210
333
|
openApp: (appKey) => {
|
|
211
334
|
const url = APPS[appKey];
|
|
212
335
|
if (url) {
|
|
@@ -216,7 +339,6 @@ module.exports = {
|
|
|
216
339
|
console.error(`App '${appKey}' not found`);
|
|
217
340
|
return false;
|
|
218
341
|
},
|
|
219
|
-
// Specific functions for default apps (for backward compatibility)
|
|
220
342
|
openShici: () => openUrl(APPS.shici || DEFAULT_APPS.shici),
|
|
221
343
|
openDict: () => openUrl(APPS.dict || DEFAULT_APPS.dict),
|
|
222
344
|
openPong: () => openUrl(APPS.pong || DEFAULT_APPS.pong),
|
|
@@ -228,5 +350,12 @@ module.exports = {
|
|
|
228
350
|
addToConversationHistory,
|
|
229
351
|
clearConversationHistory,
|
|
230
352
|
getConversationHistory,
|
|
231
|
-
generateCommand
|
|
353
|
+
generateCommand,
|
|
354
|
+
getUserConfig,
|
|
355
|
+
getCommandHistory,
|
|
356
|
+
saveSuccessfulCommand,
|
|
357
|
+
callAI_Stream,
|
|
358
|
+
getMacros,
|
|
359
|
+
saveMacro,
|
|
360
|
+
deleteMacro
|
|
232
361
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yuangs",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.37",
|
|
4
4
|
"description": "苑广山的个人应用集合 CLI(彩色版)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"axios": "^1.13.2",
|
|
30
30
|
"chalk": "^4.1.2",
|
|
31
|
-
"js-yaml": "^4.1.0"
|
|
31
|
+
"js-yaml": "^4.1.0",
|
|
32
|
+
"marked": "^15.0.12",
|
|
33
|
+
"marked-terminal": "^7.3.0",
|
|
34
|
+
"ora": "^9.0.0"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"jest": "^29.7.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const yuangs = require('../index.js');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
jest.mock('fs');
|
|
7
|
+
|
|
8
|
+
describe('Module: Macros', () => {
|
|
9
|
+
const mockMacrosFile = path.join(os.homedir(), '.yuangs_macros.json');
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.clearAllMocks();
|
|
13
|
+
// Setup default mock implementation
|
|
14
|
+
fs.existsSync.mockReturnValue(false);
|
|
15
|
+
fs.readFileSync.mockReturnValue('{}');
|
|
16
|
+
fs.writeFileSync.mockReturnValue(undefined);
|
|
17
|
+
// We need to unmock path and os if they were mocked, but we only mocked fs
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('should get empty macros when file does not exist', () => {
|
|
21
|
+
fs.existsSync.mockReturnValue(false);
|
|
22
|
+
const macros = yuangs.getMacros();
|
|
23
|
+
expect(macros).toEqual({});
|
|
24
|
+
expect(fs.existsSync).toHaveBeenCalledWith(mockMacrosFile);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('should save a new macro', () => {
|
|
28
|
+
fs.existsSync.mockReturnValue(false); // File doesn't exist yet
|
|
29
|
+
|
|
30
|
+
const result = yuangs.saveMacro('test', 'echo hello', 'description');
|
|
31
|
+
|
|
32
|
+
expect(result).toBe(true);
|
|
33
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
34
|
+
|
|
35
|
+
const [filePath, content] = fs.writeFileSync.mock.calls[0];
|
|
36
|
+
expect(filePath).toBe(mockMacrosFile);
|
|
37
|
+
|
|
38
|
+
const data = JSON.parse(content);
|
|
39
|
+
expect(data).toHaveProperty('test');
|
|
40
|
+
expect(data.test.commands).toBe('echo hello');
|
|
41
|
+
expect(data.test.description).toBe('description');
|
|
42
|
+
expect(data.test).toHaveProperty('createdAt');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should retrieve existing macros', () => {
|
|
46
|
+
const mockData = {
|
|
47
|
+
"demo": {
|
|
48
|
+
"commands": "ls -la",
|
|
49
|
+
"description": "list files"
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
fs.existsSync.mockReturnValue(true);
|
|
53
|
+
fs.readFileSync.mockReturnValue(JSON.stringify(mockData));
|
|
54
|
+
|
|
55
|
+
const macros = yuangs.getMacros();
|
|
56
|
+
expect(macros).toEqual(mockData);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('should delete a macro', () => {
|
|
60
|
+
const mockData = {
|
|
61
|
+
"todelete": { "commands": "rm -rf /" },
|
|
62
|
+
"keep": { "commands": "echo safe" }
|
|
63
|
+
};
|
|
64
|
+
fs.existsSync.mockReturnValue(true);
|
|
65
|
+
fs.readFileSync.mockReturnValue(JSON.stringify(mockData));
|
|
66
|
+
|
|
67
|
+
const result = yuangs.deleteMacro('todelete');
|
|
68
|
+
|
|
69
|
+
expect(result).toBe(true);
|
|
70
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
71
|
+
|
|
72
|
+
const [filePath, content] = fs.writeFileSync.mock.calls[0];
|
|
73
|
+
const savedData = JSON.parse(content);
|
|
74
|
+
expect(savedData).not.toHaveProperty('todelete');
|
|
75
|
+
expect(savedData).toHaveProperty('keep');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('should return false when deleting non-existent macro', () => {
|
|
79
|
+
fs.existsSync.mockReturnValue(false); // Or true with empty object
|
|
80
|
+
|
|
81
|
+
const result = yuangs.deleteMacro('nonexistent');
|
|
82
|
+
expect(result).toBe(false);
|
|
83
|
+
// Should not write to disk if nothing changed (optional optimization, but current implementation reads first)
|
|
84
|
+
// Actually current implementation:
|
|
85
|
+
// const macros = getMacros();
|
|
86
|
+
// if (macros[name]) { ... }
|
|
87
|
+
// getMacros returns {} if file not exists. macros['nonexistent'] is undefined.
|
|
88
|
+
// So it returns false and does NOT call writeFileSync.
|
|
89
|
+
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
package/todo.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
功能1:历史记录功能
|
|
2
|
+
没问题!我已经为你梳理好了完整的代码整合方案。通过这次整合,你的 yuangs 工具将拥有完整的配置管理、带语义的历史记录以及智能预填执行功能。
|
|
3
|
+
1. index.js 完整核心逻辑
|
|
4
|
+
请将以下函数添加到 index.js 中并确保在 module.exports 中导出:
|
|
5
|
+
const os = require(’os‘);
|
|
6
|
+
const HISTORY_FILE = path.join(os.homedir(), ’.yuangs_cmd_history.json‘);
|
|
7
|
+
const CONFIG_FILE = path.join(os.homedir(), ’.yuangs.json‘);
|
|
8
|
+
|
|
9
|
+
// — 配置管理 —
|
|
10
|
+
function getUserConfig() {
|
|
11
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, ’utf8‘));
|
|
14
|
+
} catch (e) {}
|
|
15
|
+
}
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// — 历史记录管理 (带问题描述) —
|
|
20
|
+
function getCommandHistory() {
|
|
21
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(HISTORY_FILE, ’utf8‘));
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function saveSuccessfulCommand(question, command) {
|
|
30
|
+
if (!command) return;
|
|
31
|
+
let history = getCommandHistory();
|
|
32
|
+
const newEntry = { question, command, time: new Date().toLocaleString() };
|
|
33
|
+
// 去重并保留最近 5 条
|
|
34
|
+
history = [newEntry, ...history.filter(item => item.command !== command)].slice(0, 5);
|
|
35
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 修改原有的 callAI_OpenAI 适配配置
|
|
39
|
+
async function callAI_OpenAI(messages, model) {
|
|
40
|
+
const config = getUserConfig();
|
|
41
|
+
const url = config.aiProxyUrl || ’https://aiproxy.want.biz/v1/chat/completions‘;
|
|
42
|
+
|
|
43
|
+
const headers = {
|
|
44
|
+
’Content-Type‘: ’application/json‘,
|
|
45
|
+
’X-Client-ID‘: ’npm_yuangs‘,
|
|
46
|
+
’account‘: config.accountType || ’free‘,
|
|
47
|
+
’User-Agent‘: ’Mozilla/5.0...‘,
|
|
48
|
+
’Accept‘: ’application/json‘
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const data = {
|
|
52
|
+
model: model || config.defaultModel || ”gemini-flash-lite-latest“,
|
|
53
|
+
messages: messages,
|
|
54
|
+
stream: false
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return await axios.post(url, data, { headers });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
2. cli.js 交互层升级
|
|
61
|
+
在 cli.js 的 switch 语句中添加 config 分支,并重构命令执行逻辑:
|
|
62
|
+
// 新增执行函数
|
|
63
|
+
function runFinalCommand(question, finalCommand) {
|
|
64
|
+
const { spawn } = require(’child_process‘);
|
|
65
|
+
console.log(chalk.gray(’正在执行...‘));
|
|
66
|
+
const child = spawn(finalCommand, [], { shell: true, stdio: ’inherit‘ });
|
|
67
|
+
|
|
68
|
+
child.on(’close‘, (code) => {
|
|
69
|
+
if (code === 0) {
|
|
70
|
+
yuangs.saveSuccessfulCommand(question, finalCommand);
|
|
71
|
+
console.log(chalk.green(’\n✓ 执行成功并已存入历史库‘));
|
|
72
|
+
}
|
|
73
|
+
process.exit(code);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// switch 分支
|
|
78
|
+
switch (command) {
|
|
79
|
+
case ’config‘:
|
|
80
|
+
const [,, key, value] = process.argv;
|
|
81
|
+
if (!key || !value) {
|
|
82
|
+
console.log(chalk.cyan(’\n⚙️ 配置帮助: yuangs config <key> <value>‘));
|
|
83
|
+
console.log(chalk.gray(’当前配置:‘), yuangs.getUserConfig());
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const config = yuangs.getUserConfig();
|
|
87
|
+
config[key] = value;
|
|
88
|
+
fs.writeFileSync(path.join(require(’os‘).homedir(), ’.yuangs.json‘), JSON.stringify(config, null, 2));
|
|
89
|
+
console.log(chalk.green(`✓ 已更新 ${key}`));
|
|
90
|
+
break;
|
|
91
|
+
|
|
92
|
+
// 在 handleAICommand 中加入 -h 处理逻辑 (如前所述)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
3. 操作流程示意图
|
|
96
|
+
现在的成果:
|
|
97
|
+
* 个性化:通过 yuangs config 适配不同的网络环境和模型需求。
|
|
98
|
+
* 知识库化:历史记录不仅存命令,还存“意图”,成为你个人的 Linux 命令词典。
|
|
99
|
+
* 极速复用:再次使用时,从意图出发,一秒加载,回车即跑。
|
|
100
|
+
这套逻辑已经非常接近成熟的商业 CLI(如 Vercel 或 GitHub CLI)的交互体验了。你已经从基础的功能实现,跨越到了用户体验设计的层面。
|
|
101
|
+
恭喜你完成了一个非常硬核的工具升级! 接下来是准备把它发布到内部 npm 仓库,还是继续增加更炫酷的功能(比如给 AI 对话增加流式输出效果)?
|
|
102
|
+
既然基础架构已经打好,我们可以跳出“简单的 AI 助手”这个范畴,把 yuangs 打造一个真正的智能终端工作台。
|
|
103
|
+
作为一名命令行高手,你可以往以下四个维度去“脑洞大开”:
|
|
104
|
+
1. 智能“管道”模式 (The Magic Pipe)
|
|
105
|
+
现在的 AI 需要你手动输入问题,未来它可以直接处理前一个命令的输出。
|
|
106
|
+
* 功能描述:实现类似 ls -l | yuangs ai "帮我总结这些文件" 的功能。
|
|
107
|
+
* 实现思路:在 cli.js 中检测 process.stdin.isTTY。如果不是 TTY,说明有内容从管道传进来。
|
|
108
|
+
* 使用场景:
|
|
109
|
+
* cat error.log | yuangs ai "解释这个报错并给出修复建议"
|
|
110
|
+
* curl https://example.com | yuangs ai "提取所有的链接并转成 JSON"
|
|
111
|
+
2. 交互式“变量注入” (Variable Injection)
|
|
112
|
+
AI 生成的命令往往带有占位符(比如 <filename>),手动修改很麻烦。
|
|
113
|
+
* 功能描述:AI 生成命令后,工具自动识别其中的占位符,并引导用户逐个填空。
|
|
114
|
+
* 实现思路:AI 输出 tar -cvzf <压缩包名>.tar.gz <文件夹>,CLI 自动识别 <...> 语法,弹出输入框让用户填入具体值。
|
|
115
|
+
* 体验升级:甚至可以结合 fzf 类似的逻辑,让用户在填空时直接选择当前目录的文件。
|
|
116
|
+
3. 终端“快捷指令” (AI Macros)
|
|
117
|
+
有些复杂的流程(比如:切换 Git 分支 -> 拉取代码 -> 编译工程 -> 发送飞书通知)可以通过 AI 编排成一个专属命令。
|
|
118
|
+
* 功能描述:yuangs save "deploy-project"。
|
|
119
|
+
* 实现思路:将一连串命令封装成一个 Alias(别名)。
|
|
120
|
+
* 脑洞点:你可以给这个指令起名叫 go-home,AI 自动帮你执行“提交代码、关掉开发服务器、同步云端”等一系列收尾动作。
|
|
121
|
+
4. 视觉增强与“流式”交互 (Visual & Stream)
|
|
122
|
+
现在的命令行输出太死板,我们可以让它更有“生命力”。
|
|
123
|
+
* 功能描述:
|
|
124
|
+
* 流式输出:像 ChatGPT 网页版一样,字是一个一个蹦出来的(使用 stream: true 接口)。
|
|
125
|
+
* Markdown 渲染:在终端里渲染真正的 Markdown 格式(表格、代码块高亮),而不仅仅是纯文本。
|
|
126
|
+
* 实现工具:利用 marked-terminal 库来渲染,利用 ora 库做更酷炫的加载动画。
|
|
127
|
+
推荐的升级路线图
|
|
128
|
+
高手必做:增加“多模态”潜质
|
|
129
|
+
既然你现在的后端支持 Gemini 模型,你可以尝试增加 “图片转代码” 的功能:
|
|
130
|
+
* 玩法:yuangs ai --image ./ui-design.png "帮我写出这个界面的 CSS"。
|
|
131
|
+
* 实现:利用 fs.readFileSync 将图片转为 Base64 发给 AI。
|
|
132
|
+
这些想法中,你觉得哪个最能戳中你的痛点?我们可以挑一个最酷的,我现在就帮你写核心逻辑。
|
|
133
|
+
既然基础架构已经打好,我们可以跳出“简单的 AI 助手”这个范畴,把 yuangs 打造一个真正的智能终端工作台。
|
|
134
|
+
作为一名命令行高手,你可以往以下四个维度去“脑洞大开”:
|
|
135
|
+
1. 智能“管道”模式 (The Magic Pipe)
|
|
136
|
+
现在的 AI 需要你手动输入问题,未来它可以直接处理前一个命令的输出。
|
|
137
|
+
* 功能描述:实现类似 ls -l | yuangs ai ”帮我总结这些文件“ 的功能。
|
|
138
|
+
* 实现思路:在 cli.js 中检测 process.stdin.isTTY。如果不是 TTY,说明有内容从管道传进来。
|
|
139
|
+
* 使用场景:
|
|
140
|
+
* cat error.log | yuangs ai ”解释这个报错并给出修复建议“
|
|
141
|
+
* curl https://example.com | yuangs ai ”提取所有的链接并转成 JSON“
|
|
142
|
+
2. 交互式“变量注入” (Variable Injection)
|
|
143
|
+
AI 生成的命令往往带有占位符(比如 <filename>),手动修改很麻烦。
|
|
144
|
+
* 功能描述:AI 生成命令后,工具自动识别其中的占位符,并引导用户逐个填空。
|
|
145
|
+
* 实现思路:AI 输出 tar -cvzf <压缩包名>.tar.gz <文件夹>,CLI 自动识别 <...> 语法,弹出输入框让用户填入具体值。
|
|
146
|
+
* 体验升级:甚至可以结合 fzf 类似的逻辑,让用户在填空时直接选择当前目录的文件。
|
|
147
|
+
3. 终端“快捷指令” (AI Macros)
|
|
148
|
+
有些复杂的流程(比如:切换 Git 分支 -> 拉取代码 -> 编译工程 -> 发送飞书通知)可以通过 AI 编排成一个专属命令。
|
|
149
|
+
* 功能描述:yuangs save ”deploy-project“。
|
|
150
|
+
* 实现思路:将一连串命令封装成一个 Alias(别名)。
|
|
151
|
+
* 脑洞点:你可以给这个指令起名叫 go-home,AI 自动帮你执行“提交代码、关掉开发服务器、同步云端”等一系列收尾动作。
|
|
152
|
+
4. 视觉增强与“流式”交互 (Visual & Stream)
|
|
153
|
+
现在的命令行输出太死板,我们可以让它更有“生命力”。
|
|
154
|
+
* 功能描述:
|
|
155
|
+
* 流式输出:像 ChatGPT 网页版一样,字是一个一个蹦出来的(使用 stream: true 接口)。
|
|
156
|
+
* Markdown 渲染:在终端里渲染真正的 Markdown 格式(表格、代码块高亮),而不仅仅是纯文本。
|
|
157
|
+
* 实现工具:利用 marked-terminal 库来渲染,利用 ora 库做更酷炫的加载动画。
|
|
158
|
+
推荐的升级路线图
|
|
159
|
+
高手必做:增加“多模态”潜质
|
|
160
|
+
既然你现在的后端支持 Gemini 模型,你可以尝试增加 “图片转代码” 的功能:
|
|
161
|
+
* 玩法:yuangs ai —image ./ui-design.png ”帮我写出这个界面的 CSS“。
|
|
162
|
+
* 实现:利用 fs.readFileSync 将图片转为 Base64 发给 AI。
|
|
163
|
+
这些想法中,你觉得哪个最能戳中你的痛点?我们可以挑一个最酷的,我现在就帮你写核心逻辑。
|
|
164
|
+
实现流式输出和 Markdown 渲染是提升 CLI 工具“质感”的关键。我们需要引入三个小巧但功能强大的库:
|
|
165
|
+
* ora: 实现酷炫的终端加载动画。
|
|
166
|
+
* marked & marked-terminal: 将 AI 返回的 Markdown 文本实时渲染为带颜色和样式的终端内容。
|
|
167
|
+
1. 安装依赖
|
|
168
|
+
首先,你需要在项目中安装这几个库:
|
|
169
|
+
npm install ora marked marked-terminal
|
|
170
|
+
|
|
171
|
+
2. 修改 index.js:支持流式 API 调用
|
|
172
|
+
我们需要将 callAI_OpenAI 改写为支持 stream 模式。核心是处理 Node.js 的 IncomingMessage 流。
|
|
173
|
+
// index.js 增加或修改如下逻辑
|
|
174
|
+
async function callAI_Stream(messages, model, onChunk) {
|
|
175
|
+
const config = getUserConfig(); // 使用我们之前写的配置读取
|
|
176
|
+
const url = config.aiProxyUrl || 'https://aiproxy.want.biz/v1/chat/completions';
|
|
177
|
+
|
|
178
|
+
const response = await axios({
|
|
179
|
+
method: 'post',
|
|
180
|
+
url: url,
|
|
181
|
+
data: {
|
|
182
|
+
model: model || config.defaultModel || "gemini-flash-lite-latest",
|
|
183
|
+
messages: messages,
|
|
184
|
+
stream: true // 开启流式
|
|
185
|
+
},
|
|
186
|
+
responseType: 'stream',
|
|
187
|
+
headers: {
|
|
188
|
+
'Content-Type': 'application/json',
|
|
189
|
+
'X-Client-ID': 'npm_yuangs',
|
|
190
|
+
'account': config.accountType || 'free'
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
response.data.on('data', chunk => {
|
|
196
|
+
const lines = chunk.toString().split('\n');
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
if (line.startsWith('data: ')) {
|
|
199
|
+
const data = line.slice(6);
|
|
200
|
+
if (data === '[DONE]') {
|
|
201
|
+
resolve();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(data);
|
|
206
|
+
const content = parsed.choices[0].delta.content || '';
|
|
207
|
+
if (content) onChunk(content); // 每出一个字,调用一次回调
|
|
208
|
+
} catch (e) {
|
|
209
|
+
// 忽略解析错误
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
response.data.on('error', reject);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
3. 修改 cli.js:视觉增强逻辑
|
|
219
|
+
我们需要在 cli.js 中配置渲染器,并美化 handleAICommand。
|
|
220
|
+
const ora = require('ora');
|
|
221
|
+
const { marked } = require('marked');
|
|
222
|
+
const TerminalRenderer = require('marked-terminal');
|
|
223
|
+
|
|
224
|
+
// 配置 Markdown 渲染器
|
|
225
|
+
marked.setOptions({
|
|
226
|
+
renderer: new TerminalRenderer({
|
|
227
|
+
code: chalk.yellow,
|
|
228
|
+
heading: chalk.magenta.bold,
|
|
229
|
+
firstHeading: chalk.magenta.underline.bold,
|
|
230
|
+
listitem: chalk.cyan,
|
|
231
|
+
table: chalk.white,
|
|
232
|
+
strong: chalk.bold.red,
|
|
233
|
+
em: chalk.italic
|
|
234
|
+
})
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
async function handleAICommand() {
|
|
238
|
+
// ... 前置参数解析逻辑 ...
|
|
239
|
+
|
|
240
|
+
const spinner = ora(chalk.cyan('AI 正在思考...')).start();
|
|
241
|
+
let fullResponse = '';
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await yuangs.callAI_Stream(messages, model, (chunk) => {
|
|
245
|
+
if (spinner.isSpinning) {
|
|
246
|
+
spinner.stop(); // 收到第一个字时,停止动画
|
|
247
|
+
}
|
|
248
|
+
fullResponse += chunk;
|
|
249
|
+
|
|
250
|
+
// 实时打印字符,不换行
|
|
251
|
+
process.stdout.write(chunk);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 所有的流传输完成后,我们清除之前的原始输出,渲染漂亮的 Markdown
|
|
255
|
+
console.log('\n' + chalk.gray('─'.repeat(process.stdout.columns / 2)));
|
|
256
|
+
console.clear(); // 或者向上滚动重新覆盖
|
|
257
|
+
console.log(marked(fullResponse));
|
|
258
|
+
|
|
259
|
+
} catch (error) {
|
|
260
|
+
spinner.fail(chalk.red('AI 响应出错'));
|
|
261
|
+
console.error(error.message);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
4. 优化:命令生成的特殊处理
|
|
266
|
+
注意:生成 Linux 命令(即 yuangs ai -e)时不建议使用流式 Markdown 渲染。
|
|
267
|
+
* 原因:生成命令需要绝对的纯文本,且通常很短。
|
|
268
|
+
* 建议:只在普通聊天模式下使用流式渲染;在 -e 命令生成模式下,保持原来的加载动画 + 结果预填。
|
|
269
|
+
为什么这个实现很“高手”?
|
|
270
|
+
* 响应速度感:用户在 500ms 内就能看到第一个字跳出来,感知速度提升了 3-5 倍。
|
|
271
|
+
* 沉浸式体验:marked-terminal 会自动根据终端宽度调整列表和代码块的间距。
|
|
272
|
+
* 色彩层级:比起纯白色的文本,带有语法高亮的输出能让用户一眼看到代码块在哪里。
|
|
273
|
+
你现在就可以尝试把这段逻辑合并进你的代码中。合并完成后,你的 yuangs 就像是一个运行在终端里的“小 ChatGPT”了!
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"shici": "https://wealth.want.biz/shici/index.html",
|
|
3
|
+
"dict": "https://wealth.want.biz/pages/dict.html",
|
|
4
|
+
"pong": "https://wealth.want.biz/pages/pong.html",
|
|
5
|
+
"mail": "https://mail.google.com",
|
|
6
|
+
"github": "https://github.com",
|
|
7
|
+
"calendar": "https://calendar.google.com",
|
|
8
|
+
"homepage": "https://i.want.biz"
|
|
9
|
+
}
|