Forráskód Böngészése

fix(reference): stabilize chat input and outline mentions

Mochocyang 2 hónapja
szülő
commit
ddd52d8a38

+ 19 - 1
agent-duihua-分支说明.md

@@ -7,13 +7,21 @@
 ## 使用要求
 ## 使用要求
 
 
 1. 不合并到 main,等待用户完成全面软件测试后再决定合并。
 1. 不合并到 main,等待用户完成全面软件测试后再决定合并。
-2. 本分支优先处理 AI 会话面板;AI 大纲不在本轮强接入范围内
+2. 本分支优先处理 AI 会话面板,并按本轮反馈补齐 AI 大纲 @ 引用输入能力
 3. 保留旧 `ChatInput` 组件源码,不删除不重构无关逻辑。
 3. 保留旧 `ChatInput` 组件源码,不删除不重构无关逻辑。
 4. 面向用户的提示语保持中文。
 4. 面向用户的提示语保持中文。
 5. 修改完成后必须完成源码启动验证、旧功能测试、构建和便携版打包验证。
 5. 修改完成后必须完成源码启动验证、旧功能测试、构建和便携版打包验证。
 
 
 ## 本次更新
 ## 本次更新
 
 
+### 20260701-111236
+
+- 修复 AI 会话输入框重复输入、Enter 无法发送和二次删除触发 `removeChild` 异常的问题:引用输入改为 textarea 承载文本,引用 Chip 单独渲染。
+- 弱化 AI 会话输入框 placeholder 字体颜色,避免与用户实际输入内容混淆。
+- 删除 @ 引用弹窗左侧分类 emoji 图标,仅保留中文分类名,降低 AI 味。
+- @ 引用列表主标题改为读取文件内容中的中文标题:章节、记忆、大纲优先使用 Markdown frontmatter `title` 或一级标题;推演室优先使用框架标题、绑定标题或推荐分支标题。
+- AI 大纲接入同一套 @ 引用输入和引用弹窗,发送时会把已选引用内容读取后加入大纲生成上下文。
+
 ### 20260701-103906
 ### 20260701-103906
 
 
 - 修复 AI 会话模型选择器位置回归:模型选择器已回到输入区右下角,靠近发送按钮。
 - 修复 AI 会话模型选择器位置回归:模型选择器已回到输入区右下角,靠近发送按钮。
@@ -42,6 +50,15 @@
 
 
 ## 验证记录
 ## 验证记录
 
 
+- 20260701-111236:
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/components/sources/outline-chat-panel.spec.tsx`:4 个测试文件、26 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/reference/ReferenceChip.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/lib/reference/resolve.spec.ts src/stores/chat-store.test.ts src/components/layout/sidebar-panel.outline-tree.test.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts src/components/sources/outline-chat-panel.spec.tsx`:10 个测试文件、59 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/knowledge-tree.long-press.test.tsx src/components/layout/chat-layout.test.ts src/components/sources/outline-chat-panel.spec.tsx src/lib/reference/resolve.spec.ts`:6 个测试文件、48 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2112 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 通过 Job 验证,Vite ready 后已停止。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
 - 20260701-103906:
 - 20260701-103906:
   - `npm.cmd exec -- vitest run src/components/reference/ReferenceChip.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/stores/chat-store.test.ts src/components/layout/sidebar-panel.outline-tree.test.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts`:8 个测试文件、45 个用例通过。
   - `npm.cmd exec -- vitest run src/components/reference/ReferenceChip.spec.tsx src/components/reference/ReferenceInput.spec.tsx src/components/reference/ReferencePickerDialog.spec.tsx src/lib/reference/providers.spec.ts src/stores/chat-store.test.ts src/components/layout/sidebar-panel.outline-tree.test.tsx src/components/chat/chat-panel.spec.tsx src/lib/agent/tools/read-tools.spec.ts`:8 个测试文件、45 个用例通过。
   - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/knowledge-tree.long-press.test.tsx src/components/layout/chat-layout.test.ts src/components/sources/outline-chat-panel.spec.tsx src/lib/reference/resolve.spec.ts`:6 个测试文件、46 个用例通过。
   - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-message.spec.tsx src/components/layout/knowledge-tree.long-press.test.tsx src/components/layout/chat-layout.test.ts src/components/sources/outline-chat-panel.spec.tsx src/lib/reference/resolve.spec.ts`:6 个测试文件、46 个用例通过。
@@ -69,4 +86,5 @@
 - 20260701-092043 更新已提交,提交号 `6b08275`。
 - 20260701-092043 更新已提交,提交号 `6b08275`。
 - 20260701-095256 优化纳入本次提交。
 - 20260701-095256 优化纳入本次提交。
 - 20260701-103906 回归修复和 @ UI 优化纳入本次提交。
 - 20260701-103906 回归修复和 @ UI 优化纳入本次提交。
+- 20260701-111236 输入框稳定性、中文标题和 AI 大纲 @ 引用修复纳入本次提交。
 - 不合并 main。
 - 不合并 main。

+ 71 - 7
src/components/reference/ReferenceInput.spec.tsx

@@ -17,9 +17,13 @@ const token: ReferenceToken = {
   path: "C:/Novel/wiki/chapters/第一章.md",
   path: "C:/Novel/wiki/chapters/第一章.md",
 }
 }
 
 
-function dispatchInput(element: HTMLElement, text: string) {
-  element.textContent = text
-  element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }))
+function dispatchTextareaInput(element: HTMLTextAreaElement, text: string) {
+  const valueSetter = Object.getOwnPropertyDescriptor(
+    window.HTMLTextAreaElement.prototype,
+    "value",
+  )?.set
+  valueSetter?.call(element, text)
+  element.dispatchEvent(new Event("input", { bubbles: true }))
 }
 }
 
 
 beforeEach(() => {
 beforeEach(() => {
@@ -49,7 +53,7 @@ describe("ReferenceInput", () => {
       )
       )
     })
     })
 
 
-    const editor = host.querySelector("[contenteditable='true']") as HTMLElement
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
     await act(async () => {
     await act(async () => {
       editor.dispatchEvent(new KeyboardEvent("keydown", { key: "@", bubbles: true, cancelable: true }))
       editor.dispatchEvent(new KeyboardEvent("keydown", { key: "@", bubbles: true, cancelable: true }))
     })
     })
@@ -60,6 +64,32 @@ describe("ReferenceInput", () => {
     expect(onAtTrigger).toHaveBeenCalledTimes(2)
     expect(onAtTrigger).toHaveBeenCalledTimes(2)
   })
   })
 
 
+  it("uses a controlled textarea editor instead of contentEditable DOM mixing", async () => {
+    const onChange = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          value="侧例cec"
+          tokens={[token]}
+          onChange={onChange}
+          onSubmit={vi.fn()}
+        />,
+      )
+    })
+
+    expect(host.querySelector("[contenteditable='true']")).toBeNull()
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    expect(editor.value).toBe("侧例cec")
+    expect(host.textContent?.match(/侧例cec/g)).toHaveLength(1)
+
+    await act(async () => {
+      dispatchTextareaInput(editor, "侧例cec2")
+    })
+
+    expect(onChange).toHaveBeenLastCalledWith("侧例cec2", [token])
+  })
+
   it("keeps the resize handle and footer controls beside the send button", async () => {
   it("keeps the resize handle and footer controls beside the send button", async () => {
     await act(async () => {
     await act(async () => {
       root.render(
       root.render(
@@ -113,7 +143,7 @@ describe("ReferenceInput", () => {
     const send = host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")
     const send = host.querySelector<HTMLButtonElement>("[aria-label='发送消息']")
     expect(send?.disabled).toBe(true)
     expect(send?.disabled).toBe(true)
 
 
-    const editor = host.querySelector("[contenteditable='true']") as HTMLElement
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
     await act(async () => {
     await act(async () => {
       editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
       editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
     })
     })
@@ -128,9 +158,9 @@ describe("ReferenceInput", () => {
       root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
       root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
     })
     })
 
 
-    const editor = host.querySelector("[contenteditable='true']") as HTMLElement
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
     await act(async () => {
     await act(async () => {
-      dispatchInput(editor, "请参考这章")
+      dispatchTextareaInput(editor, "请参考这章")
     })
     })
 
 
     await act(async () => {
     await act(async () => {
@@ -140,6 +170,40 @@ describe("ReferenceInput", () => {
     expect(onSubmit).toHaveBeenCalledWith("请参考这章", [token])
     expect(onSubmit).toHaveBeenCalledWith("请参考这章", [token])
   })
   })
 
 
+  it("submits plain text with Enter", async () => {
+    const onSubmit = vi.fn()
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[token]} onSubmit={onSubmit} />)
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    await act(async () => {
+      dispatchTextareaInput(editor, "发送这一句")
+    })
+    await act(async () => {
+      editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
+    })
+
+    expect(onSubmit).toHaveBeenCalledWith("发送这一句", [token])
+  })
+
+  it("uses a weaker placeholder color than normal input text", async () => {
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          placeholder="请输入写作需求"
+        />,
+      )
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    expect(editor.placeholder).toBe("请输入写作需求")
+    expect(editor.className).toContain("placeholder:text-muted-foreground/60")
+  })
+
   it("supports inserting and removing tokens through callbacks", async () => {
   it("supports inserting and removing tokens through callbacks", async () => {
     const inserted: ReferenceToken = {
     const inserted: ReferenceToken = {
       ...token,
       ...token,

+ 23 - 36
src/components/reference/ReferenceInput.tsx

@@ -51,21 +51,6 @@ function saveInputHeight(height: number) {
   localStorage.setItem(REFERENCE_INPUT_HEIGHT_KEY, String(clampInputHeight(height)))
   localStorage.setItem(REFERENCE_INPUT_HEIGHT_KEY, String(clampInputHeight(height)))
 }
 }
 
 
-function extractPlainText(editor: HTMLDivElement | null): string {
-  if (!editor) return ""
-  return Array.from(editor.childNodes)
-    .filter((node) => {
-      if (node.nodeType === Node.TEXT_NODE) return true
-      if (node instanceof HTMLElement) {
-        return !node.hasAttribute("data-reference-id")
-      }
-      return false
-    })
-    .map((node) => node.textContent ?? "")
-    .join("")
-    .replace(/\u00a0/g, " ")
-}
-
 export function ReferenceInput({
 export function ReferenceInput({
   value,
   value,
   tokens,
   tokens,
@@ -80,7 +65,7 @@ export function ReferenceInput({
   onAtTrigger,
   onAtTrigger,
   insertTokensRef,
   insertTokensRef,
 }: ReferenceInputProps) {
 }: ReferenceInputProps) {
-  const editorRef = useRef<HTMLDivElement>(null)
+  const textareaRef = useRef<HTMLTextAreaElement>(null)
   const isControlled = value !== undefined
   const isControlled = value !== undefined
   const [draft, setDraft] = useState("")
   const [draft, setDraft] = useState("")
   const [inputHeight, setInputHeight] = useState(loadSavedInputHeight)
   const [inputHeight, setInputHeight] = useState(loadSavedInputHeight)
@@ -109,16 +94,19 @@ export function ReferenceInput({
     insertTokensRef.current = (nextTokens) => {
     insertTokensRef.current = (nextTokens) => {
       if (nextTokens.length === 0) return
       if (nextTokens.length === 0) return
       updateTokens([...tokens, ...nextTokens])
       updateTokens([...tokens, ...nextTokens])
-      editorRef.current?.focus()
+      textareaRef.current?.focus()
     }
     }
     return () => {
     return () => {
       insertTokensRef.current = null
       insertTokensRef.current = null
     }
     }
   }, [insertTokensRef, tokens, updateTokens])
   }, [insertTokensRef, tokens, updateTokens])
 
 
-  const handleInput = useCallback(() => {
-    notifyChange(extractPlainText(editorRef.current), tokens)
-  }, [notifyChange, tokens])
+  const handleTextareaChange = useCallback(
+    (event: React.ChangeEvent<HTMLTextAreaElement>) => {
+      notifyChange(event.target.value, tokens)
+    },
+    [notifyChange, tokens],
+  )
 
 
   const handleRemoveToken = useCallback(
   const handleRemoveToken = useCallback(
     (id: string) => {
     (id: string) => {
@@ -134,7 +122,7 @@ export function ReferenceInput({
   }, [inputDisabled, onSubmit, text, tokens])
   }, [inputDisabled, onSubmit, text, tokens])
 
 
   const handleKeyDown = useCallback(
   const handleKeyDown = useCallback(
-    (event: React.KeyboardEvent<HTMLDivElement>) => {
+    (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
       if (isImeComposing(event)) return
       if (isImeComposing(event)) return
 
 
       if (event.key === "@" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
       if (event.key === "@" && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
@@ -223,24 +211,23 @@ export function ReferenceInput({
       </div>
       </div>
 
 
       <div className="relative px-3 py-2">
       <div className="relative px-3 py-2">
-        <div
-          ref={editorRef}
-          className="overflow-y-auto whitespace-pre-wrap break-words text-sm outline-none disabled:cursor-not-allowed"
+        {tokens.length > 0 ? (
+          <div className="mb-2 flex flex-wrap gap-1">
+            {renderedTokens}
+          </div>
+        ) : null}
+        <textarea
+          ref={textareaRef}
+          value={text}
+          className="w-full resize-none overflow-y-auto bg-transparent px-0 py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50"
           style={{ height: inputHeight, maxHeight: inputHeight }}
           style={{ height: inputHeight, maxHeight: inputHeight }}
-          contentEditable={!inputDisabled}
-          suppressContentEditableWarning
-          onInput={handleInput}
+          placeholder={placeholder}
+          disabled={inputDisabled}
+          rows={1}
+          onChange={handleTextareaChange}
           onKeyDown={handleKeyDown}
           onKeyDown={handleKeyDown}
           aria-label="引用输入框"
           aria-label="引用输入框"
-        >
-          {renderedTokens}
-          {text}
-        </div>
-        {!text.trim() && tokens.length === 0 && (
-          <div className="pointer-events-none absolute left-3 top-2 text-sm text-muted-foreground">
-            {placeholder}
-          </div>
-        )}
+        />
       </div>
       </div>
 
 
       <div
       <div

+ 33 - 0
src/components/reference/ReferencePickerDialog.spec.tsx

@@ -21,6 +21,13 @@ function makeToken(index: number): ReferenceToken {
 }
 }
 
 
 const chapterItems = Array.from({ length: 11 }, (_, index) => makeToken(index + 1))
 const chapterItems = Array.from({ length: 11 }, (_, index) => makeToken(index + 1))
+const technicalChapterItem: ReferenceToken = {
+  id: "ref-technical",
+  category: "chapter",
+  title: "1/chapter-010",
+  displayTitle: "第10章-灯下旧影",
+  path: "C:/Novel/wiki/chapters/第10章-灯下旧影.md",
+}
 
 
 const providers: ReferenceProvider[] = [
 const providers: ReferenceProvider[] = [
   {
   {
@@ -147,6 +154,32 @@ describe("ReferencePickerDialog", () => {
     expect(host.textContent).toContain("章节")
     expect(host.textContent).toContain("章节")
     expect(host.textContent).toContain("记忆库")
     expect(host.textContent).toContain("记忆库")
     expect(host.textContent).toContain("已选 0/10")
     expect(host.textContent).toContain("已选 0/10")
+    expect(host.textContent).not.toMatch(/[📄🧠📋🔬⚡💬📝]/u)
+  })
+
+  it("uses the display title as the visible item name instead of technical file names", async () => {
+    const technicalProviders: ReferenceProvider[] = [
+      {
+        category: "chapter",
+        fetchItems: vi.fn(async () => [technicalChapterItem]),
+      },
+    ]
+
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={technicalProviders}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+    await flush()
+
+    expect(host.textContent).toContain("第10章-灯下旧影")
+    expect(host.textContent).not.toContain("1/chapter-010")
   })
   })
 
 
   it("does not select more than the maximum reference count", async () => {
   it("does not select more than the maximum reference count", async () => {

+ 1 - 2
src/components/reference/ReferencePickerDialog.tsx

@@ -128,7 +128,6 @@ export function ReferencePickerDialog({
                   setSearch("")
                   setSearch("")
                 }}
                 }}
               >
               >
-                <span className="w-5 text-center" aria-hidden="true">{tab.icon}</span>
                 <span className="truncate">{tab.label}</span>
                 <span className="truncate">{tab.label}</span>
               </button>
               </button>
             ))}
             ))}
@@ -175,7 +174,7 @@ export function ReferencePickerDialog({
                           className="h-4 w-4 accent-primary"
                           className="h-4 w-4 accent-primary"
                         />
                         />
                         <span className="min-w-0 flex-1">
                         <span className="min-w-0 flex-1">
-                          <span className="block truncate font-medium">{item.title}</span>
+                          <span className="block truncate font-medium">{item.displayTitle || item.title}</span>
                           {item.path ? (
                           {item.path ? (
                             <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.path}</span>
                             <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.path}</span>
                           ) : null}
                           ) : null}

+ 18 - 3
src/components/sources/outline-chat-panel.spec.tsx

@@ -13,10 +13,19 @@ describe("OutlineChatPanel controls", () => {
     expect(source).not.toContain("text-emerald-700")
     expect(source).not.toContain("text-emerald-700")
   })
   })
 
 
-  it("moves dock controls into the bottom left toolbar before outline generation and model selection", () => {
-    expect(source).toContain("bottomLeftControls={")
+  it("uses the shared reference input and picker for @ references", () => {
+    expect(source).toContain("ReferenceInput")
+    expect(source).toContain("ReferencePickerDialog")
+    expect(source).toContain("InsertReferenceTokens")
+    expect(source).toContain("outlineReferenceTokens")
+    expect(source).toContain("onAtTrigger={() => setReferencePickerOpen(true)}")
+    expect(source).toContain("onSubmit={handleSend}")
+    expect(source).not.toContain("<ChatInput")
+    expect(source).not.toContain('from "@/components/chat/chat-input"')
+  })
+
+  it("keeps dock controls before outline generation and model selection around the reference input", () => {
     expect(source).toContain("qmai-outline-bottom-left-controls")
     expect(source).toContain("qmai-outline-bottom-left-controls")
-    expect(source).not.toContain("leftControls={")
     expect(source).toContain("<ChatDockControls />")
     expect(source).toContain("<ChatDockControls />")
     expect(source).toContain("<OutlineGenerationMenu")
     expect(source).toContain("<OutlineGenerationMenu")
     expect(source).toContain("<ChatModelSelector")
     expect(source).toContain("<ChatModelSelector")
@@ -39,4 +48,10 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("onGenerate(config.title, config.requestHint)")
     expect(source).toContain("onGenerate(config.title, config.requestHint)")
     expect(source).toContain("onGenerate={handleGenerateSection}")
     expect(source).toContain("onGenerate={handleGenerateSection}")
   })
   })
+
+  it("adds selected references to the outline model context instead of only storing chips", () => {
+    expect(source).toContain("loadReferenceTokenContext")
+    expect(source).toContain("本次 @ 引用内容")
+    expect(source).toContain("outlineSources = [...outlineSources, ...referenceContext.sources]")
+  })
 })
 })

+ 120 - 18
src/components/sources/outline-chat-panel.tsx

@@ -17,8 +17,19 @@ import { resolveUserVisibleReasoning } from "@/lib/user-visible-reasoning"
 import { runDeepOutlineGeneration } from "@/lib/novel/deep-outline-generation"
 import { runDeepOutlineGeneration } from "@/lib/novel/deep-outline-generation"
 import { resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
 import { resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
 import { createDeepThinkingStreamRenderer } from "@/lib/deep-thinking-stream"
 import { createDeepThinkingStreamRenderer } from "@/lib/deep-thinking-stream"
-import { ChatInput } from "@/components/chat/chat-input"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
+import { ReferenceInput, type InsertReferenceTokens } from "@/components/reference/ReferenceInput"
+import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
+import {
+  chapterProvider,
+  createChatHistoryProvider,
+  createOutlineHistoryProvider,
+  deductionProvider,
+  memoryProvider,
+  outlineProvider,
+} from "@/lib/reference/providers"
+import type { ReferenceToken } from "@/lib/reference/types"
+import { useChatStore } from "@/stores/chat-store"
 import {
 import {
   buildWebResearchContext,
   buildWebResearchContext,
   collectWebResearch,
   collectWebResearch,
@@ -62,6 +73,53 @@ async function loadOutlineContext(projectPath: string): Promise<{ context: strin
   return { context: sections.join("\n\n---\n\n"), sources }
   return { context: sections.join("\n\n---\n\n"), sources }
 }
 }
 
 
+function referenceCategoryLabel(category: ReferenceToken["category"]): string {
+  switch (category) {
+    case "chapter":
+      return "章节"
+    case "memory":
+      return "记忆"
+    case "outline":
+      return "大纲"
+    case "deduction":
+      return "推演"
+    case "chat_history":
+      return "AI会话"
+    case "outline_history":
+      return "AI大纲"
+    case "skill":
+      return "技能"
+    default:
+      return "引用"
+  }
+}
+
+async function loadReferenceTokenContext(tokens: ReferenceToken[]): Promise<{ context: string; sources: string[] }> {
+  if (tokens.length === 0) return { context: "", sources: [] }
+
+  const sections: string[] = ["## 本次 @ 引用内容"]
+  const sources: string[] = []
+  for (const token of tokens) {
+    const label = referenceCategoryLabel(token.category)
+    const title = token.title || token.displayTitle
+    sources.push(`@${label}: ${title}`)
+    if (!token.path) {
+      sections.push(`【${label}:${title}】\n该引用没有可直接读取的文件路径。`)
+      continue
+    }
+
+    try {
+      const content = await readFile(token.path)
+      const trimmed = content.length > 3000 ? `${content.slice(0, 3000)}\n...(已截断)` : content
+      sections.push(`【${label}:${title}】\n路径:${token.path}\n\n${trimmed}`)
+    } catch {
+      sections.push(`【${label}:${title}】\n路径:${token.path}\n\n读取失败,已保留引用来源。`)
+    }
+  }
+
+  return { context: sections.join("\n\n---\n\n"), sources }
+}
+
 async function getUniqueOutlinePath(outlinesDir: string, title: string): Promise<string> {
 async function getUniqueOutlinePath(outlinesDir: string, title: string): Promise<string> {
   const fileName = `${title}.md`
   const fileName = `${title}.md`
   const firstPath = `${outlinesDir}/${fileName}`
   const firstPath = `${outlinesDir}/${fileName}`
@@ -279,6 +337,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const novelConfig = useWikiStore((s) => s.novelConfig)
   const novelConfig = useWikiStore((s) => s.novelConfig)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const chatConversations = useChatStore((s) => s.conversations)
 
 
   const conversations = useOutlineChatStore((s) => s.conversations)
   const conversations = useOutlineChatStore((s) => s.conversations)
   const activeConversationId = useOutlineChatStore((s) => s.activeConversationId)
   const activeConversationId = useOutlineChatStore((s) => s.activeConversationId)
@@ -315,7 +374,26 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [providerConfigs])
   }, [providerConfigs])
 
 
   const [inputValue, setInputValue] = useState("")
   const [inputValue, setInputValue] = useState("")
+  const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<ReferenceToken[]>([])
+  const [referencePickerOpen, setReferencePickerOpen] = useState(false)
   const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "")
   const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "")
+  const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null)
+
+  const referenceProviders = useMemo(
+    () => [
+      chapterProvider,
+      memoryProvider,
+      outlineProvider,
+      deductionProvider,
+      createChatHistoryProvider(() =>
+        chatConversations.map((conversation) => ({ id: conversation.id, title: conversation.title })),
+      ),
+      createOutlineHistoryProvider(() =>
+        conversations.map((conversation) => ({ id: conversation.id, title: conversation.title })),
+      ),
+    ],
+    [chatConversations, conversations],
+  )
 
 
   // 加载持久化的历史记录
   // 加载持久化的历史记录
   useEffect(() => {
   useEffect(() => {
@@ -362,9 +440,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     return () => container.removeEventListener("scroll", handleScroll)
     return () => container.removeEventListener("scroll", handleScroll)
   }, [])
   }, [])
 
 
-  const handleSend = useCallback(async (inputText: string) => {
+  const handleSend = useCallback(async (inputText: string, tokens: ReferenceToken[] = []) => {
     const prompt = inputText.trim()
     const prompt = inputText.trim()
     if (!prompt || !project || isStreaming) return
     if (!prompt || !project || isStreaming) return
+    setInputValue("")
+    setOutlineReferenceTokens([])
     let effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
     let effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
     if (activeConv?.modelId) {
     if (activeConv?.modelId) {
       effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
       effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
@@ -390,6 +470,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const { context, sources } = await loadOutlineContext(project.path)
       const { context, sources } = await loadOutlineContext(project.path)
       let outlineContext = context
       let outlineContext = context
       let outlineSources = [...sources]
       let outlineSources = [...sources]
+      const referenceContext = await loadReferenceTokenContext(tokens)
+      if (referenceContext.context.trim()) {
+        outlineContext = [outlineContext, referenceContext.context].filter(Boolean).join("\n\n---\n\n")
+      }
+      outlineSources = [...outlineSources, ...referenceContext.sources]
       if (shouldUseWebResearch(prompt)) {
       if (shouldUseWebResearch(prompt)) {
         const webResearch = await collectWebResearch({
         const webResearch = await collectWebResearch({
           text: prompt,
           text: prompt,
@@ -721,25 +806,32 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       </div>
       </div>
 
 
       {/* Input */}
       {/* Input */}
-      <div className="shrink-0">
-        <ChatInput
-          onSend={(text) => void handleSend(text)}
+      <div className="shrink-0 border-t px-3 py-2">
+        <div className="mb-2 flex items-center justify-between gap-2">
+          <TooltipProvider delay={200}>
+            <div className="qmai-outline-bottom-left-controls flex min-w-0 items-center gap-2">
+              <ChatDockControls />
+              <OutlineGenerationMenu
+                disabled={isStreaming}
+                onGenerate={handleGenerateSection}
+              />
+            </div>
+          </TooltipProvider>
+        </div>
+        <ReferenceInput
+          value={inputValue}
+          tokens={outlineReferenceTokens}
           onStop={handleStop}
           onStop={handleStop}
           isStreaming={isStreaming}
           isStreaming={isStreaming}
           placeholder="输入关于大纲的问题..."
           placeholder="输入关于大纲的问题..."
-          value={inputValue}
-          onChange={setInputValue}
-          bottomLeftControls={
-            <TooltipProvider delay={200}>
-              <div className="qmai-outline-bottom-left-controls flex min-w-0 items-center gap-2">
-                <ChatDockControls />
-                <OutlineGenerationMenu
-                  disabled={isStreaming}
-                  onGenerate={handleGenerateSection}
-                />
-              </div>
-            </TooltipProvider>
-          }
+          onChange={(text, tokens) => {
+            setInputValue(text)
+            setOutlineReferenceTokens(tokens)
+          }}
+          onTokensChange={setOutlineReferenceTokens}
+          onSubmit={handleSend}
+          onAtTrigger={() => setReferencePickerOpen(true)}
+          insertTokensRef={insertReferenceTokensRef}
           rightControls={
           rightControls={
             hasAvailableModels ? (
             hasAvailableModels ? (
               <ChatModelSelector
               <ChatModelSelector
@@ -759,6 +851,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             )
             )
           }
           }
         />
         />
+        <ReferencePickerDialog
+          open={referencePickerOpen}
+          providers={referenceProviders}
+          projectPath={project?.path ? normalizePath(project.path) : ""}
+          onConfirm={(tokens) => {
+            insertReferenceTokensRef.current?.(tokens)
+            setReferencePickerOpen(false)
+          }}
+          onClose={() => setReferencePickerOpen(false)}
+        />
       </div>
       </div>
     </div>
     </div>
   )
   )

+ 46 - 3
src/lib/reference/providers.spec.ts

@@ -11,10 +11,12 @@ import {
 
 
 const mocks = vi.hoisted(() => ({
 const mocks = vi.hoisted(() => ({
   listDirectory: vi.fn(),
   listDirectory: vi.fn(),
+  readFile: vi.fn(),
 }))
 }))
 
 
 vi.mock("@/commands/fs", () => ({
 vi.mock("@/commands/fs", () => ({
   listDirectory: mocks.listDirectory,
   listDirectory: mocks.listDirectory,
+  readFile: mocks.readFile,
 }))
 }))
 
 
 type MockNode = {
 type MockNode = {
@@ -35,6 +37,7 @@ function dir(name: string, path = name, children: MockNode[] = []): MockNode {
 describe("reference providers", () => {
 describe("reference providers", () => {
   beforeEach(() => {
   beforeEach(() => {
     mocks.listDirectory.mockReset()
     mocks.listDirectory.mockReset()
+    mocks.readFile.mockReset()
   })
   })
 
 
   it("loads chapter markdown files as reference tokens", async () => {
   it("loads chapter markdown files as reference tokens", async () => {
@@ -58,6 +61,38 @@ describe("reference providers", () => {
     expect(result[0].id).toEqual(expect.any(String))
     expect(result[0].id).toEqual(expect.any(String))
   })
   })
 
 
+  it("uses markdown frontmatter and headings as Chinese display titles", async () => {
+    mocks.listDirectory
+      .mockResolvedValueOnce([
+        dir("1", "C:/Novel/wiki/chapters/1", [
+          file("chapter-010.md", "C:/Novel/wiki/chapters/1/chapter-010.md"),
+        ]),
+      ])
+      .mockResolvedValueOnce([
+        file("canon-facts.md", "C:/Novel/wiki/memory/canon-facts.md"),
+      ])
+    mocks.readFile
+      .mockResolvedValueOnce("---\ntitle: \"第10章-灯下旧影\"\n---\n\n正文")
+      .mockResolvedValueOnce("# 事实记忆\n\n人物与规则")
+
+    await expect(chapterProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      {
+        category: "chapter",
+        title: "第10章-灯下旧影",
+        displayTitle: "第10章-灯下旧影",
+        path: "C:/Novel/wiki/chapters/1/chapter-010.md",
+      },
+    ])
+    await expect(memoryProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      {
+        category: "memory",
+        title: "事实记忆",
+        displayTitle: "事实记忆",
+        path: "C:/Novel/wiki/memory/canon-facts.md",
+      },
+    ])
+  })
+
   it("loads memory, outline, and deduction files from their project folders", async () => {
   it("loads memory, outline, and deduction files from their project folders", async () => {
     mocks.listDirectory
     mocks.listDirectory
       .mockResolvedValueOnce([file("人物.md")])
       .mockResolvedValueOnce([file("人物.md")])
@@ -94,7 +129,7 @@ describe("reference providers", () => {
     ])
     ])
     await expect(memoryProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
     await expect(memoryProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
       { category: "memory", title: "角色/主角", path: "C:/Novel/wiki/memory/角色/主角.md" },
       { category: "memory", title: "角色/主角", path: "C:/Novel/wiki/memory/角色/主角.md" },
-      { category: "memory", title: "canon-facts", path: "C:/Novel/wiki/memory/canon-facts.md" },
+      { category: "memory", title: "记忆条目", path: "C:/Novel/wiki/memory/canon-facts.md" },
     ])
     ])
   })
   })
 
 
@@ -109,10 +144,18 @@ describe("reference providers", () => {
         ]),
         ]),
       ]),
       ]),
     ])
     ])
+    mocks.readFile
+      .mockResolvedValueOnce("---\ntitle: \"旧城坠星框架\"\nshortTitle: \"坠星框架\"\n---\n\n# 旧城坠星框架")
+      .mockResolvedValueOnce(JSON.stringify({
+        report: {
+          createdAt: "2026-07-01T10:00:00.000Z",
+          branches: [{ title: "桥下追逃", recommendation: true }],
+        },
+      }))
 
 
     await expect(deductionProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
     await expect(deductionProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
-      { category: "deduction", title: "frameworks/主线框架", path: "C:/Novel/.qmai/simulations/frameworks/主线框架.md" },
-      { category: "deduction", title: "results/framework-1/result-1", path: "C:/Novel/.qmai/simulations/results/framework-1/result-1.json" },
+      { category: "deduction", title: "旧城坠星框架", path: "C:/Novel/.qmai/simulations/frameworks/主线框架.md" },
+      { category: "deduction", title: "推演结果:桥下追逃", path: "C:/Novel/.qmai/simulations/results/framework-1/result-1.json" },
     ])
     ])
   })
   })
 
 

+ 132 - 7
src/lib/reference/providers.ts

@@ -1,4 +1,4 @@
-import { listDirectory } from "@/commands/fs"
+import { listDirectory, readFile } from "@/commands/fs"
 import type { ReferenceCategory, ReferenceToken } from "./types"
 import type { ReferenceCategory, ReferenceToken } from "./types"
 
 
 type ReferenceFileNode = {
 type ReferenceFileNode = {
@@ -46,6 +46,128 @@ function stripExtension(name: string, extensions: string[]): string {
   return extension ? name.slice(0, -extension.length) : name
   return extension ? name.slice(0, -extension.length) : name
 }
 }
 
 
+function unquoteYamlValue(value: string): string {
+  return value.trim().replace(/^["']|["']$/g, "").trim()
+}
+
+function extractFrontmatterField(content: string, field: string): string | null {
+  const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+  if (!frontmatterMatch?.[1]) return null
+  const fieldPattern = new RegExp(`^${field}:\\s*(.+?)\\s*$`, "m")
+  const fieldMatch = frontmatterMatch[1].match(fieldPattern)
+  return fieldMatch?.[1] ? unquoteYamlValue(fieldMatch[1]) : null
+}
+
+function extractMarkdownHeading(content: string): string | null {
+  const headingMatch = content.match(/^#\s+(.+?)\s*$/m)
+  return headingMatch?.[1]?.trim() || null
+}
+
+function extractMarkdownTitle(content: string): string | null {
+  return extractFrontmatterField(content, "title") || extractMarkdownHeading(content)
+}
+
+function extractSimulationMarkdownTitle(content: string): string | null {
+  const title = extractMarkdownTitle(content)
+  if (title && title !== "推演结果") return title
+
+  const recommendedBranch = content.match(/^###\s+(.+?)\s*$(?=[\s\S]*?^- 推荐:是\s*$)/m)
+  if (recommendedBranch?.[1]) return `推演结果:${recommendedBranch[1].trim()}`
+
+  const firstBranch = content.match(/^##\s+分支\s*$[\s\S]*?^###\s+(.+?)\s*$/m)
+  if (firstBranch?.[1]) return `推演结果:${firstBranch[1].trim()}`
+
+  return title
+}
+
+function formatDateTitle(value: unknown): string | null {
+  if (typeof value !== "string" || !value.trim()) return null
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) return value
+  return date.toLocaleString("zh-CN", {
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+  })
+}
+
+function extractSimulationJsonTitle(content: string): string | null {
+  try {
+    const parsed = JSON.parse(content) as {
+      title?: unknown
+      shortTitle?: unknown
+      frameworkTitle?: unknown
+      report?: {
+        createdAt?: unknown
+        branches?: Array<{ title?: unknown; recommendation?: unknown }>
+      }
+    }
+    if (typeof parsed.shortTitle === "string" && parsed.shortTitle.trim()) return parsed.shortTitle.trim()
+    if (typeof parsed.title === "string" && parsed.title.trim()) return parsed.title.trim()
+    if (typeof parsed.frameworkTitle === "string" && parsed.frameworkTitle.trim()) {
+      return `当前绑定:${parsed.frameworkTitle.trim()}`
+    }
+    const branches = Array.isArray(parsed.report?.branches) ? parsed.report.branches : []
+    const branchTitle = branches.find((branch) => branch.recommendation === true)?.title ?? branches[0]?.title
+    if (typeof branchTitle === "string" && branchTitle.trim()) return `推演结果:${branchTitle.trim()}`
+    const createdAt = formatDateTitle(parsed.report?.createdAt)
+    return createdAt ? `推演结果:${createdAt}` : null
+  } catch {
+    return null
+  }
+}
+
+function hasAsciiLetter(value: string): boolean {
+  return /[A-Za-z]/.test(value)
+}
+
+function fallbackDisplayTitle(
+  category: Extract<ReferenceCategory, "chapter" | "memory" | "outline" | "deduction">,
+  rawTitle: string,
+  path: string,
+): string {
+  const stem = rawTitle.split("/").pop() || rawTitle
+  if (!hasAsciiLetter(rawTitle)) return rawTitle
+
+  if (category === "chapter") {
+    const chapterNumber = stem.match(/chapter[-_\s]*(\d+)/i)?.[1] || stem.match(/(\d+)/)?.[1]
+    return chapterNumber ? `第${Number.parseInt(chapterNumber, 10)}章` : "章节"
+  }
+
+  if (category === "memory") return "记忆条目"
+  if (category === "outline") return "大纲条目"
+
+  const normalizedPath = normalizeProjectPath(path)
+  if (normalizedPath.includes("/bindings/")) return "当前绑定"
+  if (normalizedPath.includes("/frameworks/")) return "故事框架"
+  if (normalizedPath.includes("/results/")) return "推演结果"
+  return "推演资料"
+}
+
+async function resolveDisplayTitle(
+  category: Extract<ReferenceCategory, "chapter" | "memory" | "outline" | "deduction">,
+  file: { title: string; path: string },
+): Promise<string> {
+  try {
+    const content = await readFile(file.path)
+    if (typeof content === "string" && content.trim()) {
+      const lowerPath = file.path.toLowerCase()
+      const parsedTitle = lowerPath.endsWith(".json")
+        ? extractSimulationJsonTitle(content)
+        : category === "deduction"
+          ? extractSimulationMarkdownTitle(content)
+          : extractMarkdownTitle(content)
+      if (parsedTitle?.trim()) return parsedTitle.trim()
+    }
+  } catch {
+    // 文件内容读取失败时使用中文兜底名,避免把技术文件名暴露为主标题。
+  }
+
+  return fallbackDisplayTitle(category, file.title, file.path)
+}
+
 async function collectReferenceFiles(
 async function collectReferenceFiles(
   baseDir: string,
   baseDir: string,
   nodes: ReferenceFileNode[],
   nodes: ReferenceFileNode[],
@@ -100,12 +222,15 @@ function createFileProvider(
           await listDirectory(dirPath) as ReferenceFileNode[],
           await listDirectory(dirPath) as ReferenceFileNode[],
           extensions,
           extensions,
         )
         )
-        return files.map((file) => ({
-          id: simpleId(),
-          category,
-          title: file.title,
-          path: file.path,
-          displayTitle: truncateTitle(file.title),
+        return await Promise.all(files.map(async (file) => {
+          const title = await resolveDisplayTitle(category, file)
+          return {
+            id: simpleId(),
+            category,
+            title,
+            path: file.path,
+            displayTitle: truncateTitle(title),
+          }
         }))
         }))
       } catch {
       } catch {
         return []
         return []

+ 7 - 8
src/lib/reference/types.ts

@@ -24,17 +24,16 @@ export interface ReferenceToken {
 export interface ReferencePickerTab {
 export interface ReferencePickerTab {
   key: ReferenceCategory
   key: ReferenceCategory
   label: string
   label: string
-  icon: string
 }
 }
 
 
 export const REFERENCE_TABS: ReferencePickerTab[] = [
 export const REFERENCE_TABS: ReferencePickerTab[] = [
-  { key: "chapter", label: "章节", icon: "📄" },
-  { key: "memory", label: "记忆库", icon: "🧠" },
-  { key: "outline", label: "大纲", icon: "📋" },
-  { key: "deduction", label: "推演室", icon: "🔬" },
-  { key: "skill", label: "技能库", icon: "⚡" },
-  { key: "chat_history", label: "AI对话", icon: "💬" },
-  { key: "outline_history", label: "AI大纲", icon: "📝" },
+  { key: "chapter", label: "章节" },
+  { key: "memory", label: "记忆库" },
+  { key: "outline", label: "大纲" },
+  { key: "deduction", label: "推演室" },
+  { key: "skill", label: "技能库" },
+  { key: "chat_history", label: "AI对话" },
+  { key: "outline_history", label: "AI大纲" },
 ]
 ]
 
 
 export const MAX_REFERENCE_COUNT = 10
 export const MAX_REFERENCE_COUNT = 10