From 168996a4753f455cbb4d710e64f3cdc6cd6230d0 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 12 Dec 2025 11:22:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Server=20=E5=B1=82?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=20CLI=20=E5=92=8C=20Web=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server 层增强: - 添加 Agent 适配层,支持动态加载 core 模块 - 实现 Token 认证机制,支持本地/远程模式 - WebSocket 集成 Agent 实时对话 CLI 模块 (packages/cli): - serve 命令启动 HTTP Server - attach 命令连接远程 Server - API Client 封装 Web 前端 (packages/web): - React 18 + Vite + Tailwind CSS - 会话管理侧边栏 - WebSocket 实时聊天界面 - 流式消息显示 --- packages/cli/bin/ai-assistant.js | 2 + packages/cli/package.json | 34 + packages/cli/src/client/api.ts | 190 ++ packages/cli/src/client/index.ts | 6 + packages/cli/src/commands/attach.ts | 236 +++ packages/cli/src/commands/index.ts | 6 + packages/cli/src/commands/serve.ts | 55 + packages/cli/src/index.ts | 29 + packages/cli/tsconfig.json | 11 + packages/server/src/agent/adapter.ts | 249 +++ packages/server/src/agent/index.ts | 15 + packages/server/src/auth/index.ts | 25 + packages/server/src/auth/token.ts | 263 +++ packages/server/src/bin/server.ts | 50 +- packages/server/src/index.ts | 87 +- packages/server/src/ws.ts | 40 +- packages/web/index.html | 13 + packages/web/package.json | 30 + packages/web/postcss.config.js | 6 + packages/web/src/App.tsx | 74 + packages/web/src/api/client.ts | 96 + packages/web/src/components/ChatInput.tsx | 85 + packages/web/src/components/ChatMessage.tsx | 80 + packages/web/src/components/Sidebar.tsx | 124 ++ packages/web/src/hooks/useChat.ts | 165 ++ packages/web/src/main.tsx | 10 + packages/web/src/pages/Chat.tsx | 90 + packages/web/src/styles/index.css | 78 + packages/web/src/vite-env.d.ts | 1 + packages/web/tailwind.config.js | 25 + packages/web/tsconfig.json | 25 + packages/web/tsconfig.node.json | 11 + packages/web/vite.config.ts | 25 + pnpm-lock.yaml | 1827 ++++++++++++++++++- tsconfig.base.json | 17 + 35 files changed, 4028 insertions(+), 52 deletions(-) create mode 100644 packages/cli/bin/ai-assistant.js create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/client/api.ts create mode 100644 packages/cli/src/client/index.ts create mode 100644 packages/cli/src/commands/attach.ts create mode 100644 packages/cli/src/commands/index.ts create mode 100644 packages/cli/src/commands/serve.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/server/src/agent/adapter.ts create mode 100644 packages/server/src/agent/index.ts create mode 100644 packages/server/src/auth/index.ts create mode 100644 packages/server/src/auth/token.ts create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/postcss.config.js create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/api/client.ts create mode 100644 packages/web/src/components/ChatInput.tsx create mode 100644 packages/web/src/components/ChatMessage.tsx create mode 100644 packages/web/src/components/Sidebar.tsx create mode 100644 packages/web/src/hooks/useChat.ts create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/pages/Chat.tsx create mode 100644 packages/web/src/styles/index.css create mode 100644 packages/web/src/vite-env.d.ts create mode 100644 packages/web/tailwind.config.js create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/tsconfig.node.json create mode 100644 packages/web/vite.config.ts create mode 100644 tsconfig.base.json diff --git a/packages/cli/bin/ai-assistant.js b/packages/cli/bin/ai-assistant.js new file mode 100644 index 0000000..4d6ab55 --- /dev/null +++ b/packages/cli/bin/ai-assistant.js @@ -0,0 +1,2 @@ +#!/usr/bin/env bun +import '../dist/index.js'; diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..ce31293 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ai-assistant/cli", + "version": "1.0.0", + "description": "AI Terminal Assistant CLI", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "ai-assistant": "./bin/ai-assistant.js" + }, + "scripts": { + "build": "tsc", + "dev": "bun run src/index.ts", + "start": "bun run dist/index.js", + "serve": "bun run src/index.ts serve", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ai-assistant/server": "workspace:*", + "commander": "^12.1.0", + "chalk": "^5.3.0", + "ora": "^8.0.1", + "inquirer": "^9.2.12" + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + }, + "files": [ + "dist", + "bin" + ] +} diff --git a/packages/cli/src/client/api.ts b/packages/cli/src/client/api.ts new file mode 100644 index 0000000..4a616a5 --- /dev/null +++ b/packages/cli/src/client/api.ts @@ -0,0 +1,190 @@ +/** + * API Client + * + * 用于连接远程 Server 的客户端 + */ + +export interface ClientConfig { + /** 服务器地址 */ + baseUrl: string; + /** 认证 token */ + token?: string; + /** 请求超时 (ms) */ + timeout?: number; +} + +export interface Session { + id: string; + name?: string; + createdAt: string; + updatedAt: string; + status: string; + messageCount: number; +} + +export interface Message { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: string; +} + +export interface HealthStatus { + status: string; + timestamp: string; + agent: { + coreAvailable: boolean; + }; + auth: { + enabled: boolean; + tokenCount: number; + }; + stats: { + sessions: number; + websocket: { connections: number }; + sse: { connections: number }; + }; +} + +/** + * API Client 类 + */ +export class APIClient { + private baseUrl: string; + private token?: string; + private timeout: number; + + constructor(config: ClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + this.token = config.token; + this.timeout = config.timeout || 30000; + } + + /** + * 发送 HTTP 请求 + */ + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: response.statusText })) as { error?: string }; + throw new Error(errorData.error || `HTTP ${response.status}`); + } + + return response.json() as Promise; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Request timeout'); + } + throw error; + } + } + + // ============================================================================ + // Health + // ============================================================================ + + async health(): Promise { + return this.request('GET', '/health'); + } + + // ============================================================================ + // Sessions + // ============================================================================ + + async listSessions(): Promise<{ success: boolean; data: Session[] }> { + return this.request('GET', '/api/sessions'); + } + + async createSession(name?: string): Promise<{ success: boolean; data: Session }> { + return this.request('POST', '/api/sessions', { name }); + } + + async getSession(id: string): Promise<{ success: boolean; data: Session }> { + return this.request('GET', `/api/sessions/${id}`); + } + + async deleteSession(id: string): Promise<{ success: boolean }> { + return this.request('DELETE', `/api/sessions/${id}`); + } + + // ============================================================================ + // Messages + // ============================================================================ + + async getMessages(sessionId: string): Promise<{ success: boolean; data: Message[] }> { + return this.request('GET', `/api/sessions/${sessionId}/messages`); + } + + async sendMessage( + sessionId: string, + content: string + ): Promise<{ success: boolean; data: Message }> { + return this.request('POST', `/api/sessions/${sessionId}/messages`, { content }); + } + + // ============================================================================ + // WebSocket + // ============================================================================ + + /** + * 创建 WebSocket 连接 + */ + connectWebSocket(sessionId: string): WebSocket { + const wsUrl = this.baseUrl + .replace(/^http/, 'ws') + .concat(`/api/ws/${sessionId}`); + + const url = this.token + ? `${wsUrl}?token=${encodeURIComponent(this.token)}` + : wsUrl; + + return new WebSocket(url); + } + + // ============================================================================ + // SSE + // ============================================================================ + + /** + * 获取 SSE URL (用于外部 EventSource 连接) + */ + getSSEUrl(sessionId: string): string { + const sseUrl = `${this.baseUrl}/api/sessions/${sessionId}/events`; + return this.token + ? `${sseUrl}?token=${encodeURIComponent(this.token)}` + : sseUrl; + } +} + +/** + * 创建 API Client + */ +export function createClient(config: ClientConfig): APIClient { + return new APIClient(config); +} diff --git a/packages/cli/src/client/index.ts b/packages/cli/src/client/index.ts new file mode 100644 index 0000000..d93a47b --- /dev/null +++ b/packages/cli/src/client/index.ts @@ -0,0 +1,6 @@ +/** + * Client Module + */ + +export { APIClient, createClient } from './api.js'; +export type { ClientConfig, Session, Message, HealthStatus } from './api.js'; diff --git a/packages/cli/src/commands/attach.ts b/packages/cli/src/commands/attach.ts new file mode 100644 index 0000000..2fcc601 --- /dev/null +++ b/packages/cli/src/commands/attach.ts @@ -0,0 +1,236 @@ +/** + * Attach Command + * + * 连接到远程 Server + */ + +import type { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { createClient } from '../client/index.js'; + +export function registerAttachCommand(program: Command): void { + program + .command('attach') + .description('Connect to a remote AI Assistant server') + .argument('', 'Server URL (e.g., http://192.168.1.100:3000)') + .option('-t, --token ', 'Authentication token') + .option('-s, --session ', 'Session ID to connect to') + .action(async (url: string, options) => { + const { token, session: sessionId } = options; + + const spinner = ora('Connecting to server...').start(); + + try { + // 创建客户端 + const client = createClient({ + baseUrl: url, + token, + }); + + // 检查服务器健康状态 + const health = await client.health(); + spinner.succeed(`Connected to server at ${url}`); + + console.log(chalk.gray('─'.repeat(50))); + console.log(chalk.bold('Server Status:')); + console.log(` Status: ${chalk.green(health.status)}`); + console.log(` Agent: ${health.agent.coreAvailable ? chalk.green('Available') : chalk.yellow('Not available')}`); + console.log(` Auth: ${health.auth.enabled ? chalk.yellow('Enabled') : chalk.gray('Disabled')}`); + console.log(` Sessions: ${health.stats.sessions}`); + console.log(` WebSocket: ${health.stats.websocket.connections} connections`); + console.log(chalk.gray('─'.repeat(50))); + + // 如果指定了 session,连接到该 session + if (sessionId) { + await connectToSession(client, sessionId); + } else { + // 显示可用 sessions 或创建新的 + await showSessionMenu(client); + } + } catch (error) { + spinner.fail('Failed to connect to server'); + if (error instanceof Error) { + console.error(chalk.red(`Error: ${error.message}`)); + } + process.exit(1); + } + }); +} + +async function connectToSession(client: ReturnType, sessionId: string): Promise { + const spinner = ora(`Connecting to session ${sessionId}...`).start(); + + try { + // 获取 session 信息 + const { data: session } = await client.getSession(sessionId); + spinner.succeed(`Connected to session: ${session.name || session.id}`); + + // 获取历史消息 + const { data: messages } = await client.getMessages(sessionId); + + if (messages.length > 0) { + console.log(chalk.gray('\n─── Recent Messages ───')); + const recentMessages = messages.slice(-5); + for (const msg of recentMessages) { + const prefix = msg.role === 'user' ? chalk.blue('You: ') : chalk.green('AI: '); + const content = msg.content.length > 100 + ? msg.content.slice(0, 100) + '...' + : msg.content; + console.log(prefix + content); + } + console.log(chalk.gray('───────────────────────\n')); + } + + // 启动交互式会话 + await startInteractiveSession(client, sessionId); + } catch (error) { + spinner.fail('Failed to connect to session'); + throw error; + } +} + +async function showSessionMenu(client: ReturnType): Promise { + const { default: inquirer } = await import('inquirer'); + + // 获取可用 sessions + const { data: sessions } = await client.listSessions(); + + const choices = [ + { name: chalk.green('+ Create new session'), value: 'new' }, + ...sessions.map((s) => ({ + name: `${s.name || s.id} (${s.messageCount} messages)`, + value: s.id, + })), + ]; + + const { selection } = await inquirer.prompt([ + { + type: 'list', + name: 'selection', + message: 'Select a session:', + choices, + }, + ]); + + if (selection === 'new') { + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'Session name (optional):', + }, + ]); + + const { data: newSession } = await client.createSession(name || undefined); + console.log(chalk.green(`Created new session: ${newSession.id}`)); + await startInteractiveSession(client, newSession.id); + } else { + await connectToSession(client, selection); + } +} + +async function startInteractiveSession( + client: ReturnType, + sessionId: string +): Promise { + const { createInterface } = await import('readline'); + + console.log(chalk.gray('Type your message and press Enter. Type /quit to exit.\n')); + + // 连接 WebSocket + const ws = client.connectWebSocket(sessionId); + + let currentResponse = ''; + + ws.onopen = () => { + console.log(chalk.gray('[Connected to WebSocket]')); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + + switch (message.type) { + case 'chunk': + process.stdout.write(message.payload?.content || ''); + currentResponse += message.payload?.content || ''; + break; + case 'done': + if (currentResponse) { + console.log('\n'); + currentResponse = ''; + } + break; + case 'error': + console.error(chalk.red(`\nError: ${message.payload?.message}`)); + break; + } + } catch { + // 忽略解析错误 + } + }; + + ws.onerror = (error) => { + console.error(chalk.red('WebSocket error:', error)); + }; + + ws.onclose = () => { + console.log(chalk.gray('[Disconnected from WebSocket]')); + process.exit(0); + }; + + // 创建交互式 readline + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const prompt = () => { + rl.question(chalk.blue('You: '), async (input) => { + const trimmed = input.trim(); + + if (trimmed === '/quit' || trimmed === '/exit') { + console.log(chalk.gray('Goodbye!')); + ws.close(); + rl.close(); + return; + } + + if (!trimmed) { + prompt(); + return; + } + + // 通过 WebSocket 发送消息 + ws.send(JSON.stringify({ + type: 'message', + sessionId, + payload: { content: trimmed }, + })); + + process.stdout.write(chalk.green('AI: ')); + + // 等待响应完成后再提示 + const waitForDone = () => { + if (currentResponse === '') { + prompt(); + } else { + setTimeout(waitForDone, 100); + } + }; + setTimeout(waitForDone, 500); + }); + }; + + // 等待 WebSocket 连接 + await new Promise((resolve) => { + if (ws.readyState === WebSocket.OPEN) { + resolve(); + } else { + ws.onopen = () => resolve(); + } + }); + + prompt(); +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts new file mode 100644 index 0000000..4c0f88a --- /dev/null +++ b/packages/cli/src/commands/index.ts @@ -0,0 +1,6 @@ +/** + * Commands Module + */ + +export { registerServeCommand } from './serve.js'; +export { registerAttachCommand } from './attach.js'; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts new file mode 100644 index 0000000..887e148 --- /dev/null +++ b/packages/cli/src/commands/serve.ts @@ -0,0 +1,55 @@ +/** + * Serve Command + * + * 启动 HTTP Server + */ + +import type { Command } from 'commander'; + +export function registerServeCommand(program: Command): void { + program + .command('serve') + .description('Start the AI Assistant server') + .option('-p, --port ', 'Port to listen on', '3000') + .option('-H, --host ', 'Host to bind to', '127.0.0.1') + .option('--auth', 'Enable authentication') + .option('--no-auth', 'Disable authentication') + .option('-t, --token ', 'Set authentication token') + .action(async (options) => { + const { port, host, auth, token } = options; + + // 动态导入 server 模块 + const { app, websocket, startServer } = await import('@ai-assistant/server'); + + // 初始化并打印启动信息 + await startServer({ + port: parseInt(port, 10), + host, + auth, + token, + }); + + // 启动 Bun 服务器 + const server = Bun.serve({ + port: parseInt(port, 10), + hostname: host, + fetch: app.fetch, + websocket, + }); + + console.log(`Server running at http://${host}:${port}`); + + // 优雅关闭 + process.on('SIGINT', () => { + console.log('\nShutting down server...'); + server.stop(); + process.exit(0); + }); + + process.on('SIGTERM', () => { + console.log('\nShutting down server...'); + server.stop(); + process.exit(0); + }); + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..ab999c1 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env bun +/** + * AI Assistant CLI + * + * 命令行入口 + */ + +import { Command } from 'commander'; +import { registerServeCommand, registerAttachCommand } from './commands/index.js'; + +const program = new Command(); + +program + .name('ai-assistant') + .description('AI Terminal Assistant - Your AI-powered coding companion') + .version('1.0.0'); + +// 注册命令 +registerServeCommand(program); +registerAttachCommand(program); + +// 默认命令 (无参数时的行为) +program + .action(() => { + program.help(); + }); + +// 解析命令行参数 +program.parse(process.argv); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..5ec1ff5 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts new file mode 100644 index 0000000..dd986dc --- /dev/null +++ b/packages/server/src/agent/adapter.ts @@ -0,0 +1,249 @@ +/** + * Agent Adapter + * + * 将 core 模块的 Agent 适配到 Server 环境 + * 处理流式输出、事件推送等 + * + * 使用接口定义避免直接依赖 @ai-assistant/core 类型 + */ + +import type { SessionStatus } from '../types.js'; +import { getSessionManager } from '../session/manager.js'; +import { broadcastToSession } from '../ws.js'; +import { emitStatusEvent, emitLogEvent } from '../sse.js'; + +// ============================================================================ +// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型) +// ============================================================================ + +/** + * Agent 实例接口 + */ +interface AgentInstance { + setRegistry(registry: unknown): void; + chat(message: string, onStream?: (chunk: string) => void): Promise; + getToolCount(): { core: number; discovered: number; total: number }; + getContextUsageFormatted(): string; +} + +/** + * Agent 构造函数接口 + */ +interface AgentConstructor { + new (config: unknown): AgentInstance; +} + +/** + * Tool Registry 接口 + */ +interface ToolRegistry { + getCoreTools(): unknown[]; + getAllTools(): unknown[]; +} + +/** + * Core 模块接口 + */ +interface CoreModule { + Agent: AgentConstructor; + toolRegistry: ToolRegistry; + loadConfig: () => unknown; +} + +// ============================================================================ +// 模块状态 +// ============================================================================ + +// Core 模块引用 +let coreModule: CoreModule | null = null; + +// Agent 实例缓存(每个 session 一个) +const agentCache: Map = new Map(); + +// ============================================================================ +// 公共 API +// ============================================================================ + +/** + * 初始化 core 模块 + */ +export async function initCore(): Promise { + try { + // 使用变量避免 TypeScript 静态分析 import 路径 + const corePath = '@ai-assistant/core'; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const core = (await import(/* webpackIgnore: true */ corePath)) as unknown as CoreModule; + + // 验证模块结构 + if (!core.Agent || !core.toolRegistry || !core.loadConfig) { + console.warn('[Agent] Core module missing required exports'); + return false; + } + + coreModule = core; + console.log('[Agent] Core module loaded'); + return true; + } catch (error) { + console.warn('[Agent] Core module not available:', error); + return false; + } +} + +/** + * 检查 core 模块是否可用 + */ +export function isCoreAvailable(): boolean { + return coreModule !== null; +} + +/** + * 获取或创建 Agent 实例 + */ +export function getOrCreateAgent(sessionId: string): AgentInstance | null { + if (!coreModule) { + return null; + } + + // 检查缓存 + if (agentCache.has(sessionId)) { + return agentCache.get(sessionId)!; + } + + // 创建新 Agent + const config = coreModule.loadConfig(); + const agent = new coreModule.Agent(config); + agent.setRegistry(coreModule.toolRegistry); + + agentCache.set(sessionId, agent); + return agent; +} + +/** + * 销毁 Agent 实例 + */ +export function destroyAgent(sessionId: string): void { + agentCache.delete(sessionId); +} + +/** + * 处理用户消息并流式返回响应 + */ +export async function processMessage(sessionId: string, content: string): Promise { + const sessionManager = getSessionManager(); + + // 更新状态 + sessionManager.updateStatus(sessionId, 'busy' as SessionStatus); + emitStatusEvent(sessionId, 'processing', { message: '正在处理...' }); + + // 获取 Agent + const agent = getOrCreateAgent(sessionId); + + if (!agent) { + // Core 模块不可用,返回占位响应 + broadcastToSession(sessionId, { + type: 'chunk', + sessionId, + payload: { + content: 'Agent core module not available. Please build @ai-assistant/core first.', + }, + }); + + const assistantMessage = sessionManager.addMessage(sessionId, { + role: 'assistant', + content: 'Agent core module not available. Please build @ai-assistant/core first.', + }); + + broadcastToSession(sessionId, { + type: 'done', + sessionId, + payload: assistantMessage, + }); + + sessionManager.updateStatus(sessionId, 'idle' as SessionStatus); + emitStatusEvent(sessionId, 'idle'); + return; + } + + try { + // 调用 Agent 的 chat 方法,使用流式回调 + const response = await agent.chat(content, (chunk: string) => { + // 推送流式内容 + broadcastToSession(sessionId, { + type: 'chunk', + sessionId, + payload: { content: chunk }, + }); + + // 检测工具调用 + if (chunk.includes('[调用工具:')) { + const match = chunk.match(/\[调用工具: (.+?)\]/); + if (match) { + emitLogEvent(sessionId, 'info', `调用工具: ${match[1]}`); + } + } + }); + + // 保存助手消息 + const assistantMessage = sessionManager.addMessage(sessionId, { + role: 'assistant', + content: response, + }); + + // 发送完成消息 + broadcastToSession(sessionId, { + type: 'done', + sessionId, + payload: assistantMessage, + }); + + emitStatusEvent(sessionId, 'idle'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // 发送错误 + broadcastToSession(sessionId, { + type: 'error', + sessionId, + payload: { message: errorMessage }, + }); + + emitLogEvent(sessionId, 'error', errorMessage); + } finally { + sessionManager.updateStatus(sessionId, 'idle' as SessionStatus); + } +} + +/** + * 取消正在进行的处理 + */ +export function cancelProcessing(sessionId: string): void { + // TODO: 实现取消逻辑 + // 目前 AI SDK 的 streamText 不支持取消 + const sessionManager = getSessionManager(); + sessionManager.updateStatus(sessionId, 'idle' as SessionStatus); + emitStatusEvent(sessionId, 'cancelled'); +} + +/** + * 获取 Agent 统计信息 + */ +export function getAgentStats(sessionId: string): { + available: boolean; + toolCount?: { core: number; discovered: number; total: number }; + contextUsage?: string; +} { + if (!coreModule) { + return { available: false }; + } + + const agent = agentCache.get(sessionId); + if (!agent) { + return { available: true }; + } + + return { + available: true, + toolCount: agent.getToolCount(), + contextUsage: agent.getContextUsageFormatted(), + }; +} diff --git a/packages/server/src/agent/index.ts b/packages/server/src/agent/index.ts new file mode 100644 index 0000000..1ba876a --- /dev/null +++ b/packages/server/src/agent/index.ts @@ -0,0 +1,15 @@ +/** + * Agent Module + * + * 导出 Agent 适配器 + */ + +export { + initCore, + isCoreAvailable, + getOrCreateAgent, + destroyAgent, + processMessage, + cancelProcessing, + getAgentStats, +} from './adapter.js'; diff --git a/packages/server/src/auth/index.ts b/packages/server/src/auth/index.ts new file mode 100644 index 0000000..66d0bb7 --- /dev/null +++ b/packages/server/src/auth/index.ts @@ -0,0 +1,25 @@ +/** + * Auth Module + * + * 导出认证相关功能 + */ + +export { + // 类型 + type AuthConfig, + type AuthContext, + // Token 操作 + generateToken, + maskToken, + validateToken, + extractToken, + // 配置 + initAuth, + getAuthConfig, + addToken, + removeToken, + setAuthEnabled, + // 中间件 + authMiddleware, + getAuthContext, +} from './token.js'; diff --git a/packages/server/src/auth/token.ts b/packages/server/src/auth/token.ts new file mode 100644 index 0000000..50cee7f --- /dev/null +++ b/packages/server/src/auth/token.ts @@ -0,0 +1,263 @@ +/** + * Token Authentication + * + * 简单的 Token 认证机制 + * - 本地模式 (127.0.0.1): 无需认证 + * - 远程模式: 需要 Bearer Token + */ + +import { createMiddleware } from 'hono/factory'; +import type { Context, Next } from 'hono'; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export interface AuthConfig { + /** 是否启用认证 (远程模式下自动启用) */ + enabled: boolean; + /** 有效的 token 列表 */ + tokens: string[]; + /** 跳过认证的路径 */ + skipPaths: string[]; +} + +export interface AuthContext { + /** 是否已认证 */ + authenticated: boolean; + /** 使用的 token (脱敏) */ + tokenHint?: string; +} + +// ============================================================================ +// 默认配置 +// ============================================================================ + +const defaultConfig: AuthConfig = { + enabled: false, + tokens: [], + skipPaths: ['/health', '/api/health'], +}; + +// 当前配置 +let authConfig: AuthConfig = { ...defaultConfig }; + +// ============================================================================ +// Token 生成 +// ============================================================================ + +/** + * 生成随机 token + */ +export function generateToken(length: number = 32): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let token = ''; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + for (let i = 0; i < length; i++) { + token += chars[randomValues[i] % chars.length]; + } + return token; +} + +/** + * 脱敏 token (只显示前4位和后4位) + */ +export function maskToken(token: string): string { + if (token.length <= 8) { + return '****'; + } + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +// ============================================================================ +// 配置管理 +// ============================================================================ + +/** + * 初始化认证配置 + */ +export function initAuth(config: Partial = {}): AuthConfig { + authConfig = { + ...defaultConfig, + ...config, + }; + return authConfig; +} + +/** + * 获取认证配置 + */ +export function getAuthConfig(): AuthConfig { + return { ...authConfig }; +} + +/** + * 添加 token + */ +export function addToken(token: string): void { + if (!authConfig.tokens.includes(token)) { + authConfig.tokens.push(token); + } +} + +/** + * 移除 token + */ +export function removeToken(token: string): void { + const index = authConfig.tokens.indexOf(token); + if (index !== -1) { + authConfig.tokens.splice(index, 1); + } +} + +/** + * 启用/禁用认证 + */ +export function setAuthEnabled(enabled: boolean): void { + authConfig.enabled = enabled; +} + +/** + * 检查是否为本地请求 + */ +function isLocalRequest(c: Context): boolean { + // 获取客户端 IP + const forwarded = c.req.header('x-forwarded-for'); + const realIp = c.req.header('x-real-ip'); + + // 如果有代理头,检查原始 IP + if (forwarded) { + const clientIp = forwarded.split(',')[0].trim(); + return isLocalIP(clientIp); + } + + if (realIp) { + return isLocalIP(realIp); + } + + // Bun/Hono 环境下获取连接 IP + // 这需要 server 层传递,目前假设本地 + return true; +} + +/** + * 检查是否为本地 IP + */ +function isLocalIP(ip: string): boolean { + return ( + ip === '127.0.0.1' || + ip === '::1' || + ip === 'localhost' || + ip.startsWith('192.168.') || + ip.startsWith('10.') || + ip.startsWith('172.16.') || + ip.startsWith('172.17.') || + ip.startsWith('172.18.') || + ip.startsWith('172.19.') || + ip.startsWith('172.2') || + ip.startsWith('172.30.') || + ip.startsWith('172.31.') + ); +} + +// ============================================================================ +// 认证验证 +// ============================================================================ + +/** + * 验证 token + */ +export function validateToken(token: string): boolean { + return authConfig.tokens.includes(token); +} + +/** + * 从请求中提取 token + */ +export function extractToken(c: Context): string | null { + // 1. 从 Authorization header 提取 + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + + // 2. 从 query parameter 提取 (用于 WebSocket) + const queryToken = c.req.query('token'); + if (queryToken) { + return queryToken; + } + + return null; +} + +// ============================================================================ +// Hono 中间件 +// ============================================================================ + +/** + * 认证中间件 + */ +export const authMiddleware = createMiddleware(async (c: Context, next: Next) => { + // 检查是否跳过认证 + const path = c.req.path; + if (authConfig.skipPaths.some((p) => path.startsWith(p))) { + await next(); + return; + } + + // 如果认证未启用,跳过 + if (!authConfig.enabled) { + c.set('auth', { authenticated: true } as AuthContext); + await next(); + return; + } + + // 本地请求跳过认证 + if (isLocalRequest(c)) { + c.set('auth', { authenticated: true } as AuthContext); + await next(); + return; + } + + // 提取 token + const token = extractToken(c); + + if (!token) { + return c.json( + { + success: false, + error: 'Authentication required', + message: 'Please provide a valid token in the Authorization header', + }, + 401 + ); + } + + // 验证 token + if (!validateToken(token)) { + return c.json( + { + success: false, + error: 'Invalid token', + message: 'The provided token is not valid', + }, + 401 + ); + } + + // 设置认证上下文 + c.set('auth', { + authenticated: true, + tokenHint: maskToken(token), + } as AuthContext); + + await next(); +}); + +/** + * 获取当前请求的认证上下文 + */ +export function getAuthContext(c: Context): AuthContext { + return c.get('auth') || { authenticated: false }; +} diff --git a/packages/server/src/bin/server.ts b/packages/server/src/bin/server.ts index 2118b0c..adb5251 100644 --- a/packages/server/src/bin/server.ts +++ b/packages/server/src/bin/server.ts @@ -8,35 +8,71 @@ * bun run packages/server/src/bin/server.ts * bun run packages/server/src/bin/server.ts --port 8080 * bun run packages/server/src/bin/server.ts --host 0.0.0.0 --port 3000 + * bun run packages/server/src/bin/server.ts --auth --token mytoken */ import { app, websocket, startServer } from '../index.js'; // 解析命令行参数 -function parseArgs(): { port: number; host: string } { +function parseArgs(): { port: number; host: string; auth?: boolean; token?: string } { const args = process.argv.slice(2); let port = 3000; let host = '127.0.0.1'; + let auth: boolean | undefined; + let token: string | undefined; for (let i = 0; i < args.length; i++) { if (args[i] === '--port' || args[i] === '-p') { port = parseInt(args[i + 1], 10) || 3000; i++; - } else if (args[i] === '--host' || args[i] === '-h') { + } else if (args[i] === '--host' || args[i] === '-H') { host = args[i + 1] || '127.0.0.1'; i++; + } else if (args[i] === '--auth') { + auth = true; + } else if (args[i] === '--no-auth') { + auth = false; + } else if (args[i] === '--token' || args[i] === '-t') { + token = args[i + 1]; + i++; + } else if (args[i] === '--help' || args[i] === '-h') { + console.log(` +AI Assistant Server + +Usage: + bun run server.ts [options] + +Options: + -p, --port Port to listen on (default: 3000) + -H, --host Host to bind to (default: 127.0.0.1) + --auth Enable authentication + --no-auth Disable authentication + -t, --token Set authentication token + -h, --help Show this help message + +Examples: + # Local development (no auth) + bun run server.ts + + # Remote server with auth + bun run server.ts --host 0.0.0.0 --auth + + # Custom token + bun run server.ts --host 0.0.0.0 --token mysecrettoken + `); + process.exit(0); } } - return { port, host }; + return { port, host, auth, token }; } // 主函数 async function main() { - const { port, host } = parseArgs(); + const { port, host, auth, token } = parseArgs(); - // 打印启动信息 - startServer({ port, host }); + // 初始化并打印启动信息 + await startServer({ port, host, auth, token }); // 启动 Bun 服务器 const server = Bun.serve({ @@ -46,7 +82,7 @@ async function main() { websocket, }); - console.log(`Server started at http://${host}:${port}`); + console.log(`Server running at http://${host}:${port}`); // 优雅关闭 process.on('SIGINT', () => { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3b63d14..864e52a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,6 +18,16 @@ import { } from './ws.js'; import { handleSSE, getSSEStats } from './sse.js'; import { getSessionManager } from './session/manager.js'; +import { initCore, isCoreAvailable, getAgentStats } from './agent/index.js'; +import { + authMiddleware, + initAuth, + getAuthConfig, + generateToken, + addToken, + setAuthEnabled, + maskToken, +} from './auth/index.js'; // 创建 Hono 应用 const app = new Hono(); @@ -36,15 +46,26 @@ app.use( }) ); -// 健康检查 +// 认证中间件 (在 CORS 之后) +app.use('*', authMiddleware); + +// 健康检查 (跳过认证) app.get('/health', (c) => { const sessionManager = getSessionManager(); const wsStats = getConnectionStats(); const sseStats = getSSEStats(); + const authConfig = getAuthConfig(); return c.json({ status: 'ok', timestamp: new Date().toISOString(), + agent: { + coreAvailable: isCoreAvailable(), + }, + auth: { + enabled: authConfig.enabled, + tokenCount: authConfig.tokens.length, + }, stats: { sessions: sessionManager.count(), websocket: wsStats, @@ -119,6 +140,10 @@ app.onError((err, c) => { export interface ServerOptions { port?: number; host?: string; + /** 是否启用认证 (远程模式自动启用) */ + auth?: boolean; + /** 预设的 token */ + token?: string; } /** @@ -135,12 +160,51 @@ export function createServer(options: ServerOptions = {}) { }; } +/** + * 初始化服务器(加载 core 模块等) + */ +export async function initServer(options: ServerOptions = {}): Promise { + // 尝试加载 core 模块 + const coreLoaded = await initCore(); + if (coreLoaded) { + console.log('[Server] Core module initialized'); + } else { + console.warn('[Server] Core module not available, running in limited mode'); + } + + // 初始化认证 + const { host = '127.0.0.1', auth, token } = options; + const isRemote = host !== '127.0.0.1' && host !== 'localhost'; + const authEnabled = auth !== undefined ? auth : isRemote; + + initAuth({ + enabled: authEnabled, + tokens: [], + skipPaths: ['/health', '/api/health'], + }); + + // 如果启用认证,生成或使用提供的 token + if (authEnabled) { + const serverToken = token || generateToken(); + addToken(serverToken); + console.log(`[Auth] Authentication enabled`); + console.log(`[Auth] Token: ${serverToken}`); + } +} + /** * 启动服务器 (Bun 环境) */ -export function startServer(options: ServerOptions = {}): void { +export async function startServer(options: ServerOptions = {}): Promise { const { port = 3000, host = '127.0.0.1' } = options; + // 初始化 + await initServer(options); + + const coreStatus = isCoreAvailable() ? '✅ Core loaded' : '⚠️ Core not available'; + const authConfig = getAuthConfig(); + const authStatus = authConfig.enabled ? '🔐 Enabled' : '🔓 Disabled'; + console.log(` ╔════════════════════════════════════════════╗ ║ AI Assistant Server ║ @@ -149,6 +213,8 @@ export function startServer(options: ServerOptions = {}): void { ║ WebSocket: ws://${host}:${port}/api/ws/:sessionId ║ SSE: http://${host}:${port}/api/sessions/:id/events ║ Health: http://${host}:${port}/health +║ Agent: ${coreStatus} +║ Auth: ${authStatus} ╚════════════════════════════════════════════╝ `); @@ -170,4 +236,21 @@ export { emitFileChangeEvent, } from './sse.js'; export { broadcastToSession } from './ws.js'; +export { + initCore, + isCoreAvailable, + getAgentStats, + processMessage, + cancelProcessing, +} from './agent/index.js'; +export { + initAuth, + getAuthConfig, + generateToken, + addToken, + removeToken, + setAuthEnabled, + validateToken, + maskToken, +} from './auth/index.js'; export * from './types.js'; diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index f26ab9e..6ebbc73 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -6,6 +6,7 @@ import type { WSContext } from 'hono/ws'; import { getSessionManager } from './session/manager.js'; +import { processMessage, cancelProcessing } from './agent/index.js'; import type { ClientMessage, ServerMessage } from './types.js'; // 存储活跃的 WebSocket 连接 @@ -103,9 +104,10 @@ export async function handleWebSocketMessage( switch (message.type) { case 'message': { // 用户发送消息 + const content = message.payload?.content || ''; const userMessage = sessionManager.addMessage(sessionId, { role: 'user', - content: message.payload?.content || '', + content, }); if (userMessage) { @@ -116,48 +118,22 @@ export async function handleWebSocketMessage( payload: userMessage, }); - // 更新状态为处理中 - sessionManager.updateStatus(sessionId, 'busy'); - - // TODO: 调用 Agent 处理消息并流式返回 - // 这里需要集成 core 模块的 Agent - // const agent = createAgent(); - // for await (const chunk of agent.stream(message.payload.content)) { - // broadcastToSession(sessionId, { - // type: 'chunk', - // sessionId, - // payload: { content: chunk }, - // }); - // } - - // 模拟响应 (后续替换为真实 Agent 调用) - setTimeout(() => { - const assistantMessage = sessionManager.addMessage(sessionId, { - role: 'assistant', - content: 'This is a placeholder response. Agent integration coming soon.', - }); - - broadcastToSession(sessionId, { - type: 'done', - sessionId, - payload: assistantMessage, - }); - - sessionManager.updateStatus(sessionId, 'idle'); - }, 1000); + // 调用 Agent 处理消息(异步,不阻塞) + processMessage(sessionId, content).catch((error) => { + console.error('[WS] Agent processing error:', error); + }); } break; } case 'cancel': { // 取消当前操作 - // TODO: 实现取消逻辑 + cancelProcessing(sessionId); broadcastToSession(sessionId, { type: 'cancelled', sessionId, payload: { message: 'Operation cancelled' }, }); - sessionManager.updateStatus(sessionId, 'idle'); break; } diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..5eb2d77 --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,13 @@ + + + + + + + AI Assistant + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..4be017e --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@ai-assistant/web", + "version": "1.0.0", + "description": "AI Terminal Assistant Web UI", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.0", + "zustand": "^4.5.0", + "lucide-react": "^0.344.0", + "clsx": "^2.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.18", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.1.0" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..8a7d54b --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,74 @@ +/** + * App Component + */ + +import { useState, useEffect } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { ChatPage } from './pages/Chat'; +import { listSessions, createSession, type Session } from './api/client'; + +export function App() { + const [currentSessionId, setCurrentSessionId] = useState(null); + const [isInitializing, setIsInitializing] = useState(true); + + // 初始化:加载或创建会话 + useEffect(() => { + async function init() { + try { + const { data: sessions } = await listSessions(); + + if (sessions.length > 0) { + // 选择最近的会话 + setCurrentSessionId(sessions[0].id); + } else { + // 创建新会话 + const { data: newSession } = await createSession(); + setCurrentSessionId(newSession.id); + } + } catch (error) { + console.error('Failed to initialize:', error); + } finally { + setIsInitializing(false); + } + } + + init(); + }, []); + + const handleSelectSession = (id: string) => { + setCurrentSessionId(id); + }; + + const handleCreateSession = (session: Session) => { + setCurrentSessionId(session.id); + }; + + if (isInitializing) { + return ( +
+
+
+

Initializing...

+
+
+ ); + } + + return ( +
+ + + {currentSessionId ? ( + + ) : ( +
+

Select or create a session

+
+ )} +
+ ); +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts new file mode 100644 index 0000000..3cbcda8 --- /dev/null +++ b/packages/web/src/api/client.ts @@ -0,0 +1,96 @@ +/** + * API Client for Web + */ + +export interface Session { + id: string; + name?: string; + createdAt: string; + updatedAt: string; + status: string; + messageCount: number; +} + +export interface Message { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: string; +} + +export interface HealthStatus { + status: string; + timestamp: string; + agent: { + coreAvailable: boolean; + }; + auth: { + enabled: boolean; + tokenCount: number; + }; + stats: { + sessions: number; + websocket: { connections: number }; + sse: { connections: number }; + }; +} + +const API_BASE = '/api'; + +async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${API_BASE}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return response.json(); +} + +// Health +export async function getHealth(): Promise { + return request('GET', '/../health'); +} + +// Sessions +export async function listSessions(): Promise<{ success: boolean; data: Session[] }> { + return request('GET', '/sessions'); +} + +export async function createSession(name?: string): Promise<{ success: boolean; data: Session }> { + return request('POST', '/sessions', { name }); +} + +export async function getSession(id: string): Promise<{ success: boolean; data: Session }> { + return request('GET', `/sessions/${id}`); +} + +export async function deleteSession(id: string): Promise<{ success: boolean }> { + return request('DELETE', `/sessions/${id}`); +} + +// Messages +export async function getMessages(sessionId: string): Promise<{ success: boolean; data: Message[] }> { + return request('GET', `/sessions/${sessionId}/messages`); +} + +export async function sendMessage( + sessionId: string, + content: string +): Promise<{ success: boolean; data: Message }> { + return request('POST', `/sessions/${sessionId}/messages`, { content }); +} + +// WebSocket +export function createWebSocket(sessionId: string): WebSocket { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + return new WebSocket(`${protocol}//${host}/api/ws/${sessionId}`); +} diff --git a/packages/web/src/components/ChatInput.tsx b/packages/web/src/components/ChatInput.tsx new file mode 100644 index 0000000..af2bf1e --- /dev/null +++ b/packages/web/src/components/ChatInput.tsx @@ -0,0 +1,85 @@ +/** + * Chat Input Component + */ + +import { useState, useRef, useEffect } from 'react'; +import { Send, Square } from 'lucide-react'; +import clsx from 'clsx'; + +interface ChatInputProps { + onSend: (content: string) => void; + onCancel: () => void; + isLoading: boolean; + disabled?: boolean; +} + +export function ChatInput({ onSend, onCancel, isLoading, disabled }: ChatInputProps) { + const [input, setInput] = useState(''); + const textareaRef = useRef(null); + + // 自动调整高度 + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + } + }, [input]); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (!trimmed || isLoading || disabled) return; + + onSend(trimmed); + setInput(''); + + // 重置高度 + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+
+
+