Просмотр исходного кода

大纲升级:删除底栏停靠统一右侧模式 + 修复保存格式错误 + 修复手动保存无弹窗 + 移除窗口标题剧情推演版

Mochocyang 2 месяцев назад
Родитель
Сommit
d87a805998

+ 1 - 1
src-tauri/tauri.conf.json

@@ -12,7 +12,7 @@
   "app": {
     "windows": [
       {
-        "title": "青幕AI写作 - 剧情推演版",
+        "title": "青幕AI写作",
         "width": 1200,
         "height": 800,
         "resizable": true,

+ 0 - 41
src/components/chat/chat-dock-controls.spec.tsx

@@ -1,41 +0,0 @@
-import { renderToStaticMarkup } from "react-dom/server"
-import { beforeEach, describe, expect, it, vi } from "vitest"
-import { ChatDockControls } from "./chat-dock-controls"
-
-const mocks = vi.hoisted(() => ({
-  state: {
-    chatDockPosition: "bottom" as "bottom" | "right",
-    setChatDockPosition: vi.fn(),
-  },
-}))
-
-vi.mock("@/stores/wiki-store", () => ({
-  useWikiStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
-}))
-
-describe("ChatDockControls", () => {
-  beforeEach(() => {
-    mocks.state.chatDockPosition = "bottom"
-    mocks.state.setChatDockPosition.mockClear()
-  })
-
-  it("shows only the sidebar dock option when chat is docked at the bottom", () => {
-    mocks.state.chatDockPosition = "bottom"
-
-    const html = renderToStaticMarkup(<ChatDockControls />)
-
-    expect(html).toContain("停靠在侧栏")
-    expect(html).not.toContain("停靠在底栏")
-    expect((html.match(/<button/g) || []).length).toBe(1)
-  })
-
-  it("shows only the bottom dock option when chat is docked at the sidebar", () => {
-    mocks.state.chatDockPosition = "right"
-
-    const html = renderToStaticMarkup(<ChatDockControls />)
-
-    expect(html).toContain("停靠在底栏")
-    expect(html).not.toContain("停靠在侧栏")
-    expect((html.match(/<button/g) || []).length).toBe(1)
-  })
-})

+ 0 - 32
src/components/chat/chat-dock-controls.tsx

@@ -1,32 +0,0 @@
-import { PanelBottom, PanelRight } from "lucide-react"
-import { useWikiStore, type ChatDockPosition } from "@/stores/wiki-store"
-
-const DOCK_TARGETS: Record<ChatDockPosition, {
-  value: ChatDockPosition
-  label: string
-  icon: typeof PanelBottom
-}> = {
-  bottom: { value: "right", label: "停靠在侧栏", icon: PanelRight },
-  right: { value: "bottom", label: "停靠在底栏", icon: PanelBottom },
-}
-
-export function ChatDockControls() {
-  const chatDockPosition = useWikiStore((s) => s.chatDockPosition)
-  const setChatDockPosition = useWikiStore((s) => s.setChatDockPosition)
-  const target = DOCK_TARGETS[chatDockPosition]
-  const Icon = target.icon
-
-  return (
-    <div className="flex shrink-0 items-center gap-1 rounded-md border border-border/70 bg-muted/30 p-0.5">
-      <button
-        type="button"
-        onClick={() => setChatDockPosition(target.value)}
-        className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
-        title={target.label}
-        aria-label={target.label}
-      >
-        <Icon className="h-3.5 w-3.5" />
-      </button>
-    </div>
-  )
-}

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

@@ -338,6 +338,11 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("topConversations.map((conv) => renderConversationChip(conv))")
     expect(source).not.toContain("historyConversations = sorted.filter((conv) => conv.id !== activeConversationId)")
   })
+
+  it("pins the conversation history control to the far right of the AI chat toolbar", () => {
+    expect(source).toContain('className="relative ml-auto shrink-0"')
+    expect(source).toContain("qmai-history-button")
+  })
 })
 
 describe("chat-panel chapter plan confirm integration (Stage C)", () => {

+ 3 - 7
src/components/chat/chat-panel.tsx

@@ -5,9 +5,8 @@ import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, Che
 import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { ChatMessage, StreamingMessage } from "./chat-message"
-import { ChatDockControls } from "./chat-dock-controls"
-import { useSourceFiles } from "./chat-shared"
 import { ChatModelSelector } from "./chat-model-selector"
+import { useSourceFiles } from "./chat-shared"
 import {
   ChapterPlanConfirmDialog,
   extractChapterPlan,
@@ -576,8 +575,7 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
 
   // 顶部统一为三段式:新建写作绘画 / 正在工作的绘画 / 绘画历史记录
   return (
-    <div className="flex h-12 shrink-0 items-center border-b bg-muted/20 px-2">
-      <div className="flex items-center gap-2">
+    <div className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2">
         {/* 1. 新建写作绘画 */}
         <Button
           variant="ghost"
@@ -604,7 +602,7 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
         </div>
 
         {/* 3. 绘画历史记录(点击展开下拉面板,显示全部历史会话) */}
-        <div className="relative shrink-0" ref={historyRef}>
+        <div className="relative ml-auto shrink-0" ref={historyRef}>
           <Button
             ref={historyButtonRef}
             variant="ghost"
@@ -643,7 +641,6 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
               document.body,
             )}
         </div>
-      </div>
     </div>
   )
 }
@@ -1802,7 +1799,6 @@ export function ChatPanel() {
             <div className="mb-2 flex items-center justify-between gap-2">
               <TooltipProvider delay={200}>
                 <div className="flex min-w-0 items-center gap-2 overflow-x-auto">
-                  <ChatDockControls />
                   <DeAiSkillPicker
                     value={activeConversation?.selectedDeAiSkillId}
                     buttonLabel="技能库"

+ 0 - 32
src/components/layout/chat-bar.tsx

@@ -1,32 +0,0 @@
-import { MessageSquare, ChevronDown } from "lucide-react"
-import { useWikiStore } from "@/stores/wiki-store"
-import { ChatPanel } from "@/components/chat/chat-panel"
-import { getChatBarVisibility } from "./chat-layout"
-
-export function ChatBar() {
-  const chatExpanded = useWikiStore((s) => s.chatExpanded)
-  const chatDockPosition = useWikiStore((s) => s.chatDockPosition)
-  const setChatExpanded = useWikiStore((s) => s.setChatExpanded)
-
-  if (getChatBarVisibility(chatExpanded, chatDockPosition) === "hidden") {
-    return null
-  }
-
-  return (
-    <div className="flex h-full flex-col">
-      <button
-        onClick={() => setChatExpanded(false)}
-        className="flex w-full items-center justify-between border-b px-4 py-2 text-sm text-muted-foreground hover:bg-accent/50"
-      >
-        <span className="flex items-center gap-2">
-          <MessageSquare className="h-4 w-4" />
-          AI 对话
-        </span>
-        <ChevronDown className="h-4 w-4" />
-      </button>
-      <div className="min-h-0 flex-1 overflow-hidden">
-        <ChatPanel />
-      </div>
-    </div>
-  )
-}

+ 5 - 22
src/components/layout/chat-layout.spec.ts

@@ -1,26 +1,9 @@
 import { describe, expect, it } from "vitest"
-import {
-  getChatBarVisibility,
-  shouldShowRightDockChat,
-  shouldShowWritingChat,
-} from "./chat-layout"
+import { getNextChatExpanded } from "./chat-layout"
 
-describe("chat layout docking", () => {
-  it("shows the writing chat in the bottom dock by default", () => {
-    expect(getChatBarVisibility(true, "bottom")).toBe("expanded")
-    expect(shouldShowWritingChat(true, "bottom")).toBe(true)
-    expect(shouldShowRightDockChat(true, "bottom")).toBe(false)
-  })
-
-  it("moves the writing chat to the right dock when configured", () => {
-    expect(getChatBarVisibility(true, "right")).toBe("hidden")
-    expect(shouldShowWritingChat(true, "right")).toBe(false)
-    expect(shouldShowRightDockChat(true, "right")).toBe(true)
-  })
-
-  it("keeps every dock hidden when chat is collapsed", () => {
-    expect(getChatBarVisibility(false, "bottom")).toBe("hidden")
-    expect(shouldShowWritingChat(false, "bottom")).toBe(false)
-    expect(shouldShowRightDockChat(false, "right")).toBe(false)
+describe("chat layout", () => {
+  it("toggles chat expanded state", () => {
+    expect(getNextChatExpanded(true)).toBe(false)
+    expect(getNextChatExpanded(false)).toBe(true)
   })
 })

+ 0 - 18
src/components/layout/chat-layout.ts

@@ -1,21 +1,3 @@
-import type { ChatDockPosition } from "@/stores/wiki-store"
-
-export function getChatBarVisibility(chatExpanded: boolean, chatDockPosition: ChatDockPosition = "bottom") {
-  return chatExpanded && chatDockPosition === "bottom" ? "expanded" : "hidden"
-}
-
 export function getNextChatExpanded(chatExpanded: boolean) {
   return !chatExpanded
 }
-
-export function shouldShowWritingChat(chatExpanded: boolean, chatDockPosition: ChatDockPosition = "bottom") {
-  return chatExpanded && chatDockPosition === "bottom"
-}
-
-export function shouldShowRightDockChat(chatExpanded: boolean, chatDockPosition: ChatDockPosition = "bottom") {
-  return chatExpanded && chatDockPosition === "right"
-}
-
-export function getChapterToolbarOrder() {
-  return ["ai-session", "de-ai", "chapter-status"]
-}

+ 347 - 43
src/components/layout/knowledge-tree.tsx

@@ -1,10 +1,10 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
 import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, MessageCircle, Pencil, Plus, Sparkles, Trash2, Check, X } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { ScrollArea } from "@/components/ui/scroll-area"
 import { Button } from "@/components/ui/button"
 import { useWikiStore } from "@/stores/wiki-store"
-import { deleteFile, fileExists, listDirectory, readFile, writeFile, openFileLocation, copyFile } from "@/commands/fs"
+import { createDirectory, deleteFile, fileExists, listDirectory, readFile, writeFile, openFileLocation, copyFile } from "@/commands/fs"
 import type { FileNode } from "@/types/wiki"
 import { buildChapterWordCountLabel, getChapterStatusLabel } from "@/lib/chapter-display"
 import { normalizePath } from "@/lib/path-utils"
@@ -78,6 +78,8 @@ interface CreateMenuState {
   targetFolderPath?: string
 }
 
+const EMPTY_PENDING_PAGES: WikiPageInfo[] = []
+
 function parseChineseNumber(input: string): number | null {
   const digitMap: Record<string, number> = {
     零: 0,
@@ -143,10 +145,10 @@ function truncateReferenceTitle(title: string, maxLen = 20): string {
   return title.length > maxLen ? `${title.slice(0, maxLen)}...` : title
 }
 
-function createChapterReferenceToken(page: WikiPageInfo): ReferenceToken {
+function createPageReferenceToken(page: WikiPageInfo): ReferenceToken {
   return {
     id: globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2),
-    category: "chapter",
+    category: page.type,
     title: page.title,
     path: page.path,
     displayTitle: truncateReferenceTitle(page.title),
@@ -194,6 +196,24 @@ function flattenAllFiles(nodes: FileNode[]): FileNode[] {
   return files
 }
 
+function flattenFolders(nodes: FileNode[]): FileNode[] {
+  const folders: FileNode[] = []
+  for (const node of nodes) {
+    if (!node.is_dir) continue
+    folders.push(node)
+    if (node.children) folders.push(...flattenFolders(node.children))
+  }
+  return folders
+}
+
+function getRelativePath(path: string, rootPath: string): string {
+  const normalizedPath = normalizePath(path)
+  const normalizedRoot = normalizePath(rootPath)
+  return normalizedPath.startsWith(`${normalizedRoot}/`)
+    ? normalizedPath.slice(normalizedRoot.length + 1)
+    : normalizedPath.split("/").pop() ?? ""
+}
+
 async function cleanupDeletedSourceMemory(
   projectPath: string,
   input: { kind: "chapter" | "outline"; pagePath: string; content?: string },
@@ -301,7 +321,7 @@ function countMarkdownDescendants(node: FileNode): number {
 export function KnowledgeTree({
   filterType,
   refreshKey,
-  pendingPages = [],
+  pendingPages = EMPTY_PENDING_PAGES,
   onRemovePendingPage,
   onRequestCreate,
   onSendToChat,
@@ -328,7 +348,11 @@ export function KnowledgeTree({
   const [renamingPath, setRenamingPath] = useState<string | null>(null)
   const [renameValue, setRenameValue] = useState("")
   const [renamingBusy, setRenamingBusy] = useState(false)
+  const [renamingFolderPath, setRenamingFolderPath] = useState<string | null>(null)
+  const [renameFolderValue, setRenameFolderValue] = useState("")
+  const [renamingFolderBusy, setRenamingFolderBusy] = useState(false)
   const [moveMenuTarget, setMoveMenuTarget] = useState<string | null>(null)
+  const [outlineDropFolderPath, setOutlineDropFolderPath] = useState<string | null>(null)
   const [chapterBatchMode, setChapterBatchMode] = useState(false)
   const [selectedChapterPaths, setSelectedChapterPaths] = useState<Set<string>>(() => new Set())
   const [dragSource, setDragSource] = useState<string | null>(null)
@@ -336,6 +360,8 @@ export function KnowledgeTree({
   const [isDragging, setIsDragging] = useState(false)
   const dragSourceRef = useRef<string | null>(null)
   const dragInsertIndexRef = useRef<number | null>(null)
+  const outlineDropFolderPathRef = useRef<string | null>(null)
+  const renameFolderValueRef = useRef("")
   const dragTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const activePointerIdRef = useRef<number | null>(null)
   const pendingPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
@@ -345,7 +371,8 @@ export function KnowledgeTree({
 
   useEffect(() => { dragSourceRef.current = dragSource }, [dragSource])
   useEffect(() => { dragInsertIndexRef.current = dragInsertIndex }, [dragInsertIndex])
-
+  useEffect(() => { outlineDropFolderPathRef.current = outlineDropFolderPath }, [outlineDropFolderPath])
+  useEffect(() => { renameFolderValueRef.current = renameFolderValue }, [renameFolderValue])
   const loadPages = useCallback(async () => {
     if (!project) return
     const projectPath = normalizePath(project.path)
@@ -527,6 +554,73 @@ export function KnowledgeTree({
     }))
   }, [sectionNodes])
 
+  const outlineFolders = useMemo(() => {
+    return flattenFolders(sectionNodes).map((node) => ({
+      name: node.name,
+      path: normalizePath(node.path),
+    }))
+  }, [sectionNodes])
+
+  const refreshCurrentTree = useCallback(async () => {
+    if (!project) return
+    await loadPages()
+    const tree = await listDirectory(normalizePath(project.path))
+    setFileTree(tree)
+    bumpDataVersion()
+  }, [bumpDataVersion, loadPages, project, setFileTree])
+
+  const handleMovePagesToFolder = useCallback(async (sourcePaths: string[], targetFolderPath: string) => {
+    if (!project) return
+    const normalizedTargetFolderPath = normalizePath(targetFolderPath)
+    const normalizedSources = Array.from(new Set(sourcePaths.map((sourcePath) => normalizePath(sourcePath))))
+    const plans: Array<{ source: string; dest: string }> = []
+    const plannedDestinations = new Set<string>()
+
+    for (const normalizedSource of normalizedSources) {
+      if (getDirName(normalizedSource) === normalizedTargetFolderPath) continue
+      const fileName = normalizedSource.split("/").pop()
+      if (!fileName) continue
+      const destPath = `${normalizedTargetFolderPath}/${fileName}`
+      if (normalizedSource === destPath) continue
+
+      if (plannedDestinations.has(destPath) || await fileExists(destPath)) {
+        window.alert(t("knowledgeTree.moveTargetExists", { defaultValue: "目标文件已存在,请先改名或选择其他文件夹。" }))
+        return
+      }
+
+      plannedDestinations.add(destPath)
+      plans.push({ source: normalizedSource, dest: destPath })
+    }
+
+    if (plans.length === 0) {
+      setMoveMenuTarget(null)
+      setPageMenu(null)
+      return
+    }
+
+    try {
+      await createDirectory(normalizedTargetFolderPath).catch(() => {})
+      for (const plan of plans) {
+        await copyFile(plan.source, plan.dest)
+        await deleteFile(plan.source)
+        onRemovePendingPage?.(plan.source)
+      }
+
+      await refreshCurrentTree()
+      const selectedMove = selectedFile ? plans.find((plan) => plan.source === normalizePath(selectedFile)) : undefined
+      if (selectedMove) {
+        setSelectedFile(selectedMove.dest)
+      }
+    } catch (error) {
+      console.error("[KnowledgeTree] move to folder failed:", error)
+      window.alert("移动失败,请稍后重试。")
+    } finally {
+      setMoveMenuTarget(null)
+      setPageMenu(null)
+      setOutlineDropFolderPath(null)
+    }
+  }, [project, onRemovePendingPage, refreshCurrentTree, selectedFile, setSelectedFile, t])
+
   const handleMoveChaptersToVolume = useCallback(async (sourcePaths: string[], targetVolumePath: string) => {
     if (!project) return
     const normalizedTargetVolumePath = normalizePath(targetVolumePath)
@@ -563,10 +657,7 @@ export function KnowledgeTree({
         await deleteFile(plan.source)
       }
 
-      await loadPages()
-      const tree = await listDirectory(normalizePath(project.path))
-      setFileTree(tree)
-      bumpDataVersion()
+      await refreshCurrentTree()
       setSelectedChapterPaths(new Set())
       setChapterBatchMode(false)
       const selectedMove = selectedFile ? plans.find((plan) => plan.source === normalizePath(selectedFile)) : undefined
@@ -579,7 +670,7 @@ export function KnowledgeTree({
       setMoveMenuTarget(null)
       setPageMenu(null)
     }
-  }, [project, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
+  }, [project, t, refreshCurrentTree, selectedFile, setSelectedFile])
 
   const handleExtractAllChapterMemories = useCallback(async () => {
     if (!project || filterType !== "chapter") return
@@ -800,6 +891,91 @@ export function KnowledgeTree({
     }
   }, [project, filterType, sectionNodes, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile, onRemovePendingPage])
 
+  const startRenameFolder = useCallback((folderPath: string, folderName: string) => {
+    setCreateMenu(null)
+    setPageMenu(null)
+    setIsDragging(false)
+    setDragSource(null)
+    setDragInsertIndex(null)
+    setOutlineDropFolderPath(null)
+    dragSourceRef.current = null
+    dragInsertIndexRef.current = null
+    if (dragTimerRef.current) {
+      clearTimeout(dragTimerRef.current)
+      dragTimerRef.current = null
+    }
+    removeGlobalPointerListenersRef.current?.()
+    removeGlobalPointerListenersRef.current = null
+    activePointerIdRef.current = null
+    pendingPointerPositionRef.current = null
+    setRenamingFolderPath(normalizePath(folderPath))
+    setRenameFolderValue(folderName)
+  }, [])
+
+  const submitRenameFolder = useCallback(async () => {
+    if (!project || !renamingFolderPath || renamingFolderBusy) return
+    const newName = renameFolderValueRef.current.trim()
+    if (!newName) {
+      setRenamingFolderPath(null)
+      setRenameFolderValue("")
+      return
+    }
+
+    const folderNode = findNodeByPath(sectionNodes, renamingFolderPath)
+    if (!folderNode?.is_dir || folderNode.name === newName) {
+      setRenamingFolderPath(null)
+      setRenameFolderValue("")
+      return
+    }
+
+    const targetFolderPath = `${getDirName(renamingFolderPath)}/${makeSafeFileSlug(newName, "folder")}`
+    if (targetFolderPath !== renamingFolderPath && await fileExists(targetFolderPath)) {
+      window.alert("目标文件夹已存在,请换一个名称。")
+      return
+    }
+
+    setRenamingFolderBusy(true)
+    try {
+      await createDirectory(targetFolderPath)
+      const mdFiles = flattenMdFiles([folderNode])
+      for (const file of mdFiles) {
+        const sourcePath = normalizePath(file.path)
+        const relativePath = getRelativePath(sourcePath, renamingFolderPath)
+        const destPath = `${targetFolderPath}/${relativePath}`
+        await createDirectory(getDirName(destPath)).catch(() => {})
+        if (await fileExists(destPath)) {
+          window.alert("目标文件已存在,请先改名或选择其他文件夹。")
+          return
+        }
+        await copyFile(sourcePath, destPath)
+      }
+
+      for (const file of mdFiles) {
+        await deleteFile(normalizePath(file.path))
+        onRemovePendingPage?.(normalizePath(file.path))
+      }
+      await deleteFile(renamingFolderPath)
+
+      await refreshCurrentTree()
+      if (selectedFile?.startsWith(`${renamingFolderPath}/`)) {
+        setSelectedFile(`${targetFolderPath}/${getRelativePath(selectedFile, renamingFolderPath)}`)
+      }
+    } catch (error) {
+      console.error("[KnowledgeTree] folder rename failed:", error)
+      window.alert("重命名文件夹失败,请稍后重试。")
+    } finally {
+      setRenamingFolderBusy(false)
+      setRenamingFolderPath(null)
+      setRenameFolderValue("")
+    }
+  }, [project, renamingFolderPath, renamingFolderBusy, renameFolderValue, sectionNodes, refreshCurrentTree, selectedFile, setSelectedFile, onRemovePendingPage])
+
+  const cancelRenameFolder = useCallback(() => {
+    if (renamingFolderBusy) return
+    setRenamingFolderPath(null)
+    setRenameFolderValue("")
+  }, [renamingFolderBusy])
+
   const updatePageTitleContent = useCallback((content: string, newTitle: string, newChapterNumber?: number | null) => {
     const escapedTitle = newTitle.replace(/"/g, '\\"')
     let next = content
@@ -994,6 +1170,7 @@ export function KnowledgeTree({
 
     const sourcePath = dragSourceRef.current
     const targetIndex = dragInsertIndexRef.current
+    const targetFolderPath = outlineDropFolderPathRef.current
 
     removeGlobalPointerListenersRef.current?.()
     removeGlobalPointerListenersRef.current = null
@@ -1001,17 +1178,36 @@ export function KnowledgeTree({
     pendingPointerPositionRef.current = null
     dragSourceRef.current = null
     dragInsertIndexRef.current = null
+    outlineDropFolderPathRef.current = null
     setIsDragging(false)
     setDragSource(null)
     setDragInsertIndex(null)
+    setOutlineDropFolderPath(null)
+
+    if (filterType === "outline" && sourcePath && targetFolderPath) {
+      void handleMovePagesToFolder([sourcePath], targetFolderPath)
+      return
+    }
 
-    if (sourcePath && targetIndex !== null) {
+    if (filterType === "chapter" && sourcePath && targetIndex !== null) {
       void executeChapterReorder(sourcePath, targetIndex)
     }
-  }, [executeChapterReorder])
+  }, [executeChapterReorder, filterType, handleMovePagesToFolder])
 
-  const updateDragInsertFromPoint = useCallback((clientX: number, clientY: number) => {
+  const updateDragTargetFromPoint = useCallback((clientX: number, clientY: number) => {
     const target = document.elementFromPoint(clientX, clientY)
+    if (filterType === "outline") {
+      const folderRow = target instanceof HTMLElement ? target.closest<HTMLElement>("[data-folder-path]") : null
+      const folderPath = folderRow?.dataset.folderPath
+      const sourcePath = dragSourceRef.current
+      const nextFolderPath = folderPath && sourcePath && getDirName(sourcePath) !== folderPath
+        ? folderPath
+        : null
+      outlineDropFolderPathRef.current = nextFolderPath
+      setOutlineDropFolderPath(nextFolderPath)
+      return
+    }
+
     const row = target instanceof HTMLElement ? target.closest<HTMLElement>("[data-page-path]") : null
     if (!row) return
 
@@ -1027,7 +1223,7 @@ export function KnowledgeTree({
 
     dragInsertIndexRef.current = insertIndex
     setDragInsertIndex(insertIndex)
-  }, [sortedChapterPages])
+  }, [filterType, sortedChapterPages])
 
   const handleContainerPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
     if (activePointerIdRef.current !== event.pointerId) return
@@ -1035,11 +1231,11 @@ export function KnowledgeTree({
     pendingPointerPositionRef.current = { x: event.clientX, y: event.clientY }
     if (!dragSourceRef.current) return
 
-    updateDragInsertFromPoint(event.clientX, event.clientY)
-  }, [updateDragInsertFromPoint])
+    updateDragTargetFromPoint(event.clientX, event.clientY)
+  }, [updateDragTargetFromPoint])
 
   const handleItemPointerDown = useCallback((event: React.PointerEvent, pagePath: string) => {
-    if (filterType !== "chapter" || renamingPath) return
+    if ((filterType !== "chapter" && filterType !== "outline") || renamingPath || renamingFolderPath) return
 
     lastPointerTypeRef.current = event.pointerType || "mouse"
     if (event.pointerType !== "mouse") {
@@ -1064,7 +1260,7 @@ export function KnowledgeTree({
       if (activePointerIdRef.current !== pointerEvent.pointerId) return
       pendingPointerPositionRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }
       if (!dragSourceRef.current) return
-      updateDragInsertFromPoint(pointerEvent.clientX, pointerEvent.clientY)
+      updateDragTargetFromPoint(pointerEvent.clientX, pointerEvent.clientY)
       pointerEvent.preventDefault()
     }
     window.addEventListener("pointerup", handlePointerFinish)
@@ -1084,10 +1280,10 @@ export function KnowledgeTree({
 
       const pointerPosition = pendingPointerPositionRef.current
       if (pointerPosition) {
-        updateDragInsertFromPoint(pointerPosition.x, pointerPosition.y)
+        updateDragTargetFromPoint(pointerPosition.x, pointerPosition.y)
       }
     }, 300)
-  }, [filterType, renamingPath, selectedFile, isDragging, finishDragInteraction, updateDragInsertFromPoint])
+  }, [filterType, renamingPath, renamingFolderPath, selectedFile, isDragging, finishDragInteraction, updateDragTargetFromPoint])
 
   const handlePageClick = useCallback((pagePath: string) => {
     setArmedPath(null)
@@ -1194,17 +1390,23 @@ export function KnowledgeTree({
       const normalizedPath = normalizePath(node.path)
       if (node.is_dir) {
         const isCollapsed = collapsedFolders[normalizedPath] ?? false
+        const isRenamingFolder = renamingFolderPath === normalizedPath
+        const isOutlineDropTarget = outlineDropFolderPath === normalizedPath
         const folderRow = (
           <div key={normalizedPath}>
             <div
               data-knowledge-interactive="true"
-              className="group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-muted-foreground qm-hover"
+              data-folder-path={normalizedPath}
+              className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-muted-foreground qm-hover ${isOutlineDropTarget ? "ring-2 ring-primary/50" : ""}`}
               style={{ paddingLeft: `${depth * 16 + 8}px` }}
               onContextMenu={(event) => openCreateMenu(event, normalizedPath, node.name)}
             >
               <button
                 type="button"
-                onClick={() => toggleFolder(normalizedPath)}
+                onClick={() => {
+                  if (!isRenamingFolder) toggleFolder(normalizedPath)
+                }}
+                disabled={isRenamingFolder}
                 className="flex flex-1 items-center gap-1.5 text-left"
               >
                 {isCollapsed ? (
@@ -1213,7 +1415,35 @@ export function KnowledgeTree({
                   <ChevronDown className="h-3.5 w-3.5 shrink-0" />
                 )}
                 <Folder className="h-4 w-4 shrink-0 text-amber-500" />
-                <span className="truncate font-medium">{node.name}</span>
+                {isRenamingFolder ? (
+                  <input
+                    type="text"
+                    value={renameFolderValue}
+              onChange={(event) => {
+                setRenameFolderValue(event.target.value)
+                renameFolderValueRef.current = event.target.value
+              }}
+              onMouseDown={(event) => event.stopPropagation()}
+                    onClick={(event) => event.stopPropagation()}
+                    onFocus={(event) => event.stopPropagation()}
+                    onBlur={() => void submitRenameFolder()}
+                    onKeyDown={(event) => {
+                      event.stopPropagation()
+                      if (event.key === "Enter") {
+                        event.preventDefault()
+                        void submitRenameFolder()
+                      } else if (event.key === "Escape") {
+                        event.preventDefault()
+                        cancelRenameFolder()
+                      }
+                    }}
+                    className="min-w-0 flex-1 rounded border bg-background px-1.5 py-0.5 text-xs outline-none focus:ring-1 focus:ring-ring"
+                    autoFocus
+                    disabled={renamingFolderBusy}
+                  />
+                ) : (
+                  <span className="truncate font-medium">{node.name}</span>
+                )}
                 <span className="ml-auto text-[10px] text-muted-foreground/60">{countMarkdownDescendants(node)}</span>
               </button>
             </div>
@@ -1353,10 +1583,16 @@ export function KnowledgeTree({
     renamingPath,
     renameValue,
     renamingBusy,
+    renamingFolderPath,
+    renameFolderValue,
+    renamingFolderBusy,
+    outlineDropFolderPath,
     openCreateMenu,
     toggleFolder,
     submitRenamePage,
     cancelRenamePage,
+    submitRenameFolder,
+    cancelRenameFolder,
     handleDeleteClick,
     handleItemPointerDown,
     handlePageContextMenu,
@@ -1487,20 +1723,37 @@ export function KnowledgeTree({
               {filterType === "chapter" ? t("sidebar.newVolume") : t("sidebar.newFolder")}
             </button>
             {createMenu.targetFolderPath ? (
-              <button
-                type="button"
-                className="flex w-full items-center gap-2 px-3 py-2 text-left text-destructive hover:bg-accent"
-                onClick={() => {
-                  const targetFolderPath = createMenu.targetFolderPath
-                  setCreateMenu(null)
-                  if (targetFolderPath) {
-                    void handleDeleteFolder(targetFolderPath)
-                  }
-                }}
-              >
-                <Trash2 className="h-3.5 w-3.5" />
-                {filterType === "chapter" ? t("knowledgeTree.deleteVolume") : t("knowledgeTree.deleteFolder")}
-              </button>
+              <>
+                <button
+                  type="button"
+                  className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                  onClick={() => {
+                    const targetFolderPath = createMenu.targetFolderPath
+                    const targetFolderName = createMenu.targetFolderName
+                    setCreateMenu(null)
+                    if (targetFolderPath && targetFolderName) {
+                      startRenameFolder(targetFolderPath, targetFolderName)
+                    }
+                  }}
+                >
+                  <Pencil className="h-3.5 w-3.5" />
+                  {t("knowledgeTree.rename")}
+                </button>
+                <button
+                  type="button"
+                  className="flex w-full items-center gap-2 px-3 py-2 text-left text-destructive hover:bg-accent"
+                  onClick={() => {
+                    const targetFolderPath = createMenu.targetFolderPath
+                    setCreateMenu(null)
+                    if (targetFolderPath) {
+                      void handleDeleteFolder(targetFolderPath)
+                    }
+                  }}
+                >
+                  <Trash2 className="h-3.5 w-3.5" />
+                  {filterType === "chapter" ? t("knowledgeTree.deleteVolume") : t("knowledgeTree.deleteFolder")}
+                </button>
+              </>
             ) : null}
           </div>
         )}
@@ -1603,6 +1856,43 @@ export function KnowledgeTree({
                 )}
               </div>
             )}
+            {filterType === "outline" && outlineFolders.length > 0 && (
+              <div>
+                <button
+                  type="button"
+                  className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                  onClick={() => setMoveMenuTarget(moveMenuTarget === pageMenu.path ? null : pageMenu.path)}
+                >
+                  <FolderInput className="h-3.5 w-3.5" />
+                  移动
+                  <ChevronRight className="ml-auto h-3 w-3" />
+                </button>
+                {moveMenuTarget === pageMenu.path && (
+                  <div className="max-h-48 overflow-y-auto border-t bg-background py-1 text-xs">
+                    {outlineFolders.map((folder) => {
+                      const isCurrentFolder = getDirName(pageMenu.path) === folder.path
+                      return (
+                        <button
+                          key={folder.path}
+                          type="button"
+                          className={`flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent ${isCurrentFolder ? "opacity-50 cursor-not-allowed" : ""}`}
+                          disabled={isCurrentFolder}
+                          onClick={() => {
+                            if (!isCurrentFolder) {
+                              void handleMovePagesToFolder([pageMenu.path], folder.path)
+                            }
+                          }}
+                        >
+                          <Folder className="h-3 w-3 shrink-0 text-amber-500" />
+                          <span className="truncate">{folder.name}</span>
+                          {isCurrentFolder && <span className="ml-auto text-[10px] text-muted-foreground">当前</span>}
+                        </button>
+                      )
+                    })}
+                  </div>
+                )}
+              </div>
+            )}
             {filterType === "chapter" && pageMenu && (
               <button
                 type="button"
@@ -1623,15 +1913,15 @@ export function KnowledgeTree({
               <FolderOpen className="h-4 w-4" />
               打开文件所在位置
             </button>
-            {filterType === "chapter" && (onSendToChat || onSendToOutline) && (
+            {((filterType === "chapter" && (onSendToChat || onSendToOutline)) || (filterType === "outline" && onSendToOutline)) && (
               <div className="mt-1 border-t pt-1">
-                {onSendToChat && (
+                {filterType === "chapter" && onSendToChat && (
                   <button
                     type="button"
                     className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
                     onClick={() => {
                       const target = pageInfoByPath.get(pageMenu.path)
-                      if (target) onSendToChat(createChapterReferenceToken(target))
+                      if (target) onSendToChat(createPageReferenceToken(target))
                       setPageMenu(null)
                     }}
                   >
@@ -1639,13 +1929,13 @@ export function KnowledgeTree({
                     发送到AI会话
                   </button>
                 )}
-                {onSendToOutline && (
+                {filterType === "chapter" && onSendToOutline && (
                   <button
                     type="button"
                     className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
                     onClick={() => {
                       const target = pageInfoByPath.get(pageMenu.path)
-                      if (target) onSendToOutline(createChapterReferenceToken(target))
+                      if (target) onSendToOutline(createPageReferenceToken(target))
                       setPageMenu(null)
                     }}
                   >
@@ -1653,6 +1943,20 @@ export function KnowledgeTree({
                     发送到AI大纲
                   </button>
                 )}
+                {filterType === "outline" && onSendToOutline && (
+                  <button
+                    type="button"
+                    className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                    onClick={() => {
+                      const target = pageInfoByPath.get(pageMenu.path)
+                      if (target) onSendToOutline(createPageReferenceToken(target))
+                      setPageMenu(null)
+                    }}
+                  >
+                    <FileText className="h-3.5 w-3.5" />
+                    发送到对话
+                  </button>
+                )}
               </div>
             )}
           </div>

+ 22 - 38
src/components/layout/sidebar-panel.tsx

@@ -27,13 +27,15 @@ 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 { useOutlineChatStore } from "@/stores/outline-chat-store"
+import { useOutlineGenerationStore } from "@/stores/outline-generation-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"
 import type { StoryFramework } from "@/lib/novel/story-simulation/types"
 import { createDirectory, fileExists, listDirectory, preprocessFile, readFile, writeFile } from "@/commands/fs"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
-import { buildChapterTotalWordCountLabel } from "@/lib/chapter-display"
 import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
 import {
   loadDismantlingLibrary,
@@ -67,6 +69,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 +676,11 @@ 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 setActiveView = useWikiStore((s) => s.setActiveView)
+  const enqueueChatReferenceTokens = useChatStore((s) => s.enqueueReferenceTokens)
+  const enqueueOutlineReferenceTokens = useOutlineChatStore((s) => s.enqueueReferenceTokens)
+  const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const [mode, setMode] = useState<"knowledge" | "files">("knowledge")
   const [refreshKey, setRefreshKey] = useState(0)
@@ -683,7 +691,6 @@ export function SidebarPanel() {
   const [memoryData, setMemoryData] = useState<MemoryCenterData | null>(null)
   const [memoryLoading, setMemoryLoading] = useState(false)
   const [memoryError, setMemoryError] = useState<string | null>(null)
-  const [sidebarTotalWordCount, setSidebarTotalWordCount] = useState<number | null>(null)
   const [outlineImporting, setOutlineImporting] = useState(false)
   const [outlineImportMenuOpen, setOutlineImportMenuOpen] = useState(false)
   const outlineImportMenuRef = useRef<HTMLDivElement | null>(null)
@@ -721,37 +728,6 @@ export function SidebarPanel() {
 
   const isChapter = mode === "knowledge"
 
-  useEffect(() => {
-    if (!project || !isChapter) {
-      setSidebarTotalWordCount(null)
-      return
-    }
-
-    let cancelled = false
-
-    const loadSidebarTotalWordCount = async () => {
-      try {
-        const chapterNodes = await listDirectory(`${normalizePath(project.path)}/wiki/chapters`)
-        const files = flattenMdFiles(chapterNodes)
-        const contents = await Promise.all(files.map((file) => readFile(file.path).catch(() => "")))
-        const total = contents.reduce((sum, markdown) => sum + countChapterBodyWords(markdown), 0)
-        if (!cancelled) {
-          setSidebarTotalWordCount(total)
-        }
-      } catch {
-        if (!cancelled) {
-          setSidebarTotalWordCount(null)
-        }
-      }
-    }
-
-    void loadSidebarTotalWordCount()
-
-    return () => {
-      cancelled = true
-    }
-  }, [dataVersion, isChapter, project])
-
   useEffect(() => {
     if (!pendingCreate?.kind) return
     if (isChapter && (pendingCreate.kind === "outline" || pendingCreate.kind === "folder")) {
@@ -1177,6 +1153,17 @@ export function SidebarPanel() {
     setInputTitle("")
   }
 
+  const handleSendChapterToChat = useCallback((token: ReferenceToken) => {
+    enqueueChatReferenceTokens([token])
+    setChatExpanded(true)
+  }, [enqueueChatReferenceTokens, setChatExpanded])
+
+  const handleSendOutlineToOutlineChat = useCallback((token: ReferenceToken) => {
+    enqueueOutlineReferenceTokens([token])
+    setOutlineChatOpen(true)
+    setActiveView("sources")
+  }, [enqueueOutlineReferenceTokens, setActiveView, setOutlineChatOpen])
+
   const inputPlaceholder = pendingCreate?.kind === "outline"
     ? t("sidebar.newOutlinePrompt")
     : pendingCreate?.kind === "volume"
@@ -1356,11 +1343,6 @@ export function SidebarPanel() {
               helpTitle={isChapter ? "章节功能使用说明" : "大纲功能使用说明"}
             />
           </div>
-          {isChapter && sidebarTotalWordCount !== null ? (
-            <div className="mt-0.5 text-xs text-muted-foreground">
-              {buildChapterTotalWordCountLabel(sidebarTotalWordCount)}
-            </div>
-          ) : null}
         </div>
         <div className="flex items-center gap-1">
           {isChapter ? (
@@ -1494,6 +1476,8 @@ export function SidebarPanel() {
           pendingPages={pendingPages.filter((page) => page.type === (isChapter ? "chapter" : "outline"))}
           onRemovePendingPage={handleRemovePendingPage}
           onRequestCreate={beginCreate}
+          onSendToChat={isChapter ? handleSendChapterToChat : undefined}
+          onSendToOutline={!isChapter ? handleSendOutlineToOutlineChat : undefined}
         />
       </div>
       <div className="border-t px-3 py-2">

+ 4 - 56
src/components/layout/writing-workspace.tsx

@@ -1,8 +1,7 @@
 import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"
 import { PreviewPanel } from "./preview-panel"
-import { clampChatHeight, clampChatWidth, getInitialChatWidth } from "@/lib/workspace-layout"
+import { clampChatWidth, getInitialChatWidth } from "@/lib/workspace-layout"
 import { useWikiStore } from "@/stores/wiki-store"
-import { shouldShowRightDockChat, shouldShowWritingChat } from "./chat-layout"
 
 const ChatPanel = lazy(async () => {
   const mod = await import("@/components/chat/chat-panel")
@@ -11,57 +10,19 @@ const ChatPanel = lazy(async () => {
 
 export function WritingWorkspace() {
   const containerRef = useRef<HTMLDivElement>(null)
-  const resizingRef = useRef(false)
   const horizontalResizingRef = useRef(false)
   const chatExpanded = useWikiStore((s) => s.chatExpanded)
-  const chatDockPosition = useWikiStore((s) => s.chatDockPosition)
-  const [chatHeight, setChatHeight] = useState(260)
   const [chatWidth, setChatWidth] = useState(() => getInitialChatWidth())
 
   useEffect(() => {
-    const saved = Number(localStorage.getItem("lk-chat-height") ?? "260")
-    if (Number.isFinite(saved) && saved > 0) {
-      setChatHeight(clampChatHeight(saved))
-    }
     const savedWidth = Number(localStorage.getItem("lk-chat-right-width"))
     setChatWidth(getInitialChatWidth(savedWidth))
   }, [])
 
-  useEffect(() => {
-    localStorage.setItem("lk-chat-height", String(chatHeight))
-  }, [chatHeight])
-
   useEffect(() => {
     localStorage.setItem("lk-chat-right-width", String(chatWidth))
   }, [chatWidth])
 
-  const startResize = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
-    event.preventDefault()
-    resizingRef.current = true
-    document.body.style.cursor = "row-resize"
-    document.body.style.userSelect = "none"
-    document.body.dataset.panelResizing = "true"
-
-    const handleMouseMove = (nextEvent: MouseEvent) => {
-      if (!resizingRef.current || !containerRef.current) return
-      const rect = containerRef.current.getBoundingClientRect()
-      const nextHeight = rect.bottom - nextEvent.clientY
-      setChatHeight(clampChatHeight(nextHeight))
-    }
-
-    const handleMouseUp = () => {
-      resizingRef.current = false
-      document.body.style.cursor = ""
-      document.body.style.userSelect = ""
-      delete document.body.dataset.panelResizing
-      document.removeEventListener("mousemove", handleMouseMove)
-      document.removeEventListener("mouseup", handleMouseUp)
-    }
-
-    document.addEventListener("mousemove", handleMouseMove)
-    document.addEventListener("mouseup", handleMouseUp)
-  }, [])
-
   const startHorizontalResize = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
     event.preventDefault()
     horizontalResizingRef.current = true
@@ -89,7 +50,7 @@ export function WritingWorkspace() {
     document.addEventListener("mouseup", handleMouseUp)
   }, [])
 
-  if (shouldShowRightDockChat(chatExpanded, chatDockPosition)) {
+  if (chatExpanded) {
     return (
       <div ref={containerRef} className="flex h-full min-h-0 overflow-hidden bg-background">
         <div className="min-w-0 min-h-0 flex-1 overflow-hidden">
@@ -109,23 +70,10 @@ export function WritingWorkspace() {
   }
 
   return (
-    <div ref={containerRef} className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
-      <div className="min-h-0 flex-1 overflow-hidden">
+    <div ref={containerRef} className="flex h-full min-h-0 overflow-hidden bg-background">
+      <div className="min-w-0 min-h-0 flex-1 overflow-hidden">
         <PreviewPanel />
       </div>
-      {shouldShowWritingChat(chatExpanded, chatDockPosition) && (
-        <>
-          <div
-            className="h-1.5 shrink-0 cursor-row-resize bg-border/40 transition-colors hover:bg-primary/30 active:bg-primary/40"
-            onMouseDown={startResize}
-          />
-          <div className="shrink-0 overflow-hidden border-t bg-background" style={{ height: chatHeight }}>
-            <Suspense fallback={<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading...</div>}>
-              <ChatPanel />
-            </Suspense>
-          </div>
-        </>
-      )}
     </div>
   )
 }

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

@@ -135,6 +135,28 @@ describe("ReferenceInput", () => {
     expect(footer?.querySelector("[aria-label='发送消息']")).toBeTruthy()
   })
 
+  it("places left footer controls before the reference trigger", async () => {
+    await act(async () => {
+      root.render(
+        <ReferenceInput
+          tokens={[]}
+          onSubmit={vi.fn()}
+          leftFooterControls={<button type="button">生成大纲</button>}
+          rightControls={<button type="button">模型选择</button>}
+        />,
+      )
+    })
+
+    const footer = host.querySelector("[data-reference-input-footer]")
+    const text = footer?.textContent ?? ""
+    expect(text.indexOf("生成大纲")).toBeGreaterThanOrEqual(0)
+    expect(text.indexOf("模型选择")).toBeGreaterThan(text.indexOf("生成大纲"))
+    const leftControl = Array.from(footer?.querySelectorAll("button") ?? [])
+      .find((button) => button.textContent === "生成大纲")
+    const referenceButton = footer?.querySelector("[aria-label='引用内容']")
+    expect(Boolean(leftControl?.compareDocumentPosition(referenceButton!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true)
+  })
+
   it("shows a stop action in the footer while streaming", async () => {
     const onStop = vi.fn()
 

+ 7 - 0
src/components/reference/ReferenceInput.tsx

@@ -25,6 +25,7 @@ interface ReferenceInputProps {
   placeholder?: string
   disabled?: boolean
   isStreaming?: boolean
+  leftFooterControls?: ReactNode
   rightControls?: ReactNode
   onChange?: (plainText: string, tokens: ReferenceToken[]) => void
   onTokensChange?: (tokens: ReferenceToken[]) => void
@@ -57,6 +58,7 @@ export function ReferenceInput({
   placeholder = "输入提示词,或 @ 引用内容...",
   disabled = false,
   isStreaming = false,
+  leftFooterControls,
   rightControls,
   onChange,
   onTokensChange,
@@ -234,6 +236,11 @@ export function ReferenceInput({
         data-reference-input-footer
         className="flex items-center justify-between gap-2 border-t px-2 py-1.5"
       >
+        {leftFooterControls ? (
+          <div className="flex min-w-0 shrink-0 items-center gap-2">
+            {leftFooterControls}
+          </div>
+        ) : null}
         <button
           type="button"
           className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"

+ 13 - 0
src/components/sources/outline-action-toolbar.spec.ts

@@ -0,0 +1,13 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "outline-action-toolbar.tsx"), "utf8")
+
+describe("OutlineActionToolbar", () => {
+  it("toggles the AI outline panel instead of only opening it", () => {
+    expect(source).toContain("outlineChatOpen")
+    expect(source).toContain("setOutlineChatOpen(!outlineChatOpen)")
+    expect(source).toContain('aria-pressed={outlineChatOpen}')
+  })
+})

+ 4 - 3
src/components/sources/outline-action-toolbar.tsx

@@ -23,6 +23,7 @@ export function OutlineActionToolbar({
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
   const setActiveView = useWikiStore((s) => s.setActiveView)
+  const outlineChatOpen = useOutlineGenerationStore((s) => s.panelOpen)
   const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
   const [bulkIngestRunning, setBulkIngestRunning] = useState(false)
 
@@ -41,9 +42,9 @@ export function OutlineActionToolbar({
       onToggleOutlineChat()
       return
     }
-    setOutlineChatOpen(true)
+    setOutlineChatOpen(!outlineChatOpen)
     setActiveView("sources")
-  }, [onToggleOutlineChat, setActiveView, setOutlineChatOpen])
+  }, [onToggleOutlineChat, outlineChatOpen, setActiveView, setOutlineChatOpen])
 
   const handleBulkIngest = useCallback(async () => {
     if (!project || bulkIngestActive) return
@@ -67,7 +68,7 @@ export function OutlineActionToolbar({
 
   return (
     <div className={cn("flex flex-wrap gap-1", className)}>
-      <Button size="sm" variant="outline" onClick={handleOpenOutlineChat}>
+      <Button size="sm" variant="outline" onClick={handleOpenOutlineChat} aria-pressed={outlineChatOpen}>
         <MessageSquare className="mr-1 h-4 w-4" />
         AI大纲
       </Button>

+ 12 - 9
src/components/sources/outline-chat-panel.spec.tsx

@@ -44,19 +44,22 @@ describe("OutlineChatPanel controls", () => {
     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("<ChatDockControls />")
+  it("keeps outline generation menu in the reference input footer before model selection", () => {
+    expect(source).toContain("leftFooterControls={")
+    expect(source).not.toContain("qmai-outline-bottom-left-controls")
     expect(source).toContain("<OutlineGenerationMenu")
     expect(source).toContain("<ChatModelSelector")
 
-    const dockIndex = source.indexOf("<ChatDockControls />")
+    const footerIndex = source.indexOf("leftFooterControls={")
     const outlineIndex = source.indexOf("<OutlineGenerationMenu")
+    const rightControlsIndex = source.indexOf("rightControls={")
     const modelIndex = source.indexOf("<ChatModelSelector")
 
-    expect(dockIndex).toBeGreaterThan(-1)
-    expect(outlineIndex).toBeGreaterThan(dockIndex)
-    expect(modelIndex).toBeGreaterThan(outlineIndex)
+    expect(footerIndex).toBeGreaterThan(-1)
+    expect(outlineIndex).toBeGreaterThan(-1)
+    expect(outlineIndex).toBeGreaterThan(footerIndex)
+    expect(rightControlsIndex).toBeGreaterThan(outlineIndex)
+    expect(modelIndex).toBeGreaterThan(rightControlsIndex)
   })
 
   it("renders outline generation from an icon button and keeps the menu backed by existing configs", () => {
@@ -196,11 +199,11 @@ describe("OutlineChatPanel controls", () => {
     expect(source).not.toContain("max-w-[85%]")
   })
 
-  it("在 AI 大纲输入框上方接入固定生成向导并发送结构化 Prompt", () => {
+  it("在 AI 大纲输入接入固定生成向导并发送结构化 Prompt", () => {
     expect(source).toContain('import { OutlineWizardDialog } from "@/components/sources/outline-wizard-dialog"')
     expect(source).toContain("import {")
     expect(source).toContain("buildOutlineWizardPrompt")
-    expect(source).toContain("选择生成你想要的小说")
+    expect(source).toContain('aria-label="生成大纲模块"')
     expect(source).toContain("handleSubmitOutlineWizard")
     expect(source).toContain("buildOutlineWizardPrompt(request)")
     expect(source).toContain("disableWriteTools: true")

+ 279 - 44
src/components/sources/outline-chat-panel.tsx

@@ -35,12 +35,17 @@ import {
 } from "@/commands/fs";
 import { hasUsableLlm } from "@/lib/has-usable-llm";
 import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
+import rehypeKatex from "rehype-katex";
+import "katex/dist/katex.min.css";
 import { FileEditPreview } from "@/components/chat/file-edit-preview";
+import { resolveMarkdownImageSrc } from "@/lib/markdown-image-resolver";
+import { MermaidDiagram, unwrapMermaidPre } from "@/components/mermaid-diagram";
 import {
   AgentToolCallMessage,
   type ToolCallRecord,
 } from "@/components/chat/agent-tool-call-message";
-import { ChatDockControls } from "@/components/chat/chat-dock-controls";
 import {
   OutlineSaveConfirmDialog,
   type OutlineSaveConfirmPayload,
@@ -48,6 +53,15 @@ import {
 import { OutlineWizardDialog } from "@/components/sources/outline-wizard-dialog";
 import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel";
 import { TooltipProvider } from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
 import { OUTLINE_SECTION_GENERATION_CONFIGS } from "@/lib/novel/outline-section-configs";
 import {
   buildOutlineWizardPrompt,
@@ -68,11 +82,13 @@ import { classifyOutlineSaveTarget } from "@/lib/novel/outline-save-classifier";
 import {
   buildOutlineGenerationQualityFeedback,
   formatChapterOutlineQualityReport,
+  isLikelyChapterOutline,
   type OutlineGenerationQualityFeedback,
   summarizeChapterOutlineQuality,
 } from "@/lib/novel/outline-quality-check";
 import {
   characterDraftsToSaveRequests,
+  extractBodyContent,
   formatOutlineSaveParseFeedback,
   type OutlineSaveRequest,
   parseOutlineSaveRequests,
@@ -262,8 +278,26 @@ function buildOutlineAgentSystemPrompt(options: {
     "结构节点必须包含 CBN、CPNs、CEN;CEN 必须能承接下一章 CBN。执行约束必须包含必须覆盖节点和本章禁区。基础信息必须包含时间锚点、章内时间跨度和与上章时间差。",
     "## AI 大纲输出协议",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
-    "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。fileName 必须是 .md 文件,targetFolder 必须位于大纲文件树文件夹内。",
+    "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileName 必须是 .md 文件,targetFolder 必须位于大纲文件树文件夹内。",
+    "content 字段说明:content 字段已废弃,不要在 JSON 中填写 content。系统会自动从你的回复正文中提取大纲内容作为保存内容,正文格式就是最终保存的文件格式。",
     "文件名规范:不同类型内容必须使用不同文件名,禁止多项内容写入同一文件。不同角色必须每人一个独立文件(如 角色-主角林风.md、角色-反官方傲.md),严禁将所有角色塞入「角色卡.md」或同一文件。不同势力、不同伏笔、不同卷纲、不同章纲也必须各自独立文件。",
+    "内容完整性强制要求:所有在对话正文中展示给用户的大纲内容,系统会自动提取并保存。你必须为每个生成的大纲模块都创建对应的保存请求(outlineSaveRequest),不能遗漏。如果生成了多个模块,使用 outlineSaveRequests 数组,每个模块一个请求对象。",
+    "## Markdown 格式强制要求",
+    "所有大纲正文必须使用标准 Markdown 格式输出,严格遵循以下标题层级规范:",
+    "- 一级大标题(如全书核心设定、主要人物设定、分卷大纲等)使用 # 标记,独占一行",
+    "- 二级分类标题(如核心主角、核心配角、第一卷、第二卷等)使用 ## 标记,独占一行",
+    "- 三级子标题(如具体人物名、具体章节名等)使用 ### 标记,独占一行",
+    "- 列表项使用 - 或 * 开头",
+    "- 重要属性使用 **粗体** 标注(如 **年龄:**、**身份:**、**核心技能:**)",
+    "- 禁止使用中文编号(如一、二、三、(一)(二)(三)、1. 2. 3.)作为标题格式,必须用 #、##、### 标记标题层级",
+    "示例:",
+    "# 五、主要人物设定",
+    "## 核心主角",
+    "### 林风(字子墨)",
+    "- **年龄:** 17岁(穿越前为21世纪普通大学生)",
+    "- **身份:** 穿越者→清水村村民→清水社首领→异姓王→隐士",
+    "- **核心技能:** 高中/大学化学知识(有机/无机化学基础)、物理常识、急救知识",
+    "- **性格:** 表面冷漠实则心软,前期被动应对,中后期主动布局",
     "最终回复只输出大纲标题和大纲正文;如果内容需要自动保存,末尾附加 AI 大纲输出协议 JSON 保存块。禁止输出工具调用报告、分析过程、完成报告、下一步行动、无法直接保存的大段说明。",
     "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
     "所有面向用户的回复必须使用中文。",
@@ -645,10 +679,11 @@ function OutlineAssistantMessage({
     edits: import("@/lib/novel/agent-parser").FileEditAction[];
     hasEdits: boolean;
   }>({ textContent: "", edits: [], hasEdits: false });
-  const renderedMarkdownContent = useMemo(
-    () => normalizeOutlineMarkdown(parsed.textContent || answer),
-    [answer, parsed.textContent],
-  );
+  const renderedMarkdownContent = useMemo(() => {
+    const rawContent = parsed.textContent || answer;
+    const bodyContent = extractBodyContent(rawContent);
+    return normalizeOutlineMarkdown(bodyContent || rawContent);
+  }, [answer, parsed.textContent]);
   useEffect(() => {
     if (!answer) {
       setParsed({ textContent: "", edits: [], hasEdits: false });
@@ -684,10 +719,93 @@ function OutlineAssistantMessage({
         onReject={onRejectTool}
       />
       <div
-        className="prose prose-sm dark:prose-invert max-w-none break-words"
+        className="chat-markdown prose prose-sm max-w-none dark:prose-invert 
+          prose-p:my-1.5 prose-p:leading-relaxed
+          prose-h1:text-xl prose-h1:font-bold prose-h1:mt-5 prose-h1:mb-3 prose-h1:pb-2 prose-h1:border-b prose-h1:border-border
+          prose-h2:text-lg prose-h2:font-semibold prose-h2:mt-4 prose-h2:mb-2
+          prose-h3:text-base prose-h3:font-semibold prose-h3:mt-3 prose-h3:mb-1.5
+          prose-h4:text-sm prose-h4:font-semibold prose-h4:mt-2 prose-h4:mb-1
+          prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:leading-relaxed
+          prose-strong:font-semibold prose-strong:text-foreground
+          prose-pre:my-2 prose-pre:p-3 prose-pre:rounded-md
+          prose-code:text-xs prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
+          prose-table:text-xs prose-th:font-semibold
+          prose-blockquote:border-l-4 prose-blockquote:border-primary/50 prose-blockquote:pl-3 prose-blockquote:italic prose-blockquote:text-muted-foreground
+          break-words"
         style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}
       >
-        <ReactMarkdown>{renderedMarkdownContent}</ReactMarkdown>
+        <ReactMarkdown
+          remarkPlugins={[remarkGfm, remarkMath]}
+          rehypePlugins={[rehypeKatex]}
+          components={{
+            img: ({ src, alt, ...props }) => (
+              <img
+                src={
+                  typeof src === "string"
+                    ? resolveMarkdownImageSrc(src, projectPath)
+                    : undefined
+                }
+                alt={alt ?? ""}
+                className="my-2 max-w-full rounded border border-border/40"
+                loading="lazy"
+                {...props}
+              />
+            ),
+            table: ({ children, ...props }) => (
+              <div className="my-2 overflow-x-auto rounded border border-border">
+                <table className="w-full border-collapse text-xs" {...props}>
+                  {children}
+                </table>
+              </div>
+            ),
+            thead: ({ children, ...props }) => (
+              <thead className="bg-muted" {...props}>
+                {children}
+              </thead>
+            ),
+            th: ({ children, ...props }) => (
+              <th
+                className="border border-border/80 px-3 py-1.5 text-start font-semibold bg-muted"
+                {...props}
+              >
+                {children}
+              </th>
+            ),
+            td: ({ children, ...props }) => (
+              <td className="border border-border/60 px-3 py-1.5" {...props}>
+                {children}
+              </td>
+            ),
+            pre: ({ children, ...props }) => {
+              const mermaid = unwrapMermaidPre(children);
+              if (mermaid) return <>{mermaid}</>;
+              return (
+                <pre
+                  dir="ltr"
+                  className="rounded bg-background/50 p-2 text-xs overflow-x-auto"
+                  style={{ textAlign: "left" }}
+                  {...props}
+                >
+                  {children}
+                </pre>
+              );
+            },
+            code: ({ className, children, ...props }) => {
+              const lang = className?.replace("language-", "");
+              const codeText = String(children).replace(/\n$/, "");
+              if (lang === "mermaid") {
+                return <MermaidDiagram code={codeText} />;
+              }
+              return (
+                <code dir="ltr" className={className} {...props}>
+                  {children}
+                </code>
+              );
+            },
+          }}
+        >
+          {renderedMarkdownContent}
+        </ReactMarkdown>
       </div>
       {/* File edit preview */}
       {parsed.hasEdits && !editDismissed && projectPath && !isStreaming ? (
@@ -1070,6 +1188,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const [saveStatus, setSaveStatus] = useState("");
   const [qualityFeedbackState, setQualityFeedbackState] =
     useState<OutlineGenerationQualityFeedback | null>(null);
+  const [qualityConfirmState, setQualityConfirmState] = useState<{
+    feedback: OutlineGenerationQualityFeedback;
+    requests: OutlineSaveRequest[];
+  } | null>(null);
   const [saveConfirmState, setSaveConfirmState] = useState<{
     title: string;
     mode: "normal" | "character";
@@ -1082,6 +1204,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const lastScrollTopRef = useRef(0);
   const abortRef = useRef<AbortController | null>(null);
   const streamingConversationIdRef = useRef<string | null>(null);
+  const pendingRepairMetaRef = useRef<OutlineSaveRequest[] | null>(null);
 
   // Auto-scroll
   useEffect(() => {
@@ -1158,11 +1281,48 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       if (!project) return;
       const parsed = parseOutlineSaveRequests(assistantContent);
       if (parsed.requests.length === 0) {
+        const repairMeta = pendingRepairMetaRef.current;
+        if (repairMeta && repairMeta.length > 0) {
+          const body = assistantContent
+            .replace(/```(?:json)?\s*[\s\S]*?```/gi, "")
+            .replace(/```[\s\S]*?```/g, "")
+            .trim();
+          if (body && isLikelyChapterOutline(body, repairMeta[0].fileName)) {
+            const fallbackRequests: OutlineSaveRequest[] = repairMeta.map((meta) => ({
+              ...meta,
+              content: body,
+            }));
+            pendingRepairMetaRef.current = null;
+            setSaveStatus("正在自动保存修订后的大纲...");
+            try {
+              const projectPath = normalizePath(project.path);
+              const saveResult = await saveOutlineSaveRequests({
+                outlineRoot: `${projectPath}/wiki/outlines`,
+                requests: fallbackRequests,
+                createDirectory,
+                fileExists,
+                readFile,
+                writeFile,
+              });
+              if (saveResult.saved.length > 0) {
+                await refreshProjectState(projectPath);
+                const names = saveResult.saved.map((item) => item.fileName).join("、");
+                setSaveStatus(`已保存修订后的大纲文件:${names}`);
+              } else if (saveResult.errors.length > 0) {
+                setSaveStatus(`保存失败:${saveResult.errors.slice(0, 2).join(";")}`);
+              }
+            } catch (error) {
+              setSaveStatus(`保存失败:${error instanceof Error ? error.message : String(error)}`);
+            }
+            return;
+          }
+        }
         if (parsed.errors.length > 0) {
           setSaveStatus(formatOutlineSaveParseFeedback(parsed.errors));
         }
         return;
       }
+      pendingRepairMetaRef.current = null;
 
       const qualityFeedback = parsed.requests
         .map((request) =>
@@ -1175,15 +1335,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         .find((feedback): feedback is OutlineGenerationQualityFeedback =>
           Boolean(feedback && feedback.status !== "pass"),
         );
+
       if (qualityFeedback) {
         setQualityFeedbackState(qualityFeedback);
+        const split = splitConfirmRequiredSaveRequests(parsed.requests);
+        setQualityConfirmState({
+          feedback: qualityFeedback,
+          requests: split.autoSaveable,
+        });
+        setSaveStatus("");
+        return;
       }
 
-      setSaveStatus(
-        qualityFeedback
-          ? `${qualityFeedback.title}:${qualityFeedback.summary}`
-          : "正在自动保存大纲...",
-      );
+      setSaveStatus("正在自动保存大纲...");
       try {
         const projectPath = normalizePath(project.path);
         const split = splitConfirmRequiredSaveRequests(parsed.requests);
@@ -2214,6 +2378,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             });
             if (qualityFeedback) {
               setQualityFeedbackState(qualityFeedback);
+              setQualityConfirmState({
+                feedback: qualityFeedback,
+                requests: [{
+                  targetFolder: classification.targetFolder,
+                  fileName: classification.fileName,
+                  fileType: classification.fileType,
+                  writeMode: "create",
+                  referencedSkills: [],
+                  sourceIntent: "手动保存 AI 大纲结果",
+                  content: mdContent,
+                }],
+              });
             }
             setSaveStatus(formatChapterOutlineQualityReport(quality, {
               maxIssues: 4,
@@ -2308,6 +2484,44 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     void handleSend(repairPrompt, [], { forceRefresh: true });
   }, [handleSend, qualityFeedbackState]);
 
+  const handleSaveAsIs = useCallback(async () => {
+    if (!project || !qualityConfirmState) return;
+    const { requests } = qualityConfirmState;
+    setQualityConfirmState(null);
+    setQualityFeedbackState(null);
+    if (requests.length === 0) return;
+    setSaveStatus("正在保存大纲...");
+    try {
+      const projectPath = normalizePath(project.path);
+      const saveResult = await saveOutlineSaveRequests({
+        outlineRoot: `${projectPath}/wiki/outlines`,
+        requests,
+        createDirectory,
+        fileExists,
+        readFile,
+        writeFile,
+      });
+      if (saveResult.saved.length > 0) {
+        await refreshProjectState(projectPath);
+        const names = saveResult.saved.map((item) => item.fileName).join("、");
+        setSaveStatus(`已保存 ${saveResult.saved.length} 个大纲文件:${names}`);
+      } else if (saveResult.errors.length > 0) {
+        setSaveStatus(`保存失败:${saveResult.errors.slice(0, 2).join(";")}`);
+      }
+    } catch (error) {
+      setSaveStatus(`保存失败:${error instanceof Error ? error.message : String(error)}`);
+    }
+  }, [project, qualityConfirmState, createDirectory, fileExists, readFile, writeFile]);
+
+  const handleAutoFixFromModal = useCallback(() => {
+    const repairPrompt = qualityConfirmState?.feedback.repairPrompt;
+    if (!repairPrompt) return;
+    pendingRepairMetaRef.current = qualityConfirmState.requests;
+    setQualityConfirmState(null);
+    setQualityFeedbackState(null);
+    void handleSend(repairPrompt, [], { forceRefresh: true });
+  }, [handleSend, qualityConfirmState]);
+
   return (
     <div className="flex h-full flex-col overflow-hidden border-border bg-background">
       {/* Header with conversation tabs */}
@@ -2432,21 +2646,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             : null}
         </div>
         <div className="ml-auto flex shrink-0 items-center gap-1">
-          {qualityFeedbackState && qualityFeedbackState.status !== "pass" ? (
-            <button
-              type="button"
-              disabled={isStreaming}
-              onClick={handleRepairQualityFeedback}
-              aria-label="修订生成后质量检查发现的问题"
-              className="rounded-md border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
-              title={qualityFeedbackState.summary}
-            >
-              修订质量问题
-            </button>
-          ) : null}
-          {saveStatus && (
-            <span className="text-xs text-muted-foreground">{saveStatus}</span>
-          )}
           <button
             onClick={onClose}
             className="rounded p-1 text-muted-foreground hover:bg-accent"
@@ -2516,31 +2715,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
       {/* Input */}
       <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>
         {isStreaming && (
           <div className="mb-2 animate-pulse rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-700 dark:border-sky-800 dark:bg-sky-950/30 dark:text-sky-300">
             <span className="font-medium">正在生成...</span>
           </div>
         )}
-        <div className="mb-2 flex items-center justify-between gap-2 rounded-lg border bg-muted/30 px-2 py-1.5">
-          <div className="min-w-0 text-xs text-muted-foreground">
+        <div className="mb-2 flex items-center justify-between gap-2">
+          <p className="text-xs text-muted-foreground">
             通过固定选项生成大纲需求,再交给 AI 分析和追问
-          </div>
+          </p>
           <button
             type="button"
-            className="shrink-0 rounded-md border bg-background px-2.5 py-1.5 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
-            disabled={isStreaming}
             onClick={() => setOutlineWizardOpen(true)}
+            disabled={isStreaming}
+            className="shrink-0 rounded-md border border-border bg-background px-3 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
           >
             选择生成你想要的小说
           </button>
@@ -2559,6 +2747,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           onSubmit={handleSend}
           onAtTrigger={() => setReferencePickerOpen(true)}
           insertTokensRef={insertReferenceTokensRef}
+          leftFooterControls={
+            <TooltipProvider delay={200}>
+              <OutlineGenerationMenu
+                disabled={isStreaming}
+                onGenerate={handleGenerateSection}
+              />
+            </TooltipProvider>
+          }
           rightControls={
             hasAvailableModels ? (
               <ChatModelSelector
@@ -2607,6 +2803,45 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             onConfirm={executeConfirmedOutlineSave}
           />
         ) : null}
+        {qualityConfirmState ? (
+          <Dialog open onOpenChange={(open) => { if (!open) setQualityConfirmState(null); }}>
+            <DialogContent className="max-w-lg">
+              <DialogHeader>
+                <DialogTitle>大纲质量检查发现可修复项</DialogTitle>
+                <DialogDescription className="text-left">
+                  {qualityConfirmState.feedback.summary}
+                </DialogDescription>
+              </DialogHeader>
+              <div className="max-h-48 overflow-y-auto space-y-1 py-2">
+                {qualityConfirmState.feedback.issues.slice(0, 10).map((issue, index) => (
+                  <div key={index} className="flex items-start gap-2 text-sm">
+                    <span className="mt-0.5 shrink-0 text-amber-600">·</span>
+                    <span className="text-muted-foreground">{issue}</span>
+                  </div>
+                ))}
+                {qualityConfirmState.feedback.issues.length > 10 ? (
+                  <div className="text-xs text-muted-foreground">
+                    另有 {qualityConfirmState.feedback.issues.length - 10} 项未列出
+                  </div>
+                ) : null}
+              </div>
+              <DialogFooter className="gap-2">
+                <Button
+                  variant="outline"
+                  onClick={handleSaveAsIs}
+                >
+                  按当前内容保存
+                </Button>
+                <Button
+                  onClick={handleAutoFixFromModal}
+                  disabled={isStreaming}
+                >
+                  自动修复
+                </Button>
+              </DialogFooter>
+            </DialogContent>
+          </Dialog>
+        ) : null}
       </div>
     </div>
   );

+ 38 - 2
src/components/sources/outline-workbench.spec.tsx

@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import type { FileNode } from "@/types/wiki"
 import { useWikiStore } from "@/stores/wiki-store"
+import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { OutlineWorkbench } from "./outline-workbench"
 
 ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
@@ -28,7 +29,12 @@ vi.mock("@/components/layout/preview-panel", () => ({
 }))
 
 vi.mock("@/components/sources/outline-chat-panel", () => ({
-  OutlineChatPanel: () => <div data-testid="mock-outline-chat-panel">AI 大纲对话区</div>,
+  OutlineChatPanel: ({ onClose }: { onClose?: () => void }) => (
+    <div data-testid="mock-outline-chat-panel">
+      AI 大纲对话区
+      <button type="button" onClick={onClose}>关闭AI大纲</button>
+    </div>
+  ),
 }))
 
 const outlineNodes: FileNode[] = [
@@ -60,6 +66,7 @@ describe("OutlineWorkbench", () => {
       selectedFile: null,
       fileContent: "",
     })
+    useOutlineGenerationStore.setState({ panelOpen: true })
     host = document.createElement("div")
     document.body.appendChild(host)
     root = createRoot(host)
@@ -70,7 +77,7 @@ describe("OutlineWorkbench", () => {
     host.remove()
   })
 
-  it("主内容区只渲染中间编辑区和右侧 50% AI 大纲对话区", async () => {
+  it("主内容区只渲染中间编辑区和右侧 50% AI 大纲对话区,并提供拖拽条", async () => {
     await act(async () => {
       root.render(<OutlineWorkbench />)
     })
@@ -81,11 +88,40 @@ describe("OutlineWorkbench", () => {
     const aiPane = host.querySelector('[data-testid="outline-ai-pane"]') as HTMLElement
     expect(aiPane).not.toBeNull()
     expect(aiPane.style.width).toBe("50%")
+    expect(host.querySelector('[data-testid="outline-ai-resize-handle"]')).not.toBeNull()
     expect(host.textContent).toContain("大纲显示与编辑区")
     expect(host.textContent).toContain("AI 大纲对话区")
     expect(host.textContent).not.toContain("大纲文件树")
   })
 
+  it("关闭 AI 大纲面板后隐藏右侧面板和拖拽条", async () => {
+    await act(async () => {
+      root.render(<OutlineWorkbench />)
+    })
+
+    const closeButton = Array.from(host.querySelectorAll("button")).find((button) =>
+      button.textContent?.includes("关闭AI大纲"),
+    )
+    await act(async () => {
+      closeButton?.click()
+    })
+
+    expect(host.querySelector('[data-testid="outline-ai-pane"]')).toBeNull()
+    expect(host.querySelector('[data-testid="outline-ai-resize-handle"]')).toBeNull()
+    expect(host.querySelector('[data-testid="mock-preview-panel"]')).not.toBeNull()
+  })
+
+  it("跟随 AI 大纲面板开关状态显示或隐藏右侧面板", async () => {
+    useOutlineGenerationStore.setState({ panelOpen: false })
+
+    await act(async () => {
+      root.render(<OutlineWorkbench />)
+    })
+
+    expect(host.querySelector('[data-testid="outline-ai-pane"]')).toBeNull()
+    expect(host.querySelector('[data-testid="mock-preview-panel"]')).not.toBeNull()
+  })
+
   it("主内容区不再负责创建默认文件夹和读取大纲目录", async () => {
     await act(async () => {
       root.render(<OutlineWorkbench />)

+ 64 - 7
src/components/sources/outline-workbench.tsx

@@ -1,9 +1,56 @@
 import { PreviewPanel } from "@/components/layout/preview-panel"
 import { OutlineChatPanel } from "@/components/sources/outline-chat-panel"
+import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { useWikiStore } from "@/stores/wiki-store"
+import { useCallback, useEffect, useRef, useState } from "react"
+
+const OUTLINE_CHAT_WIDTH_KEY = "qmai-outline-chat-right-width"
+const OUTLINE_CHAT_MIN_WIDTH = 320
+
+function getInitialOutlineChatWidth(): string | number {
+  if (typeof localStorage === "undefined") return "50%"
+  const saved = Number(localStorage.getItem(OUTLINE_CHAT_WIDTH_KEY))
+  return Number.isFinite(saved) && saved > 0 ? saved : "50%"
+}
 
 export function OutlineWorkbench() {
+  const containerRef = useRef<HTMLDivElement | null>(null)
   const project = useWikiStore((s) => s.project)
+  const outlineChatOpen = useOutlineGenerationStore((s) => s.panelOpen)
+  const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
+  const [outlineChatWidth, setOutlineChatWidth] = useState<string | number>(() => getInitialOutlineChatWidth())
+
+  useEffect(() => {
+    if (typeof outlineChatWidth === "number" && typeof localStorage !== "undefined") {
+      localStorage.setItem(OUTLINE_CHAT_WIDTH_KEY, String(outlineChatWidth))
+    }
+  }, [outlineChatWidth])
+
+  const startHorizontalResize = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
+    event.preventDefault()
+    document.body.style.cursor = "col-resize"
+    document.body.style.userSelect = "none"
+    document.body.dataset.panelResizing = "true"
+
+    const handleMouseMove = (nextEvent: MouseEvent) => {
+      if (!containerRef.current) return
+      const rect = containerRef.current.getBoundingClientRect()
+      const maxWidth = Math.max(OUTLINE_CHAT_MIN_WIDTH, Math.floor(rect.width * 0.5))
+      const nextWidth = Math.max(OUTLINE_CHAT_MIN_WIDTH, Math.min(maxWidth, rect.right - nextEvent.clientX))
+      setOutlineChatWidth(nextWidth)
+    }
+
+    const handleMouseUp = () => {
+      document.body.style.cursor = ""
+      document.body.style.userSelect = ""
+      delete document.body.dataset.panelResizing
+      document.removeEventListener("mousemove", handleMouseMove)
+      document.removeEventListener("mouseup", handleMouseUp)
+    }
+
+    document.addEventListener("mousemove", handleMouseMove)
+    document.addEventListener("mouseup", handleMouseUp)
+  }, [])
 
   if (!project) {
     return (
@@ -15,6 +62,7 @@ export function OutlineWorkbench() {
 
   return (
     <div
+      ref={containerRef}
       className="flex h-full min-h-0 overflow-hidden bg-background"
       data-testid="outline-workbench"
     >
@@ -22,13 +70,22 @@ export function OutlineWorkbench() {
         <PreviewPanel />
       </div>
 
-      <div
-        className="h-full min-h-0 shrink-0 overflow-hidden border-l bg-background"
-        style={{ width: "50%" }}
-        data-testid="outline-ai-pane"
-      >
-        <OutlineChatPanel onClose={() => {}} />
-      </div>
+      {outlineChatOpen ? (
+        <>
+          <div
+            className="w-1.5 shrink-0 cursor-col-resize bg-border/40 transition-colors hover:bg-primary/30 active:bg-primary/40"
+            data-testid="outline-ai-resize-handle"
+            onMouseDown={startHorizontalResize}
+          />
+          <div
+            className="h-full min-h-0 shrink-0 overflow-hidden border-l bg-background"
+            style={{ width: outlineChatWidth }}
+            data-testid="outline-ai-pane"
+          >
+            <OutlineChatPanel onClose={() => setOutlineChatOpen(false)} />
+          </div>
+        </>
+      ) : null}
     </div>
   )
 }

+ 5 - 1
src/lib/novel/outline-quality-check.ts

@@ -173,7 +173,11 @@ export function buildOutlineGenerationQualityFeedback(input: {
       "必须补齐以下可修复项,不要改变已确认的剧情方向:",
       ...issues.slice(0, 12).map((issue, index) => `${index + 1}. ${issue}`),
       "",
-      "请输出修订后的完整章纲,并在末尾附加可保存的 outlineSaveRequest JSON。",
+      "重要要求:",
+      "1. 必须输出修订后的完整章纲正文(使用标准 Markdown 格式),不能只输出修改摘要或说明。",
+      "2. 系统会自动从你的回复正文中提取完整内容并保存,你不需要在 JSON 中重复输出 content。",
+      "3. 在回复末尾附加 outlineSaveRequest JSON,只需包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent 等元数据。",
+      "4. 正文中必须包含完整的章纲所有必填章节,不能省略未修改的部分。",
     ].join("\n"),
   };
 }

+ 51 - 3
src/lib/novel/outline-save-request.ts

@@ -116,7 +116,6 @@ function normalizeRequest(raw: unknown, index: number): {
     fileName,
     fileType,
     writeMode,
-    content,
   })) {
     if (!value) errors.push(`第 ${index + 1} 个保存请求缺少 ${field}。`)
   }
@@ -159,6 +158,47 @@ function collectRawRequests(payload: Record<string, unknown>): unknown[] {
   return []
 }
 
+export function extractBodyContent(text: string): string {
+  return text
+    .replace(/```(?:json)?\s*[\s\S]*?```/gi, "")
+    .replace(/```[\s\S]*?```/g, "")
+    .trim()
+}
+
+function splitBodyByH1(body: string): string[] {
+  const lines = body.split(/\r?\n/)
+  const sections: string[] = []
+  let current: string[] = []
+
+  for (const line of lines) {
+    if (/^#\s+/.test(line.trim()) && current.length > 0) {
+      sections.push(current.join("\n").trim())
+      current = []
+    }
+    current.push(line)
+  }
+  if (current.length > 0) {
+    sections.push(current.join("\n").trim())
+  }
+  return sections.filter(Boolean)
+}
+
+function fillContentFromText(requests: OutlineSaveRequest[], text: string): OutlineSaveRequest[] {
+  const body = extractBodyContent(text)
+  if (!body) return requests
+
+  if (requests.length === 1) {
+    return requests.map((r) => ({ ...r, content: body }))
+  }
+
+  const sections = splitBodyByH1(body)
+  if (sections.length >= requests.length) {
+    return requests.map((r, i) => ({ ...r, content: sections[i] || body }))
+  }
+
+  return requests.map((r) => ({ ...r, content: body }))
+}
+
 export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseResult {
   const requests: OutlineSaveRequest[] = []
   const errors: string[] = []
@@ -179,7 +219,15 @@ export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseR
     })
   }
 
-  return { requests, errors }
+  const filled = fillContentFromText(requests, text)
+  const stillEmpty = filled.filter((r) => !r.content)
+  if (stillEmpty.length > 0) {
+    stillEmpty.forEach((_, i) => {
+      errors.push(`第 ${i + 1} 个保存请求缺少 content,且无法从正文中提取。`)
+    })
+  }
+
+  return { requests: filled, errors }
 }
 
 export function formatOutlineSaveParseFeedback(errors: string[]): string {
@@ -189,7 +237,7 @@ export function formatOutlineSaveParseFeedback(errors: string[]): string {
   const remaining = uniqueErrors.length > 4 ? `;另有 ${uniqueErrors.length - 4} 项未列出` : ""
   return [
     `自动保存失败:${preview}${remaining}。`,
-    "请让 AI 重新输出 outlineSaveRequest,必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。",
+    "请让 AI 重新输出 outlineSaveRequest,必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。",
     "当前内容不会写入文件。",
   ].join("")
 }

+ 118 - 1
src/lib/outline-save.ts

@@ -15,14 +15,131 @@ export function prepareOutlineSaveDraft(content: string, existingTitles: string[
   return { title, content: body }
 }
 
+function looksLikeHeading(line: string): boolean {
+  const trimmed = line.trim()
+  if (trimmed.length < 2 || trimmed.length > 50) return false
+  if (/^#{1,6}\s/.test(trimmed)) return false
+  if (/^[-*+]\s/.test(trimmed)) return false
+  if (/^\d+\.\s/.test(trimmed)) return false
+  if (trimmed.includes(":") || trimmed.includes(":")) return false
+  if (trimmed.startsWith("```") || trimmed.endsWith("```")) return false
+  return true
+}
+
+function convertChineseNumberedHeadings(lines: string[]): string[] {
+  const result: string[] = []
+  let i = 0
+
+  while (i < lines.length) {
+    const line = lines[i]
+    const trimmed = line.trim()
+
+    if (!looksLikeHeading(trimmed)) {
+      result.push(line)
+      i++
+      continue
+    }
+
+    if (/^[一二三四五六七八九十百]+[、..]\s*/.test(trimmed)) {
+      const title = trimmed.replace(/^[一二三四五六七八九十百]+[、..]\s*/, "")
+      if (title && !/^#/.test(trimmed)) {
+        result.push(`# ${trimmed}`)
+        i++
+        continue
+      }
+    }
+
+    if (/^([一二三四五六七八九十百]+)\s*/.test(trimmed)) {
+      const title = trimmed.replace(/^([一二三四五六七八九十百]+)\s*/, "")
+      if (title && !/^#/.test(trimmed)) {
+        result.push(`## ${trimmed}`)
+        i++
+        continue
+      }
+    }
+
+    if (/^\([一二三四五六七八九十百]+\)\s*/.test(trimmed)) {
+      const title = trimmed.replace(/^\([一二三四五六七八九十百]+\)\s*/, "")
+      if (title && !/^#/.test(trimmed)) {
+        result.push(`## ${trimmed}`)
+        i++
+        continue
+      }
+    }
+
+    if (/^\d+[、..]\s*/.test(trimmed) && trimmed.length < 30) {
+      const title = trimmed.replace(/^\d+[、..]\s*/, "")
+      if (title && !/^#/.test(trimmed)) {
+        result.push(`## ${trimmed}`)
+        i++
+        continue
+      }
+    }
+
+    const commonH2Keywords = /^(核心主角|核心配角|主要人物|次要人物|反派|主角团|世界观|修炼体系|能力体系|金手指|势力分布|伏笔|大纲|总纲|卷纲|章纲|分卷大纲|章节细纲|故事背景|核心设定|主要设定|分卷)/
+    if (commonH2Keywords.test(trimmed)) {
+      result.push(`## ${trimmed}`)
+      i++
+      continue
+    }
+
+    const nextLines = lines.slice(i + 1, i + 4).map(l => l.trim()).filter(Boolean)
+    const hasAttributeLines = nextLines.length > 0 && nextLines.every(l =>
+      /^[::]/.test(l) ||
+      /(年龄|身份|技能|性格|核心|外貌|背景|目标|动机|欲望|恐惧|关系|冲突|弧光|定位|阵营|资源|能力|限制|代价|成长|功法|武器|装备)/.test(l) ||
+      /^[-*+]\s/.test(l)
+    )
+
+    if (hasAttributeLines && trimmed.length < 30 && !trimmed.endsWith("。") && !trimmed.endsWith(",")) {
+      if (/(.*)/.test(trimmed) || /\(.*\)/.test(trimmed) || /^[\u4e00-\u9fa5]{2,6}$/.test(trimmed)) {
+        result.push(`### ${trimmed}`)
+        i++
+        continue
+      }
+    }
+
+    result.push(line)
+    i++
+  }
+
+  return result
+}
+
+function convertAttributeLines(lines: string[]): string[] {
+  return lines.map(line => {
+    const trimmed = line.trim()
+    if (/^#{1,6}\s/.test(trimmed)) return line
+    if (/^[-*+]\s/.test(trimmed)) return line
+    if (/^\d+\.\s/.test(trimmed)) return line
+    if (trimmed.startsWith("```") || trimmed.endsWith("```")) return line
+
+    const attrMatch = trimmed.match(/^([^::]{1,12})[::]\s*(.*)$/)
+    if (attrMatch) {
+      const attrName = attrMatch[1].trim()
+      const attrValue = attrMatch[2].trim()
+      if (attrName && attrValue && attrName.length <= 12) {
+        return `- **${attrName}:** ${attrValue}`
+      }
+    }
+
+    return line
+  })
+}
+
 export function normalizeOutlineMarkdown(content: string): string {
-  return content
+  let result = content
     .replace(/```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```/gi, (_, inner: string) => inner.trim())
     .replace(/^\\(#{1,6}\s)/gm, "$1")
     .replace(/^\\([-*+]\s)/gm, "$1")
     .replace(/^\\(>\s)/gm, "$1")
     .replace(/^\\(\d+\.\s)/gm, "$1")
     .replace(/\\([*_`[\]])/g, "$1")
+
+  const lines = result.split(/\r?\n/)
+  const withHeadings = convertChineseNumberedHeadings(lines)
+  const withAttributes = convertAttributeLines(withHeadings)
+
+  return withAttributes.join("\n")
 }
 
 function extractOutlineTitle(content: string): string {

+ 1 - 1
src/stores/outline-generation-store.ts

@@ -66,7 +66,7 @@ let counter = 0
 
 export const useOutlineGenerationStore = create<OutlineGenerationState>((set) => ({
   tasks: [],
-  panelOpen: false,
+  panelOpen: true,
   setPanelOpen: (open) => set({ panelOpen: open }),
   createTask: (input) => {
     const id = `outline-task-${++counter}`

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

@@ -37,12 +37,10 @@ const GRAPH_EDGE_COLOR_KEY = "lk-graph-edge-color"
 const GRAPH_EDGE_STRENGTH_KEY = "lk-graph-edge-strength"
 const GRAPH_EDGE_STYLE_KEY = "lk-graph-edge-style"
 const GRAPH_EDGE_LABELS_ALWAYS_KEY = "lk-graph-edge-labels-always"
-const CHAT_DOCK_POSITION_KEY = "qmai-chat-dock-position"
 const UI_FONT_SIZE_SCALE_KEY = "qmai-ui-font-size-scale"
 const UI_FONT_FAMILY_KEY = "qmai-ui-font-family"
 const SIDEBAR_NAV_CONFIG_KEY = "qmai-sidebar-nav-config"
 
-export type ChatDockPosition = "bottom" | "right"
 export type SettingsCategoryId =
   | "llm"
   | "rerank"
@@ -57,12 +55,6 @@ export type SettingsCategoryId =
   | "contact-support"
   | "changelog"
 
-const readStoredChatDockPosition = (): ChatDockPosition => {
-  if (typeof localStorage === "undefined") return "bottom"
-  const saved = localStorage.getItem(CHAT_DOCK_POSITION_KEY)
-  return saved === "right" || saved === "bottom" ? saved : "bottom"
-}
-
 const readStoredUiFontSizeScale = (): number => {
   if (typeof localStorage === "undefined") return 1
   const saved = Number(localStorage.getItem(UI_FONT_SIZE_SCALE_KEY) ?? "1")
@@ -553,7 +545,6 @@ interface WikiState {
   pendingScrollImageSrc: string | null
   selectedMemoryCenterEntry: string | null
   chatExpanded: boolean
-  chatDockPosition: ChatDockPosition
   searchPanelOpen: boolean
   activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "skillLibrary" | "writingSkillLibrary" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
   activeSettingsCategory: SettingsCategoryId | null
@@ -629,7 +620,6 @@ interface WikiState {
   setPendingScrollImageSrc: (src: string | null) => void
   setSelectedMemoryCenterEntry: (entry: string | null) => void
   setChatExpanded: (expanded: boolean) => void
-  setChatDockPosition: (position: ChatDockPosition) => void
   setSearchPanelOpen: (open: boolean) => void
   setActiveView: (view: WikiState["activeView"]) => void
   setActiveSettingsCategory: (category: SettingsCategoryId | null) => void
@@ -705,7 +695,6 @@ export const useWikiStore = create<WikiState>((set) => ({
   pendingScrollImageSrc: null,
   selectedMemoryCenterEntry: null,
   chatExpanded: false,
-  chatDockPosition: readStoredChatDockPosition(),
   searchPanelOpen: false,
   activeView: "wiki",
   activeSettingsCategory: null,
@@ -768,12 +757,6 @@ export const useWikiStore = create<WikiState>((set) => ({
   setPendingScrollImageSrc: (pendingScrollImageSrc) => set({ pendingScrollImageSrc }),
   setSelectedMemoryCenterEntry: (selectedMemoryCenterEntry) => set({ selectedMemoryCenterEntry }),
   setChatExpanded: (chatExpanded) => set({ chatExpanded }),
-  setChatDockPosition: (chatDockPosition) => {
-    if (typeof localStorage !== "undefined") {
-      localStorage.setItem(CHAT_DOCK_POSITION_KEY, chatDockPosition)
-    }
-    set({ chatDockPosition })
-  },
   setSearchPanelOpen: (searchPanelOpen) => set({ searchPanelOpen }),
   setActiveView: (activeView) => set((state) => {
     if (

+ 0 - 2
src/test/chat-panel-mount.ts

@@ -26,7 +26,6 @@ const wikiState = {
   aiWorkflowMode: "standard",
   planExecuteEnabled: false,
   deepChapterEnabled: false,
-  chatDockPosition: "right",
   novelConfig: { contextTokenBudget: 0 },
   setActiveView: vi.fn(),
   setAiChatModel: vi.fn(),
@@ -34,7 +33,6 @@ const wikiState = {
   setPlanExecuteEnabled: vi.fn(),
   setChatEditModeEnabled: vi.fn(),
   setDeepChapterEnabled: vi.fn(),
-  setChatDockPosition: vi.fn(),
   setSelectedFile: vi.fn(),
 }