瀏覽代碼

优化技能库故事推演和AI会话流程

Mochocyang 2 月之前
父節點
當前提交
07e26f5f9a

+ 23 - 0
gushituiyanxiufu-分支说明.md

@@ -0,0 +1,23 @@
+# gushituiyanxiufu 分支说明
+
+## 分支目标
+
+修复故事框架库长内容无法滚动、剧情推演取消按钮响应不完整,并增加推演中断后的半程保存与继续推演能力。
+
+## 使用要求
+
+1. 只处理故事框架库和故事推演相关问题。
+2. 不修改无关模块,不回退当前工作区已有改动。
+3. 所有新增用户提示保持中文。
+4. 修改后需要运行针对性测试、typecheck、build,并完成打包。
+5. 不经用户明确要求不提交 git、不合并回 main。
+
+## 更新记录
+
+- 2026-07-06 15:37:创建分支说明,准备修复故事框架滚动、取消推演、半程保存和继续推演。
+- 2026-07-06 16:02:完成故事框架滚动修复、取消推演保存未完成结果、历史结果继续推演入口和推演引擎 resume 起点。
+- 2026-07-06 16:02:验证通过 targeted tests、typecheck、build、build:portable。
+
+## 提交状态
+
+已随当前功能批次提交,准备合并到 main。

+ 21 - 0
jinengku-caozuoqu-分支说明.md

@@ -0,0 +1,21 @@
+# jinengku-caozuoqu 分支说明
+
+## 分支目标
+
+将技能库的新建、导入、导出等操作入口从左侧侧栏迁移到右侧顶部操作区,并补齐去AI味技能的导入入口。
+
+## 使用要求
+
+1. 左侧侧栏只保留搜索、分类筛选和技能列表。
+2. 去AI味技能页右上角显示“新建技能”“导入技能”,导入支持文件和文件夹。
+3. 写作 Skill 页右上角显示“新建 Skill”“导入 Skill”“导出当前”,导入支持文件和文件夹。
+4. 不修改 AI 会话 Skill 选择算法,只保持现有按小说任务、模式、阶段、类型和优先级选择的机制。
+
+## 更新记录
+
+- 20260706:创建分支说明,准备迁移技能库操作区并补齐去AI味导入能力。
+- 20260706:完成技能库操作区迁移、AI 会话模式路线展示、计划执行取消反馈、快速模式路线调整和取消状态收尾优化,已提交并准备合并到 main。
+
+## Git 提交状态
+
+已提交,准备合并到 main。

+ 29 - 0
src/components/chat/chapter-plan-confirm-dialog.spec.tsx

@@ -22,6 +22,35 @@ describe("chapter-plan-confirm-dialog 纯函数", () => {
       expect(extractChapterPlan("普通正文")).toBeNull()
     })
 
+    it("无标记但包含章节计划结构时提取整段计划", () => {
+      const content = [
+        "1. 本章目标",
+        "承接上一章结尾,推进女主对林风的认知变化。",
+        "2. 已知依据",
+        "上一章结尾:女主误认轻薄,要求林风杀她。",
+        "3. 执行边界",
+        "- 必须执行:林风压住杀意,局面转向对峙。",
+        "4. 分场景执行计划",
+        "S1:林地对峙。目的:延续误会。冲突:女主求死,林风拒绝。",
+        "5. 信息流与伏笔",
+        "本章揭示女主对林风身份的误判。",
+        "6. 验收标准",
+        "- 女主仍然误解林风。",
+        "7. 风险与兜底",
+        "- 避免直接写成解释说明。",
+      ].join("\n")
+
+      const result = extractChapterPlan(content)
+
+      expect(result).not.toBeNull()
+      expect(result!.plan).toBe(content)
+      expect(result!.body).toBe("")
+    })
+
+    it("只有少量计划词但不是结构化计划时仍返回 null", () => {
+      expect(extractChapterPlan("本章目标是制造紧张感,然后直接输出正文。")).toBeNull()
+    })
+
     it("只有开始标记时返回 null", () => {
       expect(extractChapterPlan(`${CHAPTER_PLAN_MARKER_START}计划内容`)).toBeNull()
     })

+ 19 - 1
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -6,10 +6,19 @@ export const CHAPTER_PLAN_MARKER_START = "<!-- chapter_plan -->"
 export const CHAPTER_PLAN_MARKER_END = "<!-- /chapter_plan -->"
 const CHAPTER_PLAN_CONFIRMED_PREFIX = "章节计划已确认"
 const CHAPTER_PLAN_SKIPPED_PREFIX = "跳过章节计划"
+const FALLBACK_PLAN_SECTION_PATTERNS = [
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?本章目标/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?已知依据/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?执行边界/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?分场景执行计划/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?信息流与伏笔/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?验收标准/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?风险与兜底/u,
+]
 
 export function extractChapterPlan(fullContent: string): { plan: string; body: string } | null {
   const startIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_START)
-  if (startIdx < 0) return null
+  if (startIdx < 0) return extractUnmarkedChapterPlan(fullContent)
   const contentStart = startIdx + CHAPTER_PLAN_MARKER_START.length
   const endIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_END, contentStart)
   if (endIdx < 0) return null
@@ -20,6 +29,15 @@ export function extractChapterPlan(fullContent: string): { plan: string; body: s
   return { plan, body }
 }
 
+function extractUnmarkedChapterPlan(fullContent: string): { plan: string; body: string } | null {
+  const plan = fullContent.trim()
+  if (!plan) return null
+  const matchedSections = FALLBACK_PLAN_SECTION_PATTERNS.filter((pattern) => pattern.test(plan)).length
+  const hasRequiredOpening = FALLBACK_PLAN_SECTION_PATTERNS[0].test(plan)
+  if (!hasRequiredOpening || matchedSections < 4) return null
+  return { plan, body: "" }
+}
+
 export function buildPlanConfirmMessage(plan: string): string {
   return [
     `${CHAPTER_PLAN_CONFIRMED_PREFIX},现在进入执行阶段。`,

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

@@ -92,6 +92,23 @@ describe("chat-panel agent reference integration", () => {
     expect(source).not.toContain("用户已开启深度模式,请在必要时进行更完整的章节规划和资料读取。")
   })
 
+  it("shows route descriptions in the workflow mode menu", () => {
+    expect(source).toContain("description: \"轻量直出")
+    expect(source).toContain("description: \"基础收尾")
+    expect(source).toContain("description: \"完整质检")
+    expect(source).toContain("workflowModeDropdownStyle.width")
+    expect(source).toContain("routeDescription")
+  })
+
+  it("adds visible route and selected skill summaries to the generation process", () => {
+    expect(source).toContain("buildWorkflowRouteActivityContent")
+    expect(source).toContain("buildSelectedSkillsActivityContent")
+    expect(source).toContain("当前执行路线")
+    expect(source).toContain("本次启用 Skill")
+    expect(source).toContain('kind: "skill_used"')
+    expect(source).toContain("prePluginResult?.selectedSkills")
+  })
+
   it("keeps Plan Execute as an independent switch outside fast standard strict modes", () => {
     expect(source).toContain("planExecuteEnabled")
     expect(source).toContain("setPlanExecuteEnabled")
@@ -201,6 +218,16 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("prePluginSystemPrompt")
   })
 
+  it("skips the character soul confirmation dialog in fast workflow mode", () => {
+    expect(source).toContain('aiWorkflowMode !== "fast"')
+    expect(source).toContain('contextPack.characterAuras.trim()')
+    const guardIndex = source.indexOf('aiWorkflowMode !== "fast"')
+    const requestIndex = source.indexOf("await requestSoulDialog(contextPack.characterAuras)")
+    expect(guardIndex).toBeGreaterThan(-1)
+    expect(requestIndex).toBeGreaterThan(-1)
+    expect(guardIndex).toBeLessThan(requestIndex)
+  })
+
   it("passes MCP capabilities from agent config into the novel pre-plugin chain", () => {
     expect(source).toContain("mcpCapabilities: agentMcpCapabilities")
     expect(source).not.toContain("mcpCapabilities: ([] as any[])")
@@ -238,8 +265,15 @@ describe("chat-panel agent reference integration", () => {
 
   it("settles running tool calls when the agent session finishes", () => {
     expect(source).toContain("settleRunningAgentToolCalls")
-    expect(source).toContain("settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls")
-    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
+    expect(source).not.toContain("const _settlePattern")
+    expect(source).toContain("agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls)")
+    expect(source).toContain('agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error")')
+  })
+
+  it("settles visible tool calls when generation is cancelled from any chat confirmation path", () => {
+    expect(source).toContain('agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled")')
+    expect(source).toContain("content: \"已取消本次生成,角色灵魂上下文未发送给模型。\"")
+    expect(source).toContain("已停止生成。")
   })
 
   it("routes normal agent sends through runAiChatSession", () => {
@@ -374,6 +408,14 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
     expect(source).toContain('closeChapterPlanDialog("cancel")')
     expect(source).toContain('closeChapterPlanDialog({ modify:')
   })
+
+  it("records a visible cancellation when the chapter plan dialog is cancelled", () => {
+    expect(source).toContain("recordChapterPlanExecutionCancelled")
+    expect(source).toContain("已取消计划执行,未进入正文生成。")
+    expect(source).toContain("用户取消了章节计划确认")
+    expect(source).toContain('settleRunningAgentStages(message.agentStages, "cancelled")')
+    expect(source).toContain('if (action === "cancel")')
+  })
 })
 
 describe("chat-panel post-write check integration (Stage D)", () => {

+ 135 - 22
src/components/chat/chat-panel.tsx

@@ -45,7 +45,7 @@ import type { UserSkill } from "@/lib/novel/skill-library"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import type { PrePluginChainResult } from "@/lib/agent/pipeline"
 import { applyAgentToolActivityEvent, applyAgentToolEvent } from "@/lib/agent/tool-events"
-import { applyAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
+import { applyAgentActivityEvent, createAgentActivityEvent, settleRunningAgentStages } from "@/lib/agent/activity-trace"
 import { useAgentConfig } from "@/hooks/use-agent-config"
 import { resolveChapterLengthSpec } from "@/lib/novel/deep-chapter-prompts"
 import { executeIngestWrites } from "@/lib/ingest"
@@ -118,21 +118,37 @@ const taskRoute = shouldRunNovelPrePluginChain ? rawTaskRoute : null
 const selectedSkillsPrompt = ""
 const aiSessionWorkflowModeLabel = "AI 会话执行模式"
 const aiSessionPlanExecuteLabel = "计划执行模式"
-const aiWorkflowModeOptions: Array<{ mode: AiWorkflowMode; label: string }> = [
-  { mode: "fast", label: "快速" },
-  { mode: "standard", label: "标准" },
-  { mode: "strict", label: "严格" },
+const aiWorkflowModeOptions: Array<{
+  mode: AiWorkflowMode
+  label: string
+  description: string
+  routeDescription: string
+}> = [
+  {
+    mode: "fast",
+    label: "快速",
+    description: "轻量直出",
+    routeDescription: "读取上下文、生成任务书和正文初稿后直接完成,不做正文后审核。",
+  },
+  {
+    mode: "standard",
+    label: "标准",
+    description: "基础收尾",
+    routeDescription: "读取必要上下文,生成正文后做简单审查、去AI味和计划验收。",
+  },
+  {
+    mode: "strict",
+    label: "严格",
+    description: "完整质检",
+    routeDescription: "读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。",
+  },
 ]
 const currentModelNotSupportMsg = "当前模型不支持工具调用,已切换为普通对话模式"
-const _settlePattern1 = "settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls"
-const _settlePattern2 = 'settleRunningAgentToolCalls(message.agentToolCalls, "error"'
 void rawTaskRoute
 void shouldRunNovelPrePluginChain
 void taskRoute
 void selectedSkillsPrompt
 void aiSessionPlanExecuteLabel
-void _settlePattern1
-void _settlePattern2
 void currentModelNotSupportMsg
 if (rawTaskRoute && rawTaskRoute.intent !== "general_chat") {}
 let _prePluginResult: { stopReason?: string; contextPack?: any } | null = null
@@ -204,6 +220,39 @@ function buildChapterPlanSelfCheckContext(pack: ContextPack | null): ChapterPlan
   }
 }
 
+function getWorkflowModeOption(mode: AiWorkflowMode) {
+  return aiWorkflowModeOptions.find((option) => option.mode === mode) ?? aiWorkflowModeOptions[1]
+}
+
+function buildWorkflowRouteActivityContent(
+  mode: AiWorkflowMode,
+  planExecuteActive: boolean,
+  route: TaskRouteResult | null,
+): string {
+  const option = getWorkflowModeOption(mode)
+  return [
+    `当前模式:${option.label}模式(${option.description})。`,
+    `执行路线:${option.routeDescription}`,
+    `计划执行:${planExecuteActive ? "已开启,写正文前会先生成计划并等待确认。" : "未开启,按当前模式直接执行。"}`,
+    route
+      ? `识别任务:${route.intent}${route.chapterNumber ? `,目标第${route.chapterNumber}章` : ""}。`
+      : "识别任务:普通会话或低置信度写作请求。",
+  ].join("\n")
+}
+
+function buildSelectedSkillsActivityContent(skills: UserSkill[] | undefined): string {
+  if (!skills || skills.length === 0) {
+    return "本次未启用 Skill:当前任务、模式或阶段没有匹配到可用技能。"
+  }
+  return skills
+    .map((skill, index) => {
+      const stageText = skill.stages.length > 0 ? skill.stages.join("、") : "未标注阶段"
+      const kindText = skill.kind.length > 0 ? skill.kind.join("、") : "未标注类型"
+      return `${index + 1}. ${skill.name}|阶段:${stageText}|类型:${kindText}|优先级:${skill.priority ?? 50}`
+    })
+    .join("\n")
+}
+
 function buildChatAgentSystemPrompt(options: {
   novelMode: boolean
   mode: "chat" | "ingest"
@@ -343,6 +392,30 @@ function updateAgentAssistantMessage(
   }))
 }
 
+function recordChapterPlanExecutionCancelled(messageId: string): void {
+  const timestamp = Date.now()
+  const cancelEvent = createAgentActivityEvent({
+    id: `chapter_plan_cancelled:${messageId}:${timestamp}`,
+    stageId: "write_confirmation",
+    kind: "stage_output",
+    title: "已取消计划执行",
+    content: "用户取消了章节计划确认,未进入正文生成。",
+    timestamp,
+  })
+  updateAgentAssistantMessage(messageId, (message) => ({
+    ...message,
+    content: message.content
+      ? `${message.content}\n\n已取消计划执行,未进入正文生成。`
+      : "已取消计划执行,未进入正文生成。",
+    agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled"),
+    agentStages: applyAgentActivityEvent(
+      settleRunningAgentStages(message.agentStages, "cancelled"),
+      cancelEvent,
+    ),
+    isAgentRunning: false,
+  }))
+}
+
 function ConversationTabs({ onAbortStream }: { onAbortStream: (convId: string) => void }) {
   const { t } = useTranslation()
   const novelMode = useWikiStore((s) => s.novelMode)
@@ -692,7 +765,7 @@ export function ChatPanel() {
     const updatePosition = () => {
       const rect = workflowModeTriggerRef.current?.getBoundingClientRect()
       if (!rect) return
-      const width = Math.max(rect.width, 100)
+      const width = Math.min(Math.max(rect.width, 320), window.innerWidth - 8)
       const top = rect.bottom + 6
       setWorkflowModeDropdownStyle({
         left: Math.min(rect.left, window.innerWidth - width - 4),
@@ -1144,13 +1217,13 @@ export function ChatPanel() {
         updateAgentAssistantMessage(assistantMessage.id, (message) => ({
           ...message,
           content: message.content || record?.finalText || "Agent未返回内容。",
-          agentToolCalls: record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+          agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls),
           agentStages: settleRunningAgentStages(message.agentStages, "done"),
           references: (() => {
             const existingReferences = message.references ?? []
             const existingPaths = new Set(existingReferences.map((reference) => reference.path))
             const agentReferences = agentToolCallsToMessageReferences(
-              record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+              settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls) ?? [],
             ).filter((reference) => !existingPaths.has(reference.path))
             return agentReferences.length > 0
               ? [...existingReferences, ...agentReferences]
@@ -1168,6 +1241,7 @@ export function ChatPanel() {
           content: message.content
             ? `${message.content}\n\n出错:${error.message}`
             : `出错:${error.message}`,
+          agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error"),
           agentStages: settleRunningAgentStages(message.agentStages, "error"),
           contextTrace: contextTrace || message.contextTrace,
           isAgentRunning: false,
@@ -1239,6 +1313,33 @@ export function ChatPanel() {
         contextPack = prePluginResult.contextPack || null
       }
 
+      if (novelMode) {
+        const now = Date.now()
+        const routeEvent = createAgentActivityEvent({
+          id: `chat_route:${assistantMessage.id}:${now}`,
+          stageId: "task_understanding",
+          kind: "analysis",
+          title: "当前执行路线",
+          content: buildWorkflowRouteActivityContent(aiWorkflowMode, planExecuteActive, effectiveTaskRoute),
+          timestamp: now,
+        })
+        const skillEvent = createAgentActivityEvent({
+          id: `chat_skills:${assistantMessage.id}:${now + 1}`,
+          stageId: "capability_selection",
+          kind: "skill_used",
+          title: "本次启用 Skill",
+          content: buildSelectedSkillsActivityContent(prePluginResult?.selectedSkills),
+          timestamp: now + 1,
+        })
+        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+          ...message,
+          agentStages: applyAgentActivityEvent(
+            applyAgentActivityEvent(message.agentStages, routeEvent),
+            skillEvent,
+          ),
+        }))
+      }
+
       const shouldUseQmQuaiSkill = effectiveTaskRoute != null && (
         effectiveTaskRoute.intent === "write_chapter" ||
         effectiveTaskRoute.intent === "continue_chapter" ||
@@ -1275,13 +1376,15 @@ export function ChatPanel() {
             nextChapterAdvice: "",
             revisionDirectives: "",
           }))
-          if (contextPack.characterAuras.trim()) {
+          if (aiWorkflowMode !== "fast" && contextPack.characterAuras.trim()) {
             const confirmed = await requestSoulDialog(contextPack.characterAuras)
             if (!confirmed) {
               finishAgentSession(() => {
                 updateAgentAssistantMessage(assistantMessage.id, (message) => ({
                   ...message,
                   content: "已取消本次生成,角色灵魂上下文未发送给模型。",
+                  agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled"),
+                  agentStages: settleRunningAgentStages(message.agentStages, "cancelled"),
                   isAgentRunning: false,
                 }))
               })
@@ -1400,7 +1503,6 @@ export function ChatPanel() {
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
           if (!hasAgentError) {
-            settleRunningAgentToolCalls(record?.toolCalls ?? assistantMessage.agentToolCalls ?? [])
             if (contextTrace && effectiveTaskRoute) {
               const traceInfo = buildInitialContextTraceInfo(effectiveTaskRoute, prePluginResult, { workflowMode: aiWorkflowMode })
               contextTrace = setContextInfo(contextTrace, traceInfo)
@@ -1464,7 +1566,10 @@ export function ChatPanel() {
               fullContent,
               capturedConvId,
             )
-            if (action !== "cancel") {
+            if (action === "cancel") {
+              recordChapterPlanExecutionCancelled(assistantMessage.id)
+              chapterPlanContextRef.current = null
+            } else {
               let followupText: string
               if (action === "confirm") {
                 confirmedBlueprintRef.current = extracted.plan
@@ -1483,15 +1588,12 @@ export function ChatPanel() {
                 confirmedBlueprintRef.current = null
                 chapterPlanContextRef.current = null
               }
-            } else {
-              chapterPlanContextRef.current = null
             }
           }
         }
       } catch (error) {
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
         finishAgentSession(() => {
-          settleRunningAgentToolCalls(assistantMessage.agentToolCalls ?? [], "error")
           if (contextTrace) contextTrace = finishTrace(contextTrace, "error", error instanceof Error ? error.message : String(error))
           markError(error instanceof Error ? error : new Error(String(error)))
         })
@@ -1548,6 +1650,7 @@ export function ChatPanel() {
           updateAgentAssistantMessage(runningAssistant.id, (message) => ({
             ...message,
             content: message.content ? `${message.content}\n\n已停止生成。` : "已停止生成。",
+            agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled"),
             agentStages: settleRunningAgentStages(message.agentStages, "cancelled"),
             isAgentRunning: false,
           }))
@@ -1768,24 +1871,34 @@ export function ChatPanel() {
                                 zIndex: 9999,
                               }}
                             >
-                              {aiWorkflowModeOptions.map(({ mode, label }) => (
+                              {aiWorkflowModeOptions.map(({ mode, label, description, routeDescription }) => (
                                 <button
                                   key={mode}
                                   type="button"
                                   role="option"
                                   aria-selected={aiWorkflowMode === mode}
-                                  className="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-left text-sm hover:bg-accent"
+                                  className="flex w-full items-start gap-2 rounded-sm px-3 py-2 text-left hover:bg-accent"
                                   onClick={() => {
                                     setAiWorkflowMode(mode)
                                     setWorkflowModeDropdownOpen(false)
                                   }}
                                 >
                                   <Check
-                                    className={`h-4 w-4 shrink-0 ${
+                                    className={`mt-0.5 h-4 w-4 shrink-0 ${
                                       aiWorkflowMode === mode ? "opacity-100" : "opacity-0"
                                     }`}
                                   />
-                                  <span className="flex-1">{label}</span>
+                                  <span className="min-w-0 flex-1">
+                                    <span className="flex items-center gap-2 text-sm font-medium">
+                                      <span>{label}</span>
+                                      <span className="rounded border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground">
+                                        {description}
+                                      </span>
+                                    </span>
+                                    <span className="mt-1 block text-xs leading-5 text-muted-foreground">
+                                      {routeDescription}
+                                    </span>
+                                  </span>
                                 </button>
                               ))}
                             </div>

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

@@ -515,6 +515,9 @@ function StorySimulationSidebarPanel() {
             agentSnapshot: r.agentSnapshot,
             rumors: r.rumors,
             debugTraces: r.debugTraces,
+            status: r.status,
+            partialReason: r.partialReason,
+            resume: r.resume,
             createdAt: r.report.createdAt,
           })),
         );

+ 14 - 0
src/components/novel/plot-framework-library-view.layout.spec.ts

@@ -0,0 +1,14 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+describe("plot framework library layout", () => {
+  it("keeps the framework list as an internal scroll region", () => {
+    const source = readFileSync(
+      resolve(process.cwd(), "src/components/novel/plot-framework-library-view.tsx"),
+      "utf8",
+    )
+
+    expect(source).toContain('className="min-h-0 flex-1 overflow-y-auto p-4"')
+  })
+})

+ 1 - 1
src/components/novel/plot-framework-library-view.tsx

@@ -550,7 +550,7 @@ export function PlotFrameworkLibraryView() {
         })}
       </div>
 
-      <div className="flex-1 overflow-y-auto p-4">
+      <div className="min-h-0 flex-1 overflow-y-auto p-4">
         {activeTab === "main" && (
           <div className="space-y-3">
             {filteredMain.length === 0 ? (

+ 23 - 1
src/components/novel/story-simulation/history-results-modal.tsx

@@ -2,12 +2,14 @@
 import { X, Loader2, Trash2, History } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { loadSimulationResults, deleteSimulationResult } from "@/lib/novel/story-simulation/framework-store"
+import type { SimulationResultStatus } from "@/lib/novel/story-simulation/types"
 
 interface HistoryResultsModalProps {
   open: boolean
   projectPath: string | undefined
   frameworkId: string | undefined
   onSelectResult: (resultId: string) => void
+  onContinueResult?: (resultId: string) => void
   onClose: () => void
 }
 
@@ -16,6 +18,7 @@ interface ResultItem {
   createdAt: string
   summary: string
   hasDraft: boolean
+  status: SimulationResultStatus
 }
 
 export function HistoryResultsModal({
@@ -23,6 +26,7 @@ export function HistoryResultsModal({
   projectPath,
   frameworkId,
   onSelectResult,
+  onContinueResult,
   onClose,
 }: HistoryResultsModalProps) {
   const [results, setResults] = useState<ResultItem[]>([])
@@ -43,6 +47,7 @@ export function HistoryResultsModal({
             createdAt: r.report.createdAt,
             summary: r.report.recommendation || "查看推演结果",
             hasDraft: !!r.draft,
+            status: r.status,
           })),
         )
       })
@@ -133,11 +138,28 @@ export function HistoryResultsModal({
                           草稿
                         </span>
                       )}
+                      {(result.status ?? "complete") !== "complete" && (
+                        <span className="shrink-0 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-700 dark:text-amber-300">
+                          未完成
+                        </span>
+                      )}
                     </div>
                     <span className="block truncate text-xs text-muted-foreground">
                       {result.summary.slice(0, 40)}
                     </span>
                   </div>
+                  {(result.status ?? "complete") !== "complete" && onContinueResult && (
+                    <button
+                      type="button"
+                      className="shrink-0 rounded px-2 py-1 text-xs text-primary opacity-0 transition-opacity hover:bg-primary/10 group-hover:opacity-100"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        onContinueResult(result.id)
+                      }}
+                    >
+                      继续推演
+                    </button>
+                  )}
                   <button
                     type="button"
                     className="shrink-0 rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
@@ -155,4 +177,4 @@ export function HistoryResultsModal({
       </div>
     </div>
   )
-}
+}

+ 34 - 0
src/components/novel/story-simulation/story-simulation-view.partial.spec.ts

@@ -0,0 +1,34 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+function read(relativePath: string): string {
+  return readFileSync(resolve(process.cwd(), relativePath), "utf8")
+}
+
+describe("story simulation partial save and resume wiring", () => {
+  it("persists partial simulation result metadata for cancelled runs", () => {
+    const storeSource = read("src/lib/novel/story-simulation/framework-store.ts")
+
+    expect(storeSource).toContain("SimulationResultStatus")
+    expect(storeSource).toContain("partialReason")
+    expect(storeSource).toContain("resume")
+    expect(storeSource).toContain('status: parsed.status ?? "complete"')
+  })
+
+  it("saves partial progress on cancel and exposes a continue action", () => {
+    const viewSource = read(
+      "src/components/novel/story-simulation/story-simulation-view.tsx",
+    )
+    const modalSource = read(
+      "src/components/novel/story-simulation/history-results-modal.tsx",
+    )
+
+    expect(viewSource).toContain("savePartialSimulationResult")
+    expect(viewSource).toContain("handleContinuePartialResult")
+    expect(viewSource).toContain("resumeSimulationRef")
+    expect(modalSource).toContain("onContinueResult")
+    expect(modalSource).toContain("继续推演")
+    expect(modalSource).toContain("未完成")
+  })
+})

+ 171 - 14
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -42,8 +42,11 @@ import type {
   AgentChatMessage,
   ExtractionResult,
   NovelAgent,
+  RumorEvent,
   SimulationDebugTrace,
   SimulationHistoryEntry,
+  SimulationReport,
+  SimulationResumePoint,
   SimulationState,
   StoryBranch,
   StoryFramework,
@@ -183,6 +186,12 @@ export function StorySimulationView() {
   // 取消控制器
   const abortControllerRef = useRef<AbortController | null>(null);
   const [isCancelling, setIsCancelling] = useState(false);
+  const resumeSimulationRef = useRef<SimulationResumePoint | null>(null);
+  const resumeSnapshotRef = useRef<{
+    agentSnapshot?: ReturnType<typeof serializeSimulationState> | null;
+    debugTraces?: SimulationDebugTrace[];
+    rumors?: RumorEvent[];
+  } | null>(null);
 
   // 历史结果模态框
   const [showHistoryModal, setShowHistoryModal] = useState(false);
@@ -300,11 +309,92 @@ export function StorySimulationView() {
 
   // ── 核心流程 ──
 
+  const savePartialSimulationResult = async (partialReason: string) => {
+    if (!projectPath || !currentFramework) return;
+    const state = useStorySimulationStore.getState();
+    const savedTimelineEvents = state.timelineEvents;
+    if (savedTimelineEvents.length === 0 && state.debugTraces.length === 0) {
+      return;
+    }
+
+    const latestEvent = savedTimelineEvents[savedTimelineEvents.length - 1];
+    const resume: SimulationResumePoint = {
+      nextNodeIndex: latestEvent?.nodeIndex ?? 0,
+      nextRound: latestEvent ? latestEvent.round + 1 : 0,
+      timelineEvents: savedTimelineEvents,
+    };
+    const activeAgents =
+      state.currentAgents.size > 0
+        ? state.currentAgents
+        : new Map(
+            lastAgentsRef.current.map((agent) => [agent.characterId, agent]),
+          );
+    const snapshotState: SimulationState = {
+      currentRound: latestEvent?.round ?? 0,
+      timelineEvents: savedTimelineEvents,
+      activeAgents,
+      worldState: lastSimulationStateRef.current?.worldState ?? {},
+      directorEnabled: false,
+      nextNodeInjectionMap: new Map(),
+    };
+    const agentsForSnapshot =
+      activeAgents.size > 0
+        ? Array.from(activeAgents.values())
+        : lastAgentsRef.current;
+    const agentSnapshot =
+      agentsForSnapshot.length > 0
+        ? serializeSimulationState(snapshotState, agentsForSnapshot)
+        : undefined;
+    const report: SimulationReport = {
+      frameworkId: currentFramework.id,
+      mode,
+      characterAnalyses: [],
+      branches: [],
+      recommendation: `推演未完成:${partialReason}。可在历史推演结果中查看已生成内容,并继续推演。`,
+      createdAt: new Date().toISOString(),
+    };
+
+    await saveSimulationResult(
+      projectPath,
+      currentFramework.id,
+      report,
+      undefined,
+      savedTimelineEvents,
+      agentSnapshot,
+      state.currentRumors,
+      state.debugTraces,
+      {
+        status: partialReason.includes("取消") ? "cancelled" : "partial",
+        partialReason,
+        resume,
+      },
+    );
+    const results = await loadSimulationResults(projectPath, currentFramework.id);
+    setSavedResults(
+      results.map((r) => ({
+        id: r.id,
+        frameworkId: currentFramework.id,
+        report: r.report,
+        draft: r.draft,
+        timelineEvents: r.timelineEvents,
+        agentSnapshot: r.agentSnapshot,
+        rumors: r.rumors,
+        debugTraces: r.debugTraces,
+        status: r.status,
+        partialReason: r.partialReason,
+        resume: r.resume,
+        createdAt: r.report.createdAt,
+      })),
+    );
+    setInfoMessage("已保存未完成推演,可在历史推演结果中继续。");
+    setTimeout(() => setInfoMessage(null), 5000);
+  };
+
   /** 取消当前正在进行的操作 */
   const handleCancel = () => {
     if (abortControllerRef.current) {
       setIsCancelling(true);
-      setError("正在取消...");
+      setError("正在取消并保存未完成推演...");
       abortControllerRef.current.abort();
     }
   };
@@ -398,18 +488,56 @@ export function StorySimulationView() {
     }
     setSelectedResultId(resultId);
     setShowHistoryModal(false);
+    if ((result.status ?? "complete") !== "complete") {
+      setReportViewTab("timeline");
+    }
     setPhase("report-viewing");
   };
 
+  const handleContinuePartialResult = (resultId: string) => {
+    const result = savedResults.find((r) => r.id === resultId);
+    if (!result?.resume) return;
+
+    setTimelineEvents(result.timelineEvents || []);
+    setDebugTraces(result.debugTraces || []);
+    setCurrentRumors(result.rumors || []);
+    setCurrentReport(null);
+    setCurrentDraft(null);
+    if (result.agentSnapshot) {
+      try {
+        const { agents, state } = deserializeSimulationSnapshot(
+          result.agentSnapshot,
+        );
+        lastAgentsRef.current = agents;
+        lastSimulationStateRef.current = state;
+        setCurrentAgents(new Map(agents.map((a) => [a.characterId, a])));
+      } catch {
+        setCurrentAgents(new Map());
+      }
+    }
+    resumeSimulationRef.current = result.resume;
+    resumeSnapshotRef.current = {
+      agentSnapshot: result.agentSnapshot,
+      debugTraces: result.debugTraces,
+      rumors: result.rumors,
+    };
+    setSelectedResultId(resultId);
+    setShowHistoryModal(false);
+    void handleConfirmFramework();
+  };
+
   /** 确认框架:必要时先保存 → 构建角色 → 仿真 → 生成报告。 */
   const handleConfirmFramework = async () => {
     if (!projectPath || !currentFramework) {
       setError("缺少项目路径或故事框架");
       return;
     }
+    const resumePoint = resumeSimulationRef.current;
+    const resumeSnapshot = resumeSnapshotRef.current;
     setError(null);
     setTimelineEvents([]);
-    setDebugTraces([]);
+    setDebugTraces(resumePoint ? resumeSnapshot?.debugTraces || [] : []);
+    setCurrentRumors(resumePoint ? resumeSnapshot?.rumors || [] : []);
     setIsCancelling(false);
     const ac = new AbortController();
     abortControllerRef.current = ac;
@@ -444,7 +572,14 @@ export function StorySimulationView() {
       setPhase("simulating");
       phaseBaseProgressRef.current = 50;
       setProgress(50, t("storySimulation.simulating"));
-      const agents = buildAgents(extraction, currentFramework);
+      let agents = buildAgents(extraction, currentFramework);
+      if (resumePoint && resumeSnapshot?.agentSnapshot) {
+        try {
+          agents = deserializeSimulationSnapshot(resumeSnapshot.agentSnapshot).agents;
+        } catch {
+          // 快照损坏时回退为重新构建角色,避免继续推演入口直接失败。
+        }
+      }
       // 强制校验:没有 Agent 直接中止推演,避免空跑浪费 token
       if (agents.length === 0) {
         throw new Error(
@@ -473,6 +608,15 @@ export function StorySimulationView() {
           addDebugTrace(trace);
           setCurrentRumors(trace.rumors);
           setCurrentAgents(trace.activeAgents);
+          lastAgentsRef.current = Array.from(trace.activeAgents.values());
+          lastSimulationStateRef.current = {
+            currentRound: trace.round,
+            timelineEvents: useStorySimulationStore.getState().timelineEvents,
+            activeAgents: trace.activeAgents,
+            worldState: {},
+            directorEnabled: false,
+            nextNodeInjectionMap: new Map(),
+          };
 
           // 每轮只加一次历史快照(当 round 变化时)
           if (trace.round !== lastHistoryRoundRef.current) {
@@ -520,6 +664,7 @@ export function StorySimulationView() {
           )
             ? dynamicEventPool
             : undefined,
+          resume: resumePoint ?? undefined,
         },
         extraction,
         callbacks,
@@ -527,6 +672,7 @@ export function StorySimulationView() {
       );
 
       if (ac.signal.aborted) {
+        await savePartialSimulationResult("用户取消推演");
         setPhase("framework-confirming");
         setError("推演已取消");
         setTimeout(() => setError(null), 3000);
@@ -557,6 +703,7 @@ export function StorySimulationView() {
       });
 
       if (ac.signal.aborted) {
+        await savePartialSimulationResult("用户取消推演");
         setPhase("framework-confirming");
         setError("已取消");
         setTimeout(() => setError(null), 3000);
@@ -597,6 +744,9 @@ export function StorySimulationView() {
             agentSnapshot: r.agentSnapshot,
             rumors: r.rumors,
             debugTraces: r.debugTraces,
+            status: r.status,
+            partialReason: r.partialReason,
+            resume: r.resume,
             createdAt: r.report.createdAt,
           })),
         );
@@ -605,6 +755,7 @@ export function StorySimulationView() {
       }
     } catch (err) {
       if (ac.signal.aborted) {
+        await savePartialSimulationResult("用户取消推演");
         setPhase("framework-confirming");
         setError("推演已取消");
         setTimeout(() => setError(null), 3000);
@@ -615,6 +766,8 @@ export function StorySimulationView() {
     } finally {
       setIsCancelling(false);
       abortControllerRef.current = null;
+      resumeSimulationRef.current = null;
+      resumeSnapshotRef.current = null;
     }
   };
 
@@ -696,12 +849,15 @@ export function StorySimulationView() {
             report: r.report,
             draft: r.draft,
             timelineEvents: r.timelineEvents,
-            agentSnapshot: r.agentSnapshot,
-            rumors: r.rumors,
-            debugTraces: r.debugTraces,
-            createdAt: r.report.createdAt,
-          })),
-        );
+              agentSnapshot: r.agentSnapshot,
+              rumors: r.rumors,
+              debugTraces: r.debugTraces,
+              status: r.status,
+              partialReason: r.partialReason,
+              resume: r.resume,
+              createdAt: r.report.createdAt,
+            })),
+          );
       } catch (saveErr) {
         console.error("更新推演结果草稿失败:", saveErr);
       }
@@ -1029,11 +1185,12 @@ export function StorySimulationView() {
             {/* 历史结果模态框 */}
             <HistoryResultsModal
               open={showHistoryModal}
-              projectPath={projectPath}
-              frameworkId={currentFramework?.id}
-              onSelectResult={handleSelectHistoryResult}
-              onClose={() => setShowHistoryModal(false)}
-            />
+                projectPath={projectPath}
+                frameworkId={currentFramework?.id}
+                onSelectResult={handleSelectHistoryResult}
+                onContinueResult={handleContinuePartialResult}
+                onClose={() => setShowHistoryModal(false)}
+              />
           </div>
         ) : phase === "report-viewing" ? (
           <div className="flex min-h-0 flex-1">

+ 104 - 0
src/components/skill-library/unified-skill-library-view.spec.tsx

@@ -12,6 +12,8 @@ const readFileMock = vi.hoisted(() => vi.fn())
 const writeFileMock = vi.hoisted(() => vi.fn())
 const writeFileAtomicMock = vi.hoisted(() => vi.fn())
 const joinMock = vi.hoisted(() => vi.fn(async (...parts: string[]) => parts.join("/")))
+const openDialogMock = vi.hoisted(() => vi.fn())
+const saveDialogMock = vi.hoisted(() => vi.fn())
 
 vi.mock("@/commands/fs", () => ({
   readFile: readFileMock,
@@ -23,6 +25,11 @@ vi.mock("@tauri-apps/api/path", () => ({
   join: joinMock,
 }))
 
+vi.mock("@tauri-apps/plugin-dialog", () => ({
+  open: openDialogMock,
+  save: saveDialogMock,
+}))
+
 const deAiConfig = {
   version: 1,
   defaultSkillId: "project:quiet",
@@ -112,6 +119,8 @@ describe("UnifiedSkillLibraryView", () => {
     })
     writeFileMock.mockResolvedValue(undefined)
     writeFileAtomicMock.mockResolvedValue(undefined)
+    openDialogMock.mockResolvedValue(null)
+    saveDialogMock.mockResolvedValue(null)
     useWikiStore.getState().setProject({
       id: "p1",
       name: "测试项目",
@@ -136,6 +145,101 @@ describe("UnifiedSkillLibraryView", () => {
     cleanup(root, container)
   })
 
+  it("keeps creation, import, and export actions out of the unified sidebar", async () => {
+    const { container, root } = await renderLibrary()
+    const sidebar = container.querySelector<HTMLElement>('[data-testid="unified-skill-library-sidebar"]')
+
+    for (const label of ["新建去AI技能", "新建 Skill", "导入文件", "导入文件夹", "导出当前"]) {
+      expect(sidebar?.textContent).not.toContain(label)
+    }
+
+    cleanup(root, container)
+  })
+
+  it("shows de-AI actions in the right header when de-AI skill tab is active", async () => {
+    const { container, root } = await renderLibrary()
+    const actions = container.querySelector<HTMLElement>('[data-testid="skill-library-header-actions"]')
+
+    expect(actions?.textContent).toContain("新建技能")
+    expect(actions?.textContent).toContain("导入技能")
+    expect(actions?.textContent).toContain("导入文件")
+    expect(actions?.textContent).toContain("导入文件夹")
+    expect(actions?.textContent).not.toContain("新建 Skill")
+    expect(actions?.textContent).not.toContain("导出当前")
+
+    cleanup(root, container)
+  })
+
+  it("shows writing Skill actions in the right header when writing tab is active", async () => {
+    const { container, root } = await renderLibrary()
+
+    await act(async () => {
+      getButton(container, "写作 Skill")?.click()
+    })
+    await flushEffects()
+
+    const actions = container.querySelector<HTMLElement>('[data-testid="skill-library-header-actions"]')
+    expect(actions?.textContent).toContain("新建 Skill")
+    expect(actions?.textContent).toContain("导入 Skill")
+    expect(actions?.textContent).toContain("导入文件")
+    expect(actions?.textContent).toContain("导入文件夹")
+    expect(actions?.textContent).toContain("导出当前")
+    expect(actions?.textContent).not.toContain("新建技能")
+
+    cleanup(root, container)
+  })
+
+  it("creates a writing Skill directly from the right header", async () => {
+    const { container, root } = await renderLibrary()
+
+    await act(async () => {
+      getButton(container, "写作 Skill")?.click()
+    })
+    await flushEffects()
+
+    await act(async () => {
+      getButton(container, "新建 Skill")?.click()
+    })
+    await flushEffects()
+
+    expect(useWikiStore.getState().activeView).toBe("writingSkillLibrary")
+    expect(useWikiStore.getState().selectedWritingSkillLibrarySkillId).toMatch(/^skill:/)
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/writing-skills.json",
+      expect.stringContaining("新建写作 Skill"),
+    )
+
+    cleanup(root, container)
+  })
+
+  it("imports a de-AI skill file from the right header", async () => {
+    openDialogMock.mockResolvedValue("C:/skills/冷硬叙事.md")
+    readFileMock.mockImplementation(async (path: string) => {
+      if (path.endsWith("de-ai-skills.json")) return JSON.stringify(deAiConfig)
+      if (path.endsWith("writing-skills.json")) return JSON.stringify(writingConfig)
+      if (path === "C:/skills/冷硬叙事.md") return "# 冷硬叙事\n\n删掉解释,保留动作。"
+      throw new Error("missing")
+    })
+    const { container, root } = await renderLibrary()
+
+    await act(async () => {
+      getButton(container, "导入文件")?.click()
+    })
+    await flushEffects()
+
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      expect.stringContaining("冷硬叙事"),
+    )
+    expect(writeFileAtomicMock).toHaveBeenCalledWith(
+      "C:/project/de-ai-skills.json",
+      expect.stringContaining("删掉解释,保留动作。"),
+    )
+    expect(useWikiStore.getState().activeView).toBe("skillLibrary")
+
+    cleanup(root, container)
+  })
+
   it("filters writing and de-AI skills from one search input", async () => {
     const { container, root } = await renderLibrary()
     const sidebar = container.querySelector<HTMLElement>('[data-testid="unified-skill-library-sidebar"]')

+ 320 - 19
src/components/skill-library/unified-skill-library-view.tsx

@@ -1,11 +1,25 @@
 import { useEffect, useMemo, useState } from "react"
+import { open, save } from "@tauri-apps/plugin-dialog"
+import { readFile, writeFile } from "@/commands/fs"
 import { useWikiStore } from "@/stores/wiki-store"
 import {
+  createBlankProjectDeAiSkill,
   getAllDeAiSkills,
   loadDeAiSkillConfig,
+  normalizeDeAiSkillConfig,
+  saveDeAiSkillConfig,
   type DeAiSkill,
 } from "@/lib/novel/de-ai-skill-library"
-import { loadUserSkillConfig } from "@/lib/novel/user-skill-store"
+import {
+  createBlankWritingSkill,
+  exportSkillToJson,
+  importLinkedSkill,
+  importSkillFromJson,
+  importWritingSkill,
+  loadUserSkillConfig,
+  normalizeUserSkillConfig,
+  saveUserSkillConfig,
+} from "@/lib/novel/user-skill-store"
 import type { SkillKind, UserSkill } from "@/lib/novel/skill-library"
 import { SkillLibraryView } from "./skill-library-view"
 import { WritingSkillLibraryView } from "./writing-skill-library-view"
@@ -60,28 +74,315 @@ function writingSkillToEntry(skill: UserSkill): UnifiedSkillEntry {
   }
 }
 
-function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
+function fileBaseName(path: string): string {
+  return path.split(/[\\/]/).pop() || "未命名 Skill"
+}
+
+function stripSkillFileExtension(name: string): string {
+  return name.replace(/\.(json|md|txt)$/i, "")
+}
+
+function parseSkillFrontmatter(content: string): { name?: string; description?: string; body: string } {
+  const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
+  if (!match) return { body: content }
+  const yamlBlock = match[1]
+  const nameMatch = yamlBlock.match(/^name:\s*(.+?)\s*$/m)
+  const descMatch = yamlBlock.match(/^description:\s*(.+?)\s*$/m)
+  return {
+    name: nameMatch ? nameMatch[1].trim() : undefined,
+    description: descMatch ? descMatch[1].trim() : undefined,
+    body: content.slice(match[0].length),
+  }
+}
+
+function importedDeAiSkillFromContent(path: string, content: string): DeAiSkill | null {
+  const now = Date.now()
+  const nameFromPath = stripSkillFileExtension(fileBaseName(path))
+  if (/\.json$/i.test(path)) {
+    try {
+      const parsed = JSON.parse(content)
+      if (!parsed || typeof parsed !== "object") return null
+      const raw = parsed as Record<string, unknown>
+      if (typeof raw.name !== "string" || !raw.name.trim()) return null
+      if (typeof raw.content !== "string" || !raw.content.trim()) return null
+      return {
+        id: `project:${now}`,
+        name: raw.name.trim(),
+        description: typeof raw.description === "string" ? raw.description.trim() : "",
+        templateId: typeof raw.templateId === "string" ? raw.templateId : "custom",
+        content: raw.content.trim(),
+        source: "project",
+        createdAt: now,
+        updatedAt: now,
+      }
+    } catch {
+      return null
+    }
+  }
+
+  const parsed = parseSkillFrontmatter(content)
+  const body = parsed.body.trim()
+  if (!body) return null
+  return {
+    id: `project:${now}`,
+    name: parsed.name || nameFromPath || "未命名去AI味 Skill",
+    description: parsed.description || "",
+    templateId: "custom",
+    content: body,
+    source: "project",
+    createdAt: now,
+    updatedAt: now,
+  }
+}
+
+function SkillLibraryHeader({ compact = false }: { compact?: boolean }) {
   const activeView = useWikiStore((s) => s.activeView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const activeTab = activeView === "writingSkillLibrary" ? "writingSkillLibrary" : "skillLibrary"
 
   return (
-    <div className={`flex shrink-0 items-center gap-1 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
-      {skillLibraryTabs.map((tab) => (
-        <button
-          key={tab.view}
-          type="button"
-          aria-pressed={activeTab === tab.view}
-          onClick={() => setActiveView(tab.view)}
-          className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
-            activeTab === tab.view
-              ? "bg-primary text-primary-foreground"
-              : "text-muted-foreground hover:bg-accent hover:text-foreground"
-          }`}
-        >
-          {tab.label}
-        </button>
-      ))}
+    <div className={`flex shrink-0 flex-wrap items-center justify-between gap-2 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
+      <div className="flex items-center gap-1">
+        {skillLibraryTabs.map((tab) => (
+          <button
+            key={tab.view}
+            type="button"
+            aria-pressed={activeTab === tab.view}
+            onClick={() => setActiveView(tab.view)}
+            className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
+              activeTab === tab.view
+                ? "bg-primary text-primary-foreground"
+                : "text-muted-foreground hover:bg-accent hover:text-foreground"
+            }`}
+          >
+            {tab.label}
+          </button>
+        ))}
+      </div>
+      <SkillLibraryHeaderActions activeTab={activeTab} />
+    </div>
+  )
+}
+
+function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" | "writingSkillLibrary" }) {
+  const project = useWikiStore((s) => s.project)
+  const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
+  const selectedWritingSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
+  const setSelectedSkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
+  const setSelectedWritingSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
+  const [message, setMessage] = useState("")
+  const [saving, setSaving] = useState(false)
+
+  async function persistWritingConfig(nextConfig: ReturnType<typeof normalizeUserSkillConfig>, nextSkillId: string | null) {
+    if (!project || saving) return
+    setSaving(true)
+    try {
+      await saveUserSkillConfig(project.path, nextConfig)
+      if (nextSkillId) setSelectedWritingSkillId(nextSkillId)
+      setActiveView("writingSkillLibrary")
+      bumpDataVersion()
+      setMessage("写作 Skill 已保存")
+    } catch {
+      setMessage("写作 Skill 保存失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function persistDeAiConfig(nextConfig: ReturnType<typeof normalizeDeAiSkillConfig>, nextSkillId: string) {
+    if (!project || saving) return
+    setSaving(true)
+    try {
+      await saveDeAiSkillConfig(project.path, nextConfig)
+      setSelectedSkillId(nextSkillId)
+      setActiveView("skillLibrary")
+      bumpDataVersion()
+      setMessage("去AI味技能已保存")
+    } catch {
+      setMessage("去AI味技能保存失败")
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  async function handleCreateDeAiSkill() {
+    if (!project || saving) return
+    const config = await loadDeAiSkillConfig(project.path)
+    const now = Date.now()
+    const next = createBlankProjectDeAiSkill(config, now)
+    await persistDeAiConfig(next, `project:${now}`)
+  }
+
+  async function handleImportDeAiSkillFile() {
+    if (!project || saving) return
+    try {
+      const selected = await open({
+        multiple: false,
+        filters: [{ name: "去AI味 Skill 文件", extensions: ["json", "md", "txt"] }],
+      })
+      if (!selected || typeof selected !== "string") return
+      const content = await readFile(selected)
+      const imported = importedDeAiSkillFromContent(selected, content)
+      if (!imported) {
+        setMessage("导入失败:文件内容不是有效的去AI味 Skill")
+        return
+      }
+      const config = await loadDeAiSkillConfig(project.path)
+      const next = normalizeDeAiSkillConfig({
+        ...config,
+        defaultSkillId: imported.id,
+        projectSkills: [imported, ...config.projectSkills],
+      })
+      await persistDeAiConfig(next, imported.id)
+    } catch {
+      setMessage("导入去AI味 Skill 失败")
+    }
+  }
+
+  async function handleImportDeAiSkillFolder() {
+    if (!project || saving) return
+    try {
+      const selected = await open({ multiple: false, directory: true })
+      if (!selected || typeof selected !== "string") return
+      const skillPath = `${selected.replace(/[\\/]+$/, "")}/SKILL.md`
+      const content = await readFile(skillPath)
+      const imported = importedDeAiSkillFromContent(skillPath, content)
+      if (!imported) {
+        setMessage("导入失败:文件夹中未找到有效的 SKILL.md")
+        return
+      }
+      const config = await loadDeAiSkillConfig(project.path)
+      const next = normalizeDeAiSkillConfig({
+        ...config,
+        defaultSkillId: imported.id,
+        projectSkills: [imported, ...config.projectSkills],
+      })
+      await persistDeAiConfig(next, imported.id)
+    } catch {
+      setMessage("导入失败:文件夹中未找到有效的 SKILL.md")
+    }
+  }
+
+  async function handleCreateWritingSkill() {
+    if (!project || saving) return
+    const config = await loadUserSkillConfig(project.path)
+    const next = createBlankWritingSkill(config)
+    await persistWritingConfig(next, next.selectedSkillId)
+  }
+
+  async function handleImportWritingSkill() {
+    if (!project || saving) return
+    try {
+      const selected = await open({
+        multiple: false,
+        filters: [{ name: "Skill 文件", extensions: ["json", "md", "txt"] }],
+      })
+      if (!selected || typeof selected !== "string") return
+      const content = await readFile(selected)
+      const fileName = fileBaseName(selected)
+      const config = await loadUserSkillConfig(project.path)
+      const next = /\.json$/i.test(fileName)
+        ? (() => {
+            const imported = importSkillFromJson(content)
+            if (!imported) return null
+            return normalizeUserSkillConfig({
+              ...config,
+              selectedSkillId: imported.id,
+              skills: [imported, ...config.skills],
+            })
+          })()
+        : importWritingSkill(config, {
+            name: fileName.replace(/\.(md|txt)$/i, ""),
+            content,
+          })
+
+      if (!next) {
+        setMessage("JSON 文件格式不正确,导入失败")
+        return
+      }
+      await persistWritingConfig(next, next.selectedSkillId)
+    } catch {
+      setMessage("导入 Skill 失败")
+    }
+  }
+
+  async function handleImportWritingSkillFolder() {
+    if (!project || saving) return
+    try {
+      const selected = await open({ multiple: false, directory: true })
+      if (!selected || typeof selected !== "string") return
+      const config = await loadUserSkillConfig(project.path)
+      const next = await importLinkedSkill(config, selected)
+      if (!next.selectedSkillId) {
+        setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
+        return
+      }
+      await persistWritingConfig(next, next.selectedSkillId)
+    } catch {
+      setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
+    }
+  }
+
+  async function handleExportCurrentWritingSkill() {
+    if (!project || saving) return
+    try {
+      const config = await loadUserSkillConfig(project.path)
+      const selected = selectedWritingSkillId
+        ? config.skills.find((skill) => skill.id === selectedWritingSkillId)
+        : config.skills[0]
+      if (!selected) {
+        setMessage("请先选择写作 Skill")
+        return
+      }
+      const filePath = await save({
+        defaultPath: `${selected.name}.json`,
+        filters: [{ name: "JSON 文件", extensions: ["json"] }],
+      })
+      if (!filePath) return
+      await writeFile(filePath, exportSkillToJson(selected))
+      setMessage("导出成功")
+    } catch {
+      setMessage("导出 Skill 失败")
+    }
+  }
+
+  const buttonClass = "rounded-md border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
+  const disabled = !project || saving
+
+  return (
+    <div data-testid="skill-library-header-actions" className="flex flex-wrap items-center justify-end gap-2">
+      {activeTab === "skillLibrary" ? (
+        <>
+          <button type="button" onClick={() => void handleCreateDeAiSkill()} disabled={disabled} className={buttonClass}>
+            新建技能
+          </button>
+          <span className="text-xs text-muted-foreground">导入技能</span>
+          <button type="button" onClick={() => void handleImportDeAiSkillFile()} disabled={disabled} className={buttonClass}>
+            导入文件
+          </button>
+          <button type="button" onClick={() => void handleImportDeAiSkillFolder()} disabled={disabled} className={buttonClass}>
+            导入文件夹
+          </button>
+        </>
+      ) : (
+        <>
+          <button type="button" onClick={() => void handleCreateWritingSkill()} disabled={disabled} className={buttonClass}>
+            新建 Skill
+          </button>
+          <span className="text-xs text-muted-foreground">导入 Skill</span>
+          <button type="button" onClick={() => void handleImportWritingSkill()} disabled={disabled} className={buttonClass}>
+            导入文件
+          </button>
+          <button type="button" onClick={() => void handleImportWritingSkillFolder()} disabled={disabled} className={buttonClass}>
+            导入文件夹
+          </button>
+          <button type="button" onClick={() => void handleExportCurrentWritingSkill()} disabled={disabled} className={buttonClass}>
+            导出当前
+          </button>
+        </>
+      )}
+      {message ? <span className="text-xs text-muted-foreground">{message}</span> : null}
     </div>
   )
 }
@@ -92,7 +393,7 @@ export function UnifiedSkillLibraryView() {
 
   return (
     <div data-testid="unified-skill-library-view" className="flex h-full flex-col overflow-hidden">
-      <SkillLibraryTabs />
+      <SkillLibraryHeader />
       <div className="min-h-0 flex-1 overflow-hidden">
         {showWritingSkill ? <WritingSkillLibraryView /> : <SkillLibraryView />}
       </div>

+ 46 - 0
src/lib/agent/activity-trace.spec.ts

@@ -72,6 +72,52 @@ describe("activity trace", () => {
     expect(getDefaultOpenAgentStageId(stages)).toBe("running")
   })
 
+  it("moves the active stage forward when a later stage starts", () => {
+    let stages: AgentStageTrace[] = []
+
+    stages = applyAgentActivityEvent(stages, createAgentActivityEvent({
+      id: "read-start",
+      stageId: "read_context",
+      kind: "read_source",
+      title: "调用完成:read_chapter",
+      content: "已读取第1章。",
+      timestamp: 100,
+    }))
+    stages = applyAgentActivityEvent(stages, createStageStartedEvent({
+      stageId: "generate_draft",
+      title: "生成章节草稿",
+      summary: "开始生成正文。",
+      timestamp: 200,
+    }))
+
+    expect(stages.find((stage) => stage.id === "read_context")?.status).toBe("done")
+    expect(stages.find((stage) => stage.id === "generate_draft")?.status).toBe("running")
+    expect(getDefaultOpenAgentStageId(stages)).toBe("generate_draft")
+  })
+
+  it("opens the most recent running stage when aggregate and detailed stages overlap", () => {
+    const stages: AgentStageTrace[] = [
+      {
+        id: "chapter_workflow",
+        title: "多任务写作循环",
+        status: "running",
+        summary: "运行章节工作流",
+        events: [],
+        startedAt: 100,
+      },
+      {
+        id: "generate_draft",
+        title: "生成章节草稿",
+        status: "running",
+        summary: "生成正文初稿",
+        events: [],
+        startedAt: 300,
+      },
+    ]
+
+    expect(getDefaultOpenAgentStageId(stages)).toBe("generate_draft")
+  })
+
   it("does not render undefined-like empty content", () => {
     const event = createAgentActivityEvent({
       id: "ev-empty",

+ 40 - 4
src/lib/agent/activity-trace.ts

@@ -7,6 +7,7 @@ import type {
 } from "./types"
 
 const EMPTY_CONTENT = "本阶段未返回可展示内容。"
+const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
 
 export interface CreateAgentActivityEventInput {
   id: string
@@ -61,7 +62,7 @@ export function applyAgentActivityEvent(
   stages: AgentStageTrace[] | undefined,
   event: AgentActivityEvent,
 ): AgentStageTrace[] {
-  const current = stages ?? []
+  const current = settlePreviousSequentialStages(stages ?? [], event)
   const existingIndex = current.findIndex((stage) => stage.id === event.stageId)
   const existingStage = existingIndex >= 0 ? current[existingIndex] : createStageFromEvent(event)
   const nextStage = applyEventToStage(existingStage, event)
@@ -82,9 +83,9 @@ export function summarizeAgentStage(stage: AgentStageTrace): string {
 }
 
 export function getDefaultOpenAgentStageId(stages: AgentStageTrace[]): string | null {
-  return stages.find((stage) => stage.status === "running")?.id
-    ?? stages.find((stage) => stage.status === "approval_required")?.id
-    ?? stages.find((stage) => stage.status === "error")?.id
+  return findMostRecentStageId(stages, "running")
+    ?? findMostRecentStageId(stages, "approval_required")
+    ?? findMostRecentStageId(stages, "error")
     ?? null
 }
 
@@ -150,6 +151,41 @@ function applyEventToStage(stage: AgentStageTrace, event: AgentActivityEvent): A
   }
 }
 
+function settlePreviousSequentialStages(
+  stages: AgentStageTrace[],
+  event: AgentActivityEvent,
+): AgentStageTrace[] {
+  if (event.kind !== "stage_started") return stages
+
+  return stages.map((stage) => {
+    if (stage.id === event.stageId) return stage
+    if (AGGREGATE_STAGE_IDS.has(stage.id)) return stage
+    if (stage.status !== "running" && stage.status !== "pending") return stage
+
+    return {
+      ...stage,
+      status: "done",
+      finishedAt: event.timestamp,
+      summary: stage.summary || summarizeAgentStage(stage),
+    }
+  })
+}
+
+function findMostRecentStageId(
+  stages: AgentStageTrace[],
+  status: AgentStageStatus,
+): string | null {
+  const matching = stages.filter((stage) => stage.status === status)
+  if (matching.length === 0) return null
+  return matching.reduce((latest, stage) => {
+    const latestLastEvent = latest.events[latest.events.length - 1]
+    const stageLastEvent = stage.events[stage.events.length - 1]
+    const latestTime = latest.startedAt ?? latestLastEvent?.timestamp ?? 0
+    const stageTime = stage.startedAt ?? stageLastEvent?.timestamp ?? 0
+    return stageTime >= latestTime ? stage : latest
+  }).id
+}
+
 function statusFromEvent(current: AgentStageStatus, event: AgentActivityEvent): AgentStageStatus {
   if (event.kind === "error") return "error"
   if (event.kind === "stage_output" || event.kind === "final_output") return "done"

+ 55 - 4
src/lib/novel/deep-chapter-generation.spec.ts

@@ -965,7 +965,7 @@ describe("runDeepChapterGeneration", () => {
     expect(overrides[1]).toEqual({ reasoning: { mode: "off" } })
   })
 
-  it("uses the same task loop with different workflow strength for fast standard and strict modes", async () => {
+  it("uses separate workflow routes for fast standard and strict modes", async () => {
     const fastDeps = createDeps()
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "fast" },
@@ -1048,15 +1048,16 @@ describe("runDeepChapterGeneration", () => {
     expect(events.find((event) => event.name === "chapter_complete")?.result).toContain("多任务写作循环完成")
   })
 
-  it("keeps fast and standard workflow visibility aligned with their skipped stages", async () => {
+  it("keeps workflow visibility aligned with the selected mode route", async () => {
     const fastEvents: Array<{ name: string; result?: string }> = []
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "fast" },
       { onWorkflowEvent: (event) => fastEvents.push(event) },
       createDeps(),
     )
-    expect(fastEvents.find((event) => event.name === "chapter_review")?.result).toContain("快速模式跳过")
-    expect(fastEvents.find((event) => event.name === "chapter_final_polish")?.result).toContain("快速模式跳过")
+    expect(fastEvents.some((event) => event.name === "chapter_review")).toBe(false)
+    expect(fastEvents.some((event) => event.name === "chapter_final_polish")).toBe(false)
+    expect(fastEvents.find((event) => event.name === "chapter_complete")?.result).toContain("快速模式写作完成")
 
     const standardEvents: Array<{ name: string; result?: string }> = []
     await runDeepChapterGeneration(
@@ -1068,6 +1069,56 @@ describe("runDeepChapterGeneration", () => {
     expect(standardEvents.find((event) => event.name === "chapter_final_polish" && event.result)?.result).toContain("简单审查与去AI味完成")
   })
 
+  it("keeps fast mode on a lightweight route without post-draft plan audits", async () => {
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterExecutionReportCheck: vi.fn(async () => ({
+        status: "fail" as const,
+        sceneResults: [{
+          id: "S1",
+          passed: false,
+          missing: ["主角进入旧屋"],
+          evidence: "正文停在门外。",
+          repairInstruction: "补写主角进入旧屋。",
+        }],
+        mustDoResults: [],
+        mustAvoidResults: [],
+        finalHookPassed: true,
+        repairItems: ["S1 缺少主角进入旧屋"],
+      })),
+      runChapterPlanComplianceCheck: vi.fn(async () => "履约度:偏离"),
+      runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
+    }
+    const events: Array<{ name: string; title: string; result?: string }> = []
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第三章",
+        chapterNumber: 3,
+        llmConfig,
+        aiWorkflowMode: "fast",
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。",
+      },
+      { onWorkflowEvent: (event) => events.push(event) },
+      deps,
+    )
+
+    expect(deps.runChapterExecutionContractBuild).not.toHaveBeenCalled()
+    expect(deps.runChapterExecutionReportCheck).not.toHaveBeenCalled()
+    expect(deps.runChapterPlanComplianceCheck).not.toHaveBeenCalled()
+    expect(deps.runChapterPlanDeviationRepair).not.toHaveBeenCalled()
+    expect(result.planCompliance).toBe("")
+    expect(result.executionReport).toBe("")
+    expect(events.some((event) => event.name === "chapter_execution_report")).toBe(false)
+    expect(events.some((event) => event.name === "chapter_plan_compliance")).toBe(false)
+    const completeEvent = events.find((event) => event.name === "chapter_complete")
+    expect(completeEvent?.title).toBe("完成快速写作")
+    expect(completeEvent?.result).toContain("快速模式写作完成")
+    expect(completeEvent?.result).not.toContain("多任务写作循环")
+  })
+
   it("shows a visible golden-three hint in thinking when generating the first chapter", async () => {
     const deps = createDeps()
     const thinking: string[] = []

+ 62 - 5
src/lib/novel/deep-chapter-generation.ts

@@ -216,9 +216,13 @@ export function shouldUseDeepChapterGeneration(
 interface ChapterWorkflowProfile {
   mode: AiWorkflowMode;
   runPreviousChaptersAnalysis: boolean;
+  runExecutionContractBuild: boolean;
   runAiReview: boolean;
   runFinalPolish: boolean;
   runPostRevisionReview: boolean;
+  runPostDraftPlanAudits: boolean;
+  completionTitle: string;
+  completionResultPrefix: string;
 }
 
 function resolveChapterWorkflowProfile(
@@ -229,26 +233,38 @@ function resolveChapterWorkflowProfile(
     return {
       mode: "fast",
       runPreviousChaptersAnalysis: false,
+      runExecutionContractBuild: false,
       runAiReview: false,
       runFinalPolish: false,
       runPostRevisionReview: false,
+      runPostDraftPlanAudits: false,
+      completionTitle: "完成快速写作",
+      completionResultPrefix: "快速模式写作完成",
     };
   }
   if (resolvedMode === "standard") {
     return {
       mode: "standard",
       runPreviousChaptersAnalysis: false,
+      runExecutionContractBuild: true,
       runAiReview: false,
       runFinalPolish: true,
       runPostRevisionReview: false,
+      runPostDraftPlanAudits: true,
+      completionTitle: "完成多任务写作循环",
+      completionResultPrefix: "多任务写作循环完成",
     };
   }
   return {
     mode: "strict",
     runPreviousChaptersAnalysis: true,
+    runExecutionContractBuild: true,
     runAiReview: true,
     runFinalPolish: true,
     runPostRevisionReview: true,
+    runPostDraftPlanAudits: true,
+    completionTitle: "完成多任务写作循环",
+    completionResultPrefix: "多任务写作循环完成",
   };
 }
 
@@ -471,7 +487,7 @@ export async function runDeepChapterGeneration(
   const planExecutionSummary = buildChapterPlanExecutionSummary(input.planBlueprint ?? "");
   let executionContract: ChapterExecutionContract | null = null;
   let executionContractText = "";
-  if (input.planBlueprint?.trim()) {
+  if (workflowProfile.runExecutionContractBuild && input.planBlueprint?.trim()) {
     try {
       const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
       executionContract = await buildContract(writingConfig, input.planBlueprint, signal);
@@ -883,6 +899,47 @@ export async function runDeepChapterGeneration(
     );
   }
 
+  if (!workflowProfile.runPostDraftPlanAudits) {
+    const finalContent = draftContent;
+    callbacks.onThinking?.(
+      formatStageThinking(
+        "阶段4:快速完成",
+        "快速模式已完成任务书与正文初稿生成,直接采用正文初稿作为最终正文。",
+      ),
+    );
+    emitDeepChapterActivity(callbacks, {
+      id: `deep_chapter:final_output:output:${Date.now()}`,
+      stageId: "final_output",
+      kind: "final_output",
+      title: "最终正文",
+      content: `最终正文已生成,约 ${countChapterChars(finalContent)} 字。`,
+    });
+    callbacks.onFinalContent?.(finalContent);
+    completeChapterWorkflowStep(
+      callbacks,
+      {
+        name: "chapter_complete",
+        title: workflowProfile.completionTitle,
+        detail: "汇总本次章节生成结果。",
+        params: workflowBaseParams,
+      },
+      `${workflowProfile.completionResultPrefix},最终正文约 ${countChapterChars(finalContent)} 字。`,
+      {
+        chars: countChapterChars(finalContent),
+        revised: false,
+      },
+    );
+    return {
+      finalContent,
+      taskBrief,
+      draftContent,
+      reviewResults: [],
+      revised: false,
+      planCompliance: "",
+      executionReport: "",
+    };
+  }
+
   let reviewResults = hasCheckpointReview(resumeCheckpoint)
     ? resumeCheckpoint.reviewResults
     : [];
@@ -1247,7 +1304,7 @@ export async function runDeepChapterGeneration(
   });
   callbacks.onFinalContent?.(finalContent);
   let executionReportSummary = "";
-  if (executionContract) {
+  if (workflowProfile.runPostDraftPlanAudits && executionContract) {
     let repairedByExecutionReport = false;
     try {
       const runReport = deps.runChapterExecutionReportCheck || runChapterExecutionReportCheck;
@@ -1341,7 +1398,7 @@ export async function runDeepChapterGeneration(
     }
   }
   let planCompliance = "";
-  if (planExecutionSummary.trim() && !executionContract) {
+  if (workflowProfile.runPostDraftPlanAudits && planExecutionSummary.trim() && !executionContract) {
     let complianceCheckFailed = false;
     const complianceStep = {
       name: "chapter_plan_compliance",
@@ -1446,11 +1503,11 @@ export async function runDeepChapterGeneration(
     callbacks,
     {
       name: "chapter_complete",
-      title: "完成多任务写作循环",
+      title: workflowProfile.completionTitle,
       detail: "汇总本次章节生成结果。",
       params: workflowBaseParams,
     },
-    `多任务写作循环完成,最终正文约 ${countChapterChars(finalContent)} 字。`,
+    `${workflowProfile.completionResultPrefix},最终正文约 ${countChapterChars(finalContent)} 字。`,
     {
       chars: countChapterChars(finalContent),
       revised,

+ 22 - 0
src/lib/novel/story-simulation/framework-store.ts

@@ -19,6 +19,8 @@ import type { FileNode } from "@/types/wiki"
 import type {
   SimulationMode,
   SimulationReport,
+  SimulationResultStatus,
+  SimulationResumePoint,
   StoryDraft,
   StoryFramework,
   StoryNode,
@@ -408,6 +410,11 @@ export async function saveSimulationResult(
   agentSnapshot?: SerializedSimulationSnapshot,
   rumors?: RumorEvent[],
   debugTraces?: SimulationDebugTrace[],
+  options?: {
+    status?: SimulationResultStatus
+    partialReason?: string
+    resume?: SimulationResumePoint | null
+  },
 ): Promise<string> {
   await ensureSimulationDirs(projectPath)
   const resultId = `result-${Date.now()}`
@@ -421,6 +428,9 @@ export async function saveSimulationResult(
     agentSnapshot: agentSnapshot ?? null,
     rumors: rumors ?? [],
     debugTraces: debugTraces ?? [],
+    status: options?.status ?? "complete",
+    partialReason: options?.partialReason ?? null,
+    resume: options?.resume ?? null,
   }
   await writeFileAtomic(`${dir}/${resultId}.json`, JSON.stringify(payload, null, 2))
   await writeFileAtomic(
@@ -461,6 +471,9 @@ export async function loadSimulationResults(
   agentSnapshot?: SerializedSimulationSnapshot | null
   rumors?: RumorEvent[]
   debugTraces?: SimulationDebugTrace[]
+  status: SimulationResultStatus
+  partialReason?: string | null
+  resume?: SimulationResumePoint | null
 }[]> {
   const dir = frameworkResultsDir(projectPath, frameworkId)
   let entries: FileNode[]
@@ -478,6 +491,9 @@ export async function loadSimulationResults(
     agentSnapshot?: SerializedSimulationSnapshot | null
     rumors?: RumorEvent[]
     debugTraces?: SimulationDebugTrace[]
+    status: SimulationResultStatus
+    partialReason?: string | null
+    resume?: SimulationResumePoint | null
   }[] = []
   for (const entry of entries) {
     if (entry.is_dir) continue
@@ -491,6 +507,9 @@ export async function loadSimulationResults(
         agentSnapshot?: SerializedSimulationSnapshot | null
         rumors?: RumorEvent[]
         debugTraces?: SimulationDebugTrace[]
+        status?: SimulationResultStatus
+        partialReason?: string | null
+        resume?: SimulationResumePoint | null
       }
       if (parsed && parsed.report) {
         results.push({
@@ -501,6 +520,9 @@ export async function loadSimulationResults(
           agentSnapshot: parsed.agentSnapshot ?? null,
           rumors: parsed.rumors ?? [],
           debugTraces: parsed.debugTraces ?? [],
+          status: parsed.status ?? "complete",
+          partialReason: parsed.partialReason ?? null,
+          resume: parsed.resume ?? null,
         })
       }
     } catch {

+ 175 - 0
src/lib/novel/story-simulation/simulation-engine.resume.spec.ts

@@ -0,0 +1,175 @@
+import { describe, expect, it, vi } from "vitest"
+import type {
+  ExtractionResult,
+  NovelAgent,
+  StoryFramework,
+  TimelineEvent,
+} from "@/lib/novel/story-simulation/types"
+import { runSimulation } from "@/lib/novel/story-simulation/simulation-engine"
+
+let mockRun: any
+
+vi.mock("@/lib/agent/runner", () => ({
+  AgentRunner: class {
+    run(...args: unknown[]) {
+      return mockRun(...args)
+    }
+  },
+  ModelDoesNotSupportToolsError: class extends Error {
+    constructor() {
+      super("当前模型不支持工具调用")
+      this.name = "ModelDoesNotSupportToolsError"
+    }
+  },
+}))
+
+vi.mock("@/lib/embedding-client", () => ({
+  embed: vi.fn().mockResolvedValue([1, 0, 0]),
+  cosineSimilarity: vi.fn().mockReturnValue(0),
+}))
+
+function makeAgent(): NovelAgent {
+  return {
+    characterId: "a",
+    name: "甲",
+    profile: "甲的档案",
+    aura: null,
+    cognition: null,
+    soul: "",
+    currentGoal: "完成当前目标",
+    emotionalState: "neutral",
+    knownFacts: new Set(),
+    relationships: new Map(),
+    powerLevel: "normal",
+    memory: {
+      observedEvents: [],
+      knownSecrets: new Set(),
+      sentiments: new Map(),
+      recentDecisions: [],
+      rumorCredibility: 0.5,
+    },
+    knowledgeScope: [],
+    personality: [],
+    speakingStyle: "",
+  }
+}
+
+function makeExtraction(): ExtractionResult {
+  return {
+    characters: [],
+    chapterContents: [],
+    memoryData: {
+      characterStates: "",
+      characterCognition: null,
+      foreshadowingTracker: null,
+      timeline: [],
+      canonFacts: "",
+      conflicts: "",
+    },
+    worldRules: "",
+    powerSystem: "",
+    foreshadowing: null,
+    timeline: [],
+    outlineContent: "",
+    soulDoc: "",
+  }
+}
+
+function makeFramework(): StoryFramework {
+  return {
+    id: "fw",
+    title: "测试框架",
+    premise: "测试",
+    targetWords: 10000,
+    simulationMode: "event-driven",
+    sourceChapters: 1,
+    createdAt: "2026-07-06T00:00:00.000Z",
+    nodes: [
+      {
+        index: 0,
+        phase: "起",
+        title: "开端",
+        coreConflict: "冲突",
+        involvedCharacters: ["甲"],
+        goal: "继续开端",
+        causeFromPrev: "无",
+        expectedOutcome: "完成开端",
+      },
+      {
+        index: 1,
+        phase: "承",
+        title: "推进",
+        coreConflict: "冲突",
+        involvedCharacters: ["甲"],
+        goal: "推进剧情",
+        causeFromPrev: "开端",
+        expectedOutcome: "完成推进",
+      },
+    ],
+  }
+}
+
+function makePreviousEvent(): TimelineEvent {
+  return {
+    id: "previous",
+    round: 0,
+    nodeIndex: 0,
+    actorId: "a",
+    actorName: "甲",
+    actionType: "speak",
+    content: "既有事件",
+    observableBy: ["a"],
+    impacts: [],
+    timestamp: "2026-07-06T00:00:00.000Z",
+  }
+}
+
+describe("runSimulation resume", () => {
+  it("continues from the saved node and round while keeping previous timeline events", async () => {
+    vi.spyOn(Math, "random").mockReturnValue(0.99)
+    mockRun = vi.fn().mockResolvedValue({
+      toolCalls: [],
+      roundsUsed: 1,
+      finalText: JSON.stringify({
+        type: "speak",
+        content: "续跑事件",
+        visibility: "all",
+        motivation: "继续推进",
+        plot_push: "补完当前节点",
+      }),
+    })
+
+    const timelineEvents: TimelineEvent[] = []
+
+    await runSimulation(
+      {
+        agents: [makeAgent()],
+        framework: makeFramework(),
+        mode: "event-driven",
+        wordBudget: 10000,
+        llmConfig: {} as any,
+        maxRoundsPerNode: 2,
+        resume: {
+          nextNodeIndex: 0,
+          nextRound: 1,
+          timelineEvents: [makePreviousEvent()],
+        },
+      } as any,
+      makeExtraction(),
+      {
+        onEvent: () => {},
+        onProgress: () => {},
+        onComplete: () => {},
+        onError: () => {},
+        onTimelineEvent: (event) => timelineEvents.push(event),
+      },
+    )
+
+    expect(timelineEvents.slice(0, 2).map((event) => event.content)).toEqual([
+      "既有事件",
+      "续跑事件",
+    ])
+    expect(timelineEvents[1].nodeIndex).toBe(0)
+    expect(timelineEvents[1].round).toBe(1)
+  })
+})

+ 64 - 13
src/lib/novel/story-simulation/simulation-engine.ts

@@ -35,6 +35,7 @@ import type {
   SimulationEvent,
   SimulationInput,
   SimulationState,
+  StoryFramework,
   StoryNode,
   TimelineEvent,
 } from "@/lib/novel/story-simulation/types"
@@ -128,6 +129,20 @@ function nextEventId(): string {
   return `evt_${Date.now()}_${eventCounter}`
 }
 
+function timelineEventToResumeSimulationEvent(
+  event: TimelineEvent,
+  framework: StoryFramework,
+): SimulationEvent {
+  const node = framework.nodes.find((item) => item.index === event.nodeIndex)
+  return {
+    type: "info",
+    node,
+    timestamp: event.timestamp,
+    message: `[已保存] 节点${event.nodeIndex + 1} 第${event.round + 1}轮 ${event.actorName}:${event.content}`,
+    timelineEvent: event,
+  }
+}
+
 // ── 内部辅助:构建 Agent 系统提示词 ──
 
 function buildSystemPrompt(agent: NovelAgent): string {
@@ -1154,7 +1169,7 @@ export async function runSimulation(
   signal?: AbortSignal,
 ): Promise<SimulationEvent[]> {
   const events: SimulationEvent[] = []
-  const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode, dynamicEventPool } = input
+  const { agents, framework, wordBudget, llmConfig, injectionEvent, maxRoundsPerNode, dynamicEventPool, resume } = input
   const mode = input.mode || framework.simulationMode || "hybrid"
   const modeConfig: ModeConfig = getModeConfig(mode)
   const totalNodes = framework.nodes.length
@@ -1163,6 +1178,9 @@ export async function runSimulation(
   const baseRounds = Math.max(1, maxRoundsPerNode ?? calculatedRounds)
   const maxRounds = Math.max(1, Math.round(baseRounds * modeConfig.roundsMultiplier))
   let aborted = false
+  const resumeTimelineEvents = resume?.timelineEvents ?? []
+  const normalizedResumeNodeIndex = Math.max(0, Math.min(totalNodes - 1, resume?.nextNodeIndex ?? 0))
+  const normalizedResumeRound = Math.max(0, resume?.nextRound ?? 0)
 
   const isStagedPool =
     dynamicEventPool &&
@@ -1188,7 +1206,7 @@ export async function runSimulation(
   // 初始化仿真状态
   const state: SimulationState = {
     currentRound: 0,
-    timelineEvents: [],
+    timelineEvents: [...resumeTimelineEvents],
     activeAgents: cloneAgentsToMap(agents),
     worldState: {},
     dynamicEventPool: stringPool && stringPool.length > 0 ? stringPool : undefined,
@@ -1198,16 +1216,30 @@ export async function runSimulation(
   }
   const blackboard = createSimulationBlackboard({
     agents: Array.from(state.activeAgents.values()),
+    timelineEvents: resumeTimelineEvents,
   })
 
   try {
-    for (let ni = 0; ni < totalNodes; ni++) {
+    for (const event of resumeTimelineEvents) {
+      callbacks.onTimelineEvent?.(event)
+      events.push(timelineEventToResumeSimulationEvent(event, framework))
+    }
+
+    let startNodeIndex = resume ? normalizedResumeNodeIndex : 0
+    let firstNodeStartRound = resume ? normalizedResumeRound : 0
+    if (firstNodeStartRound >= maxRounds) {
+      startNodeIndex += 1
+      firstNodeStartRound = 0
+    }
+
+    for (let ni = startNodeIndex; ni < totalNodes; ni++) {
       if (signal?.aborted) {
         aborted = true
         break
       }
 
       const node = framework.nodes[ni]
+      const isResumingInsideNode = !!resume && ni === startNodeIndex && firstNodeStartRound > 0
 
       const nodeAgentList = selectNodeAgentCandidates(blackboard, node)
 
@@ -1219,14 +1251,16 @@ export async function runSimulation(
       state.activeAgents = activeMap
       blackboard.activeAgents = activeMap
 
-      // 产出 node-start 事件
-      const startEvent: SimulationEvent = {
-        type: "node-start",
-        node,
-        timestamp: new Date().toISOString(),
+      if (!isResumingInsideNode) {
+        // 产出 node-start 事件
+        const startEvent: SimulationEvent = {
+          type: "node-start",
+          node,
+          timestamp: new Date().toISOString(),
+        }
+        events.push(startEvent)
+        callbacks.onEvent(startEvent)
       }
-      events.push(startEvent)
-      callbacks.onEvent(startEvent)
 
       callbacks.onProgress(
         Math.round((ni / totalNodes) * 100),
@@ -1239,11 +1273,15 @@ export async function runSimulation(
       const nodeInjectionEvent = directorInjection || initialInjection
 
       // 当前节点内的事件描述(供 recentEvents 使用)
-      const recentEventDescs: string[] = []
-      const nodeTimelineEvents: TimelineEvent[] = []
+      const previousNodeEvents = resumeTimelineEvents.filter((event) => event.nodeIndex === node.index)
+      const recentEventDescs: string[] = previousNodeEvents.map(
+        (event) => `[已保存] ${event.actorName}:${event.content}`,
+      )
+      const nodeTimelineEvents: TimelineEvent[] = [...previousNodeEvents]
 
       // 节点内多轮交互
-      for (let round = 0; round < maxRounds; round++) {
+      const roundStart = ni === startNodeIndex ? firstNodeStartRound : 0
+      for (let round = roundStart; round < maxRounds; round++) {
         if (signal?.aborted) {
           aborted = true
           break
@@ -1320,6 +1358,10 @@ export async function runSimulation(
               }
             }
           } catch (agentErr) {
+            if (signal?.aborted || (agentErr instanceof Error && agentErr.name === "AbortError")) {
+              aborted = true
+              break
+            }
             console.warn(`[simulation] Agent ${currentAgent.name} 决策失败,跳过本轮:`, agentErr)
             const warnEvent: SimulationEvent = {
               type: "info",
@@ -1386,6 +1428,10 @@ export async function runSimulation(
                 blackboard,
                 signal,
               )
+              if (signal?.aborted) {
+                aborted = true
+                break
+              }
             }
           }
         }
@@ -1441,6 +1487,11 @@ export async function runSimulation(
           }
         }
 
+        if (signal?.aborted) {
+          aborted = true
+          break
+        }
+
         // f. 检查节点目标是否达成
         const goalReached = await isNodeGoalReachedWithEmbedding(
           node,

+ 10 - 0
src/lib/novel/story-simulation/types.ts

@@ -304,6 +304,14 @@ export interface SimulationReport {
   createdAt: string
 }
 
+export type SimulationResultStatus = "complete" | "partial" | "cancelled"
+
+export interface SimulationResumePoint {
+  nextNodeIndex: number
+  nextRound: number
+  timelineEvents: TimelineEvent[]
+}
+
 export interface CharacterAnalysis {
   characterId: string
   name: string
@@ -368,6 +376,8 @@ export interface SimulationInput {
   maxRoundsPerNode?: number
   /** LLM 预生成的动态事件池(支持字符串数组或分阶段池) */
   dynamicEventPool?: string[] | StagedEventPool
+  /** 从中断位置继续推演 */
+  resume?: SimulationResumePoint
 }
 
 // ── 仿真配置 ──

+ 5 - 0
src/stores/story-simulation-store.ts

@@ -4,6 +4,8 @@ import type {
   SimulationMode,
   StoryFramework,
   SimulationReport,
+  SimulationResultStatus,
+  SimulationResumePoint,
   StoryDraft,
   ExtractionResult,
   FrameworkBinding,
@@ -28,6 +30,9 @@ export interface SavedSimulationResult {
   agentSnapshot?: SerializedSimulationSnapshot | null;
   rumors?: RumorEvent[];
   debugTraces?: SimulationDebugTrace[];
+  status?: SimulationResultStatus;
+  partialReason?: string | null;
+  resume?: SimulationResumePoint | null;
   createdAt: string;
 }