Skip to content

Commit 935ae1c

Browse files
author
29486
committed
perf: 首 token 延迟优化——检索缓存/单关键词/ripgrep+内存索引/MCP 并行
- 检索结果 60s TTL 缓存 + 只搜首个关键词:重复消息零搜索成本 - chat 模式跳过项目全量自动检索(@ 显式附件仍读取) - search:inFiles/search:files 三级链路:内存索引 → ripgrep → Node 遍历 - 新增 FileIndexService 内存增量索引(chokidar 事件驱动更新,64MB 内容预算, 文件清单完整、超大文件/超预算回退 rg) - MCP listTools 并行化 + tools/list 10s 超时上限(原串行 30s/个) - 工具结果压缩阈值放宽(保留最近 10 条 / >12KB 才压缩),避免工作集被误压
1 parent 40a7e81 commit 935ae1c

8 files changed

Lines changed: 812 additions & 156 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect, afterEach } from 'vitest'
2+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, appendFileSync } from 'fs'
3+
import { join } from 'path'
4+
import { tmpdir } from 'os'
5+
import { FileIndexService } from '../services/file-index'
6+
import { FileSystemService } from '../services/file-system'
7+
8+
// 用真实临时目录跑 FileIndexService(内存索引 + 增量更新 + 预算/监听门控)。
9+
// FileSystemService 直接走 node fs,无需 Electron 环境。
10+
11+
const roots: string[] = []
12+
13+
function makeProject(): string {
14+
const root = mkdtempSync(join(tmpdir(), 'file-index-'))
15+
roots.push(root)
16+
mkdirSync(join(root, 'src'))
17+
mkdirSync(join(root, 'node_modules'))
18+
writeFileSync(join(root, 'src', 'app.ts'), 'export function greet(name: string) {\n return `hello ${name}`\n}\n')
19+
writeFileSync(join(root, 'src', 'util.js'), '// util\nconst TOKEN = "secret-key"\nmodule.exports = { TOKEN }\n')
20+
writeFileSync(join(root, 'README.md'), '# Demo\n\nhello world\n')
21+
writeFileSync(join(root, 'logo.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
22+
writeFileSync(join(root, 'node_modules', 'dep.js'), 'export const HIDDEN_DEP = "x"')
23+
writeFileSync(join(root, '.secret.ts'), 'export const hidden = 1')
24+
return root
25+
}
26+
27+
afterEach(() => {
28+
for (const r of roots.splice(0)) {
29+
try { rmSync(r, { recursive: true, force: true }) } catch { /* 清理失败忽略 */ }
30+
}
31+
})
32+
33+
describe('FileIndexService', () => {
34+
it('builds a content index and searches source files (skips excluded/hidden)', async () => {
35+
const root = makeProject()
36+
const idx = new FileIndexService(new FileSystemService())
37+
idx.markWatched(root)
38+
await idx.ensureIndex(root)
39+
40+
const hits = await idx.searchContent(root, 'secret-key', {})
41+
expect(hits).not.toBeNull()
42+
expect(hits!.map((h) => h.fileName)).toContain('util.js')
43+
44+
// node_modules 与隐藏文件不进入索引
45+
const all = await idx.searchContent(root, 'HIDDEN_DEP', {})
46+
expect(all!.length).toBe(0)
47+
48+
// 非源码文件(png)不参与内容索引,但参与文件名搜索
49+
const byName = await idx.searchFiles(root, 'logo')
50+
expect(byName).toContain(join(root, 'logo.png'))
51+
})
52+
53+
it('is case-insensitive and respects filePattern', async () => {
54+
const root = makeProject()
55+
const idx = new FileIndexService(new FileSystemService())
56+
idx.markWatched(root)
57+
await idx.ensureIndex(root)
58+
59+
expect((await idx.searchContent(root, 'GREET', {}))!.length).toBeGreaterThan(0)
60+
61+
const tsOnly = await idx.searchContent(root, 'hello', { filePattern: '*.ts' })
62+
expect(tsOnly!.every((h) => h.fileName.endsWith('.ts'))).toBe(true)
63+
// README.md 的 hello 不应出现在 .ts 过滤结果里
64+
expect(tsOnly!.every((h) => h.fileName !== 'README.md')).toBe(true)
65+
})
66+
67+
it('serves only watched roots; regex/wholeWord fall back to null', async () => {
68+
const root = makeProject()
69+
const idx = new FileIndexService(new FileSystemService())
70+
// 未 markWatched:不提供内存索引服务(回退 rg/遍历)
71+
await idx.ensureIndex(root)
72+
expect(await idx.searchContent(root, 'hello', {})).toBeNull()
73+
expect(await idx.searchFiles(root, 'src')).toBeNull()
74+
75+
idx.markWatched(root)
76+
await idx.ensureIndex(root)
77+
// regex / wholeWord 语义不在内存索引里 → null(交给 rg/遍历)
78+
expect(await idx.searchContent(root, 'hel+o', { regex: true })).toBeNull()
79+
expect(await idx.searchContent(root, 'hello', { wholeWord: true })).toBeNull()
80+
expect(await idx.searchContent(root, 'hello', {})).not.toBeNull()
81+
})
82+
83+
it('updates incrementally on file change/delete', async () => {
84+
const root = makeProject()
85+
const idx = new FileIndexService(new FileSystemService())
86+
idx.markWatched(root)
87+
await idx.ensureIndex(root)
88+
89+
const target = join(root, 'src', 'app.ts')
90+
expect(await idx.searchContent(root, 'NEW_MARKER', {})).toHaveLength(0)
91+
92+
// 改动文件 → onFileChanged 后索引能查到
93+
appendFileSync(target, '\nconst NEW_MARKER = 1\n')
94+
idx.onFileChanged(target)
95+
// 原地更新是异步的,等它落盘
96+
await new Promise((r) => setTimeout(r, 50))
97+
expect(await idx.searchContent(root, 'NEW_MARKER', {})).not.toHaveLength(0)
98+
99+
// 删除文件 → 不再命中
100+
rmSync(target)
101+
idx.onFileChanged(target)
102+
await new Promise((r) => setTimeout(r, 50))
103+
expect(await idx.searchContent(root, 'greet', {})).toHaveLength(0)
104+
})
105+
106+
it('caps content budget with limited flag (huge contents)', async () => {
107+
// 用小于预算但超单文件上限的内容验证:超大文件不索引、不崩溃
108+
const root = makeProject()
109+
writeFileSync(join(root, 'src', 'huge.ts'), `// ${'x'.repeat(6 * 1024 * 1024)} BIG_FILE\n`)
110+
const idx = new FileIndexService(new FileSystemService())
111+
idx.markWatched(root)
112+
await idx.ensureIndex(root)
113+
114+
const hits = await idx.searchContent(root, 'BIG_FILE', {})
115+
// 超大文件被跳过 → 不在索引结果里(rg/遍历兜底会覆盖)
116+
expect(hits!.length).toBe(0)
117+
})
118+
})

0 commit comments

Comments
 (0)