cc-haha/src/server/api/search.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

72 lines
2.5 KiB
TypeScript

/**
* Search REST API
*
* POST /api/search — 全局工作区搜索
* POST /api/search/sessions — 搜索会话历史
*/
import { SearchService } from '../services/searchService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
const searchService = new SearchService()
export async function handleSearchApi(
req: Request,
_url: URL,
segments: string[],
): Promise<Response> {
try {
const method = req.method
const sub = segments[2] // 'sessions' | undefined
if (method !== 'POST') {
throw new ApiError(
405,
`Method ${method} not allowed. Use POST.`,
'METHOD_NOT_ALLOWED',
)
}
const body = await parseJsonBody(req)
const query = body.query as string
if (!query || typeof query !== 'string') {
throw ApiError.badRequest('Missing or invalid "query" in request body')
}
// ── POST /api/search/sessions ──────────────────────────────────────────
if (sub === 'sessions') {
const { results, truncated } = await searchService.searchSessions(query, {
limit: body.limit as number | undefined,
matchesPerSession: body.matchesPerSession as number | undefined,
caseSensitive: body.caseSensitive as boolean | undefined,
})
return Response.json({ results, total: results.length, truncated })
}
// ── POST /api/search ───────────────────────────────────────────────────
if (!sub) {
const results = await searchService.searchWorkspace(query, {
cwd: body.cwd as string | undefined,
maxResults: body.maxResults as number | undefined,
glob: body.glob as string | undefined,
caseSensitive: body.caseSensitive as boolean | undefined,
})
return Response.json({ results, total: results.length })
}
throw ApiError.notFound(`Unknown search endpoint: ${sub}`)
} catch (error) {
return errorResponse(error)
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}