1
0
Эх сурвалжийг харах

fix(chat): 修复引用路径虚造与大纲目录误引用

read 工具回写真实 .md 路径;跳过目录/快照结果;统一 QM→wiki 与 cited page 解析

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 сар өмнө
parent
commit
d6102d4a2a

+ 78 - 4
src/components/chat/agent-message-metadata.spec.ts

@@ -4,6 +4,7 @@ import type { ReferenceToken } from "@/lib/reference/types"
 import {
   agentToolCallsToMessageReferences,
   getReferenceTokensForConversation,
+  normalizeReferencePath,
   setReferenceTokensForConversation,
 } from "./agent-message-metadata"
 
@@ -11,22 +12,37 @@ function toolCall(
   name: string,
   params: Record<string, unknown>,
   status: "done" | "error" = "done",
+  result = status === "done" ? "ok" : "错误",
 ): AgentRunRecord["toolCalls"][number] {
   return {
     id: `${name}-${String(params.name ?? params.path ?? params.conversationId ?? "x")}`,
     name,
     params,
-    result: status === "done" ? "ok" : "错误",
+    result,
     status,
     startedAt: 1,
     finishedAt: 2,
   }
 }
 
+describe("normalizeReferencePath", () => {
+  it("maps QM knowledge-dir segments to wiki for UI virtualization", () => {
+    expect(normalizeReferencePath("C:/Book/QM/outlines/设定/写作通则.md")).toBe(
+      "wiki/outlines/设定/写作通则.md",
+    )
+    expect(normalizeReferencePath("/Users/a/QM/outlines/章纲/第1章.md")).toBe(
+      "wiki/outlines/章纲/第1章.md",
+    )
+  })
+})
+
 describe("agentToolCallsToMessageReferences", () => {
   it("converts successful Agent read tools into assistant message references", () => {
     const references = agentToolCallsToMessageReferences([
-      toolCall("read_chapter", { name: "第一章" }),
+      toolCall("read_chapter", {
+        name: "第40章-三百人",
+        path: "/Users/omi/book/QM/chapters/第40章-三百人.md",
+      }),
       toolCall("read_outline", { path: "C:/Book/wiki/outlines/主线.md" }),
       toolCall("read_memory", { name: "主角记忆" }),
       toolCall("read_deduction", { name: "framework_1" }),
@@ -35,16 +51,74 @@ describe("agentToolCallsToMessageReferences", () => {
     ])
 
     expect(references).toEqual([
-      { title: "第一章", path: "wiki/chapters/第一章.md" },
+      { title: "第40章-三百人", path: "wiki/chapters/第40章-三百人.md" },
       { title: "主线", path: "wiki/outlines/主线.md" },
       { title: "主角记忆", path: "wiki/memory/主角记忆.md" },
       { title: "framework_1", path: ".qmai/simulations/framework_1.json" },
     ])
   })
 
+  it("does not invent a shallow chapter path from a bare chapter number name", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_chapter", { name: "第40章" }, "done", "# 第40章-三百人\n正文"),
+    ])
+    expect(references).toEqual([])
+  })
+
+  it("skips outline snapshot fallback results that are not a single file", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall(
+        "read_outline",
+        { path: "大纲/章纲" },
+        "done",
+        "未找到 wiki/outlines 下的独立大纲文件,已读取大纲快照:\n\n## outline-1.snapshot.md\n\n# 快照",
+      ),
+    ])
+    expect(references).toEqual([])
+  })
+
+  it("keeps nested outline folders from resolved absolute paths", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_outline", {
+        name: "写作通则",
+        path: "/Users/omi/book/QM/outlines/设定/写作通则.md",
+      }),
+    ])
+
+    expect(references).toEqual([
+      { title: "写作通则", path: "wiki/outlines/设定/写作通则.md" },
+    ])
+  })
+
+  it("skips directory-only outline reads such as 卷纲/章纲", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall(
+        "read_outline",
+        { name: "卷纲" },
+        "done",
+        "「卷纲」是目录,不是单个大纲。可读取以下条目:\n1. 第一卷",
+      ),
+      toolCall(
+        "read_outline",
+        { name: "章纲", path: "/Users/omi/book/QM/outlines/章纲" },
+        "done",
+        "「章纲」是目录,不是单个大纲。可读取以下条目:\n1. 第1章-分手",
+      ),
+    ])
+
+    expect(references).toEqual([])
+  })
+
+  it("does not invent a shallow outline path from a bare name", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_outline", { name: "写作通则" }, "done", "# 通则正文"),
+    ])
+    expect(references).toEqual([])
+  })
+
   it("deduplicates references by path", () => {
     const references = agentToolCallsToMessageReferences([
-      toolCall("read_chapter", { name: "第一章" }),
+      toolCall("read_chapter", { path: "C:/Book/wiki/chapters/第一章.md", name: "第一章" }),
       toolCall("read_chapter", { path: "C:/Book/wiki/chapters/第一章.md" }),
     ])
 

+ 50 - 10
src/components/chat/agent-message-metadata.ts

@@ -9,14 +9,20 @@ function stringParam(params: Record<string, unknown>, key: string): string {
   return typeof value === "string" ? value.trim() : ""
 }
 
-function normalizeReferencePath(path: string): string {
+/** Project-relative wiki path; maps legacy QM/ → wiki/ for UI virtualization. */
+export function normalizeReferencePath(path: string): string {
   const normalized = path.replace(/\\/g, "/")
   const lower = normalized.toLowerCase()
   const wikiIndex = lower.lastIndexOf("/wiki/")
-  if (wikiIndex >= 0) return normalized.slice(wikiIndex + 1)
+  const qmIndex = lower.lastIndexOf("/qm/")
+  const knowledgeIndex = Math.max(wikiIndex, qmIndex)
+  if (knowledgeIndex >= 0) {
+    const sliced = normalized.slice(knowledgeIndex + 1)
+    return sliced.replace(/^QM\//i, "wiki/")
+  }
   const qmaiIndex = lower.lastIndexOf("/.qmai/")
   if (qmaiIndex >= 0) return normalized.slice(qmaiIndex + 1)
-  return normalized.replace(/^\/+/, "")
+  return normalized.replace(/^\/+/, "").replace(/^QM\//i, "wiki/")
 }
 
 function titleFromPath(path: string): string {
@@ -24,26 +30,60 @@ function titleFromPath(path: string): string {
   return fileName.replace(/\.[^.]+$/, "")
 }
 
+function isDirectoryToolResult(result: string): boolean {
+  return /」是目录,不是单个/.test(result) || /」是目录,但目录下没有找到/.test(result)
+}
+
+function isSnapshotFallbackResult(result: string): boolean {
+  return result.includes("已读取大纲快照")
+}
+
+function isErrorToolResult(result: string): boolean {
+  return result.startsWith("错误") || result.startsWith("错误:") || result.startsWith("错误:")
+}
+
+function looksLikeMarkdownFilePath(path: string): boolean {
+  return /\.md$/i.test(path.replace(/\\/g, "/"))
+}
+
 function referenceFromReadTool(call: AgentRunRecord["toolCalls"][number]): MessageReference | null {
   if (call.status !== "done") return null
+  if (
+    isErrorToolResult(call.result)
+    || isDirectoryToolResult(call.result)
+    || isSnapshotFallbackResult(call.result)
+  ) {
+    return null
+  }
 
   const name = stringParam(call.params, "name")
   const path = stringParam(call.params, "path")
 
   switch (call.name) {
     case "read_chapter": {
-      const referencePath = path ? normalizeReferencePath(path) : name ? `wiki/chapters/${name}.md` : ""
-      if (!referencePath) return null
-      return { title: name || titleFromPath(referencePath), path: referencePath }
+      // Prefer a real .md path (tool mutates params.path to the resolved file).
+      // Never invent wiki/chapters/第40章.md — real files are usually 第40章-标题.md.
+      if (path && looksLikeMarkdownFilePath(path)) {
+        const referencePath = normalizeReferencePath(path)
+        return { title: name || titleFromPath(referencePath), path: referencePath }
+      }
+      return null
     }
     case "read_outline": {
-      const referencePath = path ? normalizeReferencePath(path) : name ? `wiki/outlines/${name}.md` : ""
-      if (!referencePath) return null
-      return { title: name || titleFromPath(referencePath), path: referencePath }
+      if (path && looksLikeMarkdownFilePath(path)) {
+        const referencePath = normalizeReferencePath(path)
+        return { title: name || titleFromPath(referencePath), path: referencePath }
+      }
+      // Bare name / folder path without a resolved .md file must not become a citation.
+      return null
     }
     case "read_memory": {
+      if (path && looksLikeMarkdownFilePath(path)) {
+        const referencePath = normalizeReferencePath(path)
+        return { title: name || titleFromPath(referencePath), path: referencePath }
+      }
       if (!name) return null
-      return { title: name, path: `wiki/memory/${name}.md` }
+      return { title: name, path: `wiki/memory/${name.replace(/\.md$/i, "")}.md` }
     }
     case "read_deduction": {
       if (!name) return null

+ 27 - 56
src/components/chat/chat-message.tsx

@@ -27,7 +27,8 @@ import {
 } from "lucide-react";
 import { useWikiStore } from "@/stores/wiki-store";
 import { readFile } from "@/commands/fs";
-import { normalizePath, getFileName } from "@/lib/path-utils";
+import { normalizePath } from "@/lib/path-utils";
+import { resolveCitedPagePath } from "@/lib/resolve-cited-page-path";
 import { refreshProjectState } from "@/lib/project-refresh";
 import { getLastQueryPages } from "@/components/chat/chat-shared";
 import { FileEditPreview } from "@/components/chat/file-edit-preview";
@@ -450,40 +451,25 @@ function CitedReferencesPanel({
     let cancelled = false;
     Promise.all(
       citedPages.map(async (page) => {
-        // Try the path verbatim first, then the same fallback set
-        // the click-handler uses below — keeps "is the file on
-        // disk" check consistent across the panel.
-        const id = getFileName(
-          page.path.replace(/^wiki\//, "").replace(/\.md$/, ""),
-        );
-        const candidates = [
-          `${pp}/${page.path}`,
-          `${pp}/wiki/entities/${id}.md`,
-          `${pp}/wiki/concepts/${id}.md`,
-          `${pp}/wiki/sources/${id}.md`,
-          `${pp}/wiki/queries/${id}.md`,
-          `${pp}/wiki/synthesis/${id}.md`,
-          `${pp}/wiki/comparisons/${id}.md`,
-          `${pp}/wiki/${id}.md`,
-        ];
-        for (const candidate of candidates) {
-          try {
-            const text = await readFile(candidate);
-            // Reset stateful regex.lastIndex by `new RegExp(...)` —
-            // module-level `g` regexes carry state across calls
-            // and would skip matches on the second invocation.
-            const re = new RegExp(CITED_IMAGE_RE.source, CITED_IMAGE_RE.flags);
-            const matches = [...text.matchAll(re)];
-            const info: CitedImageInfo = {
-              count: matches.length,
-              firstUrl: matches.length > 0 ? matches[0][1] : null,
-            };
-            return [page.path, info] as const;
-          } catch {
-            // try next candidate
-          }
+        // Same resolver the click-handler uses — nested outlines and
+        // wiki/QM aliases stay consistent across the panel.
+        const resolved = await resolveCitedPagePath(pp, page.path);
+        if (!resolved) return [page.path, { count: 0, firstUrl: null }] as const;
+        try {
+          const text = await readFile(resolved);
+          // Reset stateful regex.lastIndex by `new RegExp(...)` —
+          // module-level `g` regexes carry state across calls
+          // and would skip matches on the second invocation.
+          const re = new RegExp(CITED_IMAGE_RE.source, CITED_IMAGE_RE.flags);
+          const matches = [...text.matchAll(re)];
+          const info: CitedImageInfo = {
+            count: matches.length,
+            firstUrl: matches.length > 0 ? matches[0][1] : null,
+          };
+          return [page.path, info] as const;
+        } catch {
+          return [page.path, { count: 0, firstUrl: null }] as const;
         }
-        return [page.path, { count: 0, firstUrl: null }] as const;
       }),
     ).then((entries) => {
       if (cancelled) return;
@@ -570,29 +556,14 @@ function CitedReferencesPanel({
           const openCitedPage = async () => {
             if (!project) return;
             const pp = normalizePath(project.path);
-            const id = getFileName(
-              page.path.replace(/^wiki\//, "").replace(/\.md$/, ""),
-            );
-            const candidates = [
-              `${pp}/${page.path}`,
-              `${pp}/wiki/entities/${id}.md`,
-              `${pp}/wiki/concepts/${id}.md`,
-              `${pp}/wiki/sources/${id}.md`,
-              `${pp}/wiki/queries/${id}.md`,
-              `${pp}/wiki/synthesis/${id}.md`,
-              `${pp}/wiki/comparisons/${id}.md`,
-              `${pp}/wiki/${id}.md`,
-            ];
-            for (const candidate of candidates) {
-              try {
-                await readFile(candidate);
-                setSelectedFile(candidate);
-                return;
-              } catch {
-                // try next
-              }
+            const resolved = await resolveCitedPagePath(pp, page.path);
+            if (resolved) {
+              setSelectedFile(resolved);
+              return;
             }
-            setSelectedFile(`${pp}/${page.path}`);
+            // Do not fall back to invented shallow paths like
+            // wiki/chapters/第40章.md — they are usually wrong and only
+            // produce "No such file". Folder citations (章纲/卷纲) stay closed.
           };
           return (
             // Outer is a div, NOT a button — we have two click

+ 62 - 17
src/lib/agent/tools/read-markdown-resource.ts

@@ -15,6 +15,11 @@ interface DirectoryCandidate {
 
 export type ReadTextFile = (path: string) => Promise<string>
 
+export type ReadMarkdownResourceOutcome =
+  | { kind: "file"; content: string; resolvedPath: string }
+  | { kind: "directory"; content: string }
+  | { kind: "error"; content: string }
+
 function resolveExplicitResourcePath(baseDir: string, path: string): string | null {
   const normalizedBase = normalizePath(baseDir).replace(/\/+$/, "")
   const normalizedPath = normalizePath(path).trim()
@@ -145,12 +150,34 @@ function findDirectoryMatch(query: string, directories: DirectoryCandidate[]): D
   return directories.find((directory) => normalizeResourceName(directory.name) === normalizedQuery) ?? null
 }
 
+/**
+ * Resolve and read a markdown resource. On success, mutates `params.path` to the
+ * absolute file that was actually read so citation builders don't invent a
+ * shallow `wiki/outlines/<name>.md` path that drops nested folders (设定/章纲/…).
+ */
 export async function readMarkdownResource(
   baseDir: string,
   params: Record<string, unknown>,
   label: string,
   readTextFile: ReadTextFile = readFile,
 ): Promise<string> {
+  const outcome = await resolveMarkdownResource(baseDir, params, label, readTextFile)
+  if (outcome.kind === "file") {
+    params.path = outcome.resolvedPath
+    if (typeof params.name !== "string" || !params.name.trim()) {
+      const fileName = outcome.resolvedPath.replace(/\\/g, "/").split("/").pop() ?? ""
+      params.name = fileName.replace(/\.md$/i, "")
+    }
+  }
+  return outcome.content
+}
+
+export async function resolveMarkdownResource(
+  baseDir: string,
+  params: Record<string, unknown>,
+  label: string,
+  readTextFile: ReadTextFile = readFile,
+): Promise<ReadMarkdownResourceOutcome> {
   const name = typeof params.name === "string" ? params.name.trim() : ""
   const explicitPath = typeof params.path === "string" ? params.path.trim() : ""
   const displayName = name || explicitPath
@@ -158,46 +185,64 @@ export async function readMarkdownResource(
   if (explicitPath) {
     const resolvedPath = resolveExplicitResourcePath(baseDir, explicitPath)
     if (!resolvedPath) {
-      return `错误:无法读取${label}「${displayName}」,文件路径必须位于${label}目录内`
+      return {
+        kind: "error",
+        content: `错误:无法读取${label}「${displayName}」,文件路径必须位于${label}目录内`,
+      }
     }
     try {
-      return await readTextFile(resolvedPath)
+      const content = await readTextFile(resolvedPath)
+      return { kind: "file", content, resolvedPath }
     } catch {
-      return `错误:无法读取${label}「${displayName}」,请确认文件存在`
+      // May be a directory path, or a bare folder name with .md wrongly appended.
+      // Fall through to directory / fuzzy resolution using the basename.
     }
   }
 
-  if (!name) {
-    return `错误:缺少${label}名称或文件路径`
+  const queryName = name
+    || (explicitPath ? stripMarkdownExt(explicitPath.replace(/\\/g, "/").split("/").pop() ?? "") : "")
+  if (!queryName) {
+    return { kind: "error", content: `错误:缺少${label}名称或文件路径` }
   }
 
-  const directPath = `${baseDir}/${ensureMarkdownName(name)}`
+  const directPath = `${baseDir}/${ensureMarkdownName(queryName)}`
   try {
-    return await readTextFile(directPath)
+    const content = await readTextFile(directPath)
+    return { kind: "file", content, resolvedPath: normalizePath(directPath) }
   } catch {
     // 继续用目录候选纠错。
   }
 
   const { files, directories } = await collectMarkdownCandidates(baseDir)
-  const directoryMatch = findDirectoryMatch(name, directories)
+  const directoryMatch = findDirectoryMatch(queryName, directories)
   if (directoryMatch) {
     const nestedList = formatCandidateList(directoryMatch.children)
-    return nestedList
-      ? `「${name}」是目录,不是单个${label}。可读取以下条目:\n${nestedList}`
-      : `「${name}」是目录,但目录下没有找到可读取的 .md 条目。`
+    return {
+      kind: "directory",
+      content: nestedList
+        ? `「${queryName}」是目录,不是单个${label}。可读取以下条目:\n${nestedList}`
+        : `「${queryName}」是目录,但目录下没有找到可读取的 .md 条目。`,
+    }
   }
 
-  const singleMatch = pickSingleMatch(name, files)
+  const singleMatch = pickSingleMatch(queryName, files)
   if (singleMatch) {
     try {
-      return await readTextFile(singleMatch.path)
+      const content = await readTextFile(singleMatch.path)
+      return { kind: "file", content, resolvedPath: normalizePath(singleMatch.path) }
     } catch {
-      return `错误:已匹配到${label}「${singleMatch.name}」,但无法读取文件,请确认文件存在`
+      return {
+        kind: "error",
+        content: `错误:已匹配到${label}「${singleMatch.name}」,但无法读取文件,请确认文件存在`,
+      }
     }
   }
 
   const available = formatCandidateList(files)
-  return available
-    ? `错误:无法读取${label}「${displayName}」。可用候选:\n${available}`
-    : `错误:无法读取${label}「${displayName}」,请确认文件存在`
+  return {
+    kind: "error",
+    content: available
+      ? `错误:无法读取${label}「${displayName}」。可用候选:\n${available}`
+      : `错误:无法读取${label}「${displayName}」,请确认文件存在`,
+  }
 }

+ 8 - 3
src/lib/agent/tools/read-outline.ts

@@ -69,11 +69,16 @@ export function createReadOutlineTool(
     },
     execute: async (params) => {
       const result = await readMarkdownResource(outlinesDir, params, "大纲", readTextFile)
+      // Directory listings and successful reads keep params.path / content as-is.
       if (!result.startsWith("错误:无法读取大纲")) return result
 
-      const name = typeof params.name === "string" ? params.name : ""
-      const path = typeof params.path === "string" ? params.path : ""
-      const broadOutlineRequest = /大纲|outline/i.test(`${name} ${path}`)
+      const name = typeof params.name === "string" ? params.name.trim() : ""
+      const path = typeof params.path === "string" ? params.path.trim() : ""
+      // Only the broad "给我大纲/outline" request falls back to snapshots.
+      // Paths like 大纲/章纲 must NOT match just because they contain 大纲 —
+      // that previously turned folder clicks into snapshot citations.
+      const broadOutlineRequest = /^(大纲|outline)s?$/i.test(name)
+        || /^(大纲|outline)s?$/i.test(path)
       if (!broadOutlineRequest) return result
 
       return (await readOutlineSnapshots(outlinesDir, readTextFile)) ?? result

+ 80 - 3
src/lib/agent/tools/read-tools.spec.ts

@@ -179,14 +179,68 @@ describe("read tools", () => {
     ])
 
     const tool = createReadOutlineTool("/project/wiki/outlines")
-    const result = await tool.execute({ name: "他,只想活着-大纲" })
+    const params: Record<string, unknown> = { name: "他,只想活着-大纲" }
+    const result = await tool.execute(params)
 
     expect(result).toBe("大纲内容")
     expect(readFile).toHaveBeenCalledWith("/project/wiki/outlines/他,只想活着-大纲.md")
     expect(readFile).toHaveBeenCalledWith("/project/wiki/outlines/他只想活着大纲.md")
+    expect(params.path).toBe("/project/wiki/outlines/他只想活着大纲.md")
   })
 
-  it("read_outline falls back to outline snapshots when wiki outlines are empty", async () => {
+  it("read_outline writes the nested resolved path back into params", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/project/wiki/outlines/设定/写作通则.md") return "通则"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/project/wiki/outlines") {
+        return [{ name: "设定", path: "/project/wiki/outlines/设定", is_dir: true }]
+      }
+      if (path === "/project/wiki/outlines/设定") {
+        return [{
+          name: "写作通则.md",
+          path: "/project/wiki/outlines/设定/写作通则.md",
+          is_dir: false,
+        }]
+      }
+      return []
+    })
+
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+    const params: Record<string, unknown> = { name: "写作通则" }
+    const result = await tool.execute(params)
+
+    expect(result).toBe("通则")
+    expect(params.path).toBe("/project/wiki/outlines/设定/写作通则.md")
+  })
+
+  it("read_outline reports directories without inventing a .md path", async () => {
+    vi.mocked(readFile).mockRejectedValue(new Error("missing"))
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/project/wiki/outlines") {
+        return [{ name: "卷纲", path: "/project/wiki/outlines/卷纲", is_dir: true }]
+      }
+      if (path === "/project/wiki/outlines/卷纲") {
+        return [{
+          name: "第一卷.md",
+          path: "/project/wiki/outlines/卷纲/第一卷.md",
+          is_dir: false,
+        }]
+      }
+      return []
+    })
+
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+    const params: Record<string, unknown> = { name: "卷纲" }
+    const result = await tool.execute(params)
+
+    expect(result).toContain("是目录,不是单个大纲")
+    expect(result).toContain("第一卷")
+    expect(params.path).toBeUndefined()
+  })
+
+  it("read_outline falls back to outline snapshots only for a bare 大纲 request", async () => {
     vi.mocked(readFile).mockImplementation(async (path) => {
       if (path === "/project/.novel/snapshots/outline-312.snapshot.md") return "# 大纲快照一"
       if (path === "/project/.novel/snapshots/outline-765.snapshot.md") return "# 大纲快照二"
@@ -204,7 +258,7 @@ describe("read tools", () => {
     })
 
     const tool = createReadOutlineTool("/project/wiki/outlines")
-    const result = await tool.execute({ name: "他,只想活着-大纲" })
+    const result = await tool.execute({ name: "大纲" })
 
     expect(result).toContain("已读取大纲快照")
     expect(result).toContain("大纲快照一")
@@ -212,6 +266,29 @@ describe("read tools", () => {
     expect(result).not.toContain("请确认文件存在")
   })
 
+  it("read_outline does not treat 大纲/章纲 as a broad snapshot request", async () => {
+    vi.mocked(readFile).mockRejectedValue(new Error("missing"))
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/project/wiki/outlines") {
+        return [{ name: "章纲", path: "/project/wiki/outlines/章纲", is_dir: true }]
+      }
+      if (path === "/project/wiki/outlines/章纲") {
+        return [{
+          name: "第41章-暑假不是假期.md",
+          path: "/project/wiki/outlines/章纲/第41章-暑假不是假期.md",
+          is_dir: false,
+        }]
+      }
+      return []
+    })
+
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+    const result = await tool.execute({ path: "大纲/章纲" })
+
+    expect(result).toContain("是目录,不是单个大纲")
+    expect(result).not.toContain("已读取大纲快照")
+  })
+
   it("read_deduction reads from simulations dir", async () => {
     vi.mocked(readFile).mockResolvedValue('{"result":"sim data"}')
     const tool = createReadDeductionTool("/project/.qmai/simulations")

+ 103 - 0
src/lib/resolve-cited-page-path.spec.ts

@@ -0,0 +1,103 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { listDirectory, readFile } from "@/commands/fs"
+import { resolveCitedPagePath } from "./resolve-cited-page-path"
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+  listDirectory: vi.fn(),
+}))
+
+describe("resolveCitedPagePath", () => {
+  beforeEach(() => {
+    vi.mocked(readFile).mockReset()
+    vi.mocked(listDirectory).mockReset()
+  })
+
+  it("finds nested outline files when the citation dropped the folder", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/book/QM/outlines/设定/写作通则.md") return "# 通则"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/book/wiki/outlines" || path === "/book/QM/outlines") {
+        return [
+          { name: "设定", path: `${path}/设定`, is_dir: true },
+          { name: "卷纲", path: `${path}/卷纲`, is_dir: true },
+        ]
+      }
+      if (path.endsWith("/设定")) {
+        return [
+          { name: "写作通则.md", path: `${path}/写作通则.md`, is_dir: false },
+        ]
+      }
+      return []
+    })
+
+    await expect(
+      resolveCitedPagePath("/book", "wiki/outlines/写作通则.md"),
+    ).resolves.toBe("/book/QM/outlines/设定/写作通则.md")
+  })
+
+  it("does not treat outline folders as openable files", async () => {
+    vi.mocked(readFile).mockRejectedValue(new Error("is a directory"))
+    vi.mocked(listDirectory).mockResolvedValue([
+      { name: "第一卷.md", path: "/book/QM/outlines/卷纲/第一卷.md", is_dir: false },
+    ])
+
+    await expect(
+      resolveCitedPagePath("/book", "wiki/outlines/卷纲.md"),
+    ).resolves.toBeNull()
+    await expect(
+      resolveCitedPagePath("/book", "大纲/章纲"),
+    ).resolves.toBeNull()
+  })
+
+  it("resolves bare chapter numbers to titled chapter files", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/book/QM/chapters/第40章-三百人.md") return "# 第40章"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/book/wiki/chapters" || path === "/book/QM/chapters") {
+        return [
+          { name: "第39章-发布.md", path: `${path}/第39章-发布.md`, is_dir: false },
+          { name: "第40章-三百人.md", path: `${path}/第40章-三百人.md`, is_dir: false },
+        ]
+      }
+      return []
+    })
+
+    await expect(
+      resolveCitedPagePath("/book", "wiki/chapters/第40章.md"),
+    ).resolves.toBe("/book/QM/chapters/第40章-三百人.md")
+    await expect(
+      resolveCitedPagePath("/book", "wiki/chapters/第 40 章.md"),
+    ).resolves.toBe("/book/QM/chapters/第40章-三百人.md")
+  })
+
+  it("resolves chapter outlines nested under 章纲/", async () => {
+    vi.mocked(readFile).mockImplementation(async (path) => {
+      if (path === "/book/QM/outlines/章纲/第41章-暑假不是假期.md") return "# 章纲"
+      throw new Error("missing")
+    })
+    vi.mocked(listDirectory).mockImplementation(async (path) => {
+      if (path === "/book/wiki/outlines" || path === "/book/QM/outlines") {
+        return [{ name: "章纲", path: `${path}/章纲`, is_dir: true }]
+      }
+      if (path.endsWith("/章纲")) {
+        return [
+          {
+            name: "第41章-暑假不是假期.md",
+            path: `${path}/第41章-暑假不是假期.md`,
+            is_dir: false,
+          },
+        ]
+      }
+      return []
+    })
+
+    await expect(
+      resolveCitedPagePath("/book", "wiki/outlines/第41章.md"),
+    ).resolves.toBe("/book/QM/outlines/章纲/第41章-暑假不是假期.md")
+  })
+})

+ 201 - 0
src/lib/resolve-cited-page-path.ts

@@ -0,0 +1,201 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import { getFileName, normalizePath } from "@/lib/path-utils"
+
+/**
+ * Resolve a saved citation path to a readable markdown file on disk.
+ *
+ * Handles:
+ * - wiki/ ↔ QM/ virtualization
+ * - nested outline folders (设定/章纲/卷纲/…) when the citation dropped the folder
+ * - chapter titles with suffixes (第40章.md → 第40章-三百人.md)
+ * - spaced chapter labels (第 40 章.md)
+ * - skipping directories (卷纲/章纲 are folders, not files)
+ */
+export async function resolveCitedPagePath(
+  projectPath: string,
+  pagePath: string,
+): Promise<string | null> {
+  const pp = normalizePath(projectPath)
+  const normalizedPage = pagePath.replace(/\\/g, "/").replace(/^\/+/, "")
+  const bareId = getFileName(
+    normalizedPage
+      .replace(/^(wiki|QM)\//i, "")
+      .replace(/\.md$/i, ""),
+  )
+  const withMd = bareId.toLowerCase().endsWith(".md") ? bareId : `${bareId}.md`
+  const compactName = compactResourceName(bareId)
+
+  // Folder-only citations are never openable as files.
+  if (isKnownDirectoryCitation(normalizedPage, bareId)) return null
+
+  const candidates = [
+    `${pp}/${normalizedPage}`,
+    `${pp}/${normalizedPage.replace(/^wiki\//i, "QM/")}`,
+    `${pp}/${normalizedPage.replace(/^QM\//i, "wiki/")}`,
+    `${pp}/wiki/outlines/${withMd}`,
+    `${pp}/QM/outlines/${withMd}`,
+    `${pp}/wiki/chapters/${withMd}`,
+    `${pp}/QM/chapters/${withMd}`,
+    `${pp}/wiki/memory/${withMd}`,
+    `${pp}/QM/memory/${withMd}`,
+    `${pp}/wiki/entities/${withMd}`,
+    `${pp}/wiki/concepts/${withMd}`,
+    `${pp}/wiki/sources/${withMd}`,
+    `${pp}/wiki/queries/${withMd}`,
+    `${pp}/wiki/synthesis/${withMd}`,
+    `${pp}/wiki/comparisons/${withMd}`,
+    `${pp}/wiki/${withMd}`,
+    `${pp}/QM/${withMd}`,
+  ]
+
+  for (const candidate of unique(candidates)) {
+    if (await isReadableMarkdownFile(candidate)) return candidate
+  }
+
+  const chapterNumber = extractChapterNumber(bareId)
+  const searchRoots = [
+    `${pp}/wiki/chapters`,
+    `${pp}/QM/chapters`,
+    `${pp}/wiki/outlines`,
+    `${pp}/QM/outlines`,
+    `${pp}/wiki/memory`,
+    `${pp}/QM/memory`,
+  ]
+
+  for (const root of searchRoots) {
+    const nested = await findMarkdownByQuery(root, {
+      fileName: withMd,
+      compactName,
+      chapterNumber,
+    }, 4)
+    if (nested) return nested
+  }
+
+  return null
+}
+
+function unique(paths: string[]): string[] {
+  const seen = new Set<string>()
+  const out: string[] = []
+  for (const path of paths) {
+    const key = normalizePath(path).toLowerCase()
+    if (seen.has(key)) continue
+    seen.add(key)
+    out.push(normalizePath(path))
+  }
+  return out
+}
+
+function bareStem(fileName: string): string {
+  return fileName.replace(/\.md$/i, "")
+}
+
+function compactResourceName(value: string): string {
+  return bareStem(value)
+    .toLowerCase()
+    .replace(/[\s\-_\u2013\u2014,,、.。::;;"'“”‘’《》<>【】\[\]()(){}]/g, "")
+}
+
+function extractChapterNumber(value: string): number | null {
+  const raw = value.replace(/\.md$/i, "")
+  const patterns = [
+    /第\s*0*(\d{1,5})\s*章/,
+    /chapter[\s\-_]*0*(\d{1,5})/i,
+    /\bch[\s\-_]*0*(\d{1,5})\b/i,
+  ]
+  for (const pattern of patterns) {
+    const match = pattern.exec(raw)
+    if (match?.[1]) return Number.parseInt(match[1], 10)
+  }
+  return null
+}
+
+function isKnownDirectoryCitation(pagePath: string, bareId: string): boolean {
+  const compact = compactResourceName(bareId)
+  if (compact === "卷纲" || compact === "章纲" || compact === "设定" || compact === "总纲") {
+    // Only treat as a folder citation when the path has no .md suffix and no
+    // chapter/title remainder (e.g. 章纲/第1章.md is a file under 章纲).
+    if (/\.md$/i.test(pagePath)) return false
+    const relative = pagePath
+      .replace(/^(wiki|QM)\//i, "")
+      .replace(/^(大纲|outlines)\//i, "")
+    return relative === bareId || relative.endsWith(`/${bareId}`)
+  }
+  return false
+}
+
+async function isReadableMarkdownFile(path: string): Promise<boolean> {
+  if (!/\.md$/i.test(path)) return false
+  try {
+    await readFile(path)
+    return true
+  } catch {
+    return false
+  }
+}
+
+interface MarkdownQuery {
+  fileName: string
+  compactName: string
+  chapterNumber: number | null
+}
+
+async function findMarkdownByQuery(
+  rootDir: string,
+  query: MarkdownQuery,
+  maxDepth: number,
+  depth = 0,
+): Promise<string | null> {
+  let entries: Array<{ name: string; path: string; is_dir: boolean }>
+  try {
+    entries = await listDirectory(rootDir)
+  } catch {
+    return null
+  }
+
+  const targetName = query.fileName.toLowerCase()
+  let chapterMatch: string | null = null
+
+  for (const entry of entries) {
+    if (entry.is_dir) continue
+    if (!entry.name.toLowerCase().endsWith(".md")) continue
+
+    if (entry.name.toLowerCase() === targetName) {
+      if (await isReadableMarkdownFile(entry.path)) return normalizePath(entry.path)
+    }
+
+    const entryCompact = compactResourceName(entry.name)
+    if (query.compactName) {
+      // Exact compact match, or titled chapter/outline like 第40章-三百人.
+      const titledPrefix = entryCompact.startsWith(query.compactName)
+        && (
+          entryCompact === query.compactName
+          || entry.name.replace(/\.md$/i, "").includes(`${bareStem(query.fileName)}-`)
+          || entry.name.replace(/\.md$/i, "").includes(`${bareStem(query.fileName)}—`)
+        )
+      if (entryCompact === query.compactName || titledPrefix) {
+        if (await isReadableMarkdownFile(entry.path)) return normalizePath(entry.path)
+      }
+    }
+
+    if (query.chapterNumber !== null && chapterMatch === null) {
+      const entryChapter = extractChapterNumber(entry.name)
+      if (entryChapter === query.chapterNumber) {
+        chapterMatch = entry.path
+      }
+    }
+  }
+
+  if (chapterMatch && await isReadableMarkdownFile(chapterMatch)) {
+    return normalizePath(chapterMatch)
+  }
+
+  if (depth >= maxDepth) return null
+
+  for (const entry of entries) {
+    if (!entry.is_dir) continue
+    const nested = await findMarkdownByQuery(entry.path, query, maxDepth, depth + 1)
+    if (nested) return nested
+  }
+  return null
+}