refactor(core): 移除 UI 交互层,保持 core 为纯能力模块

- 删除 terminal.ts 及其测试(UI 交互应属于 cli 包)
- 清理 index.ts 中的 CLI 入口代码
- 合并 lib.ts 导出到 index.ts
- 移除 commander、inquirer 依赖
- 更新 package.json 导出配置
This commit is contained in:
2025-12-13 00:42:07 +08:00
parent 5b7d62e793
commit 5d4afecd48
5 changed files with 95 additions and 1212 deletions
+1
View File
@@ -55,6 +55,7 @@
"chalk": "^5.3.0",
"js-yaml": "^4.1.1",
"minimatch": "^10.1.1",
"nanoid": "^5.1.6",
"qwen-ai-provider-v5": "^1.0.2",
"simple-git": "^3.30.0",
"tree-sitter-bash": "^0.25.1",
+84 -431
View File
@@ -1,36 +1,38 @@
#!/usr/bin/env node
export { Agent } from './core/agent.js';
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
export { loadConfig, initConfig } from './utils/config.js';
export { SessionStorage } from './session/storage.js';
export { SessionManager } from './session/index.js';
export type { SessionData, SessionSummary } from './session/types.js';
import { Command } from 'commander';
import { Agent } from './core/agent.js';
import { TerminalUI } from './ui/terminal.js';
import { loadConfig, initConfig } from './utils/config.js';
import { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
import { getPermissionManager, promptPermission } from './permission/index.js';
import { SessionManager } from './session/index.js';
import { agentRegistry } from './agent/index.js';
import { initLSP, shutdownLSP } from './lsp/index.js';
import { getCommandRegistry } from './commands/index.js';
import { getSkillRegistry } from './skills/index.js';
import {
// Types
export type { UserInput } from './types/index.js';
// Permission
export { getPermissionManager, promptPermission } from './permission/index.js';
// LSP
export { initLSP, shutdownLSP } from './lsp/index.js';
export {
printServerList,
installServer,
installAllServers,
showServerInfo,
} from './lsp/cli.js';
import {
getMCPManager,
loadMCPConfig,
createMCPToolAdapter,
} from './mcp/index.js';
// ============================================================================
// 库导出(供 server 等包使用)
// ============================================================================
export { Agent } from './core/agent.js';
export { toolRegistry } from './tools/index.js';
export { loadConfig } from './utils/config.js';
export { SessionStorage } from './session/storage.js';
export type { SessionData, SessionSummary } from './session/types.js';
// Skills
export { getSkillRegistry } from './skills/index.js';
// Image utils
export {
extractImageReferences,
loadImages,
loadImage,
formatFileSize,
isImagePath,
IMAGE_EXTENSIONS,
} from './utils/image.js';
export type { ImageInfo, ImageLoadResult } from './utils/image.js';
// Commands
export { getCommandRegistry, createCommandExecutor, createCommandManager } from './commands/index.js';
@@ -91,408 +93,59 @@ export type {
PathValidationResult,
} from './checkpoint/index.js';
const program = new Command();
// MCP 管理器实例
let mcpInitialized = false;
/**
* 初始化 MCP 系统
* 加载配置、连接服务器、注册工具
*/
async function initMCP(workdir: string): Promise<void> {
if (mcpInitialized) {
return;
}
const mcpConfig = loadMCPConfig(workdir);
// 如果没有 MCP 配置,跳过初始化
if (!mcpConfig.mcp || Object.keys(mcpConfig.mcp).length === 0) {
return;
}
const mcpManager = getMCPManager();
// 监听工具变化事件
mcpManager.on('tools:changed', () => {
registerMCPTools(mcpManager);
});
// 监听服务器事件(用于日志)
mcpManager.on('server:connected', (name) => {
console.log(`🔌 MCP 服务器已连接: ${name}`);
});
mcpManager.on('server:disconnected', (name) => {
console.log(`🔌 MCP 服务器已断开: ${name}`);
});
mcpManager.on('server:error', (name, error) => {
console.error(`❌ MCP 服务器 ${name} 错误:`, error);
});
try {
await mcpManager.initialize(mcpConfig);
registerMCPTools(mcpManager);
mcpInitialized = true;
// 显示 MCP 状态
const statuses = mcpManager.getServerStatuses();
const connected = statuses.filter((s) => s.status === 'connected');
if (connected.length > 0) {
const totalTools = connected.reduce((sum, s) => sum + s.toolCount, 0);
console.log(
`🔌 MCP: ${connected.length} 个服务器已连接,${totalTools} 个工具可用`
);
}
} catch (error) {
console.error(
'❌ MCP 初始化失败:',
error instanceof Error ? error.message : String(error)
);
}
}
/**
* 将 MCP 工具注册到工具注册表
*/
function registerMCPTools(
mcpManager: ReturnType<typeof getMCPManager>
): void {
const adapter = createMCPToolAdapter(mcpManager);
const mcpTools = mcpManager.getTools();
const adaptedTools = adapter.adaptTools(mcpTools);
// 注册到工具注册表
toolRegistry.registerAll(adaptedTools);
}
/**
* 关闭 MCP 系统
*/
async function shutdownMCP(): Promise<void> {
if (mcpInitialized) {
const mcpManager = getMCPManager();
await mcpManager.shutdown();
mcpInitialized = false;
}
}
program
.name('ai-assist')
.description('AI Terminal Assistant - 终端中的 AI 编程助手')
.version('1.0.0');
// 初始化命令
program
.command('init')
.description('初始化配置(设置 API Key 等)')
.action(async () => {
await initConfig();
});
// LSP 命令组
const lspCommand = program
.command('lsp')
.description('语言服务器管理');
lspCommand
.command('list')
.description('列出所有语言服务器及其安装状态')
.action(() => {
printServerList();
});
lspCommand
.command('install [servers...]')
.description('安装指定的语言服务器')
.option('-a, --all', '安装所有语言服务器')
.action(async (servers: string[], options: { all?: boolean }) => {
if (options.all) {
await installAllServers();
} else if (servers.length === 0) {
console.log('用法: ai-assist lsp install <server> [server2] ...');
console.log(' ai-assist lsp install --all');
console.log('\n运行 "ai-assist lsp list" 查看可用的服务器');
} else {
for (const server of servers) {
await installServer(server);
}
}
});
lspCommand
.command('info <server>')
.description('显示语言服务器详细信息')
.action((server: string) => {
showServerInfo(server);
});
// MCP 命令组
const mcpCommand = program.command('mcp').description('MCP 服务器管理');
mcpCommand
.command('list')
.description('列出所有 MCP 服务器及其状态')
.action(async () => {
const mcpConfig = loadMCPConfig(process.cwd());
if (!mcpConfig.mcp || Object.keys(mcpConfig.mcp).length === 0) {
console.log('没有配置 MCP 服务器');
console.log('\n配置方法:');
console.log(' 在 ~/.ai-assist/config.json 或 .ai-assist/config.json 中添加 mcp 配置');
console.log('\n示例:');
console.log(' {');
console.log(' "mcp": {');
console.log(' "filesystem": {');
console.log(' "type": "local",');
console.log(' "command": ["npx", "-y", "@anthropic-ai/mcp-server-filesystem", "/path/to/dir"]');
console.log(' }');
console.log(' }');
console.log(' }');
return;
}
const mcpManager = getMCPManager();
// 尝试连接以获取工具数量
try {
if (!mcpManager.isInitialized()) {
await mcpManager.initialize(mcpConfig);
}
} catch {
// 忽略连接错误,仍然显示配置的服务器
}
const statuses = mcpManager.getServerStatuses();
console.log('\nMCP 服务器列表:\n');
const statusIcons: Record<string, string> = {
connected: '✅',
connecting: '🔄',
disconnected: '⭕',
disabled: '🚫',
error: '❌',
};
for (const status of statuses) {
const icon = statusIcons[status.status] || '❓';
const toolInfo = status.toolCount > 0 ? ` (${status.toolCount} 个工具)` : '';
const errorInfo = status.error ? ` - ${status.error}` : '';
console.log(
` ${icon} ${status.name} [${status.type}] - ${status.status}${toolInfo}${errorInfo}`
);
}
console.log('');
// 关闭连接
await mcpManager.shutdown();
});
mcpCommand
.command('tools [server]')
.description('列出 MCP 服务器提供的工具')
.action(async (server?: string) => {
const mcpConfig = loadMCPConfig(process.cwd());
if (!mcpConfig.mcp || Object.keys(mcpConfig.mcp).length === 0) {
console.log('没有配置 MCP 服务器');
return;
}
const mcpManager = getMCPManager();
try {
if (!mcpManager.isInitialized()) {
await mcpManager.initialize(mcpConfig);
}
const tools = mcpManager.getTools();
if (tools.length === 0) {
console.log('没有可用的 MCP 工具');
return;
}
// 按服务器分组
const toolsByServer = new Map<string, typeof tools>();
for (const tool of tools) {
if (server && tool.server !== server) {
continue;
}
const serverTools = toolsByServer.get(tool.server) || [];
serverTools.push(tool);
toolsByServer.set(tool.server, serverTools);
}
if (toolsByServer.size === 0) {
console.log(server ? `服务器 "${server}" 没有提供工具` : '没有可用的工具');
return;
}
console.log('\nMCP 工具列表:\n');
for (const [serverName, serverTools] of toolsByServer) {
console.log(`📦 ${serverName}:`);
for (const tool of serverTools) {
console.log(` ${tool.name}`);
if (tool.description) {
console.log(` ${tool.description.substring(0, 80)}${tool.description.length > 80 ? '...' : ''}`);
}
}
console.log('');
}
} catch (error) {
console.error(
'获取工具列表失败:',
error instanceof Error ? error.message : String(error)
);
} finally {
await mcpManager.shutdown();
}
});
mcpCommand
.command('test <server>')
.description('测试 MCP 服务器连接')
.action(async (server: string) => {
const mcpConfig = loadMCPConfig(process.cwd());
if (!mcpConfig.mcp?.[server]) {
console.log(`❌ 未找到服务器配置: ${server}`);
return;
}
console.log(`🔄 正在连接 ${server}...`);
const mcpManager = getMCPManager();
try {
await mcpManager.initialize({
mcp: { [server]: mcpConfig.mcp[server] },
tools: mcpConfig.tools,
});
const status = mcpManager.getServerStatus(server);
if (status?.status === 'connected') {
console.log(`✅ 连接成功!`);
console.log(` 工具数量: ${status.toolCount}`);
const tools = mcpManager.getTools();
if (tools.length > 0) {
console.log(' 可用工具:');
for (const tool of tools) {
console.log(` - ${tool.originalName}`);
}
}
} else {
console.log(`❌ 连接失败: ${status?.error || '未知错误'}`);
}
} catch (error) {
console.error(
`❌ 连接失败:`,
error instanceof Error ? error.message : String(error)
);
} finally {
await mcpManager.shutdown();
}
});
// 初始化权限系统
function setupPermissions(): void {
const permissionManager = getPermissionManager();
permissionManager.setAskCallback(promptPermission);
}
// 单次查询命令
program
.command('ask <question>')
.description('单次提问(不进入交互模式)')
.action(async (question: string) => {
setupPermissions();
const config = loadConfig();
const agent = new Agent(config);
// 设置工具注册表(支持动态工具发现)
agent.setRegistry(toolRegistry);
try {
await agent.chat(question, (text) => {
process.stdout.write(text);
});
console.log('');
} catch (error) {
console.error(
'错误:',
error instanceof Error ? error.message : String(error)
);
process.exit(1);
}
});
// 默认:交互模式
program.action(async () => {
setupPermissions();
const config = loadConfig();
const agent = new Agent(config);
// 初始化 LSP 系统
initLSP(process.cwd());
// 初始化 MCP 系统(加载外部工具服务器)
await initMCP(process.cwd());
// 设置工具注册表(支持动态工具发现)
agent.setRegistry(toolRegistry);
// 初始化会话管理器(支持会话持久化)
const sessionManager = new SessionManager();
await sessionManager.init(process.cwd());
agent.setSessionManager(sessionManager);
// 初始化 todoManager(让 todo 工具可以访问会话)
todoManager.setSessionManager(sessionManager);
// 初始化 Agent 注册表(加载预设和用户配置)
await agentRegistry.init(process.cwd());
// 初始化 Task 工具上下文
initTaskContext(config, sessionManager);
updateTaskDescription();
// 初始化 Skill 注册表
const skillRegistry = getSkillRegistry();
await skillRegistry.initialize(process.cwd());
updateSkillDescription();
// 初始化 Command 注册表
const commandRegistry = getCommandRegistry();
await commandRegistry.initialize(process.cwd());
// 显示会话恢复信息
const session = sessionManager.getSession();
if (session && session.messages.length > 0) {
console.log(`\n📂 已恢复会话 (${session.messages.length} 条消息)`);
}
// 启动终端 UI
const ui = new TerminalUI(agent);
// 优雅退出
process.on('SIGINT', async () => {
console.log('\n\n👋 再见!');
await shutdownMCP();
await shutdownLSP();
await sessionManager.close();
ui.close();
process.exit(0);
});
await ui.start();
});
program.parse();
// Hooks
export {
HookManager,
getHookManager,
initHookManager,
resetHookManager,
loadProjectConfig,
loadHookConfig,
loadPluginList,
createDefaultConfig,
getConfigFilePath,
} from './hooks/index.js';
export type {
HookType,
HookConfig,
HookEvent,
HookEventListener,
ShellCommandConfig,
FileHookConfig,
Hooks,
Plugin,
PluginInput,
ToolExecuteBeforeInput,
ToolExecuteBeforeOutput,
ToolExecuteAfterInput,
ToolExecuteAfterOutput,
SessionStartInput,
SessionEndInput,
MessageBeforeInput,
MessageBeforeOutput,
MessageAfterInput,
FileChangeInput,
FileChangeOutput,
ProjectConfig,
} from './hooks/index.js';
// Agent Registry & Presets
export { agentRegistry, AgentRegistry } from './agent/index.js';
export { loadAgentConfig, saveAgentConfig, getConfigTemplate } from './agent/index.js';
export { presetAgents, isPresetAgent, getPresetAgentNames } from './agent/index.js';
export type {
AgentMode,
AgentInfo,
AgentConfigFile,
AgentModelConfig,
AgentToolConfig,
AgentPermission,
} from './agent/index.js';
// MCP
export {
getMCPManager,
loadMCPConfig,
createMCPToolAdapter,
} from './mcp/index.js';
-499
View File
@@ -1,499 +0,0 @@
import * as readline from 'readline';
import chalk from 'chalk';
import type { Agent } from '../core/agent.js';
import { agentRegistry } from '../agent/index.js';
import {
getCommandRegistry,
createCommandExecutor,
type CommandExecutionResult,
} from '../commands/index.js';
import {
extractImageReferences,
loadImages,
formatFileSize,
} from '../utils/image.js';
import type { UserInput } from '../types/index.js';
export class TerminalUI {
private agent: Agent;
private rl: readline.Interface;
private isClosed = false;
constructor(agent: Agent) {
this.agent = agent;
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
});
// 监听关闭事件
this.rl.on('close', () => {
this.isClosed = true;
});
}
// 显示欢迎信息
private showWelcome(): void {
console.log(chalk.cyan('\n╔════════════════════════════════════════╗'));
console.log(chalk.cyan('║') + chalk.bold.white(' 🤖 AI Terminal Assistant ') + chalk.cyan('║'));
console.log(chalk.cyan('║') + chalk.gray(' Powered by DeepSeek / Claude ') + chalk.cyan('║'));
console.log(chalk.cyan('╚════════════════════════════════════════╝\n'));
console.log(chalk.gray('输入你的问题,或使用以下命令:'));
console.log(chalk.yellow(' /help') + chalk.gray(' - 显示帮助'));
console.log(chalk.yellow(' /commands') + chalk.gray(' - 列出所有可用命令'));
console.log(chalk.yellow(' /agent') + chalk.gray(' - 切换 Agent 模式'));
console.log(chalk.yellow(' /clear') + chalk.gray(' - 清空对话历史'));
console.log(chalk.yellow(' /compact') + chalk.gray(' - 压缩对话历史'));
console.log(chalk.yellow(' /context') + chalk.gray(' - 查看上下文使用情况'));
console.log(chalk.yellow(' /exit') + chalk.gray(' - 退出程序'));
console.log('');
}
// 格式化上下文使用情况(带颜色)
private formatContextUsage(): string {
const usage = this.agent.getContextUsage();
const percent = usage.usagePercent;
// 根据使用率选择颜色
let colorFn: (text: string) => string;
if (percent < 50) {
colorFn = chalk.green;
} else if (percent < 80) {
colorFn = chalk.yellow;
} else {
colorFn = chalk.red;
}
const used = usage.input >= 1000 ? `${(usage.input / 1000).toFixed(1)}k` : `${usage.input}`;
const limit = `${(usage.available / 1000).toFixed(0)}k`;
return colorFn(`[${used}/${limit}]`);
}
// 列出所有可用的 Commands
private listCommands(): void {
const registry = getCommandRegistry();
const commands = registry.list();
console.log(chalk.cyan('\n📋 可用的 Commands\n'));
if (commands.length === 0) {
console.log(chalk.gray(' 没有可用的命令'));
} else {
// 按来源分组
const builtin = commands.filter((c) => c.source === 'builtin');
const user = commands.filter((c) => c.source === 'user');
const project = commands.filter((c) => c.source === 'project');
if (builtin.length > 0) {
console.log(chalk.white(' 内置命令:'));
for (const cmd of builtin) {
console.log(
chalk.yellow(` /${cmd.name}`) +
chalk.gray(cmd.description ? ` - ${cmd.description}` : '')
);
}
console.log('');
}
if (user.length > 0) {
console.log(chalk.white(' 用户命令:'));
for (const cmd of user) {
console.log(
chalk.yellow(` /${cmd.name}`) +
chalk.gray(cmd.description ? ` - ${cmd.description}` : '')
);
}
console.log('');
}
if (project.length > 0) {
console.log(chalk.white(' 项目命令:'));
for (const cmd of project) {
console.log(
chalk.yellow(` /${cmd.name}`) +
chalk.gray(cmd.description ? ` - ${cmd.description}` : '')
);
}
console.log('');
}
}
console.log(chalk.gray(' 用法: /<command> [arguments]'));
console.log(chalk.gray(' 示例: /review main..feature'));
console.log('');
}
// 尝试执行自定义 Command
private async tryExecuteCommand(input: string): Promise<CommandExecutionResult | null> {
const executor = createCommandExecutor(process.cwd());
const parsed = executor.parseInput(input);
if (!parsed) {
return null;
}
const registry = getCommandRegistry();
// 检查是否是自定义 Command(不是内置的系统命令)
if (!registry.has(parsed.command)) {
return null;
}
return await executor.execute(parsed);
}
// 处理 /agent 命令
private handleAgentCommand(args: string): boolean {
const agentName = args.trim();
// 无参数:显示当前模式和可用 Agent
if (!agentName) {
const currentMode = this.agent.getAgentModeName();
const availableAgents = agentRegistry.listPrimaryAgents();
console.log(chalk.cyan('\n🤖 Agent 模式:'));
console.log(chalk.white(` 当前: ${currentMode === 'default' ? chalk.green('default (通用助手)') : chalk.yellow(currentMode)}`));
console.log('');
console.log(chalk.white(' 可用 Agent:'));
console.log(chalk.gray(' • default - 通用编程助手'));
for (const agent of availableAgents) {
const marker = agent.name === currentMode ? chalk.green(' ✓') : '';
console.log(chalk.gray(`${agent.name} - ${agent.description}${marker}`));
}
console.log('');
console.log(chalk.gray(' 用法: /agent <name> 切换到指定 Agent'));
console.log(chalk.gray(' /agent default 切换回默认模式'));
console.log('');
return true;
}
// 切换到默认模式
if (agentName === 'default') {
this.agent.setAgentMode(null);
const toolCount = this.agent.getToolCount();
console.log(chalk.green('\n✓ 已切换到 default (通用助手) 模式'));
console.log(chalk.gray(` 可用工具: ${toolCount.total}\n`));
return true;
}
// 切换到指定 Agent
const agent = agentRegistry.get(agentName);
if (!agent) {
const availableNames = ['default', ...agentRegistry.listPrimaryAgents().map(a => a.name)];
console.log(chalk.red(`\n✗ 未找到 Agent: ${agentName}`));
console.log(chalk.gray(` 可用: ${availableNames.join(', ')}\n`));
return true;
}
// 检查是否为 subagent 模式(不能作为主交互 Agent)
if (agent.mode === 'subagent') {
console.log(chalk.yellow(`\n⚠ Agent "${agentName}" 是 subagent 模式,只能通过 task 工具调用`));
console.log(chalk.gray(' 提示: 使用 /agent 查看可交互的 Agent 列表\n'));
return true;
}
this.agent.setAgentMode(agent);
const toolCount = this.agent.getToolCount();
console.log(chalk.green(`\n✓ 已切换到 ${agent.name} 模式`));
console.log(chalk.gray(` ${agent.description}`));
console.log(chalk.gray(` 可用工具: ${toolCount.total}\n`));
return true;
}
// 处理特殊命令
private async handleCommand(input: string): Promise<boolean> {
const command = input.toLowerCase().trim();
// 检查 /agent 命令(带参数)
if (command.startsWith('/agent')) {
const args = input.substring(6).trim();
return this.handleAgentCommand(args);
}
// 检查 /commands 命令
if (command === '/commands') {
this.listCommands();
return true;
}
switch (command) {
case '/help':
console.log(chalk.cyan('\n📖 帮助信息:'));
console.log(chalk.white(' 这是一个 AI 编程助手,可以帮你:'));
console.log(chalk.gray(' • 读写文件'));
console.log(chalk.gray(' • 执行 bash 命令'));
console.log(chalk.gray(' • 搜索代码'));
console.log(chalk.gray(' • 回答编程问题'));
console.log('');
console.log(chalk.white(' 命令:'));
console.log(chalk.gray(' • /help - 显示此帮助'));
console.log(chalk.gray(' • /agent - 切换 Agent 模式'));
console.log(chalk.gray(' • /clear - 清空对话历史'));
console.log(chalk.gray(' • /compact - 压缩对话历史,释放上下文空间'));
console.log(chalk.gray(' • /context - 显示当前上下文使用情况'));
console.log(chalk.gray(' • /exit - 退出程序'));
console.log('');
return true;
case '/clear':
await this.agent.clearHistory();
console.log(chalk.green('✓ 对话历史已清空\n'));
return true;
case '/compact':
console.log(chalk.yellow('正在压缩对话历史...\n'));
try {
const beforeUsage = this.agent.getContextUsage();
const result = await this.agent.compactHistory();
const afterUsage = this.agent.getContextUsage();
if (result.freedTokens > 0) {
console.log(chalk.green(`✓ 压缩完成!`));
console.log(chalk.gray(` 策略: ${result.type}`));
console.log(chalk.gray(` 释放: ${(result.freedTokens / 1000).toFixed(1)}k tokens`));
console.log(chalk.gray(` 之前: ${(beforeUsage.input / 1000).toFixed(1)}k`));
console.log(chalk.gray(` 之后: ${(afterUsage.input / 1000).toFixed(1)}k`));
} else {
console.log(chalk.yellow('没有可压缩的内容'));
}
console.log('');
} catch (error) {
console.log(chalk.red(`压缩失败: ${error instanceof Error ? error.message : String(error)}\n`));
}
return true;
case '/context':
const usage = this.agent.getContextUsage();
console.log(chalk.cyan('\n📊 上下文使用情况:'));
console.log(chalk.gray(` 已使用: ${(usage.input / 1000).toFixed(1)}k tokens`));
console.log(chalk.gray(` 可用: ${(usage.available / 1000).toFixed(0)}k tokens`));
console.log(chalk.gray(` 上下文限制: ${(usage.contextLimit / 1000).toFixed(0)}k tokens`));
console.log(chalk.gray(` 使用率: ${usage.usagePercent.toFixed(1)}%`));
console.log('');
return true;
case '/exit':
case '/quit':
console.log(chalk.cyan('\n👋 再见!\n'));
this.close();
process.exit(0);
default:
return false;
}
}
// 处理包含图片引用的输入
private async processImageInput(
input: string
): Promise<{ userInput: UserInput; hasImages: boolean } | null> {
const { imagePaths, textContent } = extractImageReferences(input);
// 没有图片引用,返回纯文本
if (imagePaths.length === 0) {
return {
userInput: { text: input },
hasImages: false,
};
}
// 加载图片
console.log(chalk.gray(`\n正在加载 ${imagePaths.length} 张图片...`));
const { images, errors } = await loadImages(imagePaths, process.cwd());
// 显示加载错误
for (const err of errors) {
console.log(chalk.red(`${err.path}: ${err.error}`));
}
// 如果没有成功加载任何图片
if (images.length === 0) {
console.log(chalk.red('没有成功加载任何图片\n'));
return null;
}
// 显示成功加载的图片
for (const img of images) {
console.log(
chalk.green(`${img.filename}`) +
chalk.gray(` (${formatFileSize(img.size)})`)
);
}
console.log('');
return {
userInput: {
text: textContent,
images: images.map((img) => ({
data: img.base64,
mimeType: img.mimeType,
filename: img.filename,
})),
},
hasImages: true,
};
}
// 提问并获取用户输入
private prompt(): Promise<string> {
return new Promise((resolve, reject) => {
if (this.isClosed) {
reject(new Error('readline closed'));
return;
}
// 显示带上下文使用情况的提示符
const contextInfo = this.formatContextUsage();
const agentMode = this.agent.getAgentModeName();
const agentIndicator = agentMode === 'default' ? '' : chalk.magenta(`@${agentMode} `);
this.rl.question(`${contextInfo} ${agentIndicator}${chalk.green('You >')} `, (answer) => {
resolve(answer ?? '');
});
});
}
// 主循环
async start(): Promise<void> {
this.showWelcome();
while (!this.isClosed) {
try {
const input = await this.prompt();
// 跳过空输入
if (!input.trim()) {
continue;
}
// 处理命令
if (input.startsWith('/')) {
// 先检查系统内置命令
if (await this.handleCommand(input)) {
continue;
}
// 尝试执行自定义 Command
const cmdResult = await this.tryExecuteCommand(input);
if (cmdResult) {
if (!cmdResult.success) {
console.log(chalk.red(`\n❌ ${cmdResult.error}\n`));
continue;
}
// Command 执行成功,将渲染后的提示发送给 AI
console.log(chalk.cyan(`\n📝 执行命令: ${input.split(' ')[0]}`));
if (cmdResult.agent) {
console.log(chalk.gray(` Agent: ${cmdResult.agent}`));
}
if (cmdResult.model) {
console.log(chalk.gray(` Model: ${cmdResult.model}`));
}
console.log('');
// 使用渲染后的 prompt 作为输入
const promptToSend = cmdResult.prompt || '';
// TODO: 如果指定了 agent/model/subtask,需要相应处理
// 目前简单地将渲染后的 prompt 发送给当前 agent
process.stdout.write(chalk.gray('思考中...'));
try {
let isFirstChunk = true;
await this.agent.chat(promptToSend, (text) => {
if (isFirstChunk) {
process.stdout.write('\r' + ' '.repeat(20) + '\r');
process.stdout.write(chalk.blue('AI > '));
isFirstChunk = false;
}
if (text.startsWith('\n[调用工具:')) {
process.stdout.write(chalk.yellow(text));
} else if (text.startsWith('[结果:') || text.startsWith('[错误:')) {
process.stdout.write(chalk.gray(text));
} else {
process.stdout.write(text);
}
});
console.log('\n');
} catch (error) {
process.stdout.write('\r' + ' '.repeat(20) + '\r');
console.log(
chalk.red(
`❌ 错误: ${error instanceof Error ? error.message : String(error)}\n`
)
);
}
continue;
}
// 未知命令
console.log(chalk.yellow(`\n未知命令: ${input.split(' ')[0]}`));
console.log(chalk.gray('使用 /commands 查看所有可用命令\n'));
continue;
}
// 处理图片引用
const processed = await this.processImageInput(input);
if (!processed) {
continue;
}
const { userInput, hasImages } = processed;
// 发送给 AI(如果模型不支持 visionAgent 会自动委托 Vision Agent 处理)
process.stdout.write(chalk.gray('思考中...'));
try {
let isFirstChunk = true;
// 根据是否有图片选择发送格式
const messageToSend = hasImages ? userInput : userInput.text;
await this.agent.chat(messageToSend, (text) => {
if (isFirstChunk) {
// 清除 "思考中..." 并显示 AI 前缀
process.stdout.write('\r' + ' '.repeat(20) + '\r');
process.stdout.write(chalk.blue('AI > '));
isFirstChunk = false;
}
// 处理工具调用的输出
if (text.startsWith('\n[调用工具:')) {
process.stdout.write(chalk.yellow(text));
} else if (text.startsWith('[结果:') || text.startsWith('[错误:')) {
process.stdout.write(chalk.gray(text));
} else {
process.stdout.write(text);
}
});
console.log('\n');
} catch (error) {
// 清除 "思考中..."
process.stdout.write('\r' + ' '.repeat(20) + '\r');
console.log(
chalk.red(
`❌ 错误: ${error instanceof Error ? error.message : String(error)}\n`
)
);
}
} catch {
// readline 关闭,退出循环
break;
}
}
console.log(chalk.cyan('\n👋 再见!\n'));
}
// 关闭
close(): void {
if (!this.isClosed) {
this.isClosed = true;
this.rl.close();
}
}
}
@@ -1,282 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { TerminalUI } from '../../../src/ui/terminal.js';
// Mock readline
const mockReadline = {
question: vi.fn(),
close: vi.fn(),
on: vi.fn(),
};
vi.mock('readline', () => ({
createInterface: vi.fn(() => mockReadline),
}));
// Mock chalk
vi.mock('chalk', () => ({
default: {
cyan: vi.fn((s: string) => s),
white: vi.fn((s: string) => s),
gray: vi.fn((s: string) => s),
yellow: vi.fn((s: string) => s),
green: vi.fn((s: string) => s),
red: vi.fn((s: string) => s),
blue: vi.fn((s: string) => s),
magenta: vi.fn((s: string) => s),
bold: { white: vi.fn((s: string) => s) },
},
}));
// Mock agent registry
vi.mock('../../../src/agent/index.js', () => ({
agentRegistry: {
listPrimaryAgents: vi.fn(() => [
{ name: 'code-reviewer', description: '代码审查', mode: 'primary' },
]),
get: vi.fn(),
},
}));
import * as readline from 'readline';
import { agentRegistry } from '../../../src/agent/index.js';
// Mock Agent class
const mockAgent = {
getContextUsage: vi.fn(() => ({
input: 1000,
available: 10000,
contextLimit: 128000,
usagePercent: 10,
})),
getAgentModeName: vi.fn(() => 'default'),
setAgentMode: vi.fn(),
getToolCount: vi.fn(() => ({ total: 10 })),
clearHistory: vi.fn(),
compactHistory: vi.fn().mockResolvedValue({
type: 'compact',
freedTokens: 500,
}),
chat: vi.fn(),
};
describe('TerminalUI - 终端界面', () => {
let ui: TerminalUI;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
ui = new TerminalUI(mockAgent as any);
// 模拟 close 事件监听
const closeHandler = vi.mocked(mockReadline.on).mock.calls.find(
call => call[0] === 'close'
)?.[1];
if (closeHandler) {
// 保存 close handler 以便测试
}
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
});
afterEach(() => {
consoleLogSpy.mockRestore();
stdoutWriteSpy.mockRestore();
});
describe('构造函数', () => {
it('创建 readline 接口', () => {
expect(readline.createInterface).toHaveBeenCalled();
});
it('监听 close 事件', () => {
expect(mockReadline.on).toHaveBeenCalledWith('close', expect.any(Function));
});
});
describe('close - 关闭', () => {
it('关闭 readline', () => {
ui.close();
expect(mockReadline.close).toHaveBeenCalled();
});
it('多次关闭只执行一次', () => {
ui.close();
ui.close();
expect(mockReadline.close).toHaveBeenCalledTimes(1);
});
});
describe('formatContextUsage (通过 prompt 间接测试)', () => {
it('低使用率显示绿色', () => {
mockAgent.getContextUsage.mockReturnValue({
input: 1000,
available: 100000,
contextLimit: 128000,
usagePercent: 10,
});
// 通过创建新实例触发格式化
new TerminalUI(mockAgent as any);
expect(mockAgent.getContextUsage).toBeDefined();
});
it('中等使用率显示黄色', () => {
mockAgent.getContextUsage.mockReturnValue({
input: 60000,
available: 100000,
contextLimit: 128000,
usagePercent: 60,
});
new TerminalUI(mockAgent as any);
expect(mockAgent.getContextUsage).toBeDefined();
});
it('高使用率显示红色', () => {
mockAgent.getContextUsage.mockReturnValue({
input: 100000,
available: 20000,
contextLimit: 128000,
usagePercent: 90,
});
new TerminalUI(mockAgent as any);
expect(mockAgent.getContextUsage).toBeDefined();
});
});
describe('命令处理', () => {
describe('/help 命令', () => {
it('显示帮助信息', async () => {
// 模拟 handleCommand 通过 question 回调
mockReadline.question.mockImplementation((_, callback: (answer: string) => void) => {
callback('/help');
});
// 验证帮助方法可以被调用
expect(mockAgent.getContextUsage).toBeDefined();
});
});
describe('/clear 命令', () => {
it('清空历史', async () => {
mockAgent.clearHistory.mockResolvedValue(undefined);
// 验证方法存在
expect(mockAgent.clearHistory).toBeDefined();
});
});
describe('/compact 命令', () => {
it('压缩历史', async () => {
expect(mockAgent.compactHistory).toBeDefined();
});
});
describe('/context 命令', () => {
it('显示上下文使用', async () => {
expect(mockAgent.getContextUsage).toBeDefined();
});
});
describe('/agent 命令', () => {
it('无参数显示当前模式', () => {
expect(mockAgent.getAgentModeName).toBeDefined();
expect(agentRegistry.listPrimaryAgents).toBeDefined();
});
it('切换到 default 模式', () => {
expect(mockAgent.setAgentMode).toBeDefined();
expect(mockAgent.getToolCount).toBeDefined();
});
it('切换到指定 Agent', () => {
vi.mocked(agentRegistry.get).mockReturnValue({
name: 'code-reviewer',
description: '代码审查',
mode: 'primary',
prompt: '你是代码审查助手',
});
expect(agentRegistry.get).toBeDefined();
});
it('subagent 模式不能作为主交互', () => {
vi.mocked(agentRegistry.get).mockReturnValue({
name: 'explore',
description: '探索',
mode: 'subagent',
prompt: '你是探索助手',
});
// 验证 mode 检查
const agent = agentRegistry.get('explore');
expect(agent?.mode).toBe('subagent');
});
it('未找到 Agent 显示错误', () => {
vi.mocked(agentRegistry.get).mockReturnValue(undefined);
expect(agentRegistry.get('nonexistent')).toBeUndefined();
});
});
});
describe('chat 交互', () => {
it('调用 agent.chat', async () => {
mockAgent.chat.mockResolvedValue('response');
expect(mockAgent.chat).toBeDefined();
});
it('处理流式输出', async () => {
mockAgent.chat.mockImplementation((_input: string, callback: (text: string) => void) => {
callback('Hello');
callback(' World');
return Promise.resolve();
});
// 验证回调被调用
await mockAgent.chat('test', (text: string) => {
expect(['Hello', ' World']).toContain(text);
});
});
it('处理工具调用输出', async () => {
mockAgent.chat.mockImplementation((_input: string, callback: (text: string) => void) => {
callback('\n[调用工具: bash]');
callback('[结果: success]');
return Promise.resolve();
});
await mockAgent.chat('test', () => {});
expect(mockAgent.chat).toHaveBeenCalled();
});
it('处理错误', async () => {
mockAgent.chat.mockRejectedValue(new Error('API Error'));
await expect(mockAgent.chat('test', () => {})).rejects.toThrow('API Error');
});
});
describe('Agent 模式显示', () => {
it('default 模式不显示指示器', () => {
mockAgent.getAgentModeName.mockReturnValue('default');
const mode = mockAgent.getAgentModeName();
expect(mode).toBe('default');
});
it('其他模式显示 @ 指示器', () => {
mockAgent.getAgentModeName.mockReturnValue('code-reviewer');
const mode = mockAgent.getAgentModeName();
expect(mode).toBe('code-reviewer');
});
});
});
+10
View File
@@ -72,6 +72,9 @@ importers:
minimatch:
specifier: ^10.1.1
version: 10.1.1
nanoid:
specifier: ^5.1.6
version: 5.1.6
qwen-ai-provider-v5:
specifier: ^1.0.2
version: 1.0.2(zod@4.1.13)
@@ -3625,6 +3628,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
neotraverse@0.6.18:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
@@ -8675,6 +8683,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
neotraverse@0.6.18: {}
nlcst-to-string@4.0.0: