yuangs 1.3.34 → 1.3.38
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/README.md +55 -6
- package/cli.js +229 -22
- package/index.js +145 -16
- package/package.json +5 -2
- package/test/macros.test.js +91 -0
- package/yuangs.config.example.json +4 -1
- package/yuangs.config.example.yaml +6 -1
- package/yuangs.config.json +9 -0
- package/.github/workflows/publish.yml +0 -54
package/README.md
CHANGED
|
@@ -51,15 +51,61 @@ yuangs ai
|
|
|
51
51
|
- 直接输入问题进行对话
|
|
52
52
|
- 输入 `/clear` 清空对话历史
|
|
53
53
|
- 输入 `/history` 查看对话历史
|
|
54
|
-
###
|
|
54
|
+
### 命令生成模式
|
|
55
55
|
|
|
56
|
-
使用 `-e` 参数让 AI 为你生成 Linux
|
|
56
|
+
使用 `-e` 参数让 AI 为你生成 Linux 命令。现在支持生成后自动复制到剪贴板,并预填到输入行供你确认执行:
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
59
|
yuangs ai -e "查看当前目录下大于100M的文件"
|
|
60
|
-
#
|
|
60
|
+
# 1. 自动生成: > find . -type f -size +100M
|
|
61
|
+
# 2. 自动复制到剪贴板
|
|
62
|
+
# 3. 预填到下方,按回车即可执行
|
|
61
63
|
```
|
|
62
64
|
|
|
65
|
+
### 管道模式 (Pipe Mode)
|
|
66
|
+
|
|
67
|
+
支持将前一个命令的输出通过管道传给 AI 进行分析:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cat error.log | yuangs ai "解释这个报错"
|
|
71
|
+
ls -la | yuangs ai "帮我总结这些文件"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 流式输出与 Markdown 渲染
|
|
75
|
+
|
|
76
|
+
所有 AI 回复默认开启流式输出(打字机效果),并在回答结束后自动进行 Markdown 渲染(包含代码高亮、表格格式化等)。
|
|
77
|
+
|
|
78
|
+
## 快捷指令 (Macros)
|
|
79
|
+
|
|
80
|
+
可以将常用的复杂命令保存为快捷指令:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# 创建快捷指令 (支持多行命令)
|
|
84
|
+
yuangs save deploy
|
|
85
|
+
# > 请输入要保存的命令:
|
|
86
|
+
# > npm run build && git add . && git commit -m "deploy" && git push
|
|
87
|
+
|
|
88
|
+
# 执行快捷指令
|
|
89
|
+
yuangs run deploy
|
|
90
|
+
|
|
91
|
+
# 查看所有指令
|
|
92
|
+
yuangs macros
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 配置管理
|
|
96
|
+
|
|
97
|
+
使用 `config` 命令快速修改 AI 配置:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
yuangs config defaultModel Assistant
|
|
101
|
+
yuangs config accountType pro
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
配置项说明:
|
|
105
|
+
- `defaultModel`: 默认使用的 AI 模型 (默认为 `Assistant`)
|
|
106
|
+
- `aiProxyUrl`: 自定义 AI 接口地址
|
|
107
|
+
- `accountType`: 账户类型 (free/pro)
|
|
108
|
+
|
|
63
109
|
## 应用列表
|
|
64
110
|
|
|
65
111
|
以下是 CLI 中可以通过相应命令直接打开的应用程序链接:
|
|
@@ -126,9 +172,12 @@ yuangs mail # 打开邮箱
|
|
|
126
172
|
|
|
127
173
|
## 近期主要更新日志
|
|
128
174
|
|
|
129
|
-
### v1.3.
|
|
130
|
-
- **新增**
|
|
131
|
-
-
|
|
175
|
+
### v1.3.38 (2026-01-16)
|
|
176
|
+
- **新增** 快捷指令系统 (`save`/`run`/`macros`),支持保存复杂命令。
|
|
177
|
+
- **新增** 管道模式:支持 `cat file | yuangs ai` 分析内容。
|
|
178
|
+
- **新增** AI 流式输出 + Markdown 终端渲染,体验对齐 ChatGPT 网页版。
|
|
179
|
+
- **新增** `config` 命令,方便管理 AI 配置。
|
|
180
|
+
- **优化** 命令生成模式 (`-e`) 增加剪贴板自动复制和预填执行功能。
|
|
132
181
|
|
|
133
182
|
### v1.3.22 (2025-11-30)
|
|
134
183
|
- **新增** AI 命令支持 `-p` `-f` `-l` 简写,快速选择gemini默认模型
|
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.38",
|
|
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
|
+
});
|
|
@@ -4,5 +4,8 @@
|
|
|
4
4
|
"pong": "https://wealth.want.biz/pages/pong.html",
|
|
5
5
|
"github": "https://github.com",
|
|
6
6
|
"calendar": "https://calendar.google.com",
|
|
7
|
-
"mail": "https://mail.google.com"
|
|
7
|
+
"mail": "https://mail.google.com",
|
|
8
|
+
"aiProxyUrl": "https://aiproxy.want.biz/v1/chat/completions",
|
|
9
|
+
"defaultModel": "Assistant",
|
|
10
|
+
"accountType": "free"
|
|
8
11
|
}
|
|
@@ -8,6 +8,11 @@ github: "https://github.com"
|
|
|
8
8
|
calendar: "https://calendar.google.com"
|
|
9
9
|
mail: "https://mail.google.com"
|
|
10
10
|
|
|
11
|
+
# AI Configuration
|
|
12
|
+
aiProxyUrl: "https://aiproxy.want.biz/v1/chat/completions"
|
|
13
|
+
defaultModel: "Assistant"
|
|
14
|
+
accountType: "free"
|
|
15
|
+
|
|
11
16
|
# You can also use the apps property if you prefer to group them
|
|
12
17
|
# apps:
|
|
13
18
|
# shici: "https://wealth.want.biz/shici/index.html"
|
|
@@ -15,4 +20,4 @@ mail: "https://mail.google.com"
|
|
|
15
20
|
# pong: "https://wealth.want.biz/pages/pong.html"
|
|
16
21
|
# github: "https://github.com"
|
|
17
22
|
# calendar: "https://calendar.google.com"
|
|
18
|
-
# mail: "https://mail.google.com"
|
|
23
|
+
# mail: "https://mail.google.com"
|
|
@@ -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
|
+
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
name: Auto Bump & Publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main # 如果你的默认分支不是 main,记得改成对应名字
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
publish:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
|
|
12
|
-
# 权限:允许 Action 修改仓库代码 + 生成 provenance
|
|
13
|
-
permissions:
|
|
14
|
-
contents: write
|
|
15
|
-
id-token: write
|
|
16
|
-
|
|
17
|
-
steps:
|
|
18
|
-
- name: Checkout repository
|
|
19
|
-
uses: actions/checkout@v4
|
|
20
|
-
with:
|
|
21
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
22
|
-
fetch-depth: 0 # 完整历史,方便推 tag
|
|
23
|
-
|
|
24
|
-
- name: Set up Node.js
|
|
25
|
-
uses: actions/setup-node@v4
|
|
26
|
-
with:
|
|
27
|
-
node-version: "18"
|
|
28
|
-
registry-url: "https://registry.npmjs.org/"
|
|
29
|
-
|
|
30
|
-
- name: Configure Git
|
|
31
|
-
run: |
|
|
32
|
-
git config user.name "GitHub Action"
|
|
33
|
-
git config user.email "action@github.com"
|
|
34
|
-
|
|
35
|
-
# 自动 bump patch 版本:1.1.5 -> 1.1.6
|
|
36
|
-
# [skip ci] 防止这个 commit 再次触发 workflow 形成死循环
|
|
37
|
-
- name: Bump version
|
|
38
|
-
run: |
|
|
39
|
-
npm version patch -m "苑广山注:自动升版本号%s[skip ci]"
|
|
40
|
-
|
|
41
|
-
# 推送 commit + tag 回 GitHub
|
|
42
|
-
- name: Push changes
|
|
43
|
-
run: |
|
|
44
|
-
git push --follow-tags
|
|
45
|
-
|
|
46
|
-
# 安装依赖
|
|
47
|
-
- name: Install dependencies
|
|
48
|
-
run: npm ci
|
|
49
|
-
|
|
50
|
-
# 发布到 npm
|
|
51
|
-
- name: Publish to npm
|
|
52
|
-
run: npm publish --provenance
|
|
53
|
-
env:
|
|
54
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|