Pārlūkot izejas kodu

fix(agent): restore reference chat controls and providers

Mochocyang 2 mēneši atpakaļ
vecāks
revīzija
20401d9f0b

+ 45 - 12
agent-duihua-分支说明.md

@@ -2,18 +2,35 @@
 
 ## 分支目标
 
-本分支用于将 AI 会话面板接入 Agent 工具调用流程,并接入 @引用输入与引用弹窗
+本分支用于将 AI 会话面板接入 Agent 工具调用流程,并接入 @ 引用输入、引用弹窗和章节右键发送到 AI 会话能力
 
 ## 使用要求
 
 1. 不合并到 main,等待用户完成全面软件测试后再决定合并。
-2. 本分支只处理 AI 会话面板,不处理 AI 大纲面板
-3. 保留旧 `ChatInput` 组件源码,不删除不修改
+2. 本分支优先处理 AI 会话面板;AI 大纲不在本轮强接入范围内
+3. 保留旧 `ChatInput` 组件源码,不删除不重构无关逻辑
 4. 面向用户的提示语保持中文。
-5. 修改完成后必须完成源码启动、旧功能测试、构建和便携版打包验证。
+5. 修改完成后必须完成源码启动验证、旧功能测试、构建和便携版打包验证。
 
 ## 本次更新
 
+### 20260701-103906
+
+- 修复 AI 会话模型选择器位置回归:模型选择器已回到输入区右下角,靠近发送按钮。
+- 恢复 AI 会话引用输入框可拖拽高度,流式生成时在输入区右下角显示停止按钮。
+- 优化 @ 引用弹窗样式,改为主题化弹窗,增大内容区域,显示清晰中文标签、搜索框、路径和已选计数。
+- 修复 @ 引用数据源:章节库、记忆库、大纲支持递归读取嵌套 Markdown;推演室支持读取 `.qmai/simulations` 下的框架 Markdown 和结果 JSON。
+- 修复 Agent 读取工具:`read_memory` 和 `read_deduction` 支持按完整 `path` 读取嵌套文件。
+- 修复章节列表右键菜单在真实侧边栏未接入的问题:章节右键“发送到AI会话”会展开 AI 会话并插入引用 token,不会自动发送。
+- 修正 @ 引用 Chip 关闭按钮乱码,改为图标按钮和中文无障碍标签。
+
+### 20260701-095256
+
+- 新增 `agent-message-metadata` 纯函数模块,将 Agent 成功读取类工具调用转换为 assistant 消息引用来源。
+- AI 会话输入草稿恢复按会话隔离:文本继续使用 `conversation.inputDraft`,引用 token 按会话单独缓存。
+- Agent 完成后会把成功读取的章节、大纲、记忆和推演来源写入 assistant `references`,旧引用来源面板可继续展示。
+- 同步 `src/lib/changelog.spec.ts` 的 `2.2.31` 版本断言,使全量 mock 测试恢复通过。
+
 ### 20260701-092043
 
 - 将 `chat-panel` 的发送流程接入 `AgentRunner`。
@@ -25,15 +42,31 @@
 
 ## 验证记录
 
-- `npm.cmd exec -- vitest run src/components/chat/chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx src/hooks/use-agent-config.spec.ts`:17 个用例通过。
-- `npm.cmd exec -- vitest run src/components/layout/chat-layout.test.ts`:11 个用例通过。
-- `npm.cmd run typecheck`:通过。
-- `npm.cmd run build`:通过,存在既有 Vite chunk/动态导入警告。
-- `npm.cmd run test:mocks`:2088/2089 个用例通过,剩余 1 个既有失败为 `src/lib/changelog.spec.ts` 未包含当前 `2.2.31`。
-- 源码启动:Vite ready,`http://127.0.0.1:1420/` 返回 HTTP 200。
-- `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+- 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/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 run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2105 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - 源码启动:`npm.cmd run dev -- --host 127.0.0.1 --port 5173` 前台启动显示 Vite ready;当前环境无法可靠保留后台 dev server,未留下常驻 URL。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+- 20260701-095256:
+  - `npm.cmd exec -- vitest run src/components/chat/agent-message-metadata.spec.ts src/components/chat/chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx src/hooks/use-agent-config.spec.ts src/lib/changelog.spec.ts`:5 个测试文件、28 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/layout/chat-layout.test.ts`:11 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run test:mocks`:285 个测试文件、2094 个用例通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
+- 20260701-092043:
+  - `npm.cmd exec -- vitest run src/components/chat/chat-panel.spec.tsx src/components/chat/chat-message.spec.tsx src/hooks/use-agent-config.spec.ts`:7 个用例通过。
+  - `npm.cmd exec -- vitest run src/components/layout/chat-layout.test.ts`:1 个用例通过。
+  - `npm.cmd run typecheck`:通过。
+  - `npm.cmd run build`:通过,存在既有 Vite chunk/dynamic import 警告。
+  - `npm.cmd run build:portable`:通过,生成 `release-portable\QMaiWrite.exe`。
 
 ## Git 状态
 
-- 本次更新随当前分支提交入库。
+- 20260701-092043 更新已提交,提交号 `6b08275`。
+- 20260701-095256 优化纳入本次提交。
+- 20260701-103906 回归修复和 @ UI 优化纳入本次提交。
 - 不合并 main。

+ 76 - 0
src/components/chat/agent-message-metadata.spec.ts

@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest"
+import type { AgentRunRecord } from "@/lib/agent/types"
+import type { ReferenceToken } from "@/lib/reference/types"
+import {
+  agentToolCallsToMessageReferences,
+  getReferenceTokensForConversation,
+  setReferenceTokensForConversation,
+} from "./agent-message-metadata"
+
+function toolCall(
+  name: string,
+  params: Record<string, unknown>,
+  status: "done" | "error" = "done",
+): AgentRunRecord["toolCalls"][number] {
+  return {
+    id: `${name}-${String(params.name ?? params.path ?? params.conversationId ?? "x")}`,
+    name,
+    params,
+    result: status === "done" ? "ok" : "错误",
+    status,
+    startedAt: 1,
+    finishedAt: 2,
+  }
+}
+
+describe("agentToolCallsToMessageReferences", () => {
+  it("converts successful Agent read tools into assistant message references", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_chapter", { name: "第一章" }),
+      toolCall("read_outline", { path: "C:/Book/wiki/outlines/主线.md" }),
+      toolCall("read_memory", { name: "主角记忆" }),
+      toolCall("read_deduction", { name: "framework_1" }),
+      toolCall("write_memory", { name: "不会进入引用" }),
+      toolCall("read_memory", { name: "失败记忆" }, "error"),
+    ])
+
+    expect(references).toEqual([
+      { title: "第一章", path: "wiki/chapters/第一章.md" },
+      { title: "主线", path: "wiki/outlines/主线.md" },
+      { title: "主角记忆", path: "wiki/memory/主角记忆.md" },
+      { title: "framework_1", path: ".qmai/simulations/framework_1.json" },
+    ])
+  })
+
+  it("deduplicates references by path", () => {
+    const references = agentToolCallsToMessageReferences([
+      toolCall("read_chapter", { name: "第一章" }),
+      toolCall("read_chapter", { path: "C:/Book/wiki/chapters/第一章.md" }),
+    ])
+
+    expect(references).toEqual([
+      { title: "第一章", path: "wiki/chapters/第一章.md" },
+    ])
+  })
+})
+
+describe("reference token drafts by conversation", () => {
+  const token: ReferenceToken = {
+    id: "ref-1",
+    category: "chapter",
+    title: "第一章",
+    displayTitle: "第一章",
+    path: "C:/Book/wiki/chapters/第一章.md",
+  }
+
+  it("stores and clears draft reference tokens without touching other conversations", () => {
+    const withFirst = setReferenceTokensForConversation({}, "conv-1", [token])
+    const withSecond = setReferenceTokensForConversation(withFirst, "conv-2", [{ ...token, id: "ref-2", title: "第二章" }])
+    const clearedFirst = setReferenceTokensForConversation(withSecond, "conv-1", [])
+
+    expect(getReferenceTokensForConversation(withSecond, "conv-1")).toEqual([token])
+    expect(getReferenceTokensForConversation(withSecond, "conv-2")).toHaveLength(1)
+    expect(getReferenceTokensForConversation(clearedFirst, "conv-1")).toEqual([])
+    expect(getReferenceTokensForConversation(clearedFirst, "conv-2")).toHaveLength(1)
+  })
+})

+ 92 - 0
src/components/chat/agent-message-metadata.ts

@@ -0,0 +1,92 @@
+import type { AgentRunRecord } from "@/lib/agent/types"
+import type { ReferenceToken } from "@/lib/reference/types"
+import type { MessageReference } from "@/stores/chat-store"
+
+export type ReferenceTokensByConversation = Record<string, ReferenceToken[]>
+
+function stringParam(params: Record<string, unknown>, key: string): string {
+  const value = params[key]
+  return typeof value === "string" ? value.trim() : ""
+}
+
+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 qmaiIndex = lower.lastIndexOf("/.qmai/")
+  if (qmaiIndex >= 0) return normalized.slice(qmaiIndex + 1)
+  return normalized.replace(/^\/+/, "")
+}
+
+function titleFromPath(path: string): string {
+  const fileName = path.replace(/\\/g, "/").split("/").pop() ?? path
+  return fileName.replace(/\.[^.]+$/, "")
+}
+
+function referenceFromReadTool(call: AgentRunRecord["toolCalls"][number]): MessageReference | null {
+  if (call.status !== "done") 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 }
+    }
+    case "read_outline": {
+      const referencePath = path ? normalizeReferencePath(path) : name ? `wiki/outlines/${name}.md` : ""
+      if (!referencePath) return null
+      return { title: name || titleFromPath(referencePath), path: referencePath }
+    }
+    case "read_memory": {
+      if (!name) return null
+      return { title: name, path: `wiki/memory/${name}.md` }
+    }
+    case "read_deduction": {
+      if (!name) return null
+      return { title: name, path: `.qmai/simulations/${name}.json` }
+    }
+    default:
+      return null
+  }
+}
+
+export function agentToolCallsToMessageReferences(
+  toolCalls: AgentRunRecord["toolCalls"] | undefined,
+): MessageReference[] {
+  if (!toolCalls || toolCalls.length === 0) return []
+
+  const references: MessageReference[] = []
+  const seen = new Set<string>()
+  for (const call of toolCalls) {
+    const reference = referenceFromReadTool(call)
+    if (!reference || seen.has(reference.path)) continue
+    seen.add(reference.path)
+    references.push(reference)
+  }
+  return references
+}
+
+export function getReferenceTokensForConversation(
+  drafts: ReferenceTokensByConversation,
+  conversationId: string | null | undefined,
+): ReferenceToken[] {
+  if (!conversationId) return []
+  return drafts[conversationId] ?? []
+}
+
+export function setReferenceTokensForConversation(
+  drafts: ReferenceTokensByConversation,
+  conversationId: string | null | undefined,
+  tokens: ReferenceToken[],
+): ReferenceTokensByConversation {
+  if (!conversationId) return drafts
+  if (tokens.length === 0) {
+    const { [conversationId]: _removed, ...rest } = drafts
+    return rest
+  }
+  return { ...drafts, [conversationId]: tokens }
+}

+ 26 - 0
src/components/chat/chat-panel.spec.tsx

@@ -42,4 +42,30 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("agentToolCalls")
     expect(source).toContain("当前模型不支持Agent功能,请更换模型")
   })
+
+  it("scopes reference input drafts to the active conversation", () => {
+    expect(source).toContain("setConversationInputDraft")
+    expect(source).toContain("getReferenceTokensForConversation")
+    expect(source).toContain("setReferenceTokensForConversation")
+    expect(source).not.toContain('const [referenceText, setReferenceText] = useState("")')
+    expect(source).not.toContain("const [currentTokens, setCurrentTokens] = useState<ReferenceToken[]>([])")
+  })
+
+  it("stores successful Agent read tool calls as assistant message references", () => {
+    expect(source).toContain("agentToolCallsToMessageReferences")
+    expect(source).toContain("references:")
+  })
+
+  it("keeps model and stop controls in the reference input footer", () => {
+    expect(source).toContain("rightControls={")
+    expect(source).toContain("<ChatModelSelector")
+    expect(source).toContain("isStreaming={isStreaming}")
+    expect(source).toContain("onStop={handleStop}")
+  })
+
+  it("consumes externally queued reference tokens into the active chat draft", () => {
+    expect(source).toContain("pendingReferenceTokens")
+    expect(source).toContain("consumePendingReferenceTokens")
+    expect(source).toContain("setReferenceTokensForConversation(drafts, targetConversationId")
+  })
 })

+ 83 - 29
src/components/chat/chat-panel.tsx

@@ -1,6 +1,6 @@
 import { useRef, useEffect, useCallback, useState, useMemo } from "react"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Brain, Plus, Trash2, MessageSquare, FileEdit, Drama, Square } from "lucide-react"
+import { BookOpen, Brain, Plus, Trash2, MessageSquare, FileEdit, Drama } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -56,6 +56,12 @@ import {
   detectGoldenThreeChapterRequest,
 } from "@/lib/novel/golden-three-chapters"
 import { createStreamSessionGuard } from "./stream-session"
+import {
+  agentToolCallsToMessageReferences,
+  getReferenceTokensForConversation,
+  setReferenceTokensForConversation,
+  type ReferenceTokensByConversation,
+} from "./agent-message-metadata"
 import {
   appendContinueUnfinishedDeepChapterContext,
   buildContinueUnfinishedDeepChapterPrompt,
@@ -317,7 +323,10 @@ export function ChatPanel() {
   const maxHistoryMessages = useChatStore((s) => s.maxHistoryMessages)
   const isConversationStreaming = useChatStore((s) => s.isConversationStreaming)
   const conversations = useChatStore((s) => s.conversations)
+  const setConversationInputDraft = useChatStore((s) => s.setConversationInputDraft)
   const setConversationDeAiSkillId = useChatStore((s) => s.setConversationDeAiSkillId)
+  const pendingReferenceTokens = useChatStore((s) => s.pendingReferenceTokens)
+  const consumePendingReferenceTokens = useChatStore((s) => s.consumePendingReferenceTokens)
   const outlineConversations = useOutlineChatStore((s) => s.conversations)
   // Derive active messages via selector to re-render on message changes
   const allMessages = useChatStore((s) => s.messages)
@@ -362,10 +371,52 @@ export function ChatPanel() {
   const setDeepChapterEnabled = useWikiStore((s) => s.setDeepChapterEnabled)
   // 故事框架绑定状态
   const [activeBinding, setActiveBinding] = useState<{ binding: FrameworkBinding; framework: StoryFramework } | null>(null)
-  const [referenceText, setReferenceText] = useState("")
-  const [currentTokens, setCurrentTokens] = useState<ReferenceToken[]>([])
+  const [fallbackReferenceText, setFallbackReferenceText] = useState("")
+  const [referenceTokensByConversation, setReferenceTokensByConversation] = useState<ReferenceTokensByConversation>({})
   const [referencePickerOpen, setReferencePickerOpen] = useState(false)
   const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null)
+  const referenceDraftConversationId = activeConversationId ?? "__new_conversation__"
+  const referenceText = activeConversationId ? activeConversation?.inputDraft ?? "" : fallbackReferenceText
+  const currentTokens = getReferenceTokensForConversation(referenceTokensByConversation, referenceDraftConversationId)
+  const updateCurrentTokens = useCallback(
+    (tokens: ReferenceToken[]) => {
+      setReferenceTokensByConversation((drafts) =>
+        setReferenceTokensForConversation(drafts, referenceDraftConversationId, tokens),
+      )
+    },
+    [referenceDraftConversationId],
+  )
+  const updateReferenceDraft = useCallback(
+    (plainText: string, tokens: ReferenceToken[]) => {
+      if (activeConversationId) {
+        setConversationInputDraft(activeConversationId, plainText)
+      } else {
+        setFallbackReferenceText(plainText)
+      }
+      updateCurrentTokens(tokens)
+    },
+    [activeConversationId, setConversationInputDraft, updateCurrentTokens],
+  )
+
+  useEffect(() => {
+    if (pendingReferenceTokens.length === 0) return
+    const tokens = consumePendingReferenceTokens()
+    if (tokens.length === 0) return
+
+    let targetConversationId = useChatStore.getState().activeConversationId
+    if (!targetConversationId) {
+      targetConversationId = createConversation()
+    }
+
+    setReferenceTokensByConversation((drafts) => {
+      const existingTokens = getReferenceTokensForConversation(drafts, targetConversationId)
+      return setReferenceTokensForConversation(drafts, targetConversationId, [
+        ...existingTokens,
+        ...tokens,
+      ])
+    })
+  }, [consumePendingReferenceTokens, createConversation, pendingReferenceTokens])
+
   const agentSystemPrompt = useMemo(
     () =>
       buildChatAgentSystemPrompt({
@@ -731,8 +782,12 @@ export function ChatPanel() {
       ]
 
       const { assistantMessage } = appendAgentChatMessages(capturedConvId, plainText, tokens)
-      setReferenceText("")
-      setCurrentTokens([])
+      setConversationInputDraft(capturedConvId, "")
+      setFallbackReferenceText("")
+      setReferenceTokensByConversation((drafts) => {
+        const withoutCaptured = setReferenceTokensForConversation(drafts, capturedConvId, [])
+        return setReferenceTokensForConversation(withoutCaptured, referenceDraftConversationId, [])
+      })
       startStreaming(capturedConvId)
       const sessionId = streamSessionGuardRef.current.start(capturedConvId)
       activeStreamSessionsRef.current[capturedConvId] = sessionId
@@ -746,6 +801,16 @@ export function ChatPanel() {
           ...message,
           content: message.content || record?.finalText || "Agent未返回内容。",
           agentToolCalls: record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+          references: (() => {
+            const existingReferences = message.references ?? []
+            const existingPaths = new Set(existingReferences.map((reference) => reference.path))
+            const agentReferences = agentToolCallsToMessageReferences(
+              record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+            ).filter((reference) => !existingPaths.has(reference.path))
+            return agentReferences.length > 0
+              ? [...existingReferences, ...agentReferences]
+              : message.references
+          })(),
           isAgentRunning: false,
         }))
       }
@@ -864,8 +929,10 @@ export function ChatPanel() {
       maxHistoryMessages,
       novelMode,
       project,
+      referenceDraftConversationId,
       requestSoulDialog,
       selectedFile,
+      setConversationInputDraft,
       startStreaming,
     ],
   )
@@ -1382,19 +1449,14 @@ export function ChatPanel() {
                   )}
                 </div>
               </TooltipProvider>
-              <div className="flex shrink-0 items-center gap-2">
-                {isStreaming && (
-                  <Button
-                    type="button"
-                    variant="outline"
-                    size="icon"
-                    onClick={handleStop}
-                    title="停止生成"
-                    aria-label="停止生成"
-                  >
-                    <Square className="h-3.5 w-3.5" />
-                  </Button>
-                )}
+            </div>
+            <ReferenceInput
+              value={referenceText}
+              tokens={currentTokens}
+              disabled={isStreaming}
+              isStreaming={isStreaming}
+              onStop={handleStop}
+              rightControls={
                 <ChatModelSelector
                   value={aiChatModel}
                   onChange={(model) => {
@@ -1402,18 +1464,10 @@ export function ChatPanel() {
                     void saveAiChatModel(model)
                   }}
                 />
-              </div>
-            </div>
-            <ReferenceInput
-              value={referenceText}
-              tokens={currentTokens}
-              disabled={isStreaming}
+              }
               insertTokensRef={insertReferenceTokensRef}
-              onChange={(plainText, tokens) => {
-                setReferenceText(plainText)
-                setCurrentTokens(tokens)
-              }}
-              onTokensChange={setCurrentTokens}
+              onChange={updateReferenceDraft}
+              onTokensChange={updateCurrentTokens}
               onSubmit={handleSend}
               onAtTrigger={() => setReferencePickerOpen(true)}
               placeholder={

+ 10 - 0
src/components/layout/sidebar-panel.tsx

@@ -27,6 +27,7 @@ import { FrameworkList } from "@/components/novel/story-simulation/framework-lis
 import { SkillLibrarySidebarPanel } from "@/components/skill-library/skill-library-view"
 
 import { useWikiStore } from "@/stores/wiki-store"
+import { useChatStore } from "@/stores/chat-store"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
 import { loadFrameworks, loadSimulationResults, deleteSimulationResult } from "@/lib/novel/story-simulation/framework-store"
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
@@ -67,6 +68,7 @@ import {
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { openExternalUrl } from "@/lib/open-external-url"
+import type { ReferenceToken } from "@/lib/reference/types"
 
 const USAGE_GUIDE_URL = "https://tcnk9ik08e1c.feishu.cn/wiki/FWiSwYQKoifpwBk6mSRcSlB8nrh?from=from_copylink"
 
@@ -673,6 +675,8 @@ export function SidebarPanel() {
   const setSelectedMemoryCenterEntry = useWikiStore((s) => s.setSelectedMemoryCenterEntry)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const setFileTree = useWikiStore((s) => s.setFileTree)
+  const setChatExpanded = useWikiStore((s) => s.setChatExpanded)
+  const enqueueReferenceTokens = useChatStore((s) => s.enqueueReferenceTokens)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const [mode, setMode] = useState<"knowledge" | "files">("knowledge")
   const [refreshKey, setRefreshKey] = useState(0)
@@ -1210,6 +1214,11 @@ export function SidebarPanel() {
     setInputTitle("")
   }
 
+  const handleSendChapterToChat = useCallback((token: ReferenceToken) => {
+    enqueueReferenceTokens([token])
+    setChatExpanded(true)
+  }, [enqueueReferenceTokens, setChatExpanded])
+
   const inputPlaceholder = pendingCreate?.kind === "outline"
     ? t("sidebar.newOutlinePrompt")
     : pendingCreate?.kind === "volume"
@@ -1527,6 +1536,7 @@ export function SidebarPanel() {
           pendingPages={pendingPages.filter((page) => page.type === (isChapter ? "chapter" : "outline"))}
           onRemovePendingPage={handleRemovePendingPage}
           onRequestCreate={beginCreate}
+          onSendToChat={isChapter ? handleSendChapterToChat : undefined}
         />
       </div>
       <div className="border-t px-3 py-2">

+ 4 - 3
src/components/reference/ReferenceChip.tsx

@@ -1,3 +1,4 @@
+import { X } from "lucide-react"
 import type { ReferenceToken } from "@/lib/reference/types"
 
 const categoryColors: Record<ReferenceToken["category"], string> = {
@@ -25,7 +26,7 @@ export function ReferenceChip({
 
   return (
     <span
-      className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium ${colorClass} mx-0.5`}
+      className={`mx-0.5 inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium ${colorClass}`}
       contentEditable={readonly ? undefined : false}
       data-reference-id={token.id}
       data-reference-category={token.category}
@@ -34,7 +35,7 @@ export function ReferenceChip({
       {!readonly && (
         <button
           type="button"
-          className="ml-0.5 cursor-pointer text-gray-400 hover:text-red-500"
+          className="ml-0.5 cursor-pointer rounded-sm p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
           onClick={(event) => {
             event.preventDefault()
             onRemove?.(token.id)
@@ -42,7 +43,7 @@ export function ReferenceChip({
           tabIndex={-1}
           aria-label={`移除引用 ${token.displayTitle}`}
         >
-          ×
+          <X className="h-3 w-3" aria-hidden="true" />
         </button>
       )}
     </span>

+ 43 - 0
src/components/reference/ReferenceInput.spec.tsx

@@ -60,6 +60,49 @@ describe("ReferenceInput", () => {
     expect(onAtTrigger).toHaveBeenCalledTimes(2)
   })
 
+  it("keeps the resize handle and footer controls beside the send button", async () => {
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          rightControls={<button type="button">模型选择</button>}
+        />,
+      )
+    })
+
+    expect(host.querySelector("[aria-label='拖动调整输入框高度']")).toBeTruthy()
+    const footer = host.querySelector("[data-reference-input-footer]")
+    expect(footer?.textContent).toContain("模型选择")
+    expect(footer?.querySelector("[aria-label='发送消息']")).toBeTruthy()
+  })
+
+  it("shows a stop action in the footer while streaming", async () => {
+    const onStop = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          isStreaming
+          onStop={onStop}
+          rightControls={<button type="button">模型选择</button>}
+        />,
+      )
+    })
+
+    expect(host.querySelector("[aria-label='发送消息']")).toBeNull()
+    const stop = host.querySelector<HTMLButtonElement>("[aria-label='停止生成']")
+    expect(stop).toBeTruthy()
+
+    await act(async () => {
+      stop?.click()
+    })
+
+    expect(onStop).toHaveBeenCalledTimes(1)
+  })
+
   it("does not submit when only references are present", async () => {
     const onSubmit = vi.fn()
 

+ 130 - 20
src/components/reference/ReferenceInput.tsx

@@ -5,26 +5,52 @@ import {
   useRef,
   useState,
   type MutableRefObject,
+  type ReactNode,
 } from "react"
-import { ArrowUp, AtSign } from "lucide-react"
+import { ArrowUp, AtSign, Square } from "lucide-react"
 import { isImeComposing } from "@/lib/keyboard-utils"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { ReferenceChip } from "./ReferenceChip"
 
 export type InsertReferenceTokens = ((tokens: ReferenceToken[]) => void) | null
 
+const REFERENCE_INPUT_HEIGHT_KEY = "qmai-reference-input-height"
+const DEFAULT_REFERENCE_INPUT_HEIGHT = 56
+const MIN_REFERENCE_INPUT_HEIGHT = 48
+const MAX_REFERENCE_INPUT_HEIGHT = 220
+
 interface ReferenceInputProps {
   value?: string
   tokens: ReferenceToken[]
   placeholder?: string
   disabled?: boolean
+  isStreaming?: boolean
+  rightControls?: ReactNode
   onChange?: (plainText: string, tokens: ReferenceToken[]) => void
   onTokensChange?: (tokens: ReferenceToken[]) => void
   onSubmit: (plainText: string, tokens: ReferenceToken[]) => void
+  onStop?: () => void
   onAtTrigger?: () => void
   insertTokensRef?: MutableRefObject<InsertReferenceTokens>
 }
 
+function clampInputHeight(height: number): number {
+  if (!Number.isFinite(height)) return DEFAULT_REFERENCE_INPUT_HEIGHT
+  return Math.min(MAX_REFERENCE_INPUT_HEIGHT, Math.max(MIN_REFERENCE_INPUT_HEIGHT, Math.round(height)))
+}
+
+function loadSavedInputHeight(): number {
+  if (typeof localStorage === "undefined") return DEFAULT_REFERENCE_INPUT_HEIGHT
+  const raw = localStorage.getItem(REFERENCE_INPUT_HEIGHT_KEY)
+  const parsed = raw ? Number(raw) : Number.NaN
+  return Number.isFinite(parsed) ? clampInputHeight(parsed) : DEFAULT_REFERENCE_INPUT_HEIGHT
+}
+
+function saveInputHeight(height: number) {
+  if (typeof localStorage === "undefined") return
+  localStorage.setItem(REFERENCE_INPUT_HEIGHT_KEY, String(clampInputHeight(height)))
+}
+
 function extractPlainText(editor: HTMLDivElement | null): string {
   if (!editor) return ""
   return Array.from(editor.childNodes)
@@ -45,17 +71,22 @@ export function ReferenceInput({
   tokens,
   placeholder = "输入提示词,或 @ 引用内容...",
   disabled = false,
+  isStreaming = false,
+  rightControls,
   onChange,
   onTokensChange,
   onSubmit,
+  onStop,
   onAtTrigger,
   insertTokensRef,
 }: ReferenceInputProps) {
   const editorRef = useRef<HTMLDivElement>(null)
   const isControlled = value !== undefined
   const [draft, setDraft] = useState("")
+  const [inputHeight, setInputHeight] = useState(loadSavedInputHeight)
   const text = isControlled ? value : draft
-  const canSubmit = text.trim().length > 0 && !disabled
+  const inputDisabled = disabled || isStreaming
+  const canSubmit = text.trim().length > 0 && !inputDisabled
 
   const notifyChange = useCallback(
     (nextText: string, nextTokens: ReferenceToken[]) => {
@@ -98,9 +129,9 @@ export function ReferenceInput({
 
   const handleSubmit = useCallback(() => {
     const plainText = text.trim()
-    if (!plainText || disabled) return
+    if (!plainText || inputDisabled) return
     onSubmit(plainText, tokens)
-  }, [disabled, onSubmit, text, tokens])
+  }, [inputDisabled, onSubmit, text, tokens])
 
   const handleKeyDown = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -120,6 +151,53 @@ export function ReferenceInput({
     [handleSubmit, onAtTrigger],
   )
 
+  const handleResizePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
+    if (event.button !== 0) return
+    event.preventDefault()
+
+    const startY = event.clientY
+    const startHeight = inputHeight
+    const handle = event.currentTarget
+    const pointerId = event.pointerId
+    const previousCursor = document.body.style.cursor
+    let savedHeight = startHeight
+    document.body.style.cursor = "ns-resize"
+
+    try {
+      handle.setPointerCapture(pointerId)
+    } catch {
+      // Older WebViews can miss pointer capture; window listeners still cover dragging.
+    }
+
+    const handlePointerMove = (pointerEvent: PointerEvent) => {
+      const nextHeight = clampInputHeight(startHeight + (startY - pointerEvent.clientY))
+      savedHeight = nextHeight
+      setInputHeight(nextHeight)
+    }
+
+    const handlePointerUp = () => {
+      saveInputHeight(savedHeight)
+      try {
+        handle.releasePointerCapture(pointerId)
+      } catch {
+        // Ignore release errors when capture has already been cancelled.
+      }
+      document.body.style.cursor = previousCursor
+      window.removeEventListener("pointermove", handlePointerMove)
+      window.removeEventListener("pointerup", handlePointerUp)
+      window.removeEventListener("pointercancel", handlePointerUp)
+    }
+
+    window.addEventListener("pointermove", handlePointerMove)
+    window.addEventListener("pointerup", handlePointerUp)
+    window.addEventListener("pointercancel", handlePointerUp)
+  }, [inputHeight])
+
+  const resetInputHeight = useCallback(() => {
+    setInputHeight(DEFAULT_REFERENCE_INPUT_HEIGHT)
+    saveInputHeight(DEFAULT_REFERENCE_INPUT_HEIGHT)
+  }, [])
+
   const renderedTokens = useMemo(
     () => tokens.map((token) => (
       <ReferenceChip
@@ -132,12 +210,24 @@ export function ReferenceInput({
   )
 
   return (
-    <div className="rounded-lg border bg-background shadow-sm focus-within:ring-2 focus-within:ring-blue-400">
+    <div className="overflow-hidden rounded-lg border bg-background shadow-sm focus-within:ring-2 focus-within:ring-ring">
+      <div
+        role="separator"
+        aria-label="拖动调整输入框高度"
+        title="拖动调整输入框高度,双击恢复默认高度"
+        className="flex h-2 cursor-ns-resize items-center justify-center bg-muted/20 transition-colors hover:bg-muted/50"
+        onPointerDown={handleResizePointerDown}
+        onDoubleClick={resetInputHeight}
+      >
+        <span className="h-0.5 w-10 rounded-full bg-border" />
+      </div>
+
       <div className="relative px-3 py-2">
         <div
           ref={editorRef}
-          className="min-h-[48px] max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words text-sm outline-none disabled:cursor-not-allowed"
-          contentEditable={!disabled}
+          className="overflow-y-auto whitespace-pre-wrap break-words text-sm outline-none disabled:cursor-not-allowed"
+          style={{ height: inputHeight, maxHeight: inputHeight }}
+          contentEditable={!inputDisabled}
           suppressContentEditableWarning
           onInput={handleInput}
           onKeyDown={handleKeyDown}
@@ -153,27 +243,47 @@ export function ReferenceInput({
         )}
       </div>
 
-      <div className="flex items-center justify-between border-t px-2 py-1.5">
+      <div
+        data-reference-input-footer
+        className="flex items-center justify-between gap-2 border-t px-2 py-1.5"
+      >
         <button
           type="button"
-          className="rounded-md p-1.5 text-gray-500 hover:bg-accent hover:text-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
+          className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
           onClick={() => onAtTrigger?.()}
-          disabled={disabled}
+          disabled={inputDisabled}
           title="引用内容"
           aria-label="引用内容"
         >
           <AtSign className="h-4 w-4" />
         </button>
-        <button
-          type="button"
-          className="rounded-md bg-blue-500 p-1.5 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
-          disabled={!canSubmit}
-          onClick={handleSubmit}
-          title="发送消息"
-          aria-label="发送消息"
-        >
-          <ArrowUp className="h-4 w-4" />
-        </button>
+
+        <div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
+          {rightControls}
+          {isStreaming ? (
+            <button
+              type="button"
+              className="rounded-md border border-destructive/40 p-1.5 text-destructive hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-50"
+              disabled={!onStop}
+              onClick={onStop}
+              title="停止生成"
+              aria-label="停止生成"
+            >
+              <Square className="h-4 w-4" />
+            </button>
+          ) : (
+            <button
+              type="button"
+              className="rounded-md bg-primary p-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground"
+              disabled={!canSubmit}
+              onClick={handleSubmit}
+              title="发送消息"
+              aria-label="发送消息"
+            >
+              <ArrowUp className="h-4 w-4" />
+            </button>
+          )}
+        </div>
       </div>
     </div>
   )

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

@@ -125,6 +125,30 @@ describe("ReferencePickerDialog", () => {
     expect(onClose).toHaveBeenCalled()
   })
 
+  it("uses the app surface styles and clear Chinese labels", async () => {
+    await act(async () => {
+      root.render(
+        <ReferencePickerDialog
+          open
+          providers={providers}
+          projectPath="C:/Novel"
+          onConfirm={vi.fn()}
+          onClose={vi.fn()}
+        />,
+      )
+    })
+    await flush()
+
+    const dialog = host.querySelector<HTMLElement>("[role='dialog']")
+    expect(dialog).toBeTruthy()
+    expect(dialog?.className).toContain("bg-background")
+    expect(dialog?.className).toContain("max-w-[min(920px,calc(100vw-32px))]")
+    expect(host.textContent).toContain("选择引用内容")
+    expect(host.textContent).toContain("章节")
+    expect(host.textContent).toContain("记忆库")
+    expect(host.textContent).toContain("已选 0/10")
+  })
+
   it("does not select more than the maximum reference count", async () => {
     await act(async () => {
       root.render(

+ 95 - 62
src/components/reference/ReferencePickerDialog.tsx

@@ -1,4 +1,5 @@
 import { useEffect, useMemo, useState } from "react"
+import { Search, X } from "lucide-react"
 import type { ReferenceCategory, ReferenceToken } from "@/lib/reference/types"
 import { MAX_REFERENCE_COUNT, REFERENCE_TABS } from "@/lib/reference/types"
 import type { ReferenceProvider } from "@/lib/reference/providers"
@@ -61,6 +62,11 @@ export function ReferencePickerDialog({
     }
   }, [activeTab, open, projectPath, providers])
 
+  const availableTabs = useMemo(
+    () => REFERENCE_TABS.filter((tab) => providers.some((provider) => provider.category === tab.key)),
+    [providers],
+  )
+
   const filteredItems = useMemo(() => {
     const keyword = search.trim().toLowerCase()
     if (!keyword) return items
@@ -84,88 +90,115 @@ export function ReferencePickerDialog({
   if (!open) return null
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
-      <div className="flex max-h-[520px] w-[640px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-lg bg-white shadow-xl">
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4">
+      <div
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="reference-picker-title"
+        className="flex max-h-[min(720px,calc(100vh-32px))] w-[920px] max-w-[min(920px,calc(100vw-32px))] flex-col overflow-hidden rounded-lg border bg-background text-foreground shadow-2xl"
+      >
         <div className="flex items-center justify-between border-b px-4 py-3">
-          <h3 className="text-base font-semibold">选择引用内容</h3>
+          <div className="min-w-0">
+            <h3 id="reference-picker-title" className="text-base font-semibold">选择引用内容</h3>
+            <p className="mt-0.5 text-xs text-muted-foreground">选择后会插入到 AI 会话输入框,不会自动发送。</p>
+          </div>
           <button
             type="button"
             onClick={onClose}
-            className="text-lg leading-none text-gray-400 hover:text-gray-600"
+            className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
             aria-label="关闭引用选择弹窗"
           >
-            ×
+            <X className="h-4 w-4" />
           </button>
         </div>
 
         <div className="flex min-h-0 flex-1 overflow-hidden">
-          <div className="flex w-28 flex-col gap-0.5 border-r bg-gray-50 py-2">
-            {REFERENCE_TABS.map((tab) => {
-              if (!providers.some((provider) => provider.category === tab.key)) return null
-              return (
-                <button
-                  key={tab.key}
-                  type="button"
-                  className={`px-3 py-2 text-left text-sm transition-colors ${
-                    activeTab === tab.key
-                      ? "border-r-2 border-blue-600 bg-white font-medium text-blue-600"
-                      : "text-gray-600 hover:bg-gray-100"
-                  }`}
-                  onClick={() => {
-                    setActiveTab(tab.key)
-                    setSearch("")
-                  }}
-                >
-                  {tab.icon} {tab.label}
-                </button>
-              )
-            })}
+          <div className="flex w-40 shrink-0 flex-col gap-1 border-r bg-muted/30 p-2">
+            {availableTabs.map((tab) => (
+              <button
+                key={tab.key}
+                type="button"
+                className={`flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
+                  activeTab === tab.key
+                    ? "border border-primary/30 bg-background font-medium text-primary shadow-sm"
+                    : "text-muted-foreground hover:bg-accent hover:text-foreground"
+                }`}
+                onClick={() => {
+                  setActiveTab(tab.key)
+                  setSearch("")
+                }}
+              >
+                <span className="w-5 text-center" aria-hidden="true">{tab.icon}</span>
+                <span className="truncate">{tab.label}</span>
+              </button>
+            ))}
           </div>
 
           <div className="flex min-w-0 flex-1 flex-col">
             <div className="border-b px-3 py-2">
-              <input
-                type="search"
-                className="w-full rounded-md border px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-blue-400"
-                placeholder="搜索..."
-                value={search}
-                onChange={(event) => setSearch(event.target.value)}
-              />
+              <label className="relative block">
+                <Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+                <input
+                  type="search"
+                  className="w-full rounded-md border bg-background py-1.5 pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-ring"
+                  placeholder="搜索引用内容"
+                  value={search}
+                  onChange={(event) => setSearch(event.target.value)}
+                />
+              </label>
             </div>
 
-            <div className="min-h-[260px] flex-1 overflow-y-auto px-2 py-1">
+            <div className="min-h-[320px] flex-1 overflow-y-auto p-2">
               {loading ? (
-                <div className="py-8 text-center text-sm text-gray-400">加载中...</div>
+                <div className="flex h-full items-center justify-center text-sm text-muted-foreground">加载中...</div>
               ) : filteredItems.length === 0 ? (
-                <div className="py-8 text-center text-sm text-gray-400">暂无内容</div>
+                <div className="flex h-full items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
+                  暂无内容
+                </div>
               ) : (
-                filteredItems.map((item) => {
-                  const isSelected = selected.some((selectedItem) => selectedItem.id === item.id)
-                  return (
-                    <label
-                      key={item.id}
-                      className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-blue-50 ${
-                        isSelected ? "bg-blue-50" : ""
-                      }`}
-                    >
-                      <input
-                        type="checkbox"
-                        checked={isSelected}
-                        onChange={() => toggleItem(item)}
-                        className="accent-blue-500"
-                      />
-                      <span className="min-w-0 flex-1 truncate">{item.title}</span>
-                    </label>
-                  )
-                })
+                <div className="grid gap-1">
+                  {filteredItems.map((item) => {
+                    const isSelected = selected.some((selectedItem) => selectedItem.id === item.id)
+                    return (
+                      <label
+                        key={item.id}
+                        className={`flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors ${
+                          isSelected
+                            ? "border-primary/40 bg-primary/10"
+                            : "border-transparent hover:border-border hover:bg-accent/60"
+                        }`}
+                      >
+                        <input
+                          type="checkbox"
+                          checked={isSelected}
+                          onChange={() => toggleItem(item)}
+                          className="h-4 w-4 accent-primary"
+                        />
+                        <span className="min-w-0 flex-1">
+                          <span className="block truncate font-medium">{item.title}</span>
+                          {item.path ? (
+                            <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.path}</span>
+                          ) : null}
+                        </span>
+                      </label>
+                    )
+                  })}
+                </div>
               )}
             </div>
 
-            <div className="border-t px-3 py-2 text-xs text-gray-400">
-              已选 {selected.length}/{MAX_REFERENCE_COUNT}
-              {selected.length >= MAX_REFERENCE_COUNT && (
-                <span className="ml-1 text-red-400">(已达上限)</span>
-              )}
+            <div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
+              <span>
+                已选 {selected.length}/{MAX_REFERENCE_COUNT}
+                {selected.length >= MAX_REFERENCE_COUNT && (
+                  <span className="ml-1 text-destructive">(已达上限)</span>
+                )}
+              </span>
+              {selected.length > 0 ? (
+                <span className="max-w-[60%] truncate">
+                  {selected.map((item) => item.displayTitle || item.title).join("、")}
+                </span>
+              ) : null}
             </div>
           </div>
         </div>
@@ -173,14 +206,14 @@ export function ReferencePickerDialog({
         <div className="flex justify-end gap-2 border-t px-4 py-3">
           <button
             type="button"
-            className="rounded-md border px-4 py-1.5 text-sm hover:bg-gray-50"
+            className="rounded-md border px-4 py-1.5 text-sm hover:bg-accent"
             onClick={onClose}
           >
             取消
           </button>
           <button
             type="button"
-            className="rounded-md bg-blue-500 px-4 py-1.5 text-sm text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
+            className="rounded-md bg-primary px-4 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
             disabled={selected.length === 0}
             onClick={handleConfirm}
           >

+ 8 - 5
src/lib/agent/tools/read-deduction.ts

@@ -4,17 +4,20 @@ import { readFile } from "@/commands/fs"
 export function createReadDeductionTool(simDir: string): Tool {
   return {
     name: "read_deduction",
-    description: "读取推演室的推演结果或故事框架内容。参数 name 为推演结果名称。",
+    description: "读取推演室的推演结果或故事框架内容。参数 name 为推演结果名称,或 path 为完整文件路径。",
     category: "read",
     parameters: {
-      name: { type: "string", description: "推演结果名称", required: true },
+      name: { type: "string", description: "推演结果名称" },
+      path: { type: "string", description: "推演室文件完整路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
-      const name = params.name as string
+      const name = params.name as string | undefined
+      const path = params.path as string | undefined
+      const filePath = path || `${simDir}/${name}.json`
       try {
-        return await readFile(`${simDir}/${name}.json`)
+        return await readFile(filePath)
       } catch {
-        return `错误:无法读取推演结果「${name}」`
+        return `错误:无法读取推演室内容「${name || path}」`
       }
     },
   }

+ 8 - 5
src/lib/agent/tools/read-memory.ts

@@ -4,17 +4,20 @@ import { readFile } from "@/commands/fs"
 export function createReadMemoryTool(memoryDir: string): Tool {
   return {
     name: "read_memory",
-    description: "读取记忆库中的指定条目内容。参数 name 为记忆条目名称。",
+    description: "读取记忆库中的指定条目内容。参数 name 为记忆条目名称,或 path 为完整文件路径。",
     category: "read",
     parameters: {
-      name: { type: "string", description: "记忆条目名称", required: true },
+      name: { type: "string", description: "记忆条目名称" },
+      path: { type: "string", description: "记忆文件的完整路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
-      const name = params.name as string
+      const name = params.name as string | undefined
+      const path = params.path as string | undefined
+      const filePath = path || `${memoryDir}/${name}.md`
       try {
-        return await readFile(`${memoryDir}/${name}.md`)
+        return await readFile(filePath)
       } catch {
-        return `错误:无法读取记忆条目「${name}」,请确认文件存在`
+        return `错误:无法读取记忆条目「${name || path}」,请确认文件存在`
       }
     },
   }

+ 16 - 0
src/lib/agent/tools/read-tools.spec.ts

@@ -34,6 +34,14 @@ describe("read tools", () => {
     expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/曙光组织.md")
   })
 
+  it("read_memory reads an explicit nested memory path", async () => {
+    vi.mocked(readFile).mockResolvedValue("nested memory content")
+    const tool = createReadMemoryTool("/project/wiki/memory")
+    const result = await tool.execute({ path: "/project/wiki/memory/角色/主角.md" })
+    expect(result).toBe("nested memory content")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/memory/角色/主角.md")
+  })
+
   it("read_outline reads from outlines dir", async () => {
     vi.mocked(readFile).mockResolvedValue("outline content")
     const tool = createReadOutlineTool("/project/wiki/outlines")
@@ -48,6 +56,14 @@ describe("read tools", () => {
     expect(result).toContain("sim data")
   })
 
+  it("read_deduction reads an explicit framework or result path", async () => {
+    vi.mocked(readFile).mockResolvedValue("# framework")
+    const tool = createReadDeductionTool("/project/.qmai/simulations")
+    const result = await tool.execute({ path: "/project/.qmai/simulations/frameworks/main.md" })
+    expect(result).toBe("# framework")
+    expect(readFile).toHaveBeenCalledWith("/project/.qmai/simulations/frameworks/main.md")
+  })
+
   it("search_chapters searches by keyword", async () => {
     const tool = createSearchChaptersTool("/project/wiki/chapters")
     const result = await tool.execute({ keyword: "无我" })

+ 3 - 2
src/lib/changelog.spec.ts

@@ -6,7 +6,8 @@ describe("changelog", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 24)).toEqual([
+    expect(versions.slice(0, 25)).toEqual([
+      "2.2.31",
       "2.2.30",
       "2.2.29",
       "2.2.27",
@@ -32,7 +33,7 @@ describe("changelog", () => {
       "2.2.0",
       "2.1.0",
     ])
-    expect(versions[24]).toBe("2.0.0")
+    expect(versions[25]).toBe("2.0.0")
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)

+ 52 - 4
src/lib/reference/providers.spec.ts

@@ -17,12 +17,19 @@ vi.mock("@/commands/fs", () => ({
   listDirectory: mocks.listDirectory,
 }))
 
-function file(name: string) {
-  return { name, path: name, is_dir: false }
+type MockNode = {
+  name: string
+  path: string
+  is_dir: boolean
+  children?: MockNode[]
 }
 
-function dir(name: string) {
-  return { name, path: name, is_dir: true }
+function file(name: string, path = name): MockNode {
+  return { name, path, is_dir: false }
+}
+
+function dir(name: string, path = name, children: MockNode[] = []): MockNode {
+  return { name, path, is_dir: true, children }
 }
 
 describe("reference providers", () => {
@@ -68,6 +75,47 @@ describe("reference providers", () => {
     ])
   })
 
+  it("loads nested chapter and memory markdown files instead of hiding folder contents", async () => {
+    mocks.listDirectory
+      .mockResolvedValueOnce([
+        dir("第一卷", "C:/Novel/wiki/chapters/第一卷", [
+          file("第1章.md", "C:/Novel/wiki/chapters/第一卷/第1章.md"),
+        ]),
+      ])
+      .mockResolvedValueOnce([
+        dir("角色", "C:/Novel/wiki/memory/角色", [
+          file("主角.md", "C:/Novel/wiki/memory/角色/主角.md"),
+        ]),
+        file("canon-facts.md", "C:/Novel/wiki/memory/canon-facts.md"),
+      ])
+
+    await expect(chapterProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      { category: "chapter", title: "第一卷/第1章", path: "C:/Novel/wiki/chapters/第一卷/第1章.md" },
+    ])
+    await expect(memoryProvider.fetchItems("C:/Novel")).resolves.toMatchObject([
+      { category: "memory", title: "角色/主角", path: "C:/Novel/wiki/memory/角色/主角.md" },
+      { category: "memory", title: "canon-facts", path: "C:/Novel/wiki/memory/canon-facts.md" },
+    ])
+  })
+
+  it("loads deduction room frameworks and nested simulation results", async () => {
+    mocks.listDirectory.mockResolvedValue([
+      dir("frameworks", "C:/Novel/.qmai/simulations/frameworks", [
+        file("主线框架.md", "C:/Novel/.qmai/simulations/frameworks/主线框架.md"),
+      ]),
+      dir("results", "C:/Novel/.qmai/simulations/results", [
+        dir("framework-1", "C:/Novel/.qmai/simulations/results/framework-1", [
+          file("result-1.json", "C:/Novel/.qmai/simulations/results/framework-1/result-1.json"),
+        ]),
+      ]),
+    ])
+
+    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" },
+    ])
+  })
+
   it("returns an empty list when a file source cannot be read", async () => {
     mocks.listDirectory.mockRejectedValue(new Error("missing"))
 

+ 73 - 18
src/lib/reference/providers.ts

@@ -1,6 +1,13 @@
 import { listDirectory } from "@/commands/fs"
 import type { ReferenceCategory, ReferenceToken } from "./types"
 
+type ReferenceFileNode = {
+  name: string
+  path?: string
+  is_dir: boolean
+  children?: ReferenceFileNode[]
+}
+
 export interface ReferenceProvider {
   category: ReferenceCategory
   fetchItems: (projectPath: string) => Promise<ReferenceToken[]>
@@ -28,10 +35,59 @@ function normalizeProjectPath(projectPath: string): string {
   return projectPath.replace(/\\/g, "/")
 }
 
+function nodePath(baseDir: string, node: ReferenceFileNode): string {
+  const normalizedPath = node.path ? normalizeProjectPath(node.path) : ""
+  if (normalizedPath && normalizedPath !== node.name) return normalizedPath
+  return `${baseDir}/${node.name}`
+}
+
+function stripExtension(name: string, extensions: string[]): string {
+  const extension = extensions.find((candidate) => name.toLowerCase().endsWith(candidate))
+  return extension ? name.slice(0, -extension.length) : name
+}
+
+async function collectReferenceFiles(
+  baseDir: string,
+  nodes: ReferenceFileNode[],
+  extensions: string[],
+  parentParts: string[] = [],
+  visitedDirs = new Set<string>(),
+): Promise<Array<{ title: string; path: string }>> {
+  const items: Array<{ title: string; path: string }> = []
+
+  for (const node of nodes) {
+    const path = nodePath(baseDir, node)
+    if (node.is_dir) {
+      const nextParentParts = [...parentParts, node.name]
+      const childNodes = node.children ?? await (async () => {
+        if (visitedDirs.has(path)) return []
+        visitedDirs.add(path)
+        try {
+          return await listDirectory(path) as ReferenceFileNode[]
+        } catch {
+          return []
+        }
+      })()
+      items.push(...await collectReferenceFiles(path, childNodes, extensions, nextParentParts, visitedDirs))
+      continue
+    }
+
+    if (!extensions.some((extension) => node.name.toLowerCase().endsWith(extension))) {
+      continue
+    }
+
+    const stem = stripExtension(node.name, extensions)
+    const title = [...parentParts, stem].join("/")
+    items.push({ title, path })
+  }
+
+  return items
+}
+
 function createFileProvider(
   category: Extract<ReferenceCategory, "chapter" | "memory" | "outline" | "deduction">,
   relativeDir: string,
-  extension: ".md" | ".json",
+  extensions: Array<".md" | ".json">,
 ): ReferenceProvider {
   return {
     category,
@@ -39,19 +95,18 @@ function createFileProvider(
       try {
         const pp = normalizeProjectPath(projectPath)
         const dirPath = `${pp}/${relativeDir}`
-        const files = await listDirectory(dirPath)
-        return files
-          .filter((file) => !file.is_dir && file.name.endsWith(extension))
-          .map((file) => {
-            const title = file.name.slice(0, -extension.length)
-            return {
-              id: simpleId(),
-              category,
-              title,
-              path: `${dirPath}/${file.name}`,
-              displayTitle: truncateTitle(title),
-            }
-          })
+        const files = await collectReferenceFiles(
+          dirPath,
+          await listDirectory(dirPath) as ReferenceFileNode[],
+          extensions,
+        )
+        return files.map((file) => ({
+          id: simpleId(),
+          category,
+          title: file.title,
+          path: file.path,
+          displayTitle: truncateTitle(file.title),
+        }))
       } catch {
         return []
       }
@@ -62,25 +117,25 @@ function createFileProvider(
 export const chapterProvider = createFileProvider(
   "chapter",
   "wiki/chapters",
-  ".md",
+  [".md"],
 )
 
 export const memoryProvider = createFileProvider(
   "memory",
   "wiki/memory",
-  ".md",
+  [".md"],
 )
 
 export const outlineProvider = createFileProvider(
   "outline",
   "wiki/outlines",
-  ".md",
+  [".md"],
 )
 
 export const deductionProvider = createFileProvider(
   "deduction",
   ".qmai/simulations",
-  ".json",
+  [".md", ".json"],
 )
 
 export function createSkillProvider(getSkills: () => SkillSummary[]): ReferenceProvider {

+ 17 - 0
src/stores/chat-store.ts

@@ -38,6 +38,7 @@ interface ChatState {
   messages: DisplayMessage[]
   /** 按会话 ID 存储流式内容,支持多会话同时生成 */
   streamingContents: Record<string, string>
+  pendingReferenceTokens: ReferenceToken[]
   mode: "chat" | "ingest"
   ingestSource: string | null
   maxHistoryMessages: number
@@ -71,6 +72,8 @@ interface ChatState {
   setMaxHistoryMessages: (n: number) => void
   removeLastAssistantMessage: () => void  // for regenerate: remove last assistant reply
   markLastAssistantDiscarded: () => void   // for novel draft discard
+  enqueueReferenceTokens: (tokens: ReferenceToken[]) => void
+  consumePendingReferenceTokens: () => ReferenceToken[]
 
   // Helpers
   getActiveMessages: () => DisplayMessage[]
@@ -96,6 +99,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
   activeConversationId: null,
   messages: [],
   streamingContents: {},
+  pendingReferenceTokens: [],
   mode: "chat",
   ingestSource: null,
   maxHistoryMessages: 20,
@@ -309,6 +313,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
       }
     }),
 
+  enqueueReferenceTokens: (tokens) => {
+    if (tokens.length === 0) return
+    set((state) => ({
+      pendingReferenceTokens: [...state.pendingReferenceTokens, ...tokens],
+    }))
+  },
+
+  consumePendingReferenceTokens: () => {
+    const tokens = get().pendingReferenceTokens
+    set({ pendingReferenceTokens: [] })
+    return tokens
+  },
+
   getActiveMessages: () => {
     const { messages, activeConversationId } = get()
     if (!activeConversationId) return []