diff --git a/packages/core/package.json b/packages/core/package.json index 6ab9076..881aafa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a64e78..ec3eaa0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { - 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 -): void { - const adapter = createMCPToolAdapter(mcpManager); - const mcpTools = mcpManager.getTools(); - const adaptedTools = adapter.adaptTools(mcpTools); - - // 注册到工具注册表 - toolRegistry.registerAll(adaptedTools); -} - -/** - * 关闭 MCP 系统 - */ -async function shutdownMCP(): Promise { - 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 [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 ') - .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 = { - 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(); - 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 ') - .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 ') - .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'; diff --git a/packages/core/src/ui/terminal.ts b/packages/core/src/ui/terminal.ts deleted file mode 100644 index 5768e38..0000000 --- a/packages/core/src/ui/terminal.ts +++ /dev/null @@ -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(' 用法: / [arguments]')); - console.log(chalk.gray(' 示例: /review main..feature')); - console.log(''); - } - - // 尝试执行自定义 Command - private async tryExecuteCommand(input: string): Promise { - 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 切换到指定 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 { - 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 { - 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 { - 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(如果模型不支持 vision,Agent 会自动委托 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(); - } - } -} diff --git a/packages/core/tests/unit/ui/terminal.test.ts b/packages/core/tests/unit/ui/terminal.test.ts deleted file mode 100644 index c9206ad..0000000 --- a/packages/core/tests/unit/ui/terminal.test.ts +++ /dev/null @@ -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; - let stdoutWriteSpy: ReturnType; - - 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'); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b776581..77bf83d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: