From 865e0906b9e28c8c93d49fc0b5a7060d315b8c94 Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 15 Dec 2025 16:32:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=AE=9E=E7=8E=B0=20@=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8F=90=E5=8F=8A=E8=87=AA=E5=8A=A8=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core: 添加 file-index 模块,使用 ripgrep 索引文件,fuzzysort 模糊搜索 - Server: 添加 /api/files/search 端点,支持文件模糊搜索 - Server: WebSocket 消息处理中将 @filepath 转换为 ./filepath 格式 - UI: 新增 FileMenu 组件,显示文件搜索结果列表 - UI: 新增 FileMentionTag 组件,高亮显示文件提及 - UI: 新增 useFileMention hook,管理文件提及状态 - UI: ChatInput 集成 @ 触发的文件自动补全 - UI: ChatMessage 用户消息中高亮显示 @filepath --- packages/core/package.json | 1 + packages/core/src/file-index/index.ts | 296 ++++++++++++++++++ packages/core/src/index.ts | 13 + packages/server/package.json | 1 + packages/server/src/routes/files.ts | 30 ++ packages/server/src/ws.ts | 7 +- packages/ui/src/api/client.ts | 23 ++ packages/ui/src/api/types.ts | 23 ++ packages/ui/src/components/ChatInput.tsx | 219 ++++++++++--- packages/ui/src/components/ChatMessage.tsx | 9 +- packages/ui/src/components/FileMentionTag.tsx | 181 +++++++++++ packages/ui/src/components/FileMenu.tsx | 212 +++++++++++++ packages/ui/src/hooks/useFileMention.ts | 150 +++++++++ packages/ui/src/index.ts | 14 + pnpm-lock.yaml | 11 + 15 files changed, 1137 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/file-index/index.ts create mode 100644 packages/ui/src/components/FileMentionTag.tsx create mode 100644 packages/ui/src/components/FileMenu.tsx create mode 100644 packages/ui/src/hooks/useFileMention.ts diff --git a/packages/core/package.json b/packages/core/package.json index 984d22a..4f5fdda 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -71,6 +71,7 @@ "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.15", + "fuzzysort": "^3.1.0", "typescript": "^5.6.0", "vitest": "^4.0.15" } diff --git a/packages/core/src/file-index/index.ts b/packages/core/src/file-index/index.ts new file mode 100644 index 0000000..de3a3a8 --- /dev/null +++ b/packages/core/src/file-index/index.ts @@ -0,0 +1,296 @@ +/** + * File Index Module + * + * 使用 ripgrep 索引项目文件,支持模糊搜索 + */ + +import fuzzysort from 'fuzzysort'; +import { spawn } from 'node:child_process'; + +export interface FileIndexEntry { + path: string; + name: string; + type: 'file' | 'directory'; + extension?: string; +} + +export interface FileSearchOptions { + query: string; + limit?: number; + type?: 'file' | 'directory' | 'all'; +} + +/** + * 文件索引类 + */ +class FileIndex { + private files: FileIndexEntry[] = []; + private dirs: Set = new Set(); + private cwd: string; + private initialized = false; + private initializing: Promise | null = null; + + constructor(cwd: string) { + this.cwd = cwd; + } + + /** + * 初始化索引(使用 ripgrep 或 fallback 到 find) + */ + async initialize(): Promise { + if (this.initialized) return; + + // 防止并发初始化 + if (this.initializing) { + return this.initializing; + } + + this.initializing = this._doInitialize(); + await this.initializing; + this.initializing = null; + } + + private async _doInitialize(): Promise { + try { + // 尝试使用 ripgrep + const paths = await this.listFilesWithRipgrep(); + this.processFilePaths(paths); + this.initialized = true; + } catch { + // ripgrep 不可用,使用 find 命令 (macOS/Linux) 或 fallback + try { + const paths = await this.listFilesWithFind(); + this.processFilePaths(paths); + this.initialized = true; + } catch (error) { + console.error('Failed to initialize file index:', error); + this.files = []; + this.dirs = new Set(); + this.initialized = true; + } + } + } + + private processFilePaths(paths: string[]): void { + this.files = []; + this.dirs = new Set(); + + for (const path of paths) { + if (!path) continue; + + const name = path.split('/').pop() || path; + const ext = name.includes('.') ? name.split('.').pop() : undefined; + + this.files.push({ + path, + name, + type: 'file', + extension: ext, + }); + + // 收集目录 + const parts = path.split('/'); + for (let i = 1; i < parts.length; i++) { + const dir = parts.slice(0, i).join('/'); + if (dir) this.dirs.add(dir); + } + } + } + + private listFilesWithRipgrep(): Promise { + return new Promise((resolve, reject) => { + const args = [ + '--files', + '--follow', + '--hidden', + '--glob=!.git/*', + '--glob=!node_modules/*', + '--glob=!.next/*', + '--glob=!dist/*', + '--glob=!build/*', + '--glob=!coverage/*', + '--glob=!.turbo/*', + '--glob=!*.lock', + '--glob=!package-lock.json', + ]; + + const proc = spawn('rg', args, { + cwd: this.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let output = ''; + let error = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.stderr.on('data', (data) => { + error += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0 || code === 1) { + // ripgrep returns 1 if no matches, which is fine + const paths = output.trim().split('\n').filter(Boolean); + resolve(paths); + } else { + reject(new Error(`ripgrep failed: ${error}`)); + } + }); + + proc.on('error', reject); + }); + } + + private listFilesWithFind(): Promise { + return new Promise((resolve, reject) => { + const args = [ + '.', + '-type', 'f', + '-not', '-path', '*/.git/*', + '-not', '-path', '*/node_modules/*', + '-not', '-path', '*/.next/*', + '-not', '-path', '*/dist/*', + '-not', '-path', '*/build/*', + '-not', '-path', '*/coverage/*', + '-not', '-name', '*.lock', + '-not', '-name', 'package-lock.json', + ]; + + const proc = spawn('find', args, { + cwd: this.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let output = ''; + let error = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.stderr.on('data', (data) => { + error += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + const paths = output + .trim() + .split('\n') + .filter(Boolean) + .map((p) => p.replace(/^\.\//, '')); // 移除开头的 ./ + resolve(paths); + } else { + reject(new Error(`find failed: ${error}`)); + } + }); + + proc.on('error', reject); + }); + } + + /** + * 模糊搜索文件 + */ + async search(options: FileSearchOptions): Promise { + await this.initialize(); + + const { query, limit = 10, type = 'file' } = options; + + // 构建搜索列表 + let items: FileIndexEntry[]; + + if (type === 'directory') { + items = Array.from(this.dirs).map((d) => ({ + path: d, + name: d.split('/').pop()!, + type: 'directory' as const, + })); + } else if (type === 'all') { + const dirItems = Array.from(this.dirs).map((d) => ({ + path: d, + name: d.split('/').pop()!, + type: 'directory' as const, + })); + items = [...this.files, ...dirItems]; + } else { + items = this.files; + } + + // 如果没有查询,返回前 N 个 + if (!query) { + return items.slice(0, limit); + } + + // 模糊搜索 + const results = fuzzysort.go(query, items, { + key: 'path', + limit, + threshold: -10000, + }); + + return results.map((r) => r.obj); + } + + /** + * 刷新索引 + */ + async refresh(): Promise { + this.initialized = false; + await this.initialize(); + } + + /** + * 获取索引统计 + */ + getStats(): { files: number; directories: number } { + return { + files: this.files.length, + directories: this.dirs.size, + }; + } +} + +// 索引实例缓存 +const indexCache = new Map(); + +/** + * 获取文件索引实例 + */ +export function getFileIndex(cwd: string): FileIndex { + let index = indexCache.get(cwd); + if (!index) { + index = new FileIndex(cwd); + indexCache.set(cwd, index); + } + return index; +} + +/** + * 搜索文件(便捷函数) + */ +export async function searchFiles(options: FileSearchOptions & { cwd: string }): Promise { + const { cwd, ...searchOptions } = options; + const index = getFileIndex(cwd); + return index.search(searchOptions); +} + +/** + * 刷新文件索引 + */ +export async function refreshFileIndex(cwd: string): Promise { + const index = getFileIndex(cwd); + await index.refresh(); +} + +/** + * 获取索引统计 + */ +export async function getFileIndexStats(cwd: string): Promise<{ files: number; directories: number }> { + const index = getFileIndex(cwd); + await index.initialize(); + return index.getStats(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08ed199..d534234 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -226,3 +226,16 @@ export type { ProviderListItem, ProviderDetail, } from './provider/index.js'; + +// File Index +export { + getFileIndex, + searchFiles, + refreshFileIndex, + getFileIndexStats, +} from './file-index/index.js'; + +export type { + FileIndexEntry, + FileSearchOptions, +} from './file-index/index.js'; diff --git a/packages/server/package.json b/packages/server/package.json index a3a5ae6..3c1506b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -30,6 +30,7 @@ "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.15", + "fuzzysort": "^3.1.0", "typescript": "^5.6.0", "vitest": "^4.0.15" } diff --git a/packages/server/src/routes/files.ts b/packages/server/src/routes/files.ts index 2f8b7b3..1a2868b 100644 --- a/packages/server/src/routes/files.ts +++ b/packages/server/src/routes/files.ts @@ -7,6 +7,7 @@ import { Hono } from 'hono'; import { readdir, stat, readFile } from 'node:fs/promises'; import { join, resolve, basename, extname, dirname } from 'node:path'; +import { searchFiles as coreSearchFiles, type FileIndexEntry } from '@ai-assistant/core'; const filesRouter = new Hono(); @@ -368,4 +369,33 @@ filesRouter.get('/tree', async (c) => { } }); +// ============================================================================ +// GET /api/files/search?query=&limit=&type= - 模糊搜索文件 +// ============================================================================ +filesRouter.get('/search', async (c) => { + const query = c.req.query('query') || ''; + const limit = parseInt(c.req.query('limit') || '10', 10); + const type = (c.req.query('type') || 'file') as 'file' | 'directory' | 'all'; + + try { + const results = await coreSearchFiles({ + query, + limit, + type, + cwd: workingDirectory, + }); + + return c.json({ + success: true, + data: { + files: results, + total: results.length, + }, + }); + } 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/ws.ts b/packages/server/src/ws.ts index fb6f385..fca750c 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -105,13 +105,16 @@ export async function handleWebSocketMessage( switch (message.type) { case 'message': { // 用户发送消息 - const content = message.payload?.content || ''; + let content = message.payload?.content || ''; + + // 将 @filepath 转换为 ./filepath 格式(方便 AI 识别为文件路径) + content = content.replace(/@([\w./-]+)/g, './$1'); // 广播确认收到消息 broadcastToSession(sessionId, { type: 'message_received', sessionId, - payload: { content }, + payload: { content: message.payload?.content || '' }, // 广播原始内容 }); // 调用 Agent 处理消息(异步,不阻塞) diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 5aa9615..2fa6cfa 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -48,6 +48,8 @@ import type { // Context types ContextUsageInfo, CompressionResult, + // File search types + FileSearchResponse, } from './types.js'; // Re-export types @@ -124,6 +126,9 @@ export type { CompressionResult, // WebSocket error types ConfigErrorPayload, + // File search types + FileSearchResult, + FileSearchResponse, } from './types.js'; // API Configuration @@ -974,3 +979,21 @@ export async function compressContext( }> { return request('POST', `/sessions/${encodeURIComponent(sessionId)}/compress`, options || {}); } + +// ============ File Search API ============ + +/** + * 模糊搜索项目文件 + */ +export async function searchFiles( + query: string = '', + limit: number = 10, + type: 'file' | 'directory' | 'all' = 'file' +): Promise { + const params = new URLSearchParams({ + query, + limit: String(limit), + type, + }); + return request('GET', `/files/search?${params}`); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index de6a729..c551728 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -854,3 +854,26 @@ export interface ConfigErrorPayload { action: 'open_providers_panel' | 'open_settings'; } +// ============ 文件搜索相关 ============ + +/** 文件搜索结果 */ +export interface FileSearchResult { + /** 相对路径 */ + path: string; + /** 文件名 */ + name: string; + /** 文件类型 */ + type: 'file' | 'directory'; + /** 扩展名 */ + extension?: string; +} + +/** 文件搜索响应 */ +export interface FileSearchResponse { + success: boolean; + data: { + files: FileSearchResult[]; + total: number; + }; +} + diff --git a/packages/ui/src/components/ChatInput.tsx b/packages/ui/src/components/ChatInput.tsx index 442537b..e683df3 100644 --- a/packages/ui/src/components/ChatInput.tsx +++ b/packages/ui/src/components/ChatInput.tsx @@ -3,13 +3,17 @@ * * 支持响应式:responsive=true 时适配移动端键盘和触摸操作 * 支持斜杠命令:输入 / 时显示命令菜单 + * 支持文件提及:输入 @ 时显示文件搜索菜单 */ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Send, Square } from 'lucide-react'; import clsx from 'clsx'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; +import { FileMenu, type FileMenuItem } from './FileMenu.js'; +import { FileMentionTag } from './FileMentionTag.js'; import { useCommands } from '../hooks/useCommands.js'; +import { useFileMention } from '../hooks/useFileMention.js'; interface ChatInputProps { onSend: (content: string) => void; @@ -20,6 +24,8 @@ interface ChatInputProps { responsive?: boolean; /** 是否启用斜杠命令 */ enableCommands?: boolean; + /** 是否启用文件提及 (@) */ + enableFileMention?: boolean; } export function ChatInput({ @@ -29,6 +35,7 @@ export function ChatInput({ disabled, responsive = false, enableCommands = true, + enableFileMention = true, }: ChatInputProps) { const [input, setInput] = useState(''); const [showCommandMenu, setShowCommandMenu] = useState(false); @@ -42,6 +49,41 @@ export function ChatInput({ filterCommands, } = useCommands({ autoLoad: enableCommands }); + // 文件提及系统 + const { + isOpen: showFileMenu, + files: filteredFiles, + isLoading: filesLoading, + selectedIndex: fileSelectedIndex, + setSelectedIndex: setFileSelectedIndex, + checkTrigger: checkFileTrigger, + getReplacementText, + close: closeFileMenu, + mentionStart, + } = useFileMention({ enabled: enableFileMention }); + + // 解析输入中已存在的文件提及 (匹配 @path/to/file 格式) + const mentionedFiles = useMemo(() => { + const regex = /@([\w./-]+)/g; + const files: string[] = []; + let match; + while ((match = regex.exec(input)) !== null) { + files.push(match[1]); + } + return files; + }, [input]); + + // 移除指定的文件提及 + const handleRemoveFile = useCallback( + (filePath: string) => { + // 移除 @filepath 和后面的空格 + const regex = new RegExp(`@${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g'); + setInput((prev) => prev.replace(regex, '').trim()); + textareaRef.current?.focus(); + }, + [] + ); + // 自动调整高度 useEffect(() => { const textarea = textareaRef.current; @@ -77,21 +119,55 @@ export function ChatInput({ // 处理输入变化 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; + const cursorPos = e.target.selectionStart; setInput(value); + + // 检查命令触发 checkCommandTrigger(value); + + // 检查文件提及触发(只在非命令输入模式下) + if (enableFileMention && !value.startsWith('/')) { + checkFileTrigger(value, cursorPos); + } else { + closeFileMenu(); + } }; // 选择命令 - const handleCommandSelect = useCallback( - (command: CommandMenuItem) => { - // 替换输入内容为 /command + 空格,准备输入参数 - setInput(`/${command.name} `); - setShowCommandMenu(false); + const handleCommandSelect = useCallback((command: CommandMenuItem) => { + // 替换输入内容为 /command + 空格,准备输入参数 + setInput(`/${command.name} `); + setShowCommandMenu(false); - // 聚焦输入框 - textareaRef.current?.focus(); + // 聚焦输入框 + textareaRef.current?.focus(); + }, []); + + // 选择文件 + const handleFileSelect = useCallback( + (file: FileMenuItem) => { + if (mentionStart === null) return; + + // 获取当前光标位置 + const cursorPos = textareaRef.current?.selectionStart || input.length; + + // 替换 @ 到光标之间的内容 + const before = input.slice(0, mentionStart); + const after = input.slice(cursorPos); + const replacement = getReplacementText(file); + + const newInput = before + replacement + after; + setInput(newInput); + closeFileMenu(); + + // 聚焦输入框并设置光标位置 + setTimeout(() => { + textareaRef.current?.focus(); + const newPos = before.length + replacement.length; + textareaRef.current?.setSelectionRange(newPos, newPos); + }, 0); }, - [] + [input, mentionStart, getReplacementText, closeFileMenu] ); // 关闭命令菜单 @@ -103,8 +179,9 @@ export function ChatInput({ const trimmed = input.trim(); if (!trimmed || isLoading || disabled) return; - // 关闭命令菜单 + // 关闭菜单 setShowCommandMenu(false); + closeFileMenu(); onSend(trimmed); setInput(''); @@ -116,7 +193,22 @@ export function ChatInput({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - // 如果命令菜单打开,让菜单处理键盘事件 + // 文件菜单优先处理(因为可以在任意位置触发) + if (showFileMenu && filteredFiles.length > 0) { + if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { + // 这些键由 FileMenu 处理 + return; + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (filteredFiles[fileSelectedIndex]) { + handleFileSelect(filteredFiles[fileSelectedIndex]); + } + return; + } + } + + // 命令菜单处理 if (showCommandMenu && filteredCommands.length > 0) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { // 这些键由 CommandMenu 处理,阻止默认行为 @@ -159,51 +251,82 @@ export function ChatInput({ /> )} -
-
-