Bläddra i källkod

merge branch 'zhangjielandu'

Mochocyang 2 månader sedan
förälder
incheckning
6910b246e2

+ 8 - 0
src/components/chat/agent-stage-stream.spec.tsx

@@ -60,4 +60,12 @@ describe("AgentStageStream", () => {
     expect(html).toContain("已读取上一章结尾")
     expect(html).not.toContain("上一章结尾:铜铃线索未揭示。")
   })
+
+  it("fills the assistant message width instead of shrinking to stage text", () => {
+    const html = renderToStaticMarkup(<AgentStageStream stages={stages} />)
+
+    expect(html).toContain("w-full")
+    expect(html).toContain("min-w-0")
+    expect(html).toContain("max-w-full")
+  })
 })

+ 1 - 1
src/components/chat/agent-stage-stream.tsx

@@ -34,7 +34,7 @@ export function AgentStageStream({ stages }: AgentStageStreamProps) {
   }
 
   return (
-    <div className="mb-2 max-w-full overflow-hidden border-l border-border/80 pl-3">
+    <div className="mb-2 w-full min-w-0 max-w-full overflow-hidden border-l border-border/80 pl-3">
       <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
         <div className="flex min-w-0 items-center gap-2 text-[13px] font-medium text-foreground">
           <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">

+ 40 - 0
src/components/chat/agent-tool-call-message.spec.tsx

@@ -171,6 +171,46 @@ describe("AgentToolCallMessage", () => {
     expect(host.textContent).toContain("写入章节《第1章》")
   })
 
+  it("shows confirmation actions for approval-required write tools without opening raw details", async () => {
+    const onConfirmSave = vi.fn()
+    const onReject = vi.fn()
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "call-approval",
+              name: "write_outline_node",
+              params: { outlineName: "将乱天下大纲.md", nodeTitle: "人物关系", nodeContent: "正文" },
+              result: "写入工具需要用户确认后才能执行。",
+              status: "approval_required",
+              startedAt: 100,
+              finishedAt: 100,
+            },
+          ]}
+          onConfirmSave={onConfirmSave}
+          onReject={onReject}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("大纲写入需要确认")
+    expect(host.textContent).toContain("确认保存")
+    expect(host.textContent).toContain("放弃")
+    expect(host.textContent).not.toContain("write_outline_node")
+
+    const confirmButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("确认保存"))
+    expect(confirmButton).not.toBeUndefined()
+
+    await act(async () => {
+      confirmButton?.click()
+    })
+
+    expect(onConfirmSave).toHaveBeenCalledTimes(1)
+    expect(onConfirmSave.mock.calls[0][0].name).toBe("write_outline_node")
+  })
+
   it("shows a user-facing thinking process before raw tool details", async () => {
     await act(async () => {
       root.render(

+ 29 - 0
src/components/chat/agent-workflow-panel.tsx

@@ -187,6 +187,7 @@ export function AgentWorkflowPanel({
   const overallStatus = getOverallStatus(steps)
   const duration = getWorkflowDuration(steps)
   const safeToolCalls = toolCalls ?? []
+  const pendingApprovalCalls = safeToolCalls.filter((call) => call.status === "approval_required")
 
   const isStepOpen = (step: AgentWorkflowStep) => {
     if (allOpen) return true
@@ -251,6 +252,34 @@ export function AgentWorkflowPanel({
         ))}
       </div>
 
+      {pendingApprovalCalls.length > 0 && onConfirmSave && onReject ? (
+        <div className="mt-3 space-y-2 rounded-md border border-amber-200 bg-amber-50/70 p-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">
+          {pendingApprovalCalls.map((call) => (
+            <div key={call.id} className="flex min-w-0 flex-wrap items-center justify-between gap-2">
+              <span className="min-w-0 flex-1 truncate">
+                {call.name === "write_outline_node" ? "大纲写入需要确认" : "写入操作需要确认"}
+              </span>
+              <span className="flex shrink-0 items-center gap-1.5">
+                <button
+                  type="button"
+                  onClick={() => onConfirmSave(call)}
+                  className="rounded bg-green-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-green-700"
+                >
+                  确认保存
+                </button>
+                <button
+                  type="button"
+                  onClick={() => onReject(call)}
+                  className="rounded border border-amber-300/70 bg-background px-2.5 py-1 text-[11px] font-medium text-muted-foreground hover:bg-accent"
+                >
+                  放弃
+                </button>
+              </span>
+            </div>
+          ))}
+        </div>
+      ) : null}
+
       {detailsOpen && safeToolCalls.length > 0 && (
         <div className="mt-3 border-t border-border/70 pt-2">
           <ToolCallTimeline

+ 9 - 0
src/components/chat/chat-message.spec.tsx

@@ -151,6 +151,15 @@ describe("chat message width", () => {
     expect(source).toContain("max-w-full")
     expect(source).not.toContain("max-w-[80%]")
   })
+
+  it("lets assistant messages use the available chat column width for generation process cards", () => {
+    const source = readFileSync(resolve(__dirname, "chat-message.tsx"), "utf8")
+
+    expect(source).toContain('className={`flex w-full min-w-0 gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}')
+    expect(source).toContain('isUser ? "w-fit max-w-full lg:max-w-[50vw]" : "min-w-0 flex-1 max-w-full"')
+    expect(source).toContain('className="flex w-full min-w-0 gap-2 flex-row"')
+    expect(source).toContain('className="min-w-0 flex-1 max-w-full rounded-lg border bg-background px-3 py-2 text-sm text-foreground"')
+  })
 })
 
 describe("chapter save preview sync regression", () => {

+ 4 - 4
src/components/chat/chat-message.tsx

@@ -116,7 +116,7 @@ export function ChatMessage({
 
   return (
     <div
-      className={`flex gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}
+      className={`flex w-full min-w-0 gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}
       onMouseEnter={() => setHovered(true)}
       onMouseLeave={() => setHovered(false)}
     >
@@ -131,7 +131,7 @@ export function ChatMessage({
       >
         {isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
       </div>
-      <div className="w-fit max-w-full lg:max-w-[50vw] flex flex-col gap-1.5">
+      <div className={`${isUser ? "w-fit max-w-full lg:max-w-[50vw]" : "min-w-0 flex-1 max-w-full"} flex flex-col gap-1.5`}>
         <div
           className={`rounded-lg px-3 py-2 text-sm ${
             isUser
@@ -699,11 +699,11 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
   const isThinking = thinking !== null && answer.length === 0;
 
   return (
-    <div className="flex gap-2 flex-row">
+    <div className="flex w-full min-w-0 gap-2 flex-row">
       <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
         <Bot className="h-4 w-4" />
       </div>
-      <div className="w-fit max-w-full lg:max-w-[50vw] rounded-lg border bg-background px-3 py-2 text-sm text-foreground">
+      <div className="min-w-0 flex-1 max-w-full rounded-lg border bg-background px-3 py-2 text-sm text-foreground">
         {isThinking ? (
           <StreamingWorkflowBlock content={thinking} />
         ) : (

+ 13 - 2
src/components/chat/chat-panel.spec.tsx

@@ -14,9 +14,11 @@ describe("chat-panel de-AI skill handling", () => {
     expect(source).not.toContain("setChapterSaveStatus(deAiSkillWarning)")
   })
 
-  it("uses an icon-only de-AI skill trigger in the chat input toolbar", () => {
+  it("uses a skill library trigger instead of the old de-AI-only label in the chat input toolbar", () => {
     expect(source).toContain("<DeAiSkillPicker")
-    expect(source).toContain("iconOnly")
+    expect(source).toContain('buttonLabel="技能库"')
+    expect(source).not.toContain('title="当前启用的 Skill"')
+    expect(source).not.toContain('aria-label="当前启用的 Skill"')
   })
 
   it("uses an icon-only accent new conversation button", () => {
@@ -277,6 +279,15 @@ describe("chat-panel agent reference integration", () => {
     expect(validationIndex).toBeGreaterThan(confirmIndex)
     expect(validationIndex).toBeLessThan(confirmDraftIndex)
   })
+
+  it("keeps up to three working or today's conversations in the top toolbar and moves the rest into history", () => {
+    expect(source).toContain("splitConversationToolbarItems")
+    expect(source).toContain("isStreamingConversation")
+    expect(source).toContain("topConversations")
+    expect(source).toContain("historyConversations")
+    expect(source).toContain("topConversations.map((conv) => renderConversationChip(conv))")
+    expect(source).not.toContain("historyConversations = sorted.filter((conv) => conv.id !== activeConversationId)")
+  })
 })
 
 describe("chat-panel chapter plan confirm integration (Stage C)", () => {

+ 19 - 175
src/components/chat/chat-panel.tsx

@@ -1,7 +1,7 @@
 import { useRef, useEffect, useCallback, useState, useMemo, type CSSProperties } from "react"
 import { createPortal } from "react-dom"
 import { useTranslation } from "react-i18next"
-import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, Sparkles, ChevronDown, Check, History } from "lucide-react"
+import { BookOpen, Plus, Trash2, MessageSquare, FileEdit, Drama, ListChecks, ChevronDown, Check, History } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -63,7 +63,10 @@ import { loadEffectiveDeAiSkillSafely, resolveAvailableDeAiSkills } from "@/lib/
 import { cleanGeneratedChapterContentWithTitle } from "@/lib/novel/chapter-content-cleanup"
 import { normalizePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { getConversationTabTitle, sortConversationsByUpdatedAt } from "@/lib/workspace-layout"
+import {
+  getConversationTabTitle,
+  splitConversationToolbarItems,
+} from "@/lib/workspace-layout"
 import { saveAiChatModel } from "@/lib/project-store"
 import {
   buildGoldenThreeChapterDirective,
@@ -357,9 +360,12 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
   const historyButtonRef = useRef<HTMLButtonElement | null>(null)
   const [historyDropdownStyle, setHistoryDropdownStyle] = useState<CSSProperties | null>(null)
 
-  const sorted = sortConversationsByUpdatedAt(conversations)
-  const activeConversation = sorted.find((conv) => conv.id === activeConversationId) ?? null
-  const historyConversations = sorted.filter((conv) => conv.id !== activeConversationId)
+  const isStreamingConversation = (convId: string) => convId in streamingContents
+  const { topConversations, historyConversations } = splitConversationToolbarItems(
+    conversations,
+    activeConversationId,
+    isStreamingConversation,
+  )
   const historyCount = historyConversations.length
 
   // 点击历史记录浮层外部关闭
@@ -491,7 +497,7 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
 
   // 顶部统一为三段式:新建写作绘画 / 正在工作的绘画 / 绘画历史记录
   return (
-    <div className="shrink-0 border-b bg-muted/20 px-2 py-2">
+    <div className="flex h-12 shrink-0 items-center border-b bg-muted/20 px-2">
       <div className="flex items-center gap-2">
         {/* 1. 新建写作绘画 */}
         <Button
@@ -505,10 +511,12 @@ function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) =
           <Plus className="h-3.5 w-3.5" />
         </Button>
 
-        {/* 2. 正在工作的绘画(当前激活会话),没有则展示占位文案 */}
+        {/* 2. 正在工作的绘画:当前、生成中和今日保留项,最多显示三个 */}
         <div className="flex min-w-0 flex-1 items-center overflow-hidden">
-          {activeConversation ? (
-            <div className="flex min-w-0 flex-1 overflow-hidden">{renderConversationChip(activeConversation)}</div>
+          {topConversations.length > 0 ? (
+            <div className="flex min-w-0 flex-1 gap-1.5 overflow-hidden">
+              {topConversations.map((conv) => renderConversationChip(conv))}
+            </div>
           ) : (
             <span className="shrink-0 truncate text-xs text-muted-foreground">
               {t(novelMode ? "novel.chat.noConversationsYet" : "chat.noConversationsYet")}
@@ -589,19 +597,6 @@ export function ChatPanel() {
     ? conversations.find((conversation) => conversation.id === activeConversationId) ?? null
     : null
 
-  const lastSelectedSkills = useMemo(() => {
-    const assistantMessages = activeMessages.filter(
-      (m) => m.role === "assistant" && m.contextTrace?.contextInfo?.selectedSkills,
-    )
-    if (assistantMessages.length === 0) return []
-    const lastMsg = assistantMessages[assistantMessages.length - 1]
-    return lastMsg.contextTrace?.contextInfo?.selectedSkills ?? []
-  }, [activeMessages])
-
-  const [showSkillsPanel, setShowSkillsPanel] = useState(false)
-  const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null)
-  const skillsPanelRef = useRef<HTMLDivElement | null>(null)
-
   // 当前活跃会话的流式内容
   const streamingContent = activeConversationId ? streamingContents[activeConversationId] ?? "" : ""
   // 当前活跃会话是否正在流式生成
@@ -689,27 +684,6 @@ export function ChatPanel() {
     })
   }, [consumePendingReferenceTokens, createConversation, pendingReferenceTokens])
 
-  useEffect(() => {
-    if (!showSkillsPanel) {
-      setExpandedSkillId(null)
-      return
-    }
-    const handleMouseDown = (event: MouseEvent) => {
-      if (!skillsPanelRef.current?.contains(event.target as Node)) {
-        setShowSkillsPanel(false)
-      }
-    }
-    const handleKeyDown = (event: KeyboardEvent) => {
-      if (event.key === "Escape") setShowSkillsPanel(false)
-    }
-    document.addEventListener("mousedown", handleMouseDown)
-    document.addEventListener("keydown", handleKeyDown)
-    return () => {
-      document.removeEventListener("mousedown", handleMouseDown)
-      document.removeEventListener("keydown", handleKeyDown)
-    }
-  }, [showSkillsPanel])
-
   useEffect(() => {
     if (!workflowModeDropdownOpen) {
       setWorkflowModeDropdownStyle(null)
@@ -1750,143 +1724,13 @@ export function ChatPanel() {
                   <ChatDockControls />
                   <DeAiSkillPicker
                     value={activeConversation?.selectedDeAiSkillId}
-                    iconOnly
+                    buttonLabel="技能库"
+                    showLibraryShortcut
                     onChange={(skillId) => {
                       const convId = useChatStore.getState().activeConversationId
                       if (convId) setConversationDeAiSkillId(convId, skillId)
                     }}
                   />
-                  {novelMode && (
-                    <div ref={skillsPanelRef} className="relative">
-                      <Tooltip>
-                        <TooltipTrigger
-                          render={(
-                            <button
-                              type="button"
-                              onClick={() => setShowSkillsPanel(!showSkillsPanel)}
-                              className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
-                              title="当前启用的 Skill"
-                              aria-label="当前启用的 Skill"
-                            >
-                              <Sparkles className="h-4 w-4" />
-                            </button>
-                          )}
-                        >
-                          <TooltipContent side="top" className="leading-5">
-                            {lastSelectedSkills.length > 0
-                              ? `上次生成启用了 ${lastSelectedSkills.length} 个 Skill`
-                              : "暂无启用的 Skill 记录"}
-                          </TooltipContent>
-                        </TooltipTrigger>
-                      </Tooltip>
-                      {showSkillsPanel && (
-                        <div className="fixed left-0 z-50 w-72 rounded-md border bg-popover p-3 text-sm text-popover-foreground shadow-lg"
-                          style={{
-                            left: skillsPanelRef.current?.getBoundingClientRect().left ?? 8,
-                            top: (skillsPanelRef.current?.getBoundingClientRect().bottom ?? 0) + 8,
-                          }}
-                        >
-                          <div className="mb-2 flex items-center gap-2">
-                            <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-violet-100 text-violet-600 dark:bg-violet-950/40 dark:text-violet-400">
-                              <Sparkles className="h-3.5 w-3.5" />
-                            </div>
-                            <div className="text-sm font-medium">本次启用的 Skill</div>
-                            {lastSelectedSkills.length > 0 && (
-                              <span className="rounded-full bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/40 dark:text-violet-300">
-                                {lastSelectedSkills.length}
-                              </span>
-                            )}
-                          </div>
-                          {lastSelectedSkills.length === 0 ? (
-                            <div className="py-3 text-center text-xs text-muted-foreground">
-                              暂无启用记录
-                              <div className="mt-1 text-[11px] opacity-70">
-                                发送消息后将显示本次启用的 Skill
-                              </div>
-                            </div>
-                          ) : (
-                            <div className="max-h-96 space-y-1.5 overflow-y-auto">
-                              {lastSelectedSkills.map((skill) => {
-                                const isExpanded = expandedSkillId === skill.id
-                                return (
-                                  <div
-                                    key={skill.id}
-                                    className={`rounded-md border transition-colors ${
-                                      isExpanded
-                                        ? "border-violet-200 bg-violet-50/50 dark:border-violet-800/50 dark:bg-violet-950/20"
-                                        : "bg-background"
-                                    }`}
-                                  >
-                                    <button
-                                      type="button"
-                                      className="flex w-full items-start gap-2 px-2 py-1.5 text-left"
-                                      onClick={() =>
-                                        setExpandedSkillId(isExpanded ? null : skill.id)
-                                      }
-                                    >
-                                      <ChevronDown
-                                        className={`mt-0.5 h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-200 ${
-                                          isExpanded ? "rotate-180" : ""
-                                        }`}
-                                      />
-                                      <div className="flex-1 min-w-0">
-                                        <div className="mb-1 text-xs font-medium text-foreground">
-                                          {skill.name}
-                                        </div>
-                                        <div className="flex flex-wrap gap-1">
-                                          {[...skill.kind, ...skill.stages, skill.source]
-                                            .filter(Boolean)
-                                            .map((tag, index) => (
-                                              <span
-                                                key={`${skill.id}-${tag}-${index}`}
-                                                className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
-                                              >
-                                                {tag}
-                                              </span>
-                                            ))}
-                                        </div>
-                                      </div>
-                                    </button>
-                                    <div
-                                      className="grid overflow-hidden transition-all duration-200 ease-in-out"
-                                      style={{
-                                        gridTemplateRows: isExpanded ? "1fr" : "0fr",
-                                      }}
-                                    >
-                                      <div className="min-h-0 overflow-hidden">
-                                        <div className="border-t border-border/50 px-2 py-2">
-                                          {skill.description && (
-                                            <div className="mb-2">
-                                              <div className="mb-1 text-[11px] font-medium text-muted-foreground">
-                                                描述
-                                              </div>
-                                              <div className="text-xs leading-relaxed text-foreground/80">
-                                                {skill.description}
-                                              </div>
-                                            </div>
-                                          )}
-                                          {skill.content && (
-                                            <div>
-                                              <div className="mb-1 text-[11px] font-medium text-muted-foreground">
-                                                正文
-                                              </div>
-                                              <div className="max-h-[200px] overflow-y-auto rounded-md bg-background/60 p-2 text-[11px] leading-relaxed text-foreground/70 whitespace-pre-wrap">
-                                                {skill.content}
-                                              </div>
-                                            </div>
-                                          )}
-                                        </div>
-                                      </div>
-                                    </div>
-                                  </div>
-                                )
-                              })}
-                            </div>
-                          )}
-                        </div>
-                      )}
-                    </div>
-                  )}
                   {novelMode && (
                     <>
                       <div className="relative">

+ 19 - 0
src/components/layout/knowledge-tree.spec.tsx

@@ -0,0 +1,19 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "knowledge-tree.tsx"), "utf8")
+const previewSource = readFileSync(resolve(__dirname, "preview-panel.tsx"), "utf8")
+
+describe("KnowledgeTree chapter memory extraction menu", () => {
+  it("places one-click all chapter memory extraction in the chapter right-click menu", () => {
+    expect(source).toContain("handleExtractAllChapterMemories")
+    expect(source).toContain("一键提取所有章节")
+    expect(source).toContain('filterType === "chapter" && pageMenu')
+    expect(source).toContain("sortedChapterPages.map((page) => page.path)")
+    expect(source).toContain('kind: "chapter"')
+    expect(source).toContain("allowDraft: true")
+    expect(source).toContain("useImportProgressStore.getState().startTask")
+    expect(previewSource).not.toContain("一键提取所有章节")
+  })
+})

+ 103 - 1
src/components/layout/knowledge-tree.tsx

@@ -498,6 +498,98 @@ export function KnowledgeTree({
     }
   }, [project, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
 
+  const handleExtractAllChapterMemories = useCallback(async () => {
+    if (!project || filterType !== "chapter") return
+    const chapterPaths = sortedChapterPages.map((page) => page.path)
+    if (chapterPaths.length === 0) {
+      window.alert("当前没有可提取的章节。")
+      return
+    }
+
+    const projectPath = normalizePath(project.path)
+    const abortController = new AbortController()
+    const taskId = useImportProgressStore.getState().startTask({
+      projectPath,
+      kind: "chapter",
+      total: chapterPaths.length,
+      currentTitle: sortedChapterPages[0]?.title ?? "",
+      message: "正在一键提取所有章节记忆",
+      abortController,
+    })
+
+    let completed = 0
+    let failed = 0
+    const titleByPath = new Map(sortedChapterPages.map((page) => [page.path, page.title]))
+
+    try {
+      const { ingestChapter } = await import("@/lib/novel/chapter-ingest")
+      for (const chapterPath of chapterPaths) {
+        if (abortController.signal.aborted) {
+          useImportProgressStore.getState().finishTask(taskId, "cancelled", {
+            completed,
+            total: chapterPaths.length,
+            currentTitle: "",
+            message: `已取消章节记忆提取,已完成 ${completed}/${chapterPaths.length} 个章节。`,
+          })
+          return
+        }
+
+        useImportProgressStore.getState().updateTask(taskId, {
+          completed,
+          total: chapterPaths.length,
+          currentTitle: titleByPath.get(chapterPath) ?? chapterPath,
+        })
+
+        const page = sortedChapterPages.find((item) => item.path === chapterPath)
+        const result = await ingestChapter(
+          projectPath,
+          chapterPath,
+          undefined,
+          abortController.signal,
+          page?.chapterNumber,
+          { allowDraft: true },
+        )
+        if (result.snapshot) {
+          completed += 1
+        } else {
+          failed += 1
+        }
+      }
+
+      useImportProgressStore.getState().finishTask(taskId, failed > 0 ? "error" : "done", {
+        completed,
+        total: chapterPaths.length,
+        currentTitle: "",
+        message: failed > 0
+          ? `章节记忆提取完成:成功 ${completed} 个,失败 ${failed} 个。`
+          : `章节记忆提取完成:成功 ${completed} 个章节。`,
+      })
+      await loadPages()
+      const tree = await listDirectory(projectPath)
+      setFileTree(tree)
+      bumpDataVersion()
+    } catch (error) {
+      if (abortController.signal.aborted) {
+        useImportProgressStore.getState().finishTask(taskId, "cancelled", {
+          completed,
+          total: chapterPaths.length,
+          currentTitle: "",
+          message: `已取消章节记忆提取,已完成 ${completed}/${chapterPaths.length} 个章节。`,
+        })
+        return
+      }
+      console.error("[KnowledgeTree] extract all chapter memories failed:", error)
+      useImportProgressStore.getState().finishTask(taskId, "error", {
+        completed,
+        total: chapterPaths.length,
+        currentTitle: "",
+        message: `章节记忆提取失败:已完成 ${completed}/${chapterPaths.length} 个章节。`,
+      })
+    } finally {
+      setPageMenu(null)
+    }
+  }, [project, filterType, sortedChapterPages, loadPages, setFileTree, bumpDataVersion])
+
   const toggleChapterSelection = useCallback((pagePath: string) => {
     const normalizedPath = normalizePath(pagePath)
     setSelectedChapterPaths((previous) => {
@@ -1305,7 +1397,7 @@ export function KnowledgeTree({
 
         {pageMenu && (
           <div
-            className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
+            className="absolute z-20 w-48 rounded-md border bg-background py-1 text-xs shadow-lg"
             style={{ left: pageMenu.x, top: pageMenu.y }}
             onMouseDown={(event) => event.stopPropagation()}
             onClick={(event) => event.stopPropagation()}
@@ -1401,6 +1493,16 @@ export function KnowledgeTree({
                 )}
               </div>
             )}
+            {filterType === "chapter" && pageMenu && (
+              <button
+                type="button"
+                className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
+                onClick={() => void handleExtractAllChapterMemories()}
+              >
+                <BookOpen className="h-3.5 w-3.5" />
+                一键提取所有章节
+              </button>
+            )}
             <button
               type="button"
               className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"

+ 1 - 1
src/components/layout/preview-panel.tsx

@@ -1251,7 +1251,7 @@ export function PreviewPanel() {
 
   return (
     <div className="flex h-full flex-col">
-      <div className="border-b px-3 py-1.5">
+      <div className="flex h-12 shrink-0 items-center border-b px-3">
         <div ref={chapterToolbarRef} className="flex min-w-0 items-center gap-2">
           <div className="relative flex min-w-0 min-h-0 flex-1 items-center gap-1 overflow-hidden">
             {chapterHeader ? (

+ 8 - 8
src/components/layout/sidebar-panel.tsx

@@ -1707,20 +1707,20 @@ export function SidebarPanel() {
 
   return (
     <div className="flex h-full flex-col">
-      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+      <div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
         <div className="min-w-0">
-          <div className="flex items-center gap-1.5 text-sm font-semibold">
+          <div className="flex min-w-0 items-center gap-1.5 text-sm font-semibold">
             <PanelHeaderWithHelp
               title={isChapter ? t("sidebar.knowledge") : t("sidebar.files")}
               helpKey={isChapter ? "chapter" : "outline"}
               helpTitle={isChapter ? "章节功能使用说明" : "大纲功能使用说明"}
             />
+            {isChapter && sidebarTotalWordCount !== null ? (
+              <span className="shrink-0 text-xs font-normal text-muted-foreground">
+                {buildChapterTotalWordCountLabel(sidebarTotalWordCount)}
+              </span>
+            ) : null}
           </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 ? (
@@ -1729,7 +1729,7 @@ export function SidebarPanel() {
                 type="button"
                 size="sm"
                 variant="outline"
-                className="h-7 px-2 text-xs"
+                className="h-8 px-2 text-xs"
                 onClick={() => setChapterImportMenuOpen((prev) => !prev)}
                 disabled={chapterImporting}
               >

+ 23 - 0
src/components/layout/workspace-top-bars.spec.ts

@@ -0,0 +1,23 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const sidebarSource = readFileSync(resolve(__dirname, "sidebar-panel.tsx"), "utf8")
+const previewSource = readFileSync(resolve(__dirname, "preview-panel.tsx"), "utf8")
+const chatSource = readFileSync(resolve(__dirname, "../chat/chat-panel.tsx"), "utf8")
+const outlineChatSource = readFileSync(resolve(__dirname, "../sources/outline-chat-panel.tsx"), "utf8")
+
+describe("workspace top bars", () => {
+  it("uses one fixed height for the chapter sidebar, editor toolbar, AI chat and outline chat headers", () => {
+    expect(sidebarSource).toContain('className="flex h-12 shrink-0 items-center justify-between border-b px-3"')
+    expect(previewSource).toContain('className="flex h-12 shrink-0 items-center border-b px-3"')
+    expect(chatSource).toContain('className="flex h-12 shrink-0 items-center border-b bg-muted/20 px-2"')
+    expect(outlineChatSource).toContain('className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2"')
+  })
+
+  it("keeps the total word count inline after the book title instead of as a second header line", () => {
+    expect(sidebarSource).toContain("buildChapterTotalWordCountLabel(sidebarTotalWordCount)")
+    expect(sidebarSource).toContain('className="flex min-w-0 items-center gap-1.5 text-sm font-semibold"')
+    expect(sidebarSource).toContain('className="shrink-0 text-xs font-normal text-muted-foreground"')
+  })
+})

+ 19 - 2
src/components/skill-library/de-ai-skill-picker.tsx

@@ -10,6 +10,7 @@ interface DeAiSkillPickerProps {
   includeDisableOption?: boolean
   buttonLabel?: string
   iconOnly?: boolean
+  showLibraryShortcut?: boolean
 }
 
 export { getDeAiSkillLoadErrorMessage } from "./de-ai-skill-errors"
@@ -153,8 +154,10 @@ export function DeAiSkillPicker({
   includeDisableOption = true,
   buttonLabel,
   iconOnly = false,
+  showLibraryShortcut = false,
 }: DeAiSkillPickerProps) {
   const project = useWikiStore((s) => s.project)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
   const [open, setOpen] = useState(false)
   const [panelStyle, setPanelStyle] = useState<CSSProperties>({ left: 8, top: 8 })
   const containerRef = useRef<HTMLDivElement | null>(null)
@@ -167,8 +170,10 @@ export function DeAiSkillPicker({
     modifiedSkillIds,
     loadError,
   } = useDeAiSkillOptions({ projectPath: project?.path, selectedSkillId: value })
-  const triggerTitle = `当前去AI味 Skill:${effectiveName}`
-  const iconTriggerDescription = `${triggerTitle}。点击选择去AI味 Skill`
+  const triggerTitle = buttonLabel === "技能库" ? "技能库:选择写作 Skill 或去AI味 Skill" : `当前去AI味 Skill:${effectiveName}`
+  const iconTriggerDescription = buttonLabel === "技能库"
+    ? `${triggerTitle}。点击打开技能选择`
+    : `${triggerTitle}。点击选择去AI味 Skill`
 
   useEffect(() => {
     if (!open) return
@@ -242,6 +247,18 @@ export function DeAiSkillPicker({
               关闭去AI味技能
             </button>
           ) : null}
+          {showLibraryShortcut ? (
+            <button
+              type="button"
+              className="mt-1 flex w-full items-center gap-2 rounded border-t px-2 py-2 text-left text-muted-foreground hover:bg-accent"
+              onClick={() => {
+                setOpen(false)
+                setActiveView("writingSkillLibrary")
+              }}
+            >
+              打开完整技能库
+            </button>
+          ) : null}
         </div>
       ) : null}
     </div>

+ 19 - 0
src/components/sources/outline-chat-panel.spec.tsx

@@ -14,6 +14,25 @@ describe("OutlineChatPanel controls", () => {
     expect(source).not.toContain("text-emerald-700")
   })
 
+  it("uses the same top conversation/history split as AI chat", () => {
+    expect(source).toContain("splitConversationToolbarItems")
+    expect(source).toContain("topConversations")
+    expect(source).toContain("historyConversations")
+    expect(source).toContain("qmai-outline-history-button")
+    expect(source).toContain('aria-label="大纲会话历史"')
+    expect(source).not.toContain("conversations.map((conv) => (")
+  })
+
+  it("passes confirm and reject handlers into the outline tool workflow", () => {
+    expect(source).toContain("handleConfirmToolSave")
+    expect(source).toContain("handleRejectTool")
+    expect(source).toContain("createWriteOutlineNodeTool")
+    expect(source).toContain("onConfirmToolSave={handleConfirmToolSave}")
+    expect(source).toContain("onRejectTool={handleRejectTool}")
+    expect(source).toContain("onConfirmSave={onConfirmToolSave}")
+    expect(source).toContain("onReject={onRejectTool}")
+  })
+
   it("uses the shared reference input and picker for @ references", () => {
     expect(source).toContain("ReferenceInput")
     expect(source).toContain("ReferencePickerDialog")

+ 290 - 21
src/components/sources/outline-chat-panel.tsx

@@ -1,11 +1,14 @@
 import React, {
+  type CSSProperties,
   useRef,
   useCallback,
   useEffect,
   useMemo,
   useState,
 } from "react";
+import { createPortal } from "react-dom";
 import {
+  ChevronDown,
   X,
   Save,
   Copy,
@@ -14,6 +17,7 @@ import {
   Plus,
   Trash2,
   ListPlus,
+  History,
 } from "lucide-react";
 import { useWikiStore } from "@/stores/wiki-store";
 import {
@@ -31,7 +35,10 @@ import {
 import { hasUsableLlm } from "@/lib/has-usable-llm";
 import ReactMarkdown from "react-markdown";
 import { FileEditPreview } from "@/components/chat/file-edit-preview";
-import { AgentToolCallMessage } from "@/components/chat/agent-tool-call-message";
+import {
+  AgentToolCallMessage,
+  type ToolCallRecord,
+} from "@/components/chat/agent-tool-call-message";
 import { ChatDockControls } from "@/components/chat/chat-dock-controls";
 import { TooltipProvider } from "@/components/ui/tooltip";
 import { OUTLINE_SECTION_GENERATION_CONFIGS } from "@/lib/novel/outline-generation";
@@ -75,6 +82,11 @@ import {
   collectWebResearch,
   shouldUseWebResearch,
 } from "@/lib/web-research";
+import {
+  getConversationTabTitle,
+  splitConversationToolbarItems,
+} from "@/lib/workspace-layout";
+import { createWriteOutlineNodeTool } from "@/lib/agent/tools/write-outline-node";
 
 const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory"];
 
@@ -265,6 +277,38 @@ function updateOutlineAssistantMessage(
   }));
 }
 
+function updateOutlineToolCall(
+  callId: string,
+  updater: (call: ToolCallRecord) => ToolCallRecord,
+): void {
+  useOutlineChatStore.setState((state) => ({
+    conversations: state.conversations.map((conversation) => ({
+      ...conversation,
+      messages: conversation.messages.map((message) => {
+        if (!message.agentToolCalls?.some((call) => call.id === callId)) {
+          return message;
+        }
+        return {
+          ...message,
+          agentToolCalls: message.agentToolCalls.map((call) =>
+            call.id === callId ? updater(call) : call,
+          ),
+          isAgentRunning: false,
+        };
+      }),
+    })),
+  }));
+}
+
+function formatOutlineConversationDate(timestamp: number): string {
+  const date = new Date(timestamp);
+  const now = new Date();
+  if (date.toDateString() === now.toDateString()) {
+    return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+  }
+  return date.toLocaleDateString([], { month: "short", day: "numeric" });
+}
+
 async function getUniqueOutlinePath(
   outlinesDir: string,
   title: string,
@@ -336,6 +380,8 @@ function OutlineAssistantMessage({
   onSaveAsOutline,
   onCopy,
   onRegenerate,
+  onConfirmToolSave,
+  onRejectTool,
 }: {
   msg: import("@/stores/outline-chat-store").OutlineChatMessage;
   index: number;
@@ -347,6 +393,8 @@ function OutlineAssistantMessage({
   onSaveAsOutline: (content: string) => Promise<void>;
   onCopy: (content: string, id: string) => void;
   onRegenerate: (index: number) => Promise<void>;
+  onConfirmToolSave: (call: ToolCallRecord & { preview?: string }) => void;
+  onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
 }) {
   const [editApplied, setEditApplied] = useState(false);
   const [editResults, setEditResults] = useState<
@@ -397,7 +445,11 @@ function OutlineAssistantMessage({
       {thinking ? (
         <OutlineThinkingBlock content={thinking} open={isStreaming} />
       ) : null}
-      <AgentToolCallMessage toolCalls={msg.agentToolCalls} />
+      <AgentToolCallMessage
+        toolCalls={msg.agentToolCalls}
+        onConfirmSave={onConfirmToolSave}
+        onReject={onRejectTool}
+      />
       <div className="prose prose-sm dark:prose-invert max-w-none">
         <ReactMarkdown>{parsed.textContent || answer}</ReactMarkdown>
       </div>
@@ -581,6 +633,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
   const activeConv = conversations.find((c) => c.id === activeConversationId);
   const activeMessages = activeConv?.messages ?? [];
+  const isWorkingConversation = useCallback(
+    (convId: string) => Boolean(isStreaming && convId === activeConversationId),
+    [activeConversationId, isStreaming],
+  );
+  const { topConversations, historyConversations } = useMemo(
+    () => splitConversationToolbarItems(
+      conversations,
+      activeConversationId,
+      isWorkingConversation,
+    ),
+    [activeConversationId, conversations, isWorkingConversation],
+  );
+  const historyCount = historyConversations.length;
 
   const hasAvailableModels = useMemo(() => {
     for (const key of Object.keys(providerConfigs)) {
@@ -604,6 +669,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const [referencePickerOpen, setReferencePickerOpen] = useState(false);
   const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "");
   const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null);
+  const [hoveredConversationId, setHoveredConversationId] = useState<string | null>(null);
+  const [historyOpen, setHistoryOpen] = useState(false);
+  const historyRef = useRef<HTMLDivElement | null>(null);
+  const historyButtonRef = useRef<HTMLButtonElement | null>(null);
+  const [historyDropdownStyle, setHistoryDropdownStyle] = useState<CSSProperties | null>(null);
 
   const referenceProviders = useMemo(
     () => [
@@ -639,6 +709,60 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     setLocalModelId(activeConv?.modelId ?? "");
   }, [activeConv?.modelId]);
 
+  useEffect(() => {
+    if (!historyOpen) return;
+    function handleClick(event: MouseEvent) {
+      if (historyRef.current && !historyRef.current.contains(event.target as Node)) {
+        setHistoryOpen(false);
+      }
+    }
+    function handleKey(event: KeyboardEvent) {
+      if (event.key === "Escape") setHistoryOpen(false);
+    }
+    document.addEventListener("mousedown", handleClick);
+    document.addEventListener("keydown", handleKey);
+    return () => {
+      document.removeEventListener("mousedown", handleClick);
+      document.removeEventListener("keydown", handleKey);
+    };
+  }, [historyOpen]);
+
+  useEffect(() => {
+    if (!historyOpen) {
+      setHistoryDropdownStyle(null);
+      return;
+    }
+    const panelWidth = 288;
+    const gap = 6;
+    function updatePosition() {
+      const rect = historyButtonRef.current?.getBoundingClientRect();
+      if (!rect) return;
+      const viewportWidth = window.innerWidth;
+      const viewportHeight = window.innerHeight;
+      const left = Math.min(
+        Math.max(gap, rect.right - panelWidth),
+        Math.max(gap, viewportWidth - panelWidth - gap),
+      );
+      const availableBelow = viewportHeight - rect.bottom;
+      const availableAbove = rect.top;
+      const maxHeight = Math.min(360, Math.max(160, Math.max(availableBelow, availableAbove) - gap));
+      const top = availableBelow >= 160 || availableBelow >= availableAbove
+        ? rect.bottom + gap
+        : Math.max(gap, rect.top - maxHeight - gap);
+      setHistoryDropdownStyle({ left, top, width: panelWidth, maxHeight });
+    }
+    const raf = requestAnimationFrame(updatePosition);
+    window.addEventListener("resize", updatePosition);
+    return () => {
+      cancelAnimationFrame(raf);
+      window.removeEventListener("resize", updatePosition);
+    };
+  }, [historyOpen]);
+
+  useEffect(() => {
+    setHistoryOpen(false);
+  }, [activeConversationId]);
+
   const [saveStatus, setSaveStatus] = useState("");
   const [copied, setCopied] = useState<string | null>(null);
   const scrollRef = useRef<HTMLDivElement>(null);
@@ -1255,10 +1379,66 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     [project],
   );
 
+  const handleConfirmToolSave = useCallback(
+    async (call: ToolCallRecord & { preview?: string }) => {
+      if (!project) return;
+      if (call.status !== "approval_required") return;
+      if (call.name !== "write_outline_node") {
+        setSaveStatus("当前写入工具暂不支持在 AI 大纲中确认。");
+        return;
+      }
+
+      setSaveStatus("正在确认写入大纲...");
+      updateOutlineToolCall(call.id, (current) => ({
+        ...current,
+        status: "running",
+        result: "正在写入大纲...",
+        finishedAt: 0,
+      }));
+
+      try {
+        const projectPath = normalizePath(project.path);
+        const tool = createWriteOutlineNodeTool(`${projectPath}/wiki/outlines`);
+        const result = await tool.execute(call.params);
+        updateOutlineToolCall(call.id, (current) => ({
+          ...current,
+          status: result.startsWith("错误:") ? "error" : "done",
+          result,
+          finishedAt: Date.now(),
+        }));
+        await refreshProjectState(projectPath);
+        setSaveStatus(result.startsWith("错误:") ? result : "已确认写入大纲");
+      } catch (error) {
+        const message = `写入大纲失败:${error instanceof Error ? error.message : String(error)}`;
+        updateOutlineToolCall(call.id, (current) => ({
+          ...current,
+          status: "error",
+          result: message,
+          finishedAt: Date.now(),
+        }));
+        setSaveStatus(message);
+      } finally {
+        void useOutlineChatStore.getState().saveToDisk();
+      }
+    },
+    [project],
+  );
+
+  const handleRejectTool = useCallback((call: ToolCallRecord & { preview?: string }) => {
+    updateOutlineToolCall(call.id, (current) => ({
+      ...current,
+      status: "cancelled",
+      result: "已放弃写入。",
+      finishedAt: Date.now(),
+    }));
+    setSaveStatus("已放弃写入。");
+    void useOutlineChatStore.getState().saveToDisk();
+  }, []);
+
   return (
     <div className="flex h-full flex-col overflow-hidden border-border bg-background">
       {/* Header with conversation tabs */}
-      <div className="flex shrink-0 items-center gap-1 border-b px-2 py-1.5 overflow-x-auto">
+      <div className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2">
         <button
           type="button"
           onClick={() => {
@@ -1270,27 +1450,114 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         >
           <Plus className="h-3.5 w-3.5" />
         </button>
-        {conversations.map((conv) => (
+        <div className="flex min-w-0 flex-1 items-center overflow-hidden">
+          {topConversations.length > 0 ? (
+            <div className="flex min-w-0 flex-1 gap-1.5 overflow-hidden">
+              {topConversations.map((conv) => {
+                const isActive = conv.id === activeConversationId;
+                const isThisStreaming = isStreaming && conv.id === activeConversationId;
+                return (
+                  <button
+                    key={conv.id}
+                    type="button"
+                    onClick={() => setActiveConversation(conv.id)}
+                    onMouseEnter={() => setHoveredConversationId(conv.id)}
+                    onMouseLeave={() => setHoveredConversationId(null)}
+                    className={`group flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors ${
+                      isActive
+                        ? "border-primary/40 bg-background text-foreground shadow-sm"
+                        : "border-border bg-background/70 text-muted-foreground hover:bg-accent hover:text-foreground"
+                    }`}
+                    title={conv.title}
+                  >
+                    {isThisStreaming ? (
+                      <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
+                    ) : null}
+                    <span className="max-w-[140px] truncate font-medium">
+                      {getConversationTabTitle(conv.title, 10)}
+                    </span>
+                    <span className="text-[10px] opacity-70">{conv.messages.length}</span>
+                    <span className="text-[10px] opacity-70">{formatOutlineConversationDate(conv.updatedAt)}</span>
+                    {hoveredConversationId === conv.id ? (
+                      <span
+                        className="rounded p-0.5 text-muted-foreground hover:text-destructive"
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          deleteConversation(conv.id);
+                        }}
+                      >
+                        <Trash2 className="h-3 w-3" />
+                      </span>
+                    ) : null}
+                  </button>
+                );
+              })}
+            </div>
+          ) : (
+            <span className="shrink-0 truncate text-xs text-muted-foreground">
+              暂无大纲对话
+            </span>
+          )}
+        </div>
+        <div className="relative shrink-0" ref={historyRef}>
           <button
-            key={conv.id}
-            onClick={() => setActiveConversation(conv.id)}
-            className={`group shrink-0 flex items-center gap-1 rounded px-2 py-1 text-xs ${
-              conv.id === activeConversationId
-                ? "bg-accent text-foreground"
-                : "text-muted-foreground hover:bg-accent/50"
-            }`}
+            ref={historyButtonRef}
+            type="button"
+            onClick={() => setHistoryOpen((value) => !value)}
+            className="qmai-outline-history-button inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-border bg-background/70 px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
+            title="大纲会话历史"
+            aria-label="大纲会话历史"
+            aria-expanded={historyOpen}
           >
-            <span className="max-w-[100px] truncate">{conv.title}</span>
-            <Trash2
-              className="h-3 w-3 opacity-0 group-hover:opacity-100 hover:text-destructive"
-              onClick={(e) => {
-                e.stopPropagation();
-                deleteConversation(conv.id);
-              }}
-            />
+            <History className="h-3.5 w-3.5" />
+            <span>会话历史</span>
+            {historyCount > 0 ? (
+              <span className="ml-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/15 px-1 text-[10px] font-medium text-primary">
+                {historyCount}
+              </span>
+            ) : null}
+            <ChevronDown className={`h-3 w-3 transition-transform ${historyOpen ? "rotate-180" : ""}`} />
           </button>
-        ))}
-        <div className="ml-auto flex items-center gap-1">
+          {historyOpen && historyDropdownStyle
+            ? createPortal(
+                <div
+                  className="fixed z-50 max-h-[60vh] w-72 overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg"
+                  style={historyDropdownStyle}
+                >
+                  {historyCount === 0 ? (
+                    <div className="px-2 py-3 text-center text-xs text-muted-foreground">
+                      暂无历史大纲对话
+                    </div>
+                  ) : (
+                    historyConversations.map((conv) => (
+                      <button
+                        key={conv.id}
+                        type="button"
+                        onClick={() => setActiveConversation(conv.id)}
+                        className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
+                        title={conv.title}
+                      >
+                        <span className="min-w-0 flex-1 truncate font-medium">
+                          {getConversationTabTitle(conv.title, 16)}
+                        </span>
+                        <span className="shrink-0 text-[10px] opacity-70">{conv.messages.length}</span>
+                        <span className="shrink-0 text-[10px] opacity-70">{formatOutlineConversationDate(conv.updatedAt)}</span>
+                        <Trash2
+                          className="h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100 hover:text-destructive"
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            deleteConversation(conv.id);
+                          }}
+                        />
+                      </button>
+                    ))
+                  )}
+                </div>,
+                document.body,
+              )
+            : null}
+        </div>
+        <div className="ml-auto flex shrink-0 items-center gap-1">
           {saveStatus && (
             <span className="text-xs text-muted-foreground">{saveStatus}</span>
           )}
@@ -1338,6 +1605,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onSaveAsOutline={handleSaveAsOutline}
                   onCopy={handleCopy}
                   onRegenerate={handleRegenerate}
+                  onConfirmToolSave={handleConfirmToolSave}
+                  onRejectTool={handleRejectTool}
                 />
               ) : (
                 <>

+ 15 - 0
src/lib/novel/chapter-ingest.spec.ts

@@ -0,0 +1,15 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "chapter-ingest.ts"), "utf8")
+
+describe("chapter ingest draft boundary", () => {
+  it("keeps draft ingestion opt-in so existing final-only flows do not change", () => {
+    expect(source).toContain("interface IngestChapterOptions")
+    expect(source).toContain("allowDraft?: boolean")
+    expect(source).toContain("options: IngestChapterOptions = {}")
+    expect(source).toContain("if (!options.allowDraft && !isFinalChapter(fm))")
+    expect(source).toContain('failReason: "not_final"')
+  })
+})

+ 6 - 1
src/lib/novel/chapter-ingest.ts

@@ -294,12 +294,17 @@ export interface IngestResult {
   failReason?: IngestFailReason
 }
 
+interface IngestChapterOptions {
+  allowDraft?: boolean
+}
+
 export async function ingestChapter(
   projectPath: string,
   chapterPath: string,
   _reviewModel?: string,
   signal?: AbortSignal,
   chapterNumberOverride?: number,
+  options: IngestChapterOptions = {},
 ): Promise<IngestResult> {
   const pp = normalizePath(projectPath)
   const state = useWikiStore.getState()
@@ -315,7 +320,7 @@ export async function ingestChapter(
   const parsed = parseFrontmatter(content)
   const fm = parsed.frontmatter as Record<string, unknown> | null
   if (!fm || !isChapterPage(fm)) return { snapshot: null, failReason: "not_chapter" }
-  if (!isFinalChapter(fm)) {
+  if (!options.allowDraft && !isFinalChapter(fm)) {
     console.warn(`[Chapter Ingest] Chapter status is not final, skipping ingest.`)
     return { snapshot: null, failReason: "not_final" }
   }

+ 23 - 0
src/lib/workspace-layout.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
   getPreviewContentContainerClass,
+  splitConversationToolbarItems,
   shouldUseCompactChapterToolbar,
 } from "./workspace-layout"
 
@@ -22,4 +23,26 @@ describe("workspace layout", () => {
   it("keeps the outer preview content scrollable for normal files", () => {
     expect(getPreviewContentContainerClass(false)).toContain("overflow-auto")
   })
+
+  it("keeps active, working, and today's conversations in the top toolbar and moves the rest to history", () => {
+    const today = Date.now()
+    const yesterday = today - 24 * 60 * 60 * 1000
+    const conversations = [
+      { id: "old", updatedAt: yesterday },
+      { id: "today-1", updatedAt: today - 1000 },
+      { id: "today-2", updatedAt: today - 2000 },
+      { id: "working", updatedAt: yesterday + 1000 },
+      { id: "active", updatedAt: yesterday + 2000 },
+    ]
+
+    const result = splitConversationToolbarItems(
+      conversations,
+      "active",
+      (id) => id === "working",
+    )
+
+    expect(result.topConversations.map((item) => item.id)).toEqual(["active", "working", "today-1"])
+    expect(result.historyConversations.map((item) => item.id)).toContain("today-2")
+    expect(result.historyConversations.map((item) => item.id)).toContain("old")
+  })
 })

+ 42 - 3
src/lib/workspace-layout.ts

@@ -1,5 +1,3 @@
-import type { Conversation } from "@/stores/chat-store"
-
 export function isWorkspaceView(view: "wiki" | "sources" | "search" | "graph" | "lint" | "review" | "characterAura" | "settings" | "trash"): boolean {
   return view === "wiki" || view === "trash"
 }
@@ -57,6 +55,47 @@ export function getConversationTabTitle(title: string, maxLength = 12): string {
   return `${title.slice(0, Math.max(1, maxLength - 1))}…`
 }
 
-export function sortConversationsByUpdatedAt(conversations: Conversation[]): Conversation[] {
+export interface ConversationToolbarItem {
+  id: string
+  updatedAt: number
+}
+
+export const MAX_TOP_CONVERSATIONS = 3
+
+export function sortConversationsByUpdatedAt<T extends ConversationToolbarItem>(conversations: T[]): T[] {
   return [...conversations].sort((a, b) => b.updatedAt - a.updatedAt)
 }
+
+export function isTodayConversation(conv: ConversationToolbarItem, now = new Date()): boolean {
+  return new Date(conv.updatedAt).toDateString() === now.toDateString()
+}
+
+export function splitConversationToolbarItems<T extends ConversationToolbarItem>(
+  conversations: T[],
+  activeConversationId: string | null,
+  isWorkingConversation: (convId: string) => boolean,
+): {
+  sorted: T[]
+  topConversations: T[]
+  historyConversations: T[]
+} {
+  const sorted = sortConversationsByUpdatedAt(conversations)
+  const activeConversation = sorted.find((conv) => conv.id === activeConversationId) ?? null
+  const topConversationCandidates = [
+    ...(activeConversation ? [activeConversation] : []),
+    ...sorted.filter((conv) => conv.id !== activeConversationId && isWorkingConversation(conv.id)),
+    ...sorted.filter((conv) => conv.id !== activeConversationId && !isWorkingConversation(conv.id) && isTodayConversation(conv)),
+  ]
+  const seenTopConversationIds = new Set<string>()
+  const topConversations = topConversationCandidates
+    .filter((conv) => {
+      if (seenTopConversationIds.has(conv.id)) return false
+      seenTopConversationIds.add(conv.id)
+      return true
+    })
+    .slice(0, MAX_TOP_CONVERSATIONS)
+  const topConversationIds = new Set(topConversations.map((conv) => conv.id))
+  const historyConversations = sorted.filter((conv) => !topConversationIds.has(conv.id))
+
+  return { sorted, topConversations, historyConversations }
+}

+ 16 - 6
src/stores/outline-chat-store.ts

@@ -19,6 +19,7 @@ export interface OutlineChatConversation {
   id: string
   title: string
   createdAt: number
+  updatedAt: number
   messages: OutlineChatMessage[]
   modelId?: string
 }
@@ -58,10 +59,12 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
 
   createConversation: () => {
     const id = crypto.randomUUID()
+    const now = Date.now()
     const conv: OutlineChatConversation = {
       id,
       title: `大纲对话 ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`,
-      createdAt: Date.now(),
+      createdAt: now,
+      updatedAt: now,
       messages: [],
     }
     set((s) => ({
@@ -75,15 +78,17 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
   setActiveConversation: (id) => set({ activeConversationId: id }),
 
   addMessage: (convId, msg) => {
+    const now = Date.now()
     set((s) => ({
       conversations: s.conversations.map((c) =>
-        c.id === convId ? { ...c, messages: [...c.messages, msg] } : c
+        c.id === convId ? { ...c, messages: [...c.messages, msg], updatedAt: now } : c
       ),
     }))
     void get().saveToDisk()
   },
 
   replaceLastAssistant: (convId, content, sources) => {
+    const now = Date.now()
     set((s) => ({
       conversations: s.conversations.map((c) => {
         if (c.id !== convId) return c
@@ -96,16 +101,17 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
         }
         const firstUser = msgs.find((m) => m.role === "user")
         const title = firstUser ? firstUser.content.slice(0, 20) + (firstUser.content.length > 20 ? "..." : "") : c.title
-        return { ...c, messages: msgs, title }
+        return { ...c, messages: msgs, title, updatedAt: now }
       }),
     }))
     void get().saveToDisk()
   },
 
   removeLastMessage: (convId) => {
+    const now = Date.now()
     set((s) => ({
       conversations: s.conversations.map((c) =>
-        c.id === convId ? { ...c, messages: c.messages.slice(0, -1) } : c
+        c.id === convId ? { ...c, messages: c.messages.slice(0, -1), updatedAt: now } : c
       ),
     }))
     void get().saveToDisk()
@@ -120,9 +126,10 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
   },
 
   setConversationModel: (id, modelId) => {
+    const now = Date.now()
     set((s) => ({
       conversations: s.conversations.map((c) =>
-        c.id === id ? { ...c, modelId } : c
+        c.id === id ? { ...c, modelId, updatedAt: now } : c
       ),
     }))
     void get().saveToDisk()
@@ -138,7 +145,10 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
       const content = await readFile(path)
       const data = JSON.parse(content) as { conversations: OutlineChatConversation[]; activeConversationId: string | null }
       set({
-        conversations: data.conversations ?? [],
+        conversations: (data.conversations ?? []).map((conversation) => ({
+          ...conversation,
+          updatedAt: conversation.updatedAt ?? conversation.createdAt ?? Date.now(),
+        })),
         activeConversationId: data.activeConversationId ?? null,
         loaded: true,
       })