diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 864e52a..597eb31 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,7 +9,7 @@ import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { createBunWebSocket } from 'hono/bun'; -import { sessionsRouter, toolsRouter, configRouter } from './routes/index.js'; +import { sessionsRouter, toolsRouter, configRouter, filesRouter } from './routes/index.js'; import { handleWebSocket, handleWebSocketMessage, @@ -81,6 +81,7 @@ const api = new Hono(); api.route('/sessions', sessionsRouter); api.route('/tools', toolsRouter); api.route('/config', configRouter); +api.route('/files', filesRouter); // SSE 事件流 api.get('/sessions/:id/events', handleSSE); diff --git a/packages/server/src/routes/files.ts b/packages/server/src/routes/files.ts new file mode 100644 index 0000000..2f8b7b3 --- /dev/null +++ b/packages/server/src/routes/files.ts @@ -0,0 +1,371 @@ +/** + * Files API Routes + * + * 文件浏览器 API + */ + +import { Hono } from 'hono'; +import { readdir, stat, readFile } from 'node:fs/promises'; +import { join, resolve, basename, extname, dirname } from 'node:path'; + +const filesRouter = new Hono(); + +// 工作目录 (默认为当前目录) +let workingDirectory = process.cwd(); + +/** + * 设置工作目录 + */ +export function setWorkingDirectory(dir: string): void { + workingDirectory = resolve(dir); +} + +/** + * 获取工作目录 + */ +export function getWorkingDirectory(): string { + return workingDirectory; +} + +/** + * 文件/目录信息 + */ +interface FileInfo { + name: string; + path: string; + type: 'file' | 'directory'; + size: number; + modified: string; + extension?: string; +} + +/** + * 安全路径检查 - 确保路径在工作目录内 + */ +function safePath(requestedPath: string): string | null { + const resolved = resolve(workingDirectory, requestedPath); + if (!resolved.startsWith(workingDirectory)) { + return null; + } + return resolved; +} + +/** + * 获取文件类型图标提示 + */ +function getFileType(name: string): string { + const ext = extname(name).toLowerCase(); + const fileTypes: Record = { + '.ts': 'typescript', + '.tsx': 'typescript', + '.js': 'javascript', + '.jsx': 'javascript', + '.json': 'json', + '.md': 'markdown', + '.html': 'html', + '.css': 'css', + '.scss': 'scss', + '.less': 'less', + '.py': 'python', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.c': 'c', + '.cpp': 'cpp', + '.h': 'header', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + '.svg': 'svg', + '.png': 'image', + '.jpg': 'image', + '.jpeg': 'image', + '.gif': 'image', + '.webp': 'image', + '.ico': 'image', + '.txt': 'text', + '.log': 'log', + '.env': 'env', + '.gitignore': 'git', + '.dockerignore': 'docker', + }; + return fileTypes[ext] || 'file'; +} + +/** + * 判断是否为文本文件 + */ +function isTextFile(name: string): boolean { + const textExtensions = [ + '.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.html', '.css', '.scss', + '.less', '.py', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.sh', '.bash', + '.zsh', '.yaml', '.yml', '.toml', '.xml', '.txt', '.log', '.env', '.sql', + '.graphql', '.prisma', '.vue', '.svelte', '.astro', + ]; + const ext = extname(name).toLowerCase(); + return textExtensions.includes(ext) || name.startsWith('.'); +} + +// ============================================================================ +// GET /api/files - 获取工作目录信息 +// ============================================================================ +filesRouter.get('/', (c) => { + return c.json({ + success: true, + data: { + workingDirectory, + separator: '/', + }, + }); +}); + +// ============================================================================ +// GET /api/files/list?path= - 列出目录内容 +// ============================================================================ +filesRouter.get('/list', async (c) => { + const requestedPath = c.req.query('path') || '.'; + const showHidden = c.req.query('hidden') === 'true'; + + const absolutePath = safePath(requestedPath); + if (!absolutePath) { + return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403); + } + + try { + const stats = await stat(absolutePath); + if (!stats.isDirectory()) { + return c.json({ success: false, error: 'Not a directory' }, 400); + } + + const entries = await readdir(absolutePath, { withFileTypes: true }); + const files: FileInfo[] = []; + + for (const entry of entries) { + // 跳过隐藏文件 (除非明确请求) + if (!showHidden && entry.name.startsWith('.')) { + continue; + } + + const entryPath = join(absolutePath, entry.name); + const relativePath = entryPath.replace(workingDirectory, '').replace(/^\//, ''); + + try { + const entryStats = await stat(entryPath); + files.push({ + name: entry.name, + path: relativePath, + type: entry.isDirectory() ? 'directory' : 'file', + size: entryStats.size, + modified: entryStats.mtime.toISOString(), + extension: entry.isFile() ? extname(entry.name).slice(1) : undefined, + }); + } catch { + // 跳过无法访问的文件 + } + } + + // 排序: 目录在前,然后按名称 + files.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return c.json({ + success: true, + data: { + path: requestedPath, + absolutePath, + parent: requestedPath === '.' ? null : dirname(requestedPath), + files, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return c.json({ success: false, error: message }, 500); + } +}); + +// ============================================================================ +// GET /api/files/read?path= - 读取文件内容 +// ============================================================================ +filesRouter.get('/read', async (c) => { + const requestedPath = c.req.query('path'); + if (!requestedPath) { + return c.json({ success: false, error: 'Path is required' }, 400); + } + + const absolutePath = safePath(requestedPath); + if (!absolutePath) { + return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403); + } + + try { + const stats = await stat(absolutePath); + if (stats.isDirectory()) { + return c.json({ success: false, error: 'Cannot read directory' }, 400); + } + + // 限制文件大小 (10MB) + const maxSize = 10 * 1024 * 1024; + if (stats.size > maxSize) { + return c.json({ success: false, error: 'File too large (max 10MB)' }, 400); + } + + const name = basename(absolutePath); + const isText = isTextFile(name); + + if (isText) { + const content = await readFile(absolutePath, 'utf-8'); + return c.json({ + success: true, + data: { + path: requestedPath, + name, + type: getFileType(name), + size: stats.size, + modified: stats.mtime.toISOString(), + content, + encoding: 'utf-8', + }, + }); + } else { + // 二进制文件返回 base64 + const buffer = await readFile(absolutePath); + return c.json({ + success: true, + data: { + path: requestedPath, + name, + type: getFileType(name), + size: stats.size, + modified: stats.mtime.toISOString(), + content: buffer.toString('base64'), + encoding: 'base64', + }, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return c.json({ success: false, error: message }, 500); + } +}); + +// ============================================================================ +// GET /api/files/stat?path= - 获取文件/目录信息 +// ============================================================================ +filesRouter.get('/stat', async (c) => { + const requestedPath = c.req.query('path'); + if (!requestedPath) { + return c.json({ success: false, error: 'Path is required' }, 400); + } + + const absolutePath = safePath(requestedPath); + if (!absolutePath) { + return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403); + } + + try { + const stats = await stat(absolutePath); + const name = basename(absolutePath); + + return c.json({ + success: true, + data: { + path: requestedPath, + name, + type: stats.isDirectory() ? 'directory' : 'file', + size: stats.size, + modified: stats.mtime.toISOString(), + created: stats.birthtime.toISOString(), + extension: stats.isFile() ? extname(name).slice(1) : undefined, + fileType: stats.isFile() ? getFileType(name) : undefined, + isText: stats.isFile() ? isTextFile(name) : undefined, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return c.json({ success: false, error: message }, 500); + } +}); + +// ============================================================================ +// GET /api/files/tree?path=&depth= - 获取目录树 +// ============================================================================ +filesRouter.get('/tree', async (c) => { + const requestedPath = c.req.query('path') || '.'; + const maxDepth = parseInt(c.req.query('depth') || '3', 10); + const showHidden = c.req.query('hidden') === 'true'; + + const absolutePath = safePath(requestedPath); + if (!absolutePath) { + return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403); + } + + interface TreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: TreeNode[]; + } + + async function buildTree(dir: string, depth: number): Promise { + if (depth <= 0) return []; + + try { + const entries = await readdir(dir, { withFileTypes: true }); + const nodes: TreeNode[] = []; + + for (const entry of entries) { + if (!showHidden && entry.name.startsWith('.')) continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; + + const entryPath = join(dir, entry.name); + const relativePath = entryPath.replace(workingDirectory, '').replace(/^\//, ''); + + const node: TreeNode = { + name: entry.name, + path: relativePath, + type: entry.isDirectory() ? 'directory' : 'file', + }; + + if (entry.isDirectory()) { + node.children = await buildTree(entryPath, depth - 1); + } + + nodes.push(node); + } + + // 排序 + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return nodes; + } catch { + return []; + } + } + + try { + const tree = await buildTree(absolutePath, maxDepth); + return c.json({ + success: true, + data: { + path: requestedPath, + tree, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return c.json({ success: false, error: message }, 500); + } +}); + +export { filesRouter }; diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 4066003..3821201 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -7,3 +7,4 @@ export { sessionsRouter } from './sessions.js'; export { toolsRouter, registerTool, getRegisteredTools } from './tools.js'; export { configRouter, getConfig, setConfig } from './config.js'; +export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 8a7d54b..922be81 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,11 +5,13 @@ import { useState, useEffect } from 'react'; import { Sidebar } from './components/Sidebar'; import { ChatPage } from './pages/Chat'; +import { FileBrowser } from './components/FileBrowser'; import { listSessions, createSession, type Session } from './api/client'; export function App() { const [currentSessionId, setCurrentSessionId] = useState(null); const [isInitializing, setIsInitializing] = useState(true); + const [showFileBrowser, setShowFileBrowser] = useState(false); // 初始化:加载或创建会话 useEffect(() => { @@ -62,13 +64,47 @@ export function App() { onCreateSession={handleCreateSession} /> - {currentSessionId ? ( - - ) : ( -
-

Select or create a session

+ {/* 文件浏览器切换按钮 */} + + +
+ {/* 聊天区域 */} +
+ {currentSessionId ? ( + + ) : ( +
+

Select or create a session

+
+ )}
- )} + + {/* 文件浏览器 */} + {showFileBrowser && ( +
+ { + console.log('Selected file:', path); + }} + /> +
+ )} +
); } diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 3cbcda8..1689378 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -94,3 +94,70 @@ export function createWebSocket(sessionId: string): WebSocket { const host = window.location.host; return new WebSocket(`${protocol}//${host}/api/ws/${sessionId}`); } + +// Files +export interface FileInfo { + name: string; + path: string; + type: 'file' | 'directory'; + size: number; + modified: string; + extension?: string; +} + +export interface FileListResponse { + success: boolean; + data: { + path: string; + absolutePath: string; + parent: string | null; + files: FileInfo[]; + }; +} + +export interface FileReadResponse { + success: boolean; + data: { + path: string; + name: string; + type: string; + size: number; + modified: string; + content: string; + encoding: 'utf-8' | 'base64'; + }; +} + +export interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileTreeNode[]; +} + +export interface FileTreeResponse { + success: boolean; + data: { + path: string; + tree: FileTreeNode[]; + }; +} + +export async function getWorkingDirectory(): Promise<{ success: boolean; data: { workingDirectory: string; separator: string } }> { + return request('GET', '/files'); +} + +export async function listFiles(path: string = '.', showHidden: boolean = false): Promise { + const params = new URLSearchParams({ path }); + if (showHidden) params.set('hidden', 'true'); + return request('GET', `/files/list?${params}`); +} + +export async function readFile(path: string): Promise { + return request('GET', `/files/read?path=${encodeURIComponent(path)}`); +} + +export async function getFileTree(path: string = '.', depth: number = 3): Promise { + const params = new URLSearchParams({ path, depth: String(depth) }); + return request('GET', `/files/tree?${params}`); +} diff --git a/packages/web/src/components/FileBrowser.tsx b/packages/web/src/components/FileBrowser.tsx new file mode 100644 index 0000000..922f573 --- /dev/null +++ b/packages/web/src/components/FileBrowser.tsx @@ -0,0 +1,236 @@ +/** + * FileBrowser Component + * + * 文件浏览器组件 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { listFiles, readFile, type FileInfo } from '../api/client'; + +interface FileBrowserProps { + onFileSelect?: (path: string, content: string) => void; + className?: string; +} + +// 文件图标 +const FileIcon = ({ type, extension }: { type: 'file' | 'directory'; extension?: string }) => { + if (type === 'directory') { + return ( + + + + ); + } + + // 根据扩展名显示不同颜色 + const colors: Record = { + ts: 'text-blue-400', + tsx: 'text-blue-400', + js: 'text-yellow-300', + jsx: 'text-yellow-300', + json: 'text-yellow-500', + md: 'text-gray-400', + css: 'text-pink-400', + html: 'text-orange-400', + py: 'text-green-400', + go: 'text-cyan-400', + rs: 'text-orange-500', + }; + + const color = colors[extension || ''] || 'text-gray-400'; + + return ( + + + + ); +}; + +// 格式化文件大小 +const formatSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +}; + +export function FileBrowser({ onFileSelect, className = '' }: FileBrowserProps) { + const [currentPath, setCurrentPath] = useState('.'); + const [files, setFiles] = useState([]); + const [parentPath, setParentPath] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [showHidden, setShowHidden] = useState(false); + + // 加载目录内容 + const loadDirectory = useCallback(async (path: string) => { + setLoading(true); + setError(null); + setSelectedFile(null); + setFileContent(null); + + try { + const response = await listFiles(path, showHidden); + setFiles(response.data.files); + setCurrentPath(response.data.path); + setParentPath(response.data.parent); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load directory'); + } finally { + setLoading(false); + } + }, [showHidden]); + + // 初始加载 + useEffect(() => { + loadDirectory('.'); + }, [loadDirectory]); + + // 处理文件/目录点击 + const handleItemClick = async (item: FileInfo) => { + if (item.type === 'directory') { + loadDirectory(item.path); + } else { + setSelectedFile(item.path); + try { + const response = await readFile(item.path); + if (response.data.encoding === 'utf-8') { + setFileContent(response.data.content); + onFileSelect?.(item.path, response.data.content); + } else { + setFileContent('[Binary file]'); + } + } catch (err) { + setFileContent(`Error: ${err instanceof Error ? err.message : 'Failed to read file'}`); + } + } + }; + + // 返回上级目录 + const handleGoUp = () => { + if (parentPath !== null) { + loadDirectory(parentPath); + } + }; + + // 刷新 + const handleRefresh = () => { + loadDirectory(currentPath); + }; + + return ( +
+ {/* 工具栏 */} +
+ + + + +
+ {currentPath} +
+ + +
+ + {/* 文件列表 */} +
+ {loading ? ( +
+ Loading... +
+ ) : error ? ( +
+ {error} +
+ ) : files.length === 0 ? ( +
+ Empty directory +
+ ) : ( +
+ {files.map((file) => ( +
handleItemClick(file)} + className={`flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-800 ${ + selectedFile === file.path ? 'bg-gray-800 border-l-2 border-blue-500' : '' + }`} + > + + {file.name} + {file.type === 'file' && ( + {formatSize(file.size)} + )} +
+ ))} +
+ )} +
+ + {/* 文件预览 */} + {selectedFile && fileContent && ( +
+
+ {selectedFile} + +
+
+            {fileContent.slice(0, 5000)}
+            {fileContent.length > 5000 && '\n... (truncated)'}
+          
+
+ )} +
+ ); +}