cc-haha/src/server/services/searchService.ts
程序员阿江(Relakkes) 1a050242b7 feat(search): global session full-text search
Add a Codex-style global search dialog (Cmd+K / sidebar button) that
full-text searches across all session transcripts, replacing the old
title-only sidebar filter.

Backend: rewrite searchService.searchSessions as a two-phase engine —
ripgrep finds candidate files + matched lines, then those lines are
parsed to keep only user/assistant text, re-confirmed against the
cleaned text to drop JSON/UUID/base64 false positives, and windowed into
highlighted snippets. Results carry real session titles (new
sessionService.getSessionTitleAndMeta reusing the list title
precedence), project path, mtime, role and match counts; falls back to a
JS scan when ripgrep is unavailable.

Frontend: new GlobalSearchModal (debounced, stale-response-safe, keyboard
nav, role badges, highlighting, recent-chats empty state); Cmd+K now
opens it; the sidebar input is replaced by a search trigger button.

Tests: 15 backend cases (searchService.sessions) + 12 frontend cases
(GlobalSearchModal); existing Sidebar/pages tests updated for the new
trigger.
2026-06-15 19:06:00 +08:00

772 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SearchService — 工作区文件搜索 & 会话历史搜索
*
* 优先使用 ripgrep (rg),不可用时降级到 grep。
*/
import { spawn } from 'child_process'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
import { sessionService } from './sessionService.js'
export type SearchResult = {
file: string
line: number
text: string
context?: string[]
}
export type SessionMatchRole = 'user' | 'assistant'
export type SessionMatch = {
/** Who produced the matched text. */
role: SessionMatchRole
/** Transcript entry uuid (for future "jump to message"); null on legacy rows. */
messageId: string | null
/** 1-based line number inside the .jsonl file. */
lineNumber: number
/** Whitespace-collapsed, window-trimmed readable excerpt. */
snippet: string
/** Match ranges relative to `snippet`, for highlighting. */
highlights: Array<{ start: number; end: number }>
timestamp?: string
}
export type SessionSearchResult = {
sessionId: string
title: string
projectPath: string
workDir: string | null
modifiedAt: string
/** Total readable matches in the session (may exceed matches.length). */
matchCount: number
matches: SessionMatch[]
}
export type SessionSearchOptions = {
limit?: number
matchesPerSession?: number
caseSensitive?: boolean
}
export type SessionSearchOutput = {
results: SessionSearchResult[]
truncated: boolean
}
/** Minimal transcript-entry shape needed for search (mirrors sessionService's RawEntry). */
type RawSearchEntry = {
type?: string
uuid?: string
timestamp?: string
message?: { role?: string; content?: unknown }
[key: string]: unknown
}
/** Cap files parsed in phase B so a broad query can't read hundreds of large files. */
const SESSION_SEARCH_MAX_FILES = 60
const SESSION_SEARCH_DEFAULT_LIMIT = 50
const SESSION_SEARCH_DEFAULT_MATCHES_PER_SESSION = 5
/** Characters of context kept on each side of a match inside a snippet. */
const SESSION_SNIPPET_WINDOW = 120
/** Guard ripgrep output against multi-MB single lines (base64 / big tool results). */
const RG_MAX_COLUMNS = 500
export class SearchService {
// ---------------------------------------------------------------------------
// 工作区搜索
// ---------------------------------------------------------------------------
/** 使用 ripgrep 搜索工作目录 */
async searchWorkspace(
query: string,
options?: {
cwd?: string
maxResults?: number
glob?: string
caseSensitive?: boolean
},
): Promise<SearchResult[]> {
if (!query) {
throw ApiError.badRequest('Search query is required')
}
const cwd = options?.cwd || process.cwd()
const maxResults = options?.maxResults || 200
// 尝试 rg降级到 grep
const hasRg = await this.commandExists('rg')
if (hasRg) {
try {
return await this.searchWithRipgrep(query, cwd, maxResults, options)
} catch {
// rg 执行失败,降级到 grep
}
}
const hasGrep = await this.commandExists('grep')
if (hasGrep) {
try {
return await this.searchWithGrep(query, cwd, maxResults, options)
} catch {
// grep failed or is not available; fall back to a portable search.
}
}
return this.searchWithFilesystem(query, cwd, maxResults, options)
}
// ---------------------------------------------------------------------------
// 会话历史搜索
// ---------------------------------------------------------------------------
/**
* Full-text search across all session transcripts.
*
* Two-phase: (A) ripgrep scans `~/.claude/projects` for candidate files +
* matched line numbers (fast — tens of ms over hundreds of MB; falls back to a
* pure-JS scan when rg is unavailable). (B) for the most-recently-modified
* candidate files we parse only the matched lines, keep just user/assistant
* text blocks, re-confirm the query against the cleaned text (dropping
* ripgrep's false positives on JSON keys / UUIDs / base64), and build
* highlighted snippets with real session titles.
*/
async searchSessions(
query: string,
options?: SessionSearchOptions,
): Promise<SessionSearchOutput> {
const trimmedQuery = query.trim()
if (!trimmedQuery) {
throw ApiError.badRequest('Search query is required')
}
const caseSensitive = options?.caseSensitive ?? false
const limit = options?.limit ?? SESSION_SEARCH_DEFAULT_LIMIT
const matchesPerSession =
options?.matchesPerSession ?? SESSION_SEARCH_DEFAULT_MATCHES_PER_SESSION
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
const projectsDir = path.join(configDir, 'projects')
try {
await fs.access(projectsDir)
} catch {
return { results: [], truncated: false }
}
// ── Phase A: candidate files + matched line numbers ──────────────────────
const candidates = await this.findSessionCandidateLines(
trimmedQuery,
projectsDir,
{ caseSensitive },
)
if (candidates.size === 0) {
return { results: [], truncated: false }
}
// Prefer the most recently modified sessions; cap how many we parse.
const ranked = await Promise.all(
[...candidates.keys()].map(async (filePath) => {
let mtimeMs = 0
try {
mtimeMs = (await fs.stat(filePath)).mtimeMs
} catch {
// unreadable — sinks to the bottom
}
return { filePath, mtimeMs }
}),
)
ranked.sort((a, b) => b.mtimeMs - a.mtimeMs)
let truncated = false
let filesToParse = ranked
if (filesToParse.length > SESSION_SEARCH_MAX_FILES) {
filesToParse = filesToParse.slice(0, SESSION_SEARCH_MAX_FILES)
truncated = true
}
// ── Phase B: parse matched lines serially (avoid concurrent big-file reads)
const results: SessionSearchResult[] = []
for (const { filePath } of filesToParse) {
const lineNumbers = candidates.get(filePath)
if (!lineNumbers || lineNumbers.size === 0) continue
const { matches, matchCount } = await this.extractSessionMatches(
filePath,
lineNumbers,
trimmedQuery,
{ caseSensitive, matchesPerSession },
)
// All ripgrep hits were JSON noise (no readable user/assistant text).
if (matchCount === 0) continue
const sessionId = path.basename(filePath, '.jsonl')
let title = sessionId
let projectPath = path.basename(path.dirname(filePath))
let workDir: string | null = null
let modifiedAt = new Date(0).toISOString()
try {
const meta = await sessionService.getSessionTitleAndMeta(filePath)
title = meta.title
projectPath = meta.projectPath
workDir = meta.workDir
modifiedAt = meta.modifiedAt
} catch {
// keep fallbacks
}
results.push({
sessionId,
title,
projectPath,
workDir,
modifiedAt,
matchCount,
matches,
})
}
// Most recently modified first.
results.sort((a, b) =>
a.modifiedAt < b.modifiedAt ? 1 : a.modifiedAt > b.modifiedAt ? -1 : 0,
)
if (results.length > limit) {
return { results: results.slice(0, limit), truncated: true }
}
return { results, truncated }
}
// ---------------------------------------------------------------------------
// 会话搜索 — Phase A: 候选文件 + 命中行号
// ---------------------------------------------------------------------------
private async findSessionCandidateLines(
query: string,
projectsDir: string,
opts: { caseSensitive: boolean },
): Promise<Map<string, Set<number>>> {
if (await this.commandExists('rg')) {
try {
return await this.findSessionCandidatesWithRipgrep(query, projectsDir, opts)
} catch {
// rg failed — fall back to a portable scan
}
}
return this.findSessionCandidatesWithFilesystem(query, projectsDir, opts)
}
private async findSessionCandidatesWithRipgrep(
query: string,
projectsDir: string,
opts: { caseSensitive: boolean },
): Promise<Map<string, Set<number>>> {
const args = ['--json', '--max-columns', String(RG_MAX_COLUMNS), '--glob', '*.jsonl']
if (!opts.caseSensitive) args.push('--ignore-case')
args.push('--', query, projectsDir)
const output = await this.runCommand('rg', args)
const map = new Map<string, Set<number>>()
for (const line of output.split('\n')) {
if (!line) continue
try {
const obj = JSON.parse(line) as Record<string, unknown>
if (obj.type !== 'match') continue
const data = obj.data as { path?: { text?: string }; line_number?: number }
const file = data.path?.text
const lineNum = data.line_number
if (!file || !lineNum) continue
let set = map.get(file)
if (!set) {
set = new Set<number>()
map.set(file, set)
}
set.add(lineNum)
} catch {
// skip unparseable rg rows
}
}
return map
}
private async findSessionCandidatesWithFilesystem(
query: string,
projectsDir: string,
opts: { caseSensitive: boolean },
): Promise<Map<string, Set<number>>> {
const files = await this.walkJsonlFiles(projectsDir)
const needle = opts.caseSensitive ? query : query.toLowerCase()
const map = new Map<string, Set<number>>()
for (const filePath of files) {
let raw: string
try {
raw = await fs.readFile(filePath, 'utf-8')
} catch {
continue
}
const lines = raw.split('\n')
const set = new Set<number>()
for (let i = 0; i < lines.length; i++) {
const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase()
if (haystack.includes(needle)) set.add(i + 1)
}
if (set.size > 0) map.set(filePath, set)
}
return map
}
// ---------------------------------------------------------------------------
// 会话搜索 — Phase B: 解析命中行 → 清洗 → 提取片段
// ---------------------------------------------------------------------------
private async extractSessionMatches(
filePath: string,
lineNumbers: Set<number>,
query: string,
opts: { caseSensitive: boolean; matchesPerSession: number },
): Promise<{ matches: SessionMatch[]; matchCount: number }> {
let raw: string
try {
raw = await fs.readFile(filePath, 'utf-8')
} catch {
return { matches: [], matchCount: 0 }
}
const lines = raw.split('\n')
const needle = opts.caseSensitive ? query : query.toLowerCase()
const matches: SessionMatch[] = []
let matchCount = 0
for (const lineNo of [...lineNumbers].sort((a, b) => a - b)) {
const line = lines[lineNo - 1]
if (!line) continue
let entry: RawSearchEntry
try {
entry = JSON.parse(line) as RawSearchEntry
} catch {
continue // half-written / malformed line
}
for (const segment of this.extractUserAssistantSegments(entry)) {
const haystack = opts.caseSensitive ? segment.text : segment.text.toLowerCase()
if (!haystack.includes(needle)) continue // ripgrep false positive (JSON noise)
matchCount += 1
if (matches.length < opts.matchesPerSession) {
const { snippet, highlights } = this.buildSnippet(
segment.text,
query,
opts.caseSensitive,
)
matches.push({
role: segment.role,
messageId: typeof entry.uuid === 'string' ? entry.uuid : null,
lineNumber: lineNo,
snippet,
highlights,
...(typeof entry.timestamp === 'string' ? { timestamp: entry.timestamp } : {}),
})
}
}
}
return { matches, matchCount }
}
/**
* Extract only the user/assistant natural-language text from a transcript
* entry. Tool calls (tool_use) and tool results (tool_result) are skipped, as
* are internal command breadcrumbs — keeping search results clean.
*/
private extractUserAssistantSegments(
entry: RawSearchEntry,
): Array<{ role: SessionMatchRole; text: string }> {
if (entry.type !== 'user' && entry.type !== 'assistant') return []
const content = entry.message?.content
if (this.isInternalCommandBreadcrumb(content)) return []
const role: SessionMatchRole =
entry.type === 'assistant' || entry.message?.role === 'assistant'
? 'assistant'
: 'user'
return this.extractPlainTextBlocks(content).map((text) => ({ role, text }))
}
/** Plain text from message content (string, or `text` blocks only). */
private extractPlainTextBlocks(content: unknown): string[] {
if (typeof content === 'string') {
const trimmed = content.trim()
return trimmed ? [trimmed] : []
}
if (!Array.isArray(content)) return []
const out: string[] = []
for (const block of content) {
if (block && typeof block === 'object') {
const record = block as Record<string, unknown>
if (record.type === 'text' && typeof record.text === 'string') {
const trimmed = record.text.trim()
if (trimmed) out.push(trimmed)
}
}
}
return out
}
private isInternalCommandBreadcrumb(content: unknown): boolean {
if (typeof content !== 'string') return false
return (
content.includes('<command-name>') ||
content.includes('<command-message>') ||
content.includes('<command-args>') ||
content.includes('<local-command-caveat>')
)
}
/** Window a single match into a one-line, highlighted snippet. */
private buildSnippet(
text: string,
query: string,
caseSensitive: boolean,
): { snippet: string; highlights: Array<{ start: number; end: number }> } {
const normalized = text.replace(/\s+/g, ' ').trim()
const haystack = caseSensitive ? normalized : normalized.toLowerCase()
const needle = caseSensitive ? query : query.toLowerCase()
const idx = haystack.indexOf(needle)
if (idx < 0) {
const head = normalized.slice(0, SESSION_SNIPPET_WINDOW * 2)
return {
snippet: head + (normalized.length > head.length ? '…' : ''),
highlights: [],
}
}
const start = Math.max(0, idx - SESSION_SNIPPET_WINDOW)
const end = Math.min(normalized.length, idx + needle.length + SESSION_SNIPPET_WINDOW)
const prefix = start > 0 ? '…' : ''
const suffix = end < normalized.length ? '…' : ''
const snippet = prefix + normalized.slice(start, end) + suffix
const highlightStart = prefix.length + (idx - start)
return {
snippet,
highlights: [{ start: highlightStart, end: highlightStart + needle.length }],
}
}
// ---------------------------------------------------------------------------
// ripgrep 搜索
// ---------------------------------------------------------------------------
private async searchWithRipgrep(
query: string,
cwd: string,
maxResults: number,
options?: {
glob?: string
caseSensitive?: boolean
},
): Promise<SearchResult[]> {
const args = ['--json', '--max-count', String(maxResults)]
if (options?.caseSensitive === false) {
args.push('--ignore-case')
}
// 添加上下文行
args.push('-C', '4')
if (options?.glob) {
args.push('--glob', options.glob)
}
args.push('--', query, cwd)
const output = await this.runCommand('rg', args)
return this.parseRipgrepJson(output, maxResults)
}
/** 解析 ripgrep JSON 输出 */
private parseRipgrepJson(
output: string,
maxResults: number,
): SearchResult[] {
const results: SearchResult[] = []
const lines = output.split('\n').filter(Boolean)
// 收集上下文key = `${file}:${matchLine}`
const contextMap = new Map<
string,
{ file: string; line: number; text: string; context: string[] }
>()
for (const line of lines) {
try {
const obj = JSON.parse(line) as Record<string, unknown>
if (obj.type === 'match') {
const data = obj.data as {
path?: { text?: string }
line_number?: number
lines?: { text?: string }
submatches?: unknown[]
}
const file = data.path?.text || ''
const lineNum = data.line_number || 0
const text = (data.lines?.text || '').replace(/\n$/, '')
const key = `${file}:${lineNum}`
contextMap.set(key, { file, line: lineNum, text, context: [] })
} else if (obj.type === 'context') {
// 上下文行归属到最近的 match
const data = obj.data as {
path?: { text?: string }
line_number?: number
lines?: { text?: string }
}
const text = (data.lines?.text || '').replace(/\n$/, '')
// 附加到最后一个相同文件的 match
const file = data.path?.text || ''
for (const [key, entry] of contextMap) {
if (key.startsWith(file + ':')) {
entry.context.push(text)
}
}
}
} catch {
// 跳过无法解析的行
}
}
for (const entry of contextMap.values()) {
if (results.length >= maxResults) break
results.push({
file: entry.file,
line: entry.line,
text: entry.text,
context: entry.context.length > 0 ? entry.context : undefined,
})
}
return results
}
// ---------------------------------------------------------------------------
// grep 降级
// ---------------------------------------------------------------------------
private async searchWithGrep(
query: string,
cwd: string,
maxResults: number,
options?: {
glob?: string
caseSensitive?: boolean
},
): Promise<SearchResult[]> {
const args = ['-rn', '--max-count', String(maxResults)]
if (options?.caseSensitive === false) {
args.push('-i')
}
if (options?.glob) {
args.push('--include', options.glob)
}
args.push('--', query, cwd)
const output = await this.runCommand('grep', args)
return this.parseGrepOutput(output, maxResults)
}
/** 解析 grep 输出 (file:line:text) */
private parseGrepOutput(output: string, maxResults: number): SearchResult[] {
const results: SearchResult[] = []
const lines = output.split('\n').filter(Boolean)
for (const line of lines) {
if (results.length >= maxResults) break
// grep -n 输出格式: file:line:text
const match = line.match(/^(.+?):(\d+):(.*)$/)
if (match) {
results.push({
file: match[1],
line: parseInt(match[2], 10),
text: match[3],
})
}
}
return results
}
// ---------------------------------------------------------------------------
// Portable filesystem fallback
// ---------------------------------------------------------------------------
private async searchWithFilesystem(
query: string,
cwd: string,
maxResults: number,
options?: {
glob?: string
caseSensitive?: boolean
},
): Promise<SearchResult[]> {
const results: SearchResult[] = []
const needle = options?.caseSensitive === false ? query.toLowerCase() : query
await this.searchDirectory(cwd, needle, results, maxResults, {
caseSensitive: options?.caseSensitive !== false,
glob: options?.glob,
})
return results
}
private async searchDirectory(
dir: string,
needle: string,
results: SearchResult[],
maxResults: number,
options: {
caseSensitive: boolean
glob?: string
},
): Promise<void> {
if (results.length >= maxResults) return
let entries: import('fs').Dirent[]
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (results.length >= maxResults) return
if (entry.name === 'node_modules' || entry.name === '.git') continue
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await this.searchDirectory(fullPath, needle, results, maxResults, options)
continue
}
if (!entry.isFile()) continue
if (options.glob && !this.matchesSimpleGlob(entry.name, options.glob)) continue
await this.searchFile(fullPath, needle, results, maxResults, options.caseSensitive)
}
}
private async searchFile(
filePath: string,
needle: string,
results: SearchResult[],
maxResults: number,
caseSensitive: boolean,
): Promise<void> {
let content: string
try {
const buffer = await fs.readFile(filePath)
if (buffer.includes(0)) return
content = buffer.toString('utf8')
} catch {
return
}
const lines = content.split(/\r?\n/)
for (let index = 0; index < lines.length && results.length < maxResults; index++) {
const haystack = caseSensitive ? lines[index] : lines[index].toLowerCase()
if (!haystack.includes(needle)) continue
results.push({
file: filePath,
line: index + 1,
text: lines[index],
})
}
}
private matchesSimpleGlob(fileName: string, glob: string): boolean {
if (!glob.includes('*')) return fileName === glob
const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`).test(fileName)
}
// ---------------------------------------------------------------------------
// 工具方法
// ---------------------------------------------------------------------------
/** 运行外部命令,返回 stdout */
private runCommand(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const chunks: Buffer[] = []
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
proc.on('close', (code) => {
const output = Buffer.concat(chunks).toString('utf-8')
// rg/grep 返回 1 表示无匹配,不视为错误
if (code === 0 || code === 1) {
resolve(output)
} else {
reject(
new Error(`Command "${cmd}" exited with code ${code}: ${output}`),
)
}
})
proc.on('error', (err) => {
reject(err)
})
})
}
/** 检测命令是否存在 */
private commandExists(cmd: string): Promise<boolean> {
return new Promise((resolve) => {
const lookup = process.platform === 'win32' ? 'where' : 'which'
const proc = spawn(lookup, [cmd], { stdio: 'ignore' })
proc.on('close', (code) => resolve(code === 0))
proc.on('error', () => resolve(false))
})
}
/** 递归查找 .jsonl 文件 */
private async walkJsonlFiles(dir: string): Promise<string[]> {
const results: string[] = []
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
const sub = await this.walkJsonlFiles(fullPath)
results.push(...sub)
} else if (entry.name.endsWith('.jsonl')) {
results.push(fullPath)
}
}
} catch {
// 跳过不可访问的目录
}
return results
}
}