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
+1
View File
@@ -71,6 +71,7 @@
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.15", "@vitest/coverage-v8": "^4.0.15",
"fuzzysort": "^3.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^4.0.15" "vitest": "^4.0.15"
} }
+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, ProviderListItem,
ProviderDetail, ProviderDetail,
} from './provider/index.js'; } 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';
+1
View File
@@ -30,6 +30,7 @@
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.15", "@vitest/coverage-v8": "^4.0.15",
"fuzzysort": "^3.1.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vitest": "^4.0.15" "vitest": "^4.0.15"
} }
+30
View File
@@ -7,6 +7,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { readdir, stat, readFile } from 'node:fs/promises'; import { readdir, stat, readFile } from 'node:fs/promises';
import { join, resolve, basename, extname, dirname } from 'node:path'; import { join, resolve, basename, extname, dirname } from 'node:path';
import { searchFiles as coreSearchFiles, type FileIndexEntry } from '@ai-assistant/core';
const filesRouter = new Hono(); 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 }; export { filesRouter };
+5 -2
View File
@@ -105,13 +105,16 @@ export async function handleWebSocketMessage(
switch (message.type) { switch (message.type) {
case 'message': { case 'message': {
// 用户发送消息 // 用户发送消息
const content = message.payload?.content || ''; let content = message.payload?.content || '';
// 将 @filepath 转换为 ./filepath 格式(方便 AI 识别为文件路径)
content = content.replace(/@([\w./-]+)/g, './$1');
// 广播确认收到消息 // 广播确认收到消息
broadcastToSession(sessionId, { broadcastToSession(sessionId, {
type: 'message_received', type: 'message_received',
sessionId, sessionId,
payload: { content }, payload: { content: message.payload?.content || '' }, // 广播原始内容
}); });
// 调用 Agent 处理消息(异步,不阻塞) // 调用 Agent 处理消息(异步,不阻塞)
+23
View File
@@ -48,6 +48,8 @@ import type {
// Context types // Context types
ContextUsageInfo, ContextUsageInfo,
CompressionResult, CompressionResult,
// File search types
FileSearchResponse,
} from './types.js'; } from './types.js';
// Re-export types // Re-export types
@@ -124,6 +126,9 @@ export type {
CompressionResult, CompressionResult,
// WebSocket error types // WebSocket error types
ConfigErrorPayload, ConfigErrorPayload,
// File search types
FileSearchResult,
FileSearchResponse,
} from './types.js'; } from './types.js';
// API Configuration // API Configuration
@@ -974,3 +979,21 @@ export async function compressContext(
}> { }> {
return request('POST', `/sessions/${encodeURIComponent(sessionId)}/compress`, options || {}); 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<FileSearchResponse> {
const params = new URLSearchParams({
query,
limit: String(limit),
type,
});
return request('GET', `/files/search?${params}`);
}
+23
View File
@@ -854,3 +854,26 @@ export interface ConfigErrorPayload {
action: 'open_providers_panel' | 'open_settings'; 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;
};
}
+171 -48
View File
@@ -3,13 +3,17 @@
* *
* 支持响应式:responsive=true 时适配移动端键盘和触摸操作 * 支持响应式:responsive=true 时适配移动端键盘和触摸操作
* 支持斜杠命令:输入 / 时显示命令菜单 * 支持斜杠命令:输入 / 时显示命令菜单
* 支持文件提及:输入 @ 时显示文件搜索菜单
*/ */
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Send, Square } from 'lucide-react'; import { Send, Square } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; 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 { useCommands } from '../hooks/useCommands.js';
import { useFileMention } from '../hooks/useFileMention.js';
interface ChatInputProps { interface ChatInputProps {
onSend: (content: string) => void; onSend: (content: string) => void;
@@ -20,6 +24,8 @@ interface ChatInputProps {
responsive?: boolean; responsive?: boolean;
/** 是否启用斜杠命令 */ /** 是否启用斜杠命令 */
enableCommands?: boolean; enableCommands?: boolean;
/** 是否启用文件提及 (@) */
enableFileMention?: boolean;
} }
export function ChatInput({ export function ChatInput({
@@ -29,6 +35,7 @@ export function ChatInput({
disabled, disabled,
responsive = false, responsive = false,
enableCommands = true, enableCommands = true,
enableFileMention = true,
}: ChatInputProps) { }: ChatInputProps) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showCommandMenu, setShowCommandMenu] = useState(false); const [showCommandMenu, setShowCommandMenu] = useState(false);
@@ -42,6 +49,41 @@ export function ChatInput({
filterCommands, filterCommands,
} = useCommands({ autoLoad: enableCommands }); } = 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(() => { useEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
@@ -77,21 +119,55 @@ export function ChatInput({
// 处理输入变化 // 处理输入变化
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value; const value = e.target.value;
const cursorPos = e.target.selectionStart;
setInput(value); setInput(value);
// 检查命令触发
checkCommandTrigger(value); checkCommandTrigger(value);
// 检查文件提及触发(只在非命令输入模式下)
if (enableFileMention && !value.startsWith('/')) {
checkFileTrigger(value, cursorPos);
} else {
closeFileMenu();
}
}; };
// 选择命令 // 选择命令
const handleCommandSelect = useCallback( const handleCommandSelect = useCallback((command: CommandMenuItem) => {
(command: CommandMenuItem) => { // 替换输入内容为 /command + 空格,准备输入参数
// 替换输入内容为 /command + 空格,准备输入参数 setInput(`/${command.name} `);
setInput(`/${command.name} `); setShowCommandMenu(false);
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(); const trimmed = input.trim();
if (!trimmed || isLoading || disabled) return; if (!trimmed || isLoading || disabled) return;
// 关闭命令菜单 // 关闭菜单
setShowCommandMenu(false); setShowCommandMenu(false);
closeFileMenu();
onSend(trimmed); onSend(trimmed);
setInput(''); setInput('');
@@ -116,7 +193,22 @@ export function ChatInput({
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { 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 (showCommandMenu && filteredCommands.length > 0) {
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) { if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) {
// 这些键由 CommandMenu 处理,阻止默认行为 // 这些键由 CommandMenu 处理,阻止默认行为
@@ -159,51 +251,82 @@ export function ChatInput({
/> />
)} )}
<div className="max-w-4xl mx-auto flex gap-2"> {/* File Menu */}
<div className="flex-1 relative"> {enableFileMention && (
<textarea <FileMenu
ref={textareaRef} files={filteredFiles}
value={input} isOpen={showFileMenu}
onChange={handleInputChange} selectedIndex={fileSelectedIndex}
onKeyDown={handleKeyDown} onSelect={handleFileSelect}
placeholder={ onClose={closeFileMenu}
responsive onSelectedIndexChange={setFileSelectedIndex}
? 'Type a message or /command...' isLoading={filesLoading}
: 'Type a message or /command... (Shift+Enter for new line)' />
} )}
disabled={disabled}
rows={1} <div className="max-w-4xl mx-auto">
{/* 已选文件标签 */}
{mentionedFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{mentionedFiles.map((file, index) => (
<FileMentionTag
key={`${file}-${index}`}
path={file}
size="sm"
removable
onRemove={() => handleRemoveFile(file)}
/>
))}
</div>
)}
{/* 输入区域 */}
<div className="flex gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
responsive
? 'Type a message, /command, or @file...'
: 'Type a message, /command, or @file... (Shift+Enter for new line)'
}
disabled={disabled}
rows={1}
className={clsx(
'w-full resize-none rounded-lg border border-line bg-surface-subtle',
responsive ? 'px-3 py-2.5 md:px-4 md:py-3' : 'px-4 py-3',
responsive ? 'text-base md:text-sm' : 'text-sm', // 移动端使用 16px 防止缩放
'text-fg placeholder-fg-subtle',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
/>
</div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx( className={clsx(
'w-full resize-none rounded-lg border border-line bg-surface-subtle', 'rounded-lg flex items-center justify-center transition-colors self-end',
responsive ? 'px-3 py-2.5 md:px-4 md:py-3' : 'px-4 py-3', responsive
responsive ? 'text-base md:text-sm' : 'text-sm', // 移动端使用 16px 防止缩放 ? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
'text-fg placeholder-fg-subtle', : 'px-4 py-3',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent', isLoading
? 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white'
: 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed' 'disabled:opacity-50 disabled:cursor-not-allowed'
)} )}
/> >
{isLoading ? <Square size={20} /> : <Send size={20} />}
</button>
</div> </div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'rounded-lg flex items-center justify-center transition-colors',
responsive
? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
: 'px-4 py-3',
isLoading
? 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white'
: 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isLoading ? <Square size={20} /> : <Send size={20} />}
</button>
</div> </div>
{/* 响应式模式下桌面端显示提示文字 */} {/* 响应式模式下桌面端显示提示文字 */}
{responsive && ( {responsive && (
<p className="hidden md:block text-xs text-fg-subtle text-center mt-2"> <p className="hidden md:block text-xs text-fg-subtle text-center mt-2">
Press Enter to send, Shift+Enter for new line, / for commands Press Enter to send, Shift+Enter for new line, / for commands, @ for files
</p> </p>
)} )}
</div> </div>
+6 -3
View File
@@ -20,6 +20,7 @@ import { useState, forwardRef } from 'react';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { fadeInUp, smoothTransition } from '../utils/animations'; import { fadeInUp, smoothTransition } from '../utils/animations';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { FileMentionText } from './FileMentionTag';
import type { Message, ToolCallInfo, ToolCallStatus, ToolMessagePart } from '../api/types.js'; import type { Message, ToolCallInfo, ToolCallStatus, ToolMessagePart } from '../api/types.js';
interface ChatMessageProps { interface ChatMessageProps {
@@ -48,8 +49,8 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
case 'text': case 'text':
if (!part.text) return null; if (!part.text) return null;
return isUser ? ( return isUser ? (
<div key={part.id} className="whitespace-pre-wrap break-words"> <div key={part.id}>
{part.text} <FileMentionText text={part.text} />
</div> </div>
) : ( ) : (
<Markdown key={part.id} content={part.text} /> <Markdown key={part.id} content={part.text} />
@@ -78,7 +79,9 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
)} )}
<div className="message-content text-fg-secondary"> <div className="message-content text-fg-secondary">
{isUser ? ( {isUser ? (
<div className="whitespace-pre-wrap break-words">{message.content ?? ''}</div> <div>
<FileMentionText text={message.content ?? ''} />
</div>
) : ( ) : (
<Markdown content={message.content ?? ''} /> <Markdown content={message.content ?? ''} />
)} )}
@@ -0,0 +1,181 @@
/**
* File Mention Tag Component
*
* 显示文件提及的标签样式组件
*/
import { File, Folder, X } from 'lucide-react';
import { cn } from '../utils/cn.js';
export interface FileMentionTagProps {
/** 文件路径 */
path: string;
/** 是否可删除 */
removable?: boolean;
/** 删除回调 */
onRemove?: () => void;
/** 点击回调 */
onClick?: () => void;
/** 尺寸 */
size?: 'sm' | 'md';
/** 额外的 class */
className?: string;
}
// 根据扩展名获取图标颜色
function getFileColor(path: string): string {
const ext = path.split('.').pop()?.toLowerCase();
const colors: Record<string, string> = {
ts: 'text-blue-400',
tsx: 'text-blue-400',
js: 'text-yellow-400',
jsx: 'text-yellow-400',
json: 'text-yellow-500',
md: 'text-fg-muted',
css: 'text-pink-400',
scss: 'text-pink-400',
html: 'text-orange-400',
py: 'text-green-400',
go: 'text-cyan-400',
rs: 'text-orange-500',
vue: 'text-emerald-400',
svelte: 'text-orange-500',
};
return colors[ext || ''] || 'text-fg-muted';
}
// 判断是否是目录
function isDirectory(path: string): boolean {
return path.endsWith('/');
}
// 获取文件名
function getFileName(path: string): string {
const parts = path.replace(/\/$/, '').split('/');
return parts[parts.length - 1] || path;
}
export function FileMentionTag({
path,
removable = false,
onRemove,
onClick,
size = 'md',
className,
}: FileMentionTagProps) {
const isDir = isDirectory(path);
const fileName = getFileName(path);
const iconColor = isDir ? 'text-yellow-400' : getFileColor(path);
const sizeClasses = {
sm: 'text-xs px-1.5 py-0.5 gap-1',
md: 'text-sm px-2 py-1 gap-1.5',
};
const iconSize = size === 'sm' ? 10 : 12;
return (
<span
className={cn(
'inline-flex items-center rounded-md',
'bg-primary-600/20 text-primary-300 border border-primary-600/30',
'font-mono',
sizeClasses[size],
onClick && 'cursor-pointer hover:bg-primary-600/30 transition-colors',
className
)}
onClick={onClick}
>
{isDir ? (
<Folder size={iconSize} className={iconColor} />
) : (
<File size={iconSize} className={iconColor} />
)}
<span className="truncate max-w-[200px]" title={path}>
{fileName}
</span>
{removable && onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-0.5 p-0.5 rounded hover:bg-primary-600/40 transition-colors"
>
<X size={iconSize} />
</button>
)}
</span>
);
}
/**
* 解析文本中的文件提及
* 返回分段后的内容数组
*/
export interface ParsedSegment {
type: 'text' | 'file';
content: string;
}
export function parseFileMentions(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// 匹配 @filepath 格式(路径可以包含 / 和 . 但不能有空格)
const regex = /@([\w./-]+)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// 添加前面的普通文本
if (match.index > lastIndex) {
segments.push({
type: 'text',
content: text.slice(lastIndex, match.index),
});
}
// 添加文件提及 (不包含 @ 符号的路径)
segments.push({
type: 'file',
content: match[1],
});
lastIndex = regex.lastIndex;
}
// 添加剩余的文本
if (lastIndex < text.length) {
segments.push({
type: 'text',
content: text.slice(lastIndex),
});
}
return segments;
}
/**
* 渲染带有文件提及高亮的文本
*/
interface FileMentionTextProps {
text: string;
className?: string;
}
export function FileMentionText({ text, className }: FileMentionTextProps) {
const segments = parseFileMentions(text);
if (segments.length === 0) {
return <span className={className}>{text}</span>;
}
return (
<span className={cn('whitespace-pre-wrap break-words', className)}>
{segments.map((segment, index) =>
segment.type === 'file' ? (
<FileMentionTag key={index} path={segment.content} size="sm" className="mx-0.5" />
) : (
<span key={index}>{segment.content}</span>
)
)}
</span>
);
}
+212
View File
@@ -0,0 +1,212 @@
/**
* File Menu Component
*
* 文件自动补全菜单,支持键盘导航
*/
import { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { File, Folder } from 'lucide-react';
import { cn } from '../utils/cn.js';
export interface FileMenuItem {
path: string;
name: string;
type: 'file' | 'directory';
extension?: string;
}
interface FileMenuProps {
/** 文件列表 */
files: FileMenuItem[];
/** 是否显示 */
isOpen: boolean;
/** 当前选中索引 */
selectedIndex: number;
/** 选择文件回调 */
onSelect: (file: FileMenuItem) => void;
/** 关闭菜单回调 */
onClose: () => void;
/** 选中索引变化回调 */
onSelectedIndexChange: (index: number) => void;
/** 是否正在加载 */
isLoading?: boolean;
}
// 文件图标颜色
function getFileIconColor(extension?: string): string {
const colors: Record<string, string> = {
ts: 'text-blue-400',
tsx: 'text-blue-400',
js: 'text-yellow-400',
jsx: 'text-yellow-400',
json: 'text-yellow-500',
md: 'text-fg-muted',
css: 'text-pink-400',
scss: 'text-pink-400',
html: 'text-orange-400',
py: 'text-green-400',
go: 'text-cyan-400',
rs: 'text-orange-500',
vue: 'text-emerald-400',
svelte: 'text-orange-500',
};
return colors[extension || ''] || 'text-fg-muted';
}
export function FileMenu({
files,
isOpen,
selectedIndex,
onSelect,
onClose,
onSelectedIndexChange,
isLoading = false,
}: FileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLButtonElement>(null);
// 滚动选中项到可见区域
useEffect(() => {
if (isOpen && selectedRef.current) {
selectedRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
}
}, [selectedIndex, isOpen]);
// 键盘导航
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
onSelectedIndexChange(selectedIndex < files.length - 1 ? selectedIndex + 1 : 0);
break;
case 'ArrowUp':
e.preventDefault();
onSelectedIndexChange(selectedIndex > 0 ? selectedIndex - 1 : files.length - 1);
break;
case 'Enter':
e.preventDefault();
if (files[selectedIndex]) {
onSelect(files[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
case 'Tab':
e.preventDefault();
if (files[selectedIndex]) {
onSelect(files[selectedIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, files, selectedIndex, onSelect, onClose, onSelectedIndexChange]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full left-0 right-0 mb-2 mx-4 md:mx-0 z-50"
>
<div className="bg-surface-subtle border border-line rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto">
{/* Header */}
<div className="px-3 py-2 border-b border-line bg-surface-subtle/80 sticky top-0">
<div className="flex items-center gap-2 text-xs text-fg-muted">
<File size={12} />
<span>Files</span>
{files.length > 0 && <span className="text-fg-subtle">({files.length})</span>}
</div>
</div>
{/* Loading */}
{isLoading && files.length === 0 && (
<div className="px-3 py-4 text-center text-fg-muted text-sm">Searching files...</div>
)}
{/* Empty state */}
{!isLoading && files.length === 0 && (
<div className="px-3 py-4 text-center text-fg-muted text-sm">No files found</div>
)}
{/* File list */}
{files.length > 0 && (
<div className="py-1">
{files.map((file, index) => (
<button
key={file.path}
ref={index === selectedIndex ? selectedRef : null}
onClick={() => onSelect(file)}
onMouseEnter={() => onSelectedIndexChange(index)}
className={cn(
'w-full px-3 py-2 flex items-center gap-3 text-left transition-colors',
index === selectedIndex ? 'bg-primary-600/20' : 'hover:bg-surface-muted/50'
)}
>
{/* Icon */}
{file.type === 'directory' ? (
<Folder size={14} className="text-yellow-400 flex-shrink-0" />
) : (
<File
size={14}
className={cn(getFileIconColor(file.extension), 'flex-shrink-0')}
/>
)}
{/* Path */}
<span
className={cn(
'font-mono text-sm truncate',
index === selectedIndex ? 'text-primary-300' : 'text-fg-secondary'
)}
>
{file.path}
</span>
{/* Keyboard hint */}
{index === selectedIndex && (
<div className="ml-auto flex items-center gap-1 text-[10px] text-fg-subtle flex-shrink-0">
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Enter</kbd>
</div>
)}
</button>
))}
</div>
)}
{/* Footer hint */}
<div className="px-3 py-1.5 border-t border-line bg-surface-subtle/80 sticky bottom-0">
<div className="flex items-center gap-3 text-[10px] text-fg-subtle">
<span>
<kbd className="px-1 py-0.5 bg-surface-muted rounded mr-1"></kbd>
<kbd className="px-1 py-0.5 bg-surface-muted rounded"></kbd> navigate
</span>
<span>
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Tab</kbd> select
</span>
<span>
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Esc</kbd> close
</span>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+150
View File
@@ -0,0 +1,150 @@
/**
* useFileMention Hook
*
* 管理文件提及的状态和逻辑
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { searchFiles } from '../api/client.js';
import type { FileSearchResult } from '../api/types.js';
interface UseFileMentionOptions {
/** 是否启用 */
enabled?: boolean;
/** 搜索结果限制 */
limit?: number;
/** 防抖延迟 (ms) */
debounceMs?: number;
}
interface UseFileMentionReturn {
/** 是否显示文件菜单 */
isOpen: boolean;
/** 搜索结果 */
files: FileSearchResult[];
/** 是否正在加载 */
isLoading: boolean;
/** 当前选中索引 */
selectedIndex: number;
/** 设置选中索引 */
setSelectedIndex: (index: number) => void;
/** 检查并处理 @ 触发 */
checkTrigger: (value: string, cursorPos: number) => void;
/** 选择文件后获取替换文本 */
getReplacementText: (file: FileSearchResult) => string;
/** 关闭菜单 */
close: () => void;
/** @ 符号在输入中的位置 */
mentionStart: number | null;
}
export function useFileMention(options: UseFileMentionOptions = {}): UseFileMentionReturn {
const { enabled = true, limit = 10, debounceMs = 150 } = options;
const [isOpen, setIsOpen] = useState(false);
const [files, setFiles] = useState<FileSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [mentionStart, setMentionStart] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 搜索文件
const search = useCallback(
async (query: string) => {
setIsLoading(true);
try {
const result = await searchFiles(query, limit, 'all');
if (result.success) {
setFiles(result.data.files);
setSelectedIndex(0);
}
} catch (error) {
console.error('File search error:', error);
setFiles([]);
} finally {
setIsLoading(false);
}
},
[limit]
);
// 检查 @ 触发
const checkTrigger = useCallback(
(value: string, cursorPos: number) => {
if (!enabled) return;
// 找到光标前最近的 @ 符号
const beforeCursor = value.slice(0, cursorPos);
const lastAtIndex = beforeCursor.lastIndexOf('@');
if (lastAtIndex === -1) {
setIsOpen(false);
setMentionStart(null);
return;
}
// 检查 @ 前面是否为空白或行首
const charBefore = lastAtIndex > 0 ? beforeCursor[lastAtIndex - 1] : '';
if (charBefore && !/\s/.test(charBefore)) {
setIsOpen(false);
setMentionStart(null);
return;
}
// 检查 @ 后面到光标之间是否有空格(如果有,说明已经完成输入)
const afterAt = beforeCursor.slice(lastAtIndex + 1);
if (afterAt.includes(' ')) {
setIsOpen(false);
setMentionStart(null);
return;
}
// 触发文件搜索
setIsOpen(true);
setMentionStart(lastAtIndex);
// 防抖搜索
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
search(afterAt);
}, debounceMs);
},
[enabled, search, debounceMs]
);
// 获取替换文本
const getReplacementText = useCallback((file: FileSearchResult): string => {
// 返回 @filepath 格式(用户友好的显示格式)
return `@${file.path} `;
}, []);
// 关闭菜单
const close = useCallback(() => {
setIsOpen(false);
setMentionStart(null);
}, []);
// 清理
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return {
isOpen,
files,
isLoading,
selectedIndex,
setSelectedIndex,
checkTrigger,
getReplacementText,
close,
mentionStart,
};
}
+14
View File
@@ -93,6 +93,8 @@ export {
// Context API // Context API
getContextUsage, getContextUsage,
compressContext, compressContext,
// File Search API
searchFiles,
} from './api/client.js'; } from './api/client.js';
// Types // Types
@@ -169,6 +171,9 @@ export type {
ContextUsageInfo, ContextUsageInfo,
CompressionStatus, CompressionStatus,
CompressionResult, CompressionResult,
// File Search types
FileSearchResult,
FileSearchResponse,
} from './api/client.js'; } from './api/client.js';
// Primitives (shadcn/ui style) // Primitives (shadcn/ui style)
@@ -182,6 +187,14 @@ export * from './utils/animations.js';
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js'; export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
export { ChatInput } from './components/ChatInput.js'; export { ChatInput } from './components/ChatInput.js';
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js'; export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
export { FileMenu, type FileMenuItem } from './components/FileMenu.js';
export {
FileMentionTag,
FileMentionText,
parseFileMentions,
type FileMentionTagProps,
type ParsedSegment,
} from './components/FileMentionTag.js';
export { CommandPanel } from './components/CommandPanel.js'; export { CommandPanel } from './components/CommandPanel.js';
export { CommandEditor } from './components/CommandEditor.js'; export { CommandEditor } from './components/CommandEditor.js';
export { MCPPanel } from './components/MCPPanel.js'; export { MCPPanel } from './components/MCPPanel.js';
@@ -212,6 +225,7 @@ export { toast } from 'sonner';
// Hooks // Hooks
export { useChat } from './hooks/useChat.js'; export { useChat } from './hooks/useChat.js';
export { useCommands } from './hooks/useCommands.js'; export { useCommands } from './hooks/useCommands.js';
export { useFileMention } from './hooks/useFileMention.js';
export { useTheme, ThemeProvider, themeInitScript, type Theme, type ResolvedTheme } from './hooks/useTheme.js'; export { useTheme, ThemeProvider, themeInitScript, type Theme, type ResolvedTheme } from './hooks/useTheme.js';
// Theme Components // Theme Components
+11
View File
@@ -118,6 +118,9 @@ importers:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.15 specifier: ^4.0.15
version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)) version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))
fuzzysort:
specifier: ^3.1.0
version: 3.1.0
typescript: typescript:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.9.3 version: 5.9.3
@@ -219,6 +222,9 @@ importers:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.15 specifier: ^4.0.15
version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)) version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))
fuzzysort:
specifier: ^3.1.0
version: 3.1.0
typescript: typescript:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.9.3 version: 5.9.3
@@ -2946,6 +2952,9 @@ packages:
functions-have-names@1.2.3: functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
generator-function@2.0.1: generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -7755,6 +7764,8 @@ snapshots:
functions-have-names@1.2.3: {} functions-have-names@1.2.3: {}
fuzzysort@3.1.0: {}
generator-function@2.0.1: {} generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}