Browse Source

fix: 修复thinking mode下reasoning_content未回传导致的HTTP 400错误;修复拆文库提取进度切换页面消失问题;版本号更新到3.0.3

Mochocyang 1 month ago
parent
commit
ebeed00648

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "qmai",
   "private": true,
-  "version": "3.0.2",
+  "version": "3.0.3",
   "license": "GPL-3.0-or-later",
   "type": "module",
   "scripts": {

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "qmai"
-version = "3.0.2"
+version = "3.0.3"
 description = "QMAI - AI writing system for long-form novels"
 authors = ["Mochocyang"]
 edition = "2021"

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

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "3.0.2",
+  "version": "3.0.3",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

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

@@ -1400,11 +1400,13 @@ export function ChatPanel() {
       void shouldRunNovelPrePluginChain
       let hasAgentError = false
       let lastAgentError = "生成失败"
+      let accumulatedReasoningContent = ""
 
       const markDone = (record?: AgentRunRecord) => {
         updateAgentAssistantMessage(assistantMessage.id, (message) => ({
           ...message,
           content: message.content || record?.finalText || "Agent未返回内容。",
+          ...(accumulatedReasoningContent ? { reasoning_content: accumulatedReasoningContent } : {}),
           agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls),
           agentStages: settleRunningAgentStages(message.agentStages, "done"),
           references: (() => {
@@ -1702,6 +1704,7 @@ export function ChatPanel() {
         ).map((message) => ({
           role: message.role,
           content: message.content,
+          ...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
         } satisfies AgentMessage)),
         { role: "user", content: userContent },
       ]
@@ -1775,6 +1778,9 @@ export function ChatPanel() {
                 ...message,
                 content: message.content + chunk,
               }))
+            },
+            onReasoningToken: (chunk: string) => {
+              accumulatedReasoningContent += chunk
             },
               onToolEvent: (event) => {
                 if (contextTrace) {
@@ -1801,6 +1807,7 @@ export function ChatPanel() {
               updateAgentAssistantMessage(assistantMessage.id, (message) => ({
                 ...message,
                 content: finalContent,
+                ...(accumulatedReasoningContent ? { reasoning_content: accumulatedReasoningContent } : {}),
                 isAgentRunning: false,
               }))
             },

+ 227 - 53
src/components/novel/book-analysis-module-view.tsx

@@ -1,7 +1,7 @@
 import { useState, type ReactNode } from "react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryBook } from "@/lib/novel/book-analysis/library-state"
-import type { AnalysisChunkRecord, AnalysisSkill, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
+import type { AnalysisChunkRecord, AnalysisChunkStatus, AnalysisSkill, BookAnalysisPipelineTask } from "@/lib/novel/book-analysis/analysis-pipeline-types"
 import { BookAnalysisCharacterPanel } from "./book-analysis-character-panel"
 import { BookAnalysisStyleCard } from "./book-analysis-style-card"
 
@@ -15,6 +15,8 @@ interface BookAnalysisModuleViewProps {
   storyContent?: ReactNode
   extractingStyle: boolean
   addingToSoul: boolean
+  activeTab?: BookAnalysisModuleTab
+  onActiveTabChange?: (tab: BookAnalysisModuleTab) => void
   onSelectCharacter: (id: string) => void
   onToggleStyle: () => void
   onAddSelectedSkillsToSoul: (skillId: string) => void
@@ -24,6 +26,7 @@ interface BookAnalysisModuleViewProps {
   onPauseTask?: () => void
   onContinueTask?: () => void
   onRetryTask?: () => void
+  onRetryChunk?: (skill: AnalysisSkill, chunkId: string) => void
   onCancelTask?: () => void
 }
 
@@ -42,111 +45,282 @@ const STATUS_LABELS = {
   skipped: "未选择",
 } as const
 
+const CHUNK_STATUS_LABELS: Record<AnalysisChunkStatus, string> = {
+  pending: "待处理",
+  running: "进行中",
+  completed: "已完成",
+  failed: "失败",
+  skipped: "已跳过",
+  cancelled: "已取消",
+}
+
 const SKILL_LABELS: Record<AnalysisSkill, string> = {
   characters: "角色 Skill",
   story: "故事 Skill",
   style: "文风 Skill",
 }
 
+const EVIDENCE_FILTERS: Array<{ id: "all" | AnalysisSkill; label: string }> = [
+  { id: "all", label: "全部" },
+  { id: "characters", label: "角色" },
+  { id: "story", label: "故事" },
+  { id: "style", label: "文风" },
+]
+
+function taskStatusLabel(status: BookAnalysisPipelineTask["status"]): string {
+  switch (status) {
+    case "queued":
+      return "排队中"
+    case "running":
+      return "进行中"
+    case "paused":
+      return "已暂停"
+    case "failed":
+      return "失败"
+    case "cancelled":
+      return "已取消"
+    case "completed":
+      return "已完成"
+    case "awaiting-range":
+      return "待选择章节"
+    default:
+      return status
+  }
+}
+
 export function BookAnalysisModuleView(props: BookAnalysisModuleViewProps) {
-  const [active, setActive] = useState<BookAnalysisModuleTab>("characters")
+  const [internalActive, setInternalActive] = useState<BookAnalysisModuleTab>("characters")
+  const [evidenceFilter, setEvidenceFilter] = useState<"all" | AnalysisSkill>("all")
+  const controlled = props.activeTab !== undefined
+  const active = controlled ? props.activeTab! : internalActive
+  const setActive = (tab: BookAnalysisModuleTab) => {
+    if (!controlled) setInternalActive(tab)
+    props.onActiveTabChange?.(tab)
+  }
+
   const skill = active === "evidence" ? null : active
   const currentTaskModule = skill && props.task?.selectedSkills.includes(skill)
     ? props.task.modules[skill]
     : null
   const moduleState = skill ? currentTaskModule ?? props.book.analysisManifest?.modules[skill] : null
-  const completed = skill && props.task
-    ? props.chunks?.filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === skill && chunk.status === "completed").length ?? 0
-    : 0
-  const total = moduleState?.chunkIds.length ?? 0
-  const currentSkill = props.task?.currentSkill
-  const currentSkillChunks = currentSkill
+  const skillChunks = skill && props.task
     ? (props.chunks ?? [])
-        .filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === currentSkill)
+        .filter((chunk) => chunk.taskId === props.task?.id && chunk.skill === skill)
         .sort((left, right) => left.startOrder - right.startOrder)
     : []
-  const activeChunk = currentSkillChunks.find((chunk) => chunk.status === "running")
-    ?? currentSkillChunks.find((chunk) => chunk.status === "failed")
-  const activeChunkIndex = activeChunk
-    ? currentSkillChunks.findIndex((chunk) => chunk.id === activeChunk.id) + 1
+  const completed = skillChunks.filter((chunk) => chunk.status === "completed").length
+  const total = moduleState?.chunkIds.length ?? skillChunks.length
+  const tabActiveChunk = skillChunks.find((chunk) => chunk.status === "running")
+    ?? skillChunks.find((chunk) => chunk.status === "failed")
+  const tabActiveChunkIndex = tabActiveChunk
+    ? skillChunks.findIndex((chunk) => chunk.id === tabActiveChunk.id) + 1
     : 0
-  const completedCurrentChunks = currentSkillChunks.filter((chunk) => chunk.status === "completed").length
-  const currentSkillIndex = currentSkill ? props.task?.selectedSkills.indexOf(currentSkill) ?? -1 : -1
-  const nextSkill = currentSkillIndex >= 0 ? props.task?.selectedSkills[currentSkillIndex + 1] : undefined
+  const currentSkill = props.task?.currentSkill
+  const taskBusy = props.task && ["queued", "running", "paused", "failed"].includes(props.task.status)
+
+  const filteredEvidence = evidenceFilter === "all"
+    ? props.book.evidence
+    : props.book.evidence.filter((item) => item.skill === evidenceFilter)
 
   return (
     <div className="flex min-h-0 flex-1 flex-col">
       <div role="tablist" aria-label="拆书分析模块" className="flex shrink-0 border-b bg-background px-5">
         {TABS.map((tab) => (
-          <button key={tab.id} role="tab" aria-selected={active === tab.id} onClick={() => setActive(tab.id)} className={`border-b-2 px-3 py-3 text-sm ${active === tab.id ? "border-primary font-medium" : "border-transparent text-muted-foreground"}`}>
+          <button
+            key={tab.id}
+            role="tab"
+            aria-selected={active === tab.id}
+            onClick={() => setActive(tab.id)}
+            className={`border-b-2 px-3 py-3 text-sm ${active === tab.id ? "border-primary font-medium" : "border-transparent text-muted-foreground"}`}
+          >
             {tab.label}
           </button>
         ))}
       </div>
       <div className="min-h-0 flex-1 overflow-y-auto p-5">
-        {props.task && ["queued", "running", "paused", "failed"].includes(props.task.status) && (
+        {taskBusy && props.task && (
           <section className={`mb-4 rounded-md border p-3 ${props.task.status === "failed" ? "border-destructive/40 bg-destructive/5" : "border-primary/30 bg-primary/5"}`}>
-            {currentSkill ? (
-              <div className="space-y-1 text-sm">
-                <div className="font-medium">
-                  {props.task.status === "failed"
-                    ? `${SKILL_LABELS[currentSkill]} 分析失败`
-                    : `正在进行:${SKILL_LABELS[currentSkill]}`}
+            <div className="space-y-1 text-sm">
+              <div className="font-medium">
+                分析任务{taskStatusLabel(props.task.status)}
+                {currentSkill ? ` · 当前:${SKILL_LABELS[currentSkill]}` : ""}
+              </div>
+              {props.task.selectedSkills.length > 0 && (
+                <div className="flex flex-wrap gap-1.5 pt-1" aria-label="各 Skill 状态">
+                  {props.task.selectedSkills.map((item) => {
+                    const module = props.task!.modules[item]
+                    const isCurrent = currentSkill === item
+                    return (
+                      <button
+                        key={item}
+                        type="button"
+                        onClick={() => setActive(item)}
+                        className={`rounded-full border px-2.5 py-0.5 text-xs transition ${
+                          active === item
+                            ? "border-primary bg-primary/10 text-foreground"
+                            : isCurrent
+                              ? "border-primary/50 text-foreground"
+                              : "border-border text-muted-foreground hover:bg-muted/60"
+                        }`}
+                      >
+                        {SKILL_LABELS[item]} · {STATUS_LABELS[module.status]}
+                      </button>
+                    )
+                  })}
                 </div>
-                {activeChunk && (
-                  <div>当前区块:第 {activeChunkIndex}/{currentSkillChunks.length} 个(第 {activeChunk.startOrder}~{activeChunk.endOrder} 章)</div>
-                )}
-                <div>已完成区块:{completedCurrentChunks}/{currentSkillChunks.length}</div>
-                <div>{props.task.status === "failed"
-                  ? "后续步骤已暂停"
-                  : `下一步:${nextSkill ? SKILL_LABELS[nextSkill] : "汇总并保存结果"}`}</div>
+              )}
+            </div>
+            {props.task.error && (
+              <div role="alert" className="mt-2 break-words text-sm text-destructive">
+                失败原因:{props.task.error}
               </div>
-            ) : (
-              <div className="text-sm font-medium">分析任务等待开始</div>
             )}
-            {props.task.error && <div role="alert" className="mt-2 break-words text-sm text-destructive">失败原因:{props.task.error}</div>}
             <div className="mt-3 flex flex-wrap gap-2">
-              {props.task.status === "running" && props.onPauseTask && <Button size="sm" variant="outline" onClick={props.onPauseTask}>暂停</Button>}
-              {props.task.status === "paused" && props.onContinueTask && <Button size="sm" onClick={props.onContinueTask}>继续</Button>}
-              {props.task.status === "failed" && props.onRetryTask && <Button size="sm" onClick={props.onRetryTask}>重试当前步骤</Button>}
+              {props.task.status === "running" && props.onPauseTask && (
+                <Button size="sm" variant="outline" onClick={props.onPauseTask}>暂停</Button>
+              )}
+              {props.task.status === "paused" && props.onContinueTask && (
+                <Button size="sm" onClick={props.onContinueTask}>
+                  从断点继续(已完成 {completed}/{total || "?"} 区块)
+                </Button>
+              )}
+              {props.task.status === "failed" && props.onRetryTask && (
+                <Button size="sm" onClick={props.onRetryTask}>重试当前步骤</Button>
+              )}
               {["queued", "running", "paused", "failed"].includes(props.task.status) && props.onCancelTask && (
                 <Button size="sm" variant="outline" onClick={props.onCancelTask}>取消任务</Button>
               )}
             </div>
           </section>
         )}
+
         {props.task?.status === "awaiting-range" && (
           <div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-primary/30 bg-primary/5 p-3">
-            <div><div className="text-sm font-medium">待选择章节</div><div className="mt-1 text-xs text-muted-foreground">已选择 {props.task.selectedSkills.length} 个提取项目</div></div>
+            <div>
+              <div className="text-sm font-medium">待选择章节</div>
+              <div className="mt-1 text-xs text-muted-foreground">已选择 {props.task.selectedSkills.length} 个提取项目</div>
+            </div>
             <Button size="sm" onClick={props.onConfigureTask}>选择章节</Button>
           </div>
         )}
+
         {skill && (
-          <div className="mb-4 flex items-start justify-between gap-3 border-b pb-3">
-            <div className="text-sm text-muted-foreground">
-              <div>{moduleState ? STATUS_LABELS[moduleState.status] : "尚未分析"}</div>
-              {moduleState && <div className="mt-1">最近范围:第 {moduleState.range.startOrder}~{moduleState.range.endOrder} 章{total > 0 ? ` · 完成区块 ${completed}/${total}` : ""}</div>}
-              {moduleState?.summary && <div className="mt-1 break-words">{moduleState.summary}</div>}
+          <div className="mb-4 space-y-3 border-b pb-3">
+            <div className="flex items-start justify-between gap-3">
+              <div className="text-sm text-muted-foreground">
+                <div>{moduleState ? STATUS_LABELS[moduleState.status] : "尚未分析"}</div>
+                {moduleState && (
+                  <div className="mt-1">
+                    最近范围:第 {moduleState.range.startOrder}~{moduleState.range.endOrder} 章
+                    {total > 0 ? ` · 完成区块 ${completed}/{total}` : ""}
+                  </div>
+                )}
+                {moduleState?.summary && <div className="mt-1 break-words">{moduleState.summary}</div>}
+                {taskBusy && currentSkill && currentSkill !== skill && (
+                  <div className="mt-1 text-xs">
+                    当前任务正在跑:{SKILL_LABELS[currentSkill]}(本 Tab 显示本 Skill 状态,不含其他 Skill 区块详情)
+                  </div>
+                )}
+                {skill && currentSkill === skill && tabActiveChunk && (
+                  <div className="mt-1">
+                    当前区块:第 {tabActiveChunkIndex}/{skillChunks.length} 个(第 {tabActiveChunk.startOrder}~{tabActiveChunk.endOrder} 章)
+                  </div>
+                )}
+              </div>
+              <Button variant="outline" size="sm" onClick={() => props.onReextract(skill)}>重新提取</Button>
             </div>
-            <Button variant="outline" size="sm" onClick={() => props.onReextract(skill)}>重新提取</Button>
+
+            {skillChunks.length > 0 && (
+              <div className="rounded-md border bg-muted/20 p-3" aria-label={`${SKILL_LABELS[skill]} 断点进度`}>
+                <div className="mb-2 text-xs font-medium text-muted-foreground">
+                  {SKILL_LABELS[skill]} 区块进度 · {completed}/{skillChunks.length}
+                  {moduleState?.resultPath ? " · 结果已发布" : ""}
+                </div>
+                <div className="space-y-1.5">
+                  {skillChunks.map((chunk) => (
+                    <div
+                      key={chunk.id}
+                      className="flex flex-wrap items-center justify-between gap-2 rounded border bg-background px-2.5 py-1.5 text-xs"
+                    >
+                      <div className="min-w-0">
+                        <span className="font-medium">第 {chunk.startOrder}~{chunk.endOrder} 章</span>
+                        <span className="ml-2 text-muted-foreground">
+                          {CHUNK_STATUS_LABELS[chunk.status]}
+                          {chunk.attempts > 0 ? ` · 尝试 ${chunk.attempts}` : ""}
+                        </span>
+                        {chunk.error && (
+                          <div className="mt-0.5 break-words text-destructive">{chunk.error}</div>
+                        )}
+                      </div>
+                      {chunk.status === "failed" && props.onRetryChunk && (
+                        <Button
+                          size="sm"
+                          variant="outline"
+                          className="h-7"
+                          onClick={() => props.onRetryChunk?.(skill, chunk.id)}
+                        >
+                          重试此区块
+                        </Button>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
         )}
+
         {active === "characters" && (
-          <BookAnalysisCharacterPanel book={props.book} selectedCharacterId={props.selectedCharacterId} addingToSoul={props.addingToSoul} onSelectCharacter={props.onSelectCharacter} onAddSelectedSkillsToSoul={props.onAddSelectedSkillsToSoul} onOpenSkillSelection={props.onOpenSkillSelection ?? (() => undefined)} />
+          <BookAnalysisCharacterPanel
+            book={props.book}
+            selectedCharacterId={props.selectedCharacterId}
+            addingToSoul={props.addingToSoul}
+            onSelectCharacter={props.onSelectCharacter}
+            onAddSelectedSkillsToSoul={props.onAddSelectedSkillsToSoul}
+            onOpenSkillSelection={props.onOpenSkillSelection ?? (() => undefined)}
+          />
         )}
         {active === "story" && props.storyContent}
         {active === "style" && (
-          <BookAnalysisStyleCard book={props.book} extracting={props.extractingStyle} onExtractStyle={() => props.onReextract("style")} onToggleStyle={props.onToggleStyle} />
+          <BookAnalysisStyleCard
+            book={props.book}
+            extracting={props.extractingStyle}
+            onExtractStyle={() => props.onReextract("style")}
+            onToggleStyle={props.onToggleStyle}
+          />
         )}
         {active === "evidence" && (
           <div className="space-y-3">
-            {props.book.evidence.length === 0 ? <p className="text-sm text-muted-foreground">暂无证据片段</p> : props.book.evidence.map((item) => (
-              <article key={item.id} className="rounded-md border p-3 text-sm">
-                <div className="text-xs text-muted-foreground">第 {item.chapterOrder} 章 · {item.tags.join("、") || item.skill}</div>
-                <p className="mt-2 whitespace-pre-wrap break-words">{item.text}</p>
-                <p className="mt-2 text-xs text-muted-foreground">用途:{item.purpose};保存原因:{item.reason}</p>
-              </article>
-            ))}
+            <div className="flex flex-wrap gap-1.5" aria-label="证据 Skill 筛选">
+              {EVIDENCE_FILTERS.map((filter) => (
+                <button
+                  key={filter.id}
+                  type="button"
+                  onClick={() => setEvidenceFilter(filter.id)}
+                  className={`rounded-full border px-2.5 py-0.5 text-xs ${
+                    evidenceFilter === filter.id
+                      ? "border-primary bg-primary/10 font-medium"
+                      : "border-border text-muted-foreground hover:bg-muted/60"
+                  }`}
+                >
+                  {filter.label}
+                </button>
+              ))}
+            </div>
+            {filteredEvidence.length === 0 ? (
+              <p className="text-sm text-muted-foreground">暂无证据片段</p>
+            ) : (
+              filteredEvidence.map((item) => (
+                <article key={item.id} className="rounded-md border p-3 text-sm">
+                  <div className="text-xs text-muted-foreground">
+                    第 {item.chapterOrder} 章 · {SKILL_LABELS[item.skill] ?? item.skill}
+                    {item.tags.length > 0 ? ` · ${item.tags.join("、")}` : ""}
+                  </div>
+                  <p className="mt-2 whitespace-pre-wrap break-words">{item.text}</p>
+                  <p className="mt-2 text-xs text-muted-foreground">用途:{item.purpose};保存原因:{item.reason}</p>
+                </article>
+              ))
+            )}
           </div>
         )}
       </div>

+ 4 - 8
src/components/novel/book-analysis-view.tsx

@@ -125,7 +125,6 @@ export function BookAnalysisView() {
   const deleteFailedImportTask = useBookAnalysisImportStore((s) => s.deleteFailedTask)
   const renameCompletedImportTask = useBookAnalysisImportStore((s) => s.renameCompletedTask)
   const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
-  const disposeImportStore = useBookAnalysisImportStore((s) => s.dispose)
   const pipelineTasks = useBookAnalysisPipelineStore((s) => s.tasks)
   const pipelineChunks = useBookAnalysisPipelineStore((s) => s.chunks)
   const initializePipelineProject = useBookAnalysisPipelineStore((s) => s.initializeProject)
@@ -136,7 +135,6 @@ export function BookAnalysisView() {
   const continuePipelineTask = useBookAnalysisPipelineStore((s) => s.continueTask)
   const retryPipelineChunk = useBookAnalysisPipelineStore((s) => s.retryFailedChunk)
   const cancelPipelineTask = useBookAnalysisPipelineStore((s) => s.cancelTask)
-  const disposePipeline = useBookAnalysisPipelineStore((s) => s.dispose)
   const importRevisionBaselineRef = useRef({ projectPath: currentProject?.path ?? null, revision: importRevision })
   const importInitializationSequenceRef = useRef(0)
   const importInitializationTokenRef = useRef<{ sequence: number; projectPath: string } | null>(null)
@@ -279,12 +277,10 @@ export function BookAnalysisView() {
       if (importInitializationTokenRef.current === initializationToken) importInitializationTokenRef.current = null
     })
 
-    return () => {
-      void Promise.all([disposeImportStore(), disposePipeline()]).catch((error) => {
-        console.error("释放批量导入任务失败", error)
-      })
-    }
-  }, [currentProject?.path, initializeImportProject, disposeImportStore, initializePipelineProject, disposePipeline])
+    // 不在卸载时 dispose:调度器在后台持续运行,
+    // 切换页面再返回时通过 initializeProject 的同项目早返回保持进度不丢失。
+    // 项目切换时 initializeProject 内部会自动释放旧调度器。
+  }, [currentProject?.path, initializeImportProject, initializePipelineProject])
 
   useEffect(() => {
     if (!currentProject?.path) return

+ 2 - 0
src/lib/agent/ai-chat-session.ts

@@ -5,6 +5,7 @@ import { scopeAgentConfigTools } from "./tool-scope"
 
 export interface RunAiChatSessionCallbacks {
   onText: (chunk: string) => void
+  onReasoningToken?: (chunk: string) => void
   onToolEvent?: AgentRunCallbacks["onToolEvent"]
   onActivityEvent?: AgentRunCallbacks["onActivityEvent"]
   onDone: () => void
@@ -38,6 +39,7 @@ export async function runAiChatSession(input: RunAiChatSessionInput): Promise<Ag
     input.messages,
     {
       onText: input.callbacks.onText,
+      onReasoningToken: input.callbacks.onReasoningToken,
       onToolCall: () => {},
       onToolResult: () => {},
       onToolError: () => {},

+ 8 - 0
src/lib/agent/runner.ts

@@ -120,12 +120,17 @@ export class AgentRunner {
 
       const toolCallDeltas: ToolCallDelta[] = []
       let roundText = ""
+      let roundReasoningContent = ""
       let streamError: Error | undefined
 
       const streamCallbacks: StreamCallbacks = {
         onToken: (t: string) => {
           roundText += t
         },
+        onReasoningToken: (t: string) => {
+          roundReasoningContent += t
+          callbacks.onReasoningToken?.(t)
+        },
         onToolCallDelta: (delta: ToolCallDelta) => {
           toolCallDeltas.push(delta)
         },
@@ -172,6 +177,7 @@ export class AgentRunner {
         attemptedToolsFallback = true
         openaiTools = undefined
         roundText = ""
+        roundReasoningContent = ""
         toolCallDeltas.length = 0
         streamError = undefined
         requestOverrides = buildRequestOverrides(config.requestOverrides)
@@ -210,6 +216,7 @@ export class AgentRunner {
         !isReasoningDisabled(config.llmConfig, requestOverrides)
       ) {
         roundText = ""
+        roundReasoningContent = ""
         toolCallDeltas.length = 0
         streamError = undefined
         requestOverrides = buildRequestOverrides(withReasoningDisabled(config.requestOverrides))
@@ -260,6 +267,7 @@ export class AgentRunner {
         role: "assistant",
         content: roundText || "",
         tool_calls: toolCalls,
+        ...(roundReasoningContent ? { reasoning_content: roundReasoningContent } : {}),
       }
       workingMessages.push(assistantMsg)
 

+ 2 - 0
src/lib/agent/types.ts

@@ -123,6 +123,7 @@ export interface AgentStageTrace {
 
 export interface AgentRunCallbacks {
   onText: (chunk: string) => void
+  onReasoningToken?: (chunk: string) => void
   onToolCall: (call: ToolCall) => void
   onToolResult: (callId: string, result: string) => void
   onToolError: (callId: string, error: string) => void
@@ -138,6 +139,7 @@ export interface AgentMessage {
   tool_calls?: { id: string; type: "function"; function: { name: string; arguments: string } }[]
   tool_call_id?: string
   name?: string
+  reasoning_content?: string
 }
 
 export interface AgentRunRecord {

+ 9 - 0
src/lib/llm-providers.ts

@@ -52,6 +52,14 @@ export interface ChatMessage {
   tool_calls?: ToolCall[]
   tool_call_id?: string
   name?: string
+  /**
+   * Chain-of-thought / reasoning content from thinking models
+   * (DeepSeek-R1, Qwen3, Kimi K2.x, etc.). Must be passed back
+   * on subsequent multi-turn requests or the API returns 400:
+   * "The `reasoning_content` in the thinking mode must be passed
+   * back to the API."
+   */
+  reasoning_content?: string
 }
 
 /**
@@ -427,6 +435,7 @@ function buildOpenAiBody(
     ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
     ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
     ...(m.name ? { name: m.name } : {}),
+    ...(m.reasoning_content ? { reasoning_content: m.reasoning_content } : {}),
   }))
   const body: Record<string, unknown> = { messages: translated, stream: true, ...stripWireAgnosticOverrides(overrides) }
   if (overrides?.tools && overrides.tools.length > 0) {

+ 2 - 0
src/stores/book-analysis-import-store.ts

@@ -335,6 +335,8 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
       initializeProject: (rawPath) => {
         const projectPath = normalizePath(rawPath).replace(/\/+$/, "")
         if (!projectPath) return Promise.reject(new Error("项目路径不能为空"))
+        // 同项目已初始化时跳过重新初始化,避免组件卸载-重挂时清空导入进度
+        if (get().projectPath === projectPath) return Promise.resolve()
         generation += 1
         const token = generation
         const previousScheduler = detachScheduler()

+ 5 - 2
src/stores/book-analysis-pipeline-store.ts

@@ -111,14 +111,17 @@ export function createBookAnalysisPipelineStore() {
     chunks: [],
     dismissedBatchIds: [],
     async initializeProject(rawPath) {
+      const projectPath = normalizePath(rawPath).replace(/\/+$/, "")
+      if (!projectPath) throw new Error("项目路径不能为空")
+      // 同项目已初始化且调度器仍在运行时,跳过重新初始化,
+      // 避免组件卸载-重挂时清空进度、停止正在运行的分析任务
+      if (get().projectPath === projectPath && scheduler) return
       generation += 1
       const token = generation
       unsubscribe?.()
       unsubscribe = null
       await scheduler?.dispose()
       scheduler = null
-      const projectPath = normalizePath(rawPath).replace(/\/+$/, "")
-      if (!projectPath) throw new Error("项目路径不能为空")
       set({ projectPath, tasks: [], chunks: [], dismissedBatchIds: [] })
       const recovered = await loadAndRecoverAnalysisTasks(projectPath)
       if (token !== generation || get().projectPath !== projectPath) return

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

@@ -44,6 +44,7 @@ export interface DisplayMessage {
   attachedReferences?: ReferenceToken[]
   contextTrace?: ContextTrace
   contextHubSnapshot?: ContextHubSnapshotRef
+  reasoning_content?: string
 }
 
 interface ChatState {
@@ -426,5 +427,6 @@ export function chatMessagesToLLM(messages: DisplayMessage[]): ChatMessage[] {
   return messages.map((m) => ({
     role: m.role,
     content: m.content,
+    ...(m.reasoning_content ? { reasoning_content: m.reasoning_content } : {}),
   }))
 }