feat(ui): 实现 @ 文件提及自动补全功能

- 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
This commit is contained in:
2025-12-15 16:32:59 +08:00
parent 5b7b0ff1e4
commit 865e0906b9
15 changed files with 1137 additions and 53 deletions
+296
View File
@@ -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<string> = new Set();
private cwd: string;
private initialized = false;
private initializing: Promise<void> | null = null;
constructor(cwd: string) {
this.cwd = cwd;
}
/**
* 初始化索引(使用 ripgrep 或 fallback 到 find
*/
async initialize(): Promise<void> {
if (this.initialized) return;
// 防止并发初始化
if (this.initializing) {
return this.initializing;
}
this.initializing = this._doInitialize();
await this.initializing;
this.initializing = null;
}
private async _doInitialize(): Promise<void> {
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<string[]> {
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<string[]> {
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<FileIndexEntry[]> {
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<void> {
this.initialized = false;
await this.initialize();
}
/**
* 获取索引统计
*/
getStats(): { files: number; directories: number } {
return {
files: this.files.length,
directories: this.dirs.size,
};
}
}
// 索引实例缓存
const indexCache = new Map<string, FileIndex>();
/**
* 获取文件索引实例
*/
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<FileIndexEntry[]> {
const { cwd, ...searchOptions } = options;
const index = getFileIndex(cwd);
return index.search(searchOptions);
}
/**
* 刷新文件索引
*/
export async function refreshFileIndex(cwd: string): Promise<void> {
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();
}
+13
View File
@@ -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';