@ww_nero/mini-cli 1.0.62 → 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 +38 -9
- package/src/tools/bash.js +12 -12
- package/src/tools/index.js +12 -2
- package/src/tools/mcp.js +5 -4
- package/src/utils/commands.js +119 -89
- package/src/utils/menu.js +156 -0
- package/src/utils/model.js +5 -1
package/src/config.js
CHANGED
|
@@ -18,8 +18,13 @@ const DEFAULT_ALLOWED_COMMANDS = [
|
|
|
18
18
|
'false', 'pip', 'python', 'ps', 'lsof', 'git', 'pandoc'
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const DEFAULT_TOOL_RESPONSE_MAX_TOKENS = 65536;
|
|
22
22
|
const DEFAULT_COMPACT_TOKEN_THRESHOLD = 65536;
|
|
23
|
+
const DEFAULT_MCP_TOOL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
|
+
const DEFAULT_OUTPUT_MAX_LENGTH = 12000;
|
|
25
|
+
const DEFAULT_EXECUTION_TIMEOUT = 300000;
|
|
26
|
+
const DEFAULT_SERVICE_BOOT_WINDOW = 5000;
|
|
27
|
+
const DEFAULT_LARGE_FILE_LINE_THRESHOLD = 2000;
|
|
23
28
|
const COMPACT_SUMMARY_PROMPT = `请对以下对话进行总结,用于上下文压缩。请按以下格式输出:
|
|
24
29
|
|
|
25
30
|
## 问题背景
|
|
@@ -86,10 +91,14 @@ const DEFAULT_SETTINGS = {
|
|
|
86
91
|
mcpServers: {},
|
|
87
92
|
tools: {},
|
|
88
93
|
commands: [],
|
|
89
|
-
maxToolTokens:
|
|
94
|
+
maxToolTokens: DEFAULT_TOOL_RESPONSE_MAX_TOKENS,
|
|
90
95
|
compactTokenThreshold: DEFAULT_COMPACT_TOKEN_THRESHOLD,
|
|
91
96
|
allowedCommands: [...DEFAULT_ALLOWED_COMMANDS],
|
|
92
|
-
|
|
97
|
+
mcpToolTimeout: DEFAULT_MCP_TOOL_TIMEOUT_MS,
|
|
98
|
+
outputMaxLength: DEFAULT_OUTPUT_MAX_LENGTH,
|
|
99
|
+
executionTimeout: DEFAULT_EXECUTION_TIMEOUT,
|
|
100
|
+
serviceBootWindow: DEFAULT_SERVICE_BOOT_WINDOW,
|
|
101
|
+
largeFileLineThreshold: DEFAULT_LARGE_FILE_LINE_THRESHOLD
|
|
93
102
|
};
|
|
94
103
|
|
|
95
104
|
const DEFAULT_MINI_CONTENT = '# 在此填写全局系统指令。\n';
|
|
@@ -187,7 +196,11 @@ const normalizePositiveInteger = (value, fallback) => {
|
|
|
187
196
|
|
|
188
197
|
const normalizeField = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
189
198
|
const createEndpointSignature = (entry = {}) => {
|
|
190
|
-
const
|
|
199
|
+
const nameVal = normalizeField(entry.alias || entry.name || entry.model);
|
|
200
|
+
const pick = (key) => {
|
|
201
|
+
if (key === 'name') return nameVal.toLowerCase();
|
|
202
|
+
return normalizeField(entry[key]).toLowerCase();
|
|
203
|
+
};
|
|
191
204
|
return ['name', 'model', 'baseUrl', 'key'].map(pick).join('::');
|
|
192
205
|
};
|
|
193
206
|
|
|
@@ -336,7 +349,7 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
336
349
|
commandPath: detectCommandPath(),
|
|
337
350
|
maxToolTokens: normalizePositiveInteger(
|
|
338
351
|
parsed.maxToolTokens,
|
|
339
|
-
|
|
352
|
+
DEFAULT_TOOL_RESPONSE_MAX_TOKENS
|
|
340
353
|
),
|
|
341
354
|
allowedCommands: (() => {
|
|
342
355
|
const list = ensureArrayOfStrings(parsed.allowedCommands);
|
|
@@ -346,9 +359,25 @@ const loadSettings = ({ defaultTools = [] } = {}) => {
|
|
|
346
359
|
parsed.compactTokenThreshold,
|
|
347
360
|
DEFAULT_COMPACT_TOKEN_THRESHOLD
|
|
348
361
|
),
|
|
349
|
-
|
|
350
|
-
parsed.
|
|
351
|
-
|
|
362
|
+
mcpToolTimeout: normalizePositiveInteger(
|
|
363
|
+
parsed.mcpToolTimeout,
|
|
364
|
+
DEFAULT_MCP_TOOL_TIMEOUT_MS
|
|
365
|
+
),
|
|
366
|
+
outputMaxLength: normalizePositiveInteger(
|
|
367
|
+
parsed.outputMaxLength,
|
|
368
|
+
DEFAULT_OUTPUT_MAX_LENGTH
|
|
369
|
+
),
|
|
370
|
+
executionTimeout: normalizePositiveInteger(
|
|
371
|
+
parsed.executionTimeout,
|
|
372
|
+
DEFAULT_EXECUTION_TIMEOUT
|
|
373
|
+
),
|
|
374
|
+
serviceBootWindow: normalizePositiveInteger(
|
|
375
|
+
parsed.serviceBootWindow,
|
|
376
|
+
DEFAULT_SERVICE_BOOT_WINDOW
|
|
377
|
+
),
|
|
378
|
+
largeFileLineThreshold: normalizePositiveInteger(
|
|
379
|
+
parsed.largeFileLineThreshold,
|
|
380
|
+
DEFAULT_LARGE_FILE_LINE_THRESHOLD
|
|
352
381
|
)
|
|
353
382
|
};
|
|
354
383
|
|
|
@@ -370,7 +399,7 @@ module.exports = {
|
|
|
370
399
|
ensureConfigFiles,
|
|
371
400
|
getConfigPath,
|
|
372
401
|
DEFAULT_ALLOWED_COMMANDS,
|
|
373
|
-
|
|
402
|
+
DEFAULT_TOOL_RESPONSE_MAX_TOKENS,
|
|
374
403
|
DEFAULT_COMPACT_TOKEN_THRESHOLD,
|
|
375
404
|
COMPACT_SUMMARY_PROMPT
|
|
376
405
|
};
|
package/src/tools/bash.js
CHANGED
|
@@ -2,10 +2,6 @@ const { spawn } = require('child_process');
|
|
|
2
2
|
const { resolveWorkspacePath } = require('../utils/helpers');
|
|
3
3
|
const { DEFAULT_ALLOWED_COMMANDS } = require('../config');
|
|
4
4
|
|
|
5
|
-
const OUTPUT_MAX_LENGTH = 12000;
|
|
6
|
-
const EXECUTION_TIMEOUT = 300000;
|
|
7
|
-
const SERVICE_RETURN_DELAY = 5000;
|
|
8
|
-
|
|
9
5
|
// Git 只读命令白名单
|
|
10
6
|
const GIT_READONLY_COMMANDS = ['show', 'diff', 'log', 'status', 'branch', 'tag', 'ls-files', 'ls-tree', 'rev-parse', 'reflog', 'blame', 'shortlog', 'describe', 'config --get', 'config --list', 'remote', 'ls-remote', 'fetch --dry-run', 'grep'];
|
|
11
7
|
|
|
@@ -86,6 +82,10 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
86
82
|
const allowedCommands = Array.isArray(context.allowedCommands) && context.allowedCommands.length > 0
|
|
87
83
|
? context.allowedCommands
|
|
88
84
|
: DEFAULT_ALLOWED_COMMANDS;
|
|
85
|
+
|
|
86
|
+
const outputMaxLength = context.outputMaxLength || 12000;
|
|
87
|
+
const executionTimeout = context.executionTimeout || 300000;
|
|
88
|
+
const serviceBootWindow = context.serviceBootWindow || 5000;
|
|
89
89
|
const commands = splitShellCommands(normalizedCommand);
|
|
90
90
|
if (commands.length === 0) {
|
|
91
91
|
return '未找到有效的命令';
|
|
@@ -129,15 +129,15 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
129
129
|
|
|
130
130
|
child.stdout.on('data', (data) => {
|
|
131
131
|
stdout += data.toString();
|
|
132
|
-
if (stdout.length >
|
|
133
|
-
stdout = stdout.slice(0,
|
|
132
|
+
if (stdout.length > outputMaxLength) {
|
|
133
|
+
stdout = stdout.slice(0, outputMaxLength);
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
child.stderr.on('data', (data) => {
|
|
138
138
|
stderr += data.toString();
|
|
139
|
-
if (stderr.length >
|
|
140
|
-
stderr = stderr.slice(0,
|
|
139
|
+
if (stderr.length > outputMaxLength) {
|
|
140
|
+
stderr = stderr.slice(0, outputMaxLength);
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
143
|
|
|
@@ -169,8 +169,8 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
169
169
|
const stdOutput = stdout.trim();
|
|
170
170
|
const errOutput = stderr.trim();
|
|
171
171
|
const captured = stdOutput || errOutput ? `\n当前输出:\n${stdOutput}${errOutput ? `\n错误:\n${errOutput}` : ''}` : '\n暂无输出';
|
|
172
|
-
resolve(`已等待 ${
|
|
173
|
-
},
|
|
172
|
+
resolve(`已等待 ${serviceBootWindow / 1000}s,命令已在后台持续运行(PID: ${child.pid})。${captured}`);
|
|
173
|
+
}, serviceBootWindow);
|
|
174
174
|
|
|
175
175
|
const serviceErrorHandler = (error) => {
|
|
176
176
|
clearTimeout(timer);
|
|
@@ -190,8 +190,8 @@ const executeCommand = async ({ command, workingDirectory = '.', isService = fal
|
|
|
190
190
|
if (settled) return;
|
|
191
191
|
child.kill('SIGTERM');
|
|
192
192
|
cleanup();
|
|
193
|
-
resolve(
|
|
194
|
-
},
|
|
193
|
+
resolve(`命令执行超时 (超过 ${executionTimeout / 1000}s)`);
|
|
194
|
+
}, executionTimeout);
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
197
|
};
|
package/src/tools/index.js
CHANGED
|
@@ -15,7 +15,15 @@ const createToolRuntime = async (workspaceRoot, options = {}) => {
|
|
|
15
15
|
const enabledMcps = Array.isArray(settings.mcps) ? settings.mcps : [];
|
|
16
16
|
const allowedCommands = settings.allowedCommands;
|
|
17
17
|
|
|
18
|
-
const context = {
|
|
18
|
+
const context = {
|
|
19
|
+
workspaceRoot,
|
|
20
|
+
allowedCommands,
|
|
21
|
+
outputMaxLength: settings.outputMaxLength,
|
|
22
|
+
executionTimeout: settings.executionTimeout,
|
|
23
|
+
serviceBootWindow: settings.serviceBootWindow,
|
|
24
|
+
largeFileLineThreshold: settings.largeFileLineThreshold,
|
|
25
|
+
...options
|
|
26
|
+
};
|
|
19
27
|
|
|
20
28
|
const tools = [];
|
|
21
29
|
const handlers = {};
|
|
@@ -35,7 +43,9 @@ const createToolRuntime = async (workspaceRoot, options = {}) => {
|
|
|
35
43
|
|
|
36
44
|
TOOL_MODULES.forEach(registerTool);
|
|
37
45
|
|
|
38
|
-
const mcpManager = await createMcpManager(workspaceRoot, enabledMcps
|
|
46
|
+
const mcpManager = await createMcpManager(workspaceRoot, enabledMcps, {
|
|
47
|
+
mcpToolTimeout: settings.mcpToolTimeout
|
|
48
|
+
});
|
|
39
49
|
let mcpConfigPath = null;
|
|
40
50
|
const mcpToolNames = new Set(); // 记录所有 MCP 工具名称
|
|
41
51
|
const enabledMcpNames = []; // 记录启用的 MCP 服务器名称
|
package/src/tools/mcp.js
CHANGED
|
@@ -305,9 +305,10 @@ const formatMcpContent = (content) => {
|
|
|
305
305
|
};
|
|
306
306
|
|
|
307
307
|
class McpManager {
|
|
308
|
-
constructor(workspaceRoot, allowedMcpNames = null) {
|
|
308
|
+
constructor(workspaceRoot, allowedMcpNames = null, options = {}) {
|
|
309
309
|
this.workspaceRoot = workspaceRoot;
|
|
310
310
|
this.allowedMcpNames = Array.isArray(allowedMcpNames) ? allowedMcpNames : null;
|
|
311
|
+
this.mcpToolTimeout = options.mcpToolTimeout || 600000;
|
|
311
312
|
this.clients = [];
|
|
312
313
|
}
|
|
313
314
|
|
|
@@ -407,7 +408,7 @@ class McpManager {
|
|
|
407
408
|
},
|
|
408
409
|
undefined,
|
|
409
410
|
{
|
|
410
|
-
timeout:
|
|
411
|
+
timeout: this.mcpToolTimeout
|
|
411
412
|
}
|
|
412
413
|
);
|
|
413
414
|
const isError = Boolean(result && result.isError);
|
|
@@ -460,11 +461,11 @@ class McpManager {
|
|
|
460
461
|
}
|
|
461
462
|
}
|
|
462
463
|
|
|
463
|
-
const createMcpManager = async (workspaceRoot, allowedMcpNames = null) => {
|
|
464
|
+
const createMcpManager = async (workspaceRoot, allowedMcpNames = null, options = {}) => {
|
|
464
465
|
if (Array.isArray(allowedMcpNames) && allowedMcpNames.length === 0) {
|
|
465
466
|
return null;
|
|
466
467
|
}
|
|
467
|
-
const manager = new McpManager(workspaceRoot, allowedMcpNames);
|
|
468
|
+
const manager = new McpManager(workspaceRoot, allowedMcpNames, options);
|
|
468
469
|
await manager.initialize();
|
|
469
470
|
const tools = manager.getTools();
|
|
470
471
|
if (!tools || tools.length === 0) {
|
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
|
|