From d31a1818f6ce8eb1890c23c9ee8f09b31ae2d157 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Tue, 28 Jul 2026 14:54:28 +0800 Subject: [PATCH] fix(web): search files in initial workspace drafts --- .changeset/web-initial-file-mention.md | 5 + apps/kimi-web/src/api/daemon/client.ts | 31 +++++ apps/kimi-web/src/api/types.ts | 1 + .../composables/client/useWorkspaceState.ts | 19 ++- .../src/session/sessionFs/fsService.ts | 36 +----- .../src/session/sessionFs/workspaceSearch.ts | 122 ++++++++++++++++++ packages/kap-server/src/routes/workspaces.ts | 71 ++++++++++ .../apiSurface.snapshot.test.ts.snap | 4 + packages/kap-server/test/workspaces.test.ts | 30 +++++ 9 files changed, 282 insertions(+), 37 deletions(-) create mode 100644 .changeset/web-initial-file-mention.md create mode 100644 packages/agent-core-v2/src/session/sessionFs/workspaceSearch.ts diff --git a/.changeset/web-initial-file-mention.md b/.changeset/web-initial-file-mention.md new file mode 100644 index 0000000000..03c515f5f2 --- /dev/null +++ b/.changeset/web-initial-file-mention.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix file mentions returning no matches while drafting the first message in a workspace. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..ce2fcd88f3 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -998,6 +998,37 @@ export class DaemonKimiWebApi implements KimiWebApi { }; } + async searchWorkspaceFiles( + workspaceId: string, + input: { query: string; limit?: number }, + ): Promise<{ + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + matchPositions: number[]; + }>; + truncated: boolean; + }> { + const body: Record = { query: input.query }; + if (input.limit !== undefined) body['limit'] = input.limit; + const data = await this.http.post( + `/workspaces/${encodeURIComponent(workspaceId)}/fs:search`, + body, + ); + return { + items: data.items.map((item) => ({ + path: item.path, + name: item.name, + kind: item.kind, + score: item.score, + matchPositions: item.match_positions, + })), + truncated: data.truncated, + }; + } + async grepFiles( sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }, diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 6c4679f165..b9d05f579b 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -748,6 +748,7 @@ export interface KimiWebApi { listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; + searchWorkspaceFiles(workspaceId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }>; getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 03a541fe0a..8aa30ed117 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -2732,16 +2732,27 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } + function resolveActiveWorkspaceId(): string | null { + const raw = rawState.activeWorkspaceId; + if (raw && workspacesView.value.some((w) => w.id === raw)) return raw; + return workspacesView.value[0]?.id ?? null; + } + /** - * Search files in the active session using the daemon searchFiles endpoint. - * Returns {path, name}[] — defensive, returns [] on error or no active session. + * Search files in the active session, or in the active workspace while the + * composer is still drafting the first prompt. */ async function searchFiles(query: string): Promise> { const sid = rawState.activeSessionId; - if (!sid) return []; try { const api = getKimiWebApi(); - const result = await api.searchFiles(sid, { query, limit: 20 }); + if (sid !== undefined) { + const result = await api.searchFiles(sid, { query, limit: 20 }); + return result.items.map((item) => ({ path: item.path, name: item.name })); + } + const workspaceId = resolveActiveWorkspaceId(); + if (workspaceId === null) return []; + const result = await api.searchWorkspaceFiles(workspaceId, { query, limit: 20 }); return result.items.map((item) => ({ path: item.path, name: item.name })); } catch { return []; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/session/sessionFs/fsService.ts index 103924b828..93d14a9e4d 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsService.ts @@ -37,7 +37,6 @@ import { type FsMkdirResponse, type FsReadRequest, type FsReadResponse, - type FsSearchHit, type FsSearchRequest, type FsSearchResponse, type FsStatManyRequest, @@ -85,16 +84,14 @@ import { readStream, runCommand } from './fsProcess'; import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator'; import { compileGrepPattern, - computeFuzzyScore, - computeMatchPositions, matchesAnyGlob, type RgJsonRecord, rgPath, rgText, stripTrailingNewline, } from './fsSearch'; +import { searchWorkspaceFiles } from './workspaceSearch'; -const SEARCH_HARD_CAP = 500; const GREP_TIMEOUT_MS = 30_000; const WALK_MAX_DEPTH = 64; @@ -432,36 +429,9 @@ export class SessionFsService implements ISessionFsService { } async search(req: FsSearchRequest): Promise { - const matcher = req.follow_gitignore ? await this.matcher() : undefined; - const candidates: FsSearchHit[] = []; - const queryLower = req.query.toLowerCase(); - - await this.walk('', matcher, async (relPath, name, kind) => { - const score = computeFuzzyScore(name, queryLower); - if (score <= 0) return; - if (req.include_globs && !matchesAnyGlob(relPath, req.include_globs)) { - return; - } - if (req.exclude_globs && matchesAnyGlob(relPath, req.exclude_globs)) { - return; - } - candidates.push({ - path: relPath, - name, - kind, - score, - match_positions: computeMatchPositions(relPath, queryLower), - }); + return searchWorkspaceFiles(this.hostFs, this.workspace.workDir, req, { + gitignoreCache: this.gitignoreCache, }); - - candidates.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return a.path.localeCompare(b.path); - }); - - const effectiveCap = Math.min(req.limit, SEARCH_HARD_CAP); - const truncated = candidates.length > effectiveCap; - return { items: candidates.slice(0, effectiveCap), truncated }; } async grep(req: FsGrepRequest): Promise { diff --git a/packages/agent-core-v2/src/session/sessionFs/workspaceSearch.ts b/packages/agent-core-v2/src/session/sessionFs/workspaceSearch.ts new file mode 100644 index 0000000000..36e2f4e61d --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionFs/workspaceSearch.ts @@ -0,0 +1,122 @@ +/** + * `sessionFs` domain (L2) — reusable workspace filename search helper. + * + * Searches a concrete workspace root through the app-level `os` filesystem + * primitive and returns the session filesystem search wire shape. Used by the + * Session-scoped filesystem service and by server workspace routes that need + * the same filename-search behavior before a Session scope exists. + */ + +import { join } from 'node:path'; + +import ignore, { type Ignore } from 'ignore'; + +import type { IHostFileSystem, HostDirEntry } from '#/os/interface/hostFileSystem'; + +import type { FsSearchHit, FsSearchRequest, FsSearchResponse } from './fs'; +import { computeFuzzyScore, computeMatchPositions, matchesAnyGlob } from './fsSearch'; + +const SEARCH_HARD_CAP = 500; +const WALK_MAX_DEPTH = 64; + +export async function searchWorkspaceFiles( + hostFs: IHostFileSystem, + root: string, + req: FsSearchRequest, + options: { readonly gitignoreCache?: Map } = {}, +): Promise { + const matcher = req.follow_gitignore + ? await workspaceMatcher(hostFs, root, options.gitignoreCache) + : undefined; + const candidates: FsSearchHit[] = []; + const queryLower = req.query.toLowerCase(); + + await walkWorkspaceFiles(hostFs, root, '', matcher, async (relPath, name, kind) => { + const score = computeFuzzyScore(name, queryLower); + if (score <= 0) return; + if (req.include_globs && !matchesAnyGlob(relPath, req.include_globs)) { + return; + } + if (req.exclude_globs && matchesAnyGlob(relPath, req.exclude_globs)) { + return; + } + candidates.push({ + path: relPath, + name, + kind, + score, + match_positions: computeMatchPositions(relPath, queryLower), + }); + }); + + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.path.localeCompare(b.path); + }); + + const effectiveCap = Math.min(req.limit, SEARCH_HARD_CAP); + const truncated = candidates.length > effectiveCap; + return { items: candidates.slice(0, effectiveCap), truncated }; +} + +async function walkWorkspaceFiles( + hostFs: IHostFileSystem, + root: string, + rootRel: string, + matcher: Ignore | undefined, + visit: ( + relPath: string, + name: string, + kind: 'file' | 'directory' | 'symlink', + ) => Promise, + depth = 0, +): Promise { + if (depth > WALK_MAX_DEPTH) return; + let entries: readonly HostDirEntry[]; + try { + entries = await hostFs.readdir(absOf(root, rootRel)); + } catch { + return; + } + for (const entry of entries) { + const { name } = entry; + if (name === '.git') continue; + const childRel = rootRel === '' ? name : `${rootRel}/${name}`; + const isDir = entry.isDirectory && entry.isSymbolicLink !== true; + if (matcher) { + const probe = isDir ? `${childRel}/` : childRel; + if (matcher.ignores(probe)) continue; + } + const kind: 'file' | 'directory' | 'symlink' = entry.isSymbolicLink + ? 'symlink' + : isDir + ? 'directory' + : 'file'; + await visit(childRel, name, kind); + if (isDir) { + await walkWorkspaceFiles(hostFs, root, childRel, matcher, visit, depth + 1); + } + } +} + +async function workspaceMatcher( + hostFs: IHostFileSystem, + root: string, + cache: Map | undefined, +): Promise { + const cached = cache?.get(root); + if (cached !== undefined) return cached; + const ig = ignore(); + ig.add('.git/'); + try { + const contents = await hostFs.readText(join(root, '.gitignore')); + ig.add(contents); + } catch { + } + cache?.set(root, ig); + return ig; +} + +function absOf(root: string, rel: string): string { + return rel === '' || rel === '.' ? root : join(root, rel); +} diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts index f6d9aca891..3e0b849637 100644 --- a/packages/kap-server/src/routes/workspaces.ts +++ b/packages/kap-server/src/routes/workspaces.ts @@ -10,6 +10,8 @@ * POST /workspaces register (idempotent on root) * PATCH /workspaces/{workspace_id} rename (display name only) * DELETE /workspaces/{workspace_id} unregister + * POST /workspaces/{workspace_id}/fs:search + * search workspace files without a session * * **Wire fidelity**: the v1 `workspaceSchema` carries more fields than v2's * `Workspace` (`{ id, root, name, createdAt, lastOpenedAt }`). The handler @@ -29,6 +31,11 @@ import { type Scope, type Workspace, } from '@moonshot-ai/agent-core-v2'; +import { + fsSearchRequestSchema, + fsSearchResponseSchema, +} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs'; +import { searchWorkspaceFiles } from '@moonshot-ai/agent-core-v2/session/sessionFs/workspaceSearch'; import { isAbsolute } from 'node:path'; import { z } from 'zod'; @@ -149,6 +156,70 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): createRoute.handler as Parameters[2], ); + const searchRoute = defineRoute( + { + method: 'POST', + path: '/workspaces/{workspace_id}/fs::search', + params: workspaceIdParamSchema, + body: fsSearchRequestSchema, + success: { data: fsSearchResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.WORKSPACE_NOT_FOUND]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + }, + description: 'Search files in a workspace before a session exists', + tags: ['workspaces'], + operationId: 'workspaceFsSearch', + }, + async (req, reply) => { + const { workspace_id } = req.params; + const ws = await core.accessor.get(IWorkspaceService).get(workspace_id); + if (ws === undefined) { + reply.send( + errEnvelope( + ErrorCode.WORKSPACE_NOT_FOUND, + `workspace ${workspace_id} does not exist`, + req.id, + ), + ); + return; + } + + const hostFs = core.accessor.get(IHostFileSystem); + try { + const stat = await hostFs.stat(ws.root); + if (!stat.isDirectory) { + reply.send( + errEnvelope( + ErrorCode.FS_PATH_NOT_FOUND, + `workspace root ${ws.root} is not a directory`, + req.id, + ), + ); + return; + } + } catch { + reply.send( + errEnvelope( + ErrorCode.FS_PATH_NOT_FOUND, + `workspace root ${ws.root} does not exist`, + req.id, + ), + ); + return; + } + + const data = await searchWorkspaceFiles(hostFs, ws.root, req.body); + reply.send(okEnvelope(data, req.id)); + }, + ); + app.post( + searchRoute.path, + searchRoute.options, + searchRoute.handler as Parameters[2], + ); + const updateRoute = defineRoute( { method: 'PATCH', diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 844b2f304b..c0a45131c5 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -376,6 +376,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/workspaces", ], + [ + "POST", + "/api/v1/workspaces/{workspace_id}/fs:search", + ], [ "PUT", "/api/v1/providers/{provider_id}", diff --git a/packages/kap-server/test/workspaces.test.ts b/packages/kap-server/test/workspaces.test.ts index bbaf4eca68..712e13bb84 100644 --- a/packages/kap-server/test/workspaces.test.ts +++ b/packages/kap-server/test/workspaces.test.ts @@ -30,6 +30,11 @@ interface ListWire { items: WorkspaceWire[]; } +interface SearchWire { + items: Array<{ path: string; name: string }>; + truncated: boolean; +} + describe('server-v2 /api/v1/workspaces', () => { let server: RunningServer | undefined; let home: string | undefined; @@ -150,6 +155,31 @@ describe('server-v2 /api/v1/workspaces', () => { expect(body.data.items.some((w) => w.id === created.body.data.id)).toBe(true); }); + it('searches workspace files before a session exists', async () => { + const root = home as string; + await writeFile(join(root, 'alpha.ts'), ''); + await writeFile(join(root, 'beta.ts'), ''); + const created = await postJson('/api/v1/workspaces', { root }); + + const { status, body } = await postJson( + `/api/v1/workspaces/${created.body.data.id}/fs:search`, + { query: 'alpha' }, + ); + + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.items.map((i) => i.path)).toContain('alpha.ts'); + expect(body.data.truncated).toBe(false); + }); + + it('maps workspace file search for an unknown workspace to WORKSPACE_NOT_FOUND', async () => { + const { body } = await postJson( + '/api/v1/workspaces/wd_missing_000000000000/fs:search', + { query: 'alpha' }, + ); + expect(body.code).toBe(40410); + }); + it('renames a workspace via PATCH', async () => { const root = home as string; const created = await postJson('/api/v1/workspaces', { root });