@ww_nero/mini-cli 1.0.63 → 1.0.67
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/package.json +1 -1
- package/src/chat.js +1064 -1030
- package/src/config.js +405 -401
- package/src/utils/commands.js +119 -89
- package/src/utils/menu.js +156 -0
- package/src/utils/model.js +5 -1
package/src/utils/commands.js
CHANGED
|
@@ -1,89 +1,119 @@
|
|
|
1
|
-
const chalk = require('chalk');
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
{ key: '
|
|
6
|
-
{ key: '
|
|
7
|
-
{ key: '
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const { selectFromList } = require('./menu');
|
|
3
|
+
|
|
4
|
+
const SLASH_COMMANDS = [
|
|
5
|
+
{ key: 'model', value: '/model', description: '查看或切换模型' },
|
|
6
|
+
{ key: 'clear', value: '/clear', description: '清空上下文' },
|
|
7
|
+
{ key: 'resume', value: '/resume', description: '查看或恢复历史会话' },
|
|
8
|
+
{ key: 'exit', value: '/exit', description: '退出对话' }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const getSlashCommandListText = () => SLASH_COMMANDS.map((entry) => entry.value).join('、');
|
|
12
|
+
|
|
13
|
+
const createCommandHandler = ({ modelManager, resetMessages, closeSession, handleResume, pauseSession, resumeSession }) => {
|
|
14
|
+
const handleClearCommand = () => {
|
|
15
|
+
resetMessages();
|
|
16
|
+
console.log(chalk.green('已清空上下文。'));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const handleModelCommand = async (rawArgs = '') => {
|
|
20
|
+
const args = rawArgs.trim();
|
|
21
|
+
if (!args || ['list', 'ls'].includes(args.toLowerCase())) {
|
|
22
|
+
const endpoints = modelManager.getModels();
|
|
23
|
+
const activeIndex = modelManager.getActiveIndex();
|
|
24
|
+
const defaultIndex = modelManager.getDefaultIndex();
|
|
25
|
+
|
|
26
|
+
if (pauseSession) pauseSession();
|
|
27
|
+
const selectedIndex = await selectFromList(endpoints, {
|
|
28
|
+
title: chalk.gray('请选择要切换的模型:'),
|
|
29
|
+
renderItem: (endpoint, idx, isSelected) => {
|
|
30
|
+
const markers = [];
|
|
31
|
+
if (idx === activeIndex) {
|
|
32
|
+
markers.push(chalk.green('当前'));
|
|
33
|
+
}
|
|
34
|
+
if (idx === defaultIndex) {
|
|
35
|
+
markers.push(chalk.cyan('默认'));
|
|
36
|
+
}
|
|
37
|
+
const markerText = markers.length > 0 ? ` [${markers.join('/')}]` : '';
|
|
38
|
+
const description = modelManager.describeEndpoint(endpoint);
|
|
39
|
+
const text = `${idx + 1}. ${description}${markerText}`;
|
|
40
|
+
return isSelected ? chalk.cyan(`> ${text}`) : ` ${text}`;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
if (resumeSession) resumeSession();
|
|
44
|
+
|
|
45
|
+
if (selectedIndex !== null) {
|
|
46
|
+
if (selectedIndex !== activeIndex) {
|
|
47
|
+
modelManager.setActiveModel(selectedIndex, { updateDefault: true, announce: true });
|
|
48
|
+
} else {
|
|
49
|
+
console.log(chalk.gray('已保持当前模型。'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const numeric = Number.parseInt(args, 10);
|
|
56
|
+
if (!Number.isNaN(numeric) && String(numeric) === args) {
|
|
57
|
+
const index = numeric - 1;
|
|
58
|
+
if (!modelManager.setActiveModel(index, { updateDefault: true, announce: true })) {
|
|
59
|
+
console.error(chalk.red(`序号 ${args} 不在 1-${modelManager.getTotalModels()} 范围内,已保持 ${modelManager.getDefaultModelDescription()}。`));
|
|
60
|
+
modelManager.resetToDefault();
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
modelManager.applyModelByName(args, { source: 'command', announceSuccess: true, showError: true });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleResumeCommand = async (rawArgs = '') => {
|
|
69
|
+
if (typeof handleResume === 'function') {
|
|
70
|
+
await handleResume(rawArgs);
|
|
71
|
+
} else {
|
|
72
|
+
console.log(chalk.yellow('当前构建未启用历史记录功能。'));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleSlashCommand = async (raw = '') => {
|
|
77
|
+
if (!raw.startsWith('/')) {
|
|
78
|
+
return { handled: false, shouldExit: false };
|
|
79
|
+
}
|
|
80
|
+
const trimmed = raw.slice(1).trim();
|
|
81
|
+
const availableCommandsText = getSlashCommandListText() || '/model、/clear、/exit';
|
|
82
|
+
if (!trimmed) {
|
|
83
|
+
console.log(chalk.yellow(`请输入完整命令,例如 ${availableCommandsText}。`));
|
|
84
|
+
return { handled: true, shouldExit: false };
|
|
85
|
+
}
|
|
86
|
+
const [command] = trimmed.split(' ');
|
|
87
|
+
const args = trimmed.slice(command.length).trim();
|
|
88
|
+
switch ((command || '').toLowerCase()) {
|
|
89
|
+
case 'model':
|
|
90
|
+
await handleModelCommand(args);
|
|
91
|
+
return { handled: true, shouldExit: false };
|
|
92
|
+
case 'clear':
|
|
93
|
+
handleClearCommand();
|
|
94
|
+
return { handled: true, shouldExit: false };
|
|
95
|
+
case 'resume':
|
|
96
|
+
await handleResumeCommand(args);
|
|
97
|
+
return { handled: true, shouldExit: false };
|
|
98
|
+
case 'exit':
|
|
99
|
+
closeSession();
|
|
100
|
+
return { handled: true, shouldExit: true };
|
|
101
|
+
default:
|
|
102
|
+
console.log(chalk.yellow(`未识别的命令 /${command},可用命令:${availableCommandsText}。`));
|
|
103
|
+
return { handled: true, shouldExit: false };
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
handleSlashCommand,
|
|
109
|
+
handleClearCommand,
|
|
110
|
+
handleModelCommand,
|
|
111
|
+
handleResumeCommand
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
SLASH_COMMANDS,
|
|
117
|
+
getSlashCommandListText,
|
|
118
|
+
createCommandHandler
|
|
119
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
|
|
4
|
+
// 简单的 ANSI 码去除正则
|
|
5
|
+
const stripAnsi = (str) => str.replace(
|
|
6
|
+
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
|
7
|
+
''
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
// 计算字符串在终端显示的实际行数(考虑中文宽度和终端宽度)
|
|
11
|
+
const measureLines = (str) => {
|
|
12
|
+
const termWidth = process.stdout.columns || 80;
|
|
13
|
+
const clean = stripAnsi(str);
|
|
14
|
+
let width = 0;
|
|
15
|
+
for (let i = 0; i < clean.length; i++) {
|
|
16
|
+
const code = clean.charCodeAt(i);
|
|
17
|
+
// 简单的宽字符判断:ASCII 范围外视为 2 个宽度
|
|
18
|
+
width += (code > 255) ? 2 : 1;
|
|
19
|
+
}
|
|
20
|
+
return Math.ceil(width / termWidth) || 1;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 交互式列表选择
|
|
25
|
+
* @param {Array} items - 待选择的项数组
|
|
26
|
+
* @param {Object} options - 配置项
|
|
27
|
+
* @param {Function} options.renderItem - 渲染每项的回调 (item, index, isSelected) => string
|
|
28
|
+
* @param {number} options.pageSize - 每页显示的条数
|
|
29
|
+
* @param {string} options.title - 标题
|
|
30
|
+
* @returns {Promise<number|null>} - 返回选中的索引,取消则返回 null
|
|
31
|
+
*/
|
|
32
|
+
const selectFromList = (items, options = {}) => {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const {
|
|
35
|
+
renderItem = (item, idx, isSelected) => (isSelected ? chalk.cyan(`> ${item}`) : ` ${item}`),
|
|
36
|
+
pageSize = 10,
|
|
37
|
+
title = '',
|
|
38
|
+
exitOnInput = false
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
if (!items || items.length === 0) {
|
|
42
|
+
resolve(null);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let selectedIndex = 0;
|
|
47
|
+
let scrollOffset = 0;
|
|
48
|
+
const total = items.length;
|
|
49
|
+
let linesRendered = 0;
|
|
50
|
+
|
|
51
|
+
// 隐藏光标
|
|
52
|
+
process.stdout.write('\x1B[?25l');
|
|
53
|
+
|
|
54
|
+
if (title) {
|
|
55
|
+
console.log(title);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const render = () => {
|
|
59
|
+
// 清除之前渲染的行
|
|
60
|
+
if (linesRendered > 0) {
|
|
61
|
+
readline.moveCursor(process.stdout, 0, -(linesRendered - 1));
|
|
62
|
+
readline.cursorTo(process.stdout, 0);
|
|
63
|
+
readline.clearScreenDown(process.stdout);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const windowSize = Math.min(pageSize, total);
|
|
67
|
+
|
|
68
|
+
// 调整滚动偏移
|
|
69
|
+
if (selectedIndex < scrollOffset) {
|
|
70
|
+
scrollOffset = selectedIndex;
|
|
71
|
+
} else if (selectedIndex >= scrollOffset + windowSize) {
|
|
72
|
+
scrollOffset = selectedIndex - windowSize + 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const visibleItems = items.slice(scrollOffset, scrollOffset + windowSize);
|
|
76
|
+
|
|
77
|
+
linesRendered = 0;
|
|
78
|
+
visibleItems.forEach((item, index) => {
|
|
79
|
+
const actualIndex = scrollOffset + index;
|
|
80
|
+
const isSelected = actualIndex === selectedIndex;
|
|
81
|
+
const line = renderItem(item, actualIndex, isSelected);
|
|
82
|
+
|
|
83
|
+
// 计算该行实际占用的物理行数
|
|
84
|
+
const lineCount = measureLines(line);
|
|
85
|
+
process.stdout.write(line + '\n');
|
|
86
|
+
linesRendered += lineCount;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 底部提示
|
|
90
|
+
const footerText = chalk.gray(' (↑/↓ 选择, Enter 确认, Esc 取消)');
|
|
91
|
+
const footerLines = measureLines(footerText);
|
|
92
|
+
process.stdout.write(footerText);
|
|
93
|
+
linesRendered += footerLines;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
render();
|
|
97
|
+
|
|
98
|
+
const cleanup = () => {
|
|
99
|
+
process.stdout.write('\x1B[?25h'); // 恢复光标
|
|
100
|
+
|
|
101
|
+
// 清除菜单显示
|
|
102
|
+
if (linesRendered > 0) {
|
|
103
|
+
readline.moveCursor(process.stdout, 0, -(linesRendered - 1));
|
|
104
|
+
readline.cursorTo(process.stdout, 0);
|
|
105
|
+
readline.clearScreenDown(process.stdout);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 清除 title 行(如果有)
|
|
109
|
+
if (title) {
|
|
110
|
+
// 计算 title 占用的行数
|
|
111
|
+
const titleLines = measureLines(title);
|
|
112
|
+
// 上移 titleLines 行
|
|
113
|
+
readline.moveCursor(process.stdout, 0, -titleLines);
|
|
114
|
+
readline.cursorTo(process.stdout, 0);
|
|
115
|
+
readline.clearScreenDown(process.stdout);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
process.stdin.removeListener('keypress', handleKeypress);
|
|
119
|
+
if (process.stdin.isTTY) {
|
|
120
|
+
process.stdin.setRawMode(false);
|
|
121
|
+
// 不要 pause stdin,因为外层 readline 需要它
|
|
122
|
+
// process.stdin.pause();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleKeypress = (str, key) => {
|
|
127
|
+
if (!key) return;
|
|
128
|
+
|
|
129
|
+
if (key.name === 'up') {
|
|
130
|
+
selectedIndex = (selectedIndex - 1 + total) % total;
|
|
131
|
+
render();
|
|
132
|
+
} else if (key.name === 'down') {
|
|
133
|
+
selectedIndex = (selectedIndex + 1) % total;
|
|
134
|
+
render();
|
|
135
|
+
} else if (key.name === 'return') {
|
|
136
|
+
cleanup();
|
|
137
|
+
resolve(exitOnInput ? { action: 'select', index: selectedIndex } : selectedIndex);
|
|
138
|
+
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
139
|
+
cleanup();
|
|
140
|
+
resolve(exitOnInput ? { action: 'cancel' } : null);
|
|
141
|
+
} else if (exitOnInput) {
|
|
142
|
+
cleanup();
|
|
143
|
+
const inputVal = key.sequence || str || key.name;
|
|
144
|
+
resolve({ action: 'input', input: inputVal });
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (process.stdin.isTTY) {
|
|
149
|
+
process.stdin.setRawMode(true);
|
|
150
|
+
process.stdin.resume();
|
|
151
|
+
}
|
|
152
|
+
process.stdin.on('keypress', handleKeypress);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
module.exports = { selectFromList };
|
package/src/utils/model.js
CHANGED
|
@@ -117,7 +117,11 @@ const createModelManager = (endpoints, { configPath } = {}) => {
|
|
|
117
117
|
resetToDefault,
|
|
118
118
|
setActiveModel,
|
|
119
119
|
applyModelByName,
|
|
120
|
-
listModels
|
|
120
|
+
listModels,
|
|
121
|
+
getModels: () => endpoints,
|
|
122
|
+
getActiveIndex: () => activeEndpointIndex,
|
|
123
|
+
getDefaultIndex: () => defaultEndpointIndex,
|
|
124
|
+
describeEndpoint: (endpoint) => describeModel(endpoint)
|
|
121
125
|
};
|
|
122
126
|
};
|
|
123
127
|
|