Pārlūkot izejas kodu

feat(workflow): 分别持久化大纲与正文写作模式

重启后各自恢复上次选择,两套 key 互不覆盖;非法值当未设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 mēnesi atpakaļ
vecāks
revīzija
fda37d049e

+ 9 - 1
src/App.tsx

@@ -6,7 +6,7 @@ import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadAiWorkflowMode, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig, loadOutlineWorkflowMode } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
@@ -237,6 +237,14 @@ function App() {
           useWikiStore.getState().setAiChatModel(savedAiChatModel)
         }
         await initializeAiOutlineModelFromStorage()
+        const savedAiWorkflowMode = await loadAiWorkflowMode()
+        if (savedAiWorkflowMode) {
+          useWikiStore.getState().setAiWorkflowMode(savedAiWorkflowMode)
+        }
+        const savedOutlineWorkflowMode = await loadOutlineWorkflowMode()
+        if (savedOutlineWorkflowMode) {
+          useWikiStore.getState().setOutlineWorkflowMode(savedOutlineWorkflowMode)
+        }
         const savedDefaultLlmModel = await loadDefaultLlmModel()
         if (savedDefaultLlmModel) {
           useWikiStore.getState().setDefaultLlmModel(savedDefaultLlmModel)

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

@@ -542,6 +542,11 @@ describe("aiWorkflowMode store 读取", () => {
     expect(source).toMatch(/useWikiStore\(\(s\) => s\.aiWorkflowMode\)/)
     expect(source).toMatch(/useWikiStore\(\(s\) => s\.setAiWorkflowMode\)/)
   })
+
+  it("切换正文执行模式时落盘", () => {
+    expect(source).toContain("saveAiWorkflowMode")
+    expect(source).toContain("void saveAiWorkflowMode(mode)")
+  })
 })
 
 describe("resolver 卸载清理", () => {

+ 2 - 1
src/components/chat/chat-panel.tsx

@@ -86,7 +86,7 @@ import {
   canCreateNewConversation,
   EMPTY_CONVERSATION_CREATE_REASON,
 } from "@/lib/conversation-create-guard"
-import { saveAiChatModel } from "@/lib/project-store"
+import { saveAiChatModel, saveAiWorkflowMode } from "@/lib/project-store"
 import {
   buildGoldenThreeChapterDirective,
   detectGoldenThreeChapterRequest,
@@ -2568,6 +2568,7 @@ export function ChatPanel() {
                                   className="flex w-full items-start gap-2 rounded-sm px-3 py-2 text-left hover:bg-accent"
                                   onClick={() => {
                                     setAiWorkflowMode(mode)
+                                    void saveAiWorkflowMode(mode)
                                     setWorkflowModeDropdownOpen(false)
                                   }}
                                 >

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

@@ -7,11 +7,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 
 const outlineModelPreferenceMocks = vi.hoisted(() => ({
   saveAiOutlineModel: vi.fn(async (_modelId: string) => {}),
+  saveOutlineWorkflowMode: vi.fn(async (_mode: string) => {}),
 }))
 
 vi.mock("@/lib/project-store", async (importOriginal) => ({
   ...(await importOriginal<typeof import("@/lib/project-store")>()),
   saveAiOutlineModel: outlineModelPreferenceMocks.saveAiOutlineModel,
+  saveOutlineWorkflowMode: outlineModelPreferenceMocks.saveOutlineWorkflowMode,
 }))
 
 import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry"
@@ -103,6 +105,8 @@ beforeEach(() => {
   })
   outlineModelPreferenceMocks.saveAiOutlineModel.mockReset()
   outlineModelPreferenceMocks.saveAiOutlineModel.mockResolvedValue(undefined)
+  outlineModelPreferenceMocks.saveOutlineWorkflowMode.mockReset()
+  outlineModelPreferenceMocks.saveOutlineWorkflowMode.mockResolvedValue(undefined)
 })
 
 afterEach(async () => {
@@ -912,6 +916,7 @@ describe("OutlineChatPanel controls", () => {
     })
 
     expect(useWikiStore.getState().outlineWorkflowMode).toBe("fast")
+    expect(outlineModelPreferenceMocks.saveOutlineWorkflowMode).toHaveBeenCalledWith("fast")
     expect(container.querySelector('[aria-label="AI 大纲执行模式"]')?.textContent).toContain("快速")
     expect(container.textContent).toContain("直接生成大纲正文")
     expect(container.textContent).not.toContain("再交给 AI 分析和追问")

+ 2 - 1
src/components/sources/outline-chat-panel.tsx

@@ -29,7 +29,7 @@ import {
 } from "@/lib/agent/workflow-mode";
 import { OUTPUT_TRUNCATED_ERROR_MARKER } from "@/lib/llm-client";
 import { Button } from "@/components/ui/button";
-import { saveAiOutlineModel } from "@/lib/project-store";
+import { saveAiOutlineModel, saveOutlineWorkflowMode } from "@/lib/project-store";
 import {
   useOutlineChatStore,
   type OutlineMultiAgentRunState,
@@ -4733,6 +4733,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                             className="flex w-full items-start gap-2 rounded-sm px-3 py-2 text-left hover:bg-accent"
                             onClick={() => {
                               setOutlineWorkflowMode(mode);
+                              void saveOutlineWorkflowMode(mode);
                               setWorkflowModeDropdownOpen(false);
                             }}
                           >

+ 6 - 0
src/lib/agent/workflow-mode.spec.ts

@@ -18,6 +18,12 @@ describe("workflow mode", () => {
     expect(resolveAiWorkflowMode(undefined)).toBe("standard")
   })
 
+  it("defaults invalid values to the standard workflow mode", () => {
+    expect(resolveAiWorkflowMode("normal")).toBe("standard")
+    expect(resolveAiWorkflowMode("")).toBe("standard")
+    expect(resolveAiWorkflowMode(1)).toBe("standard")
+  })
+
   it("accepts explicit active workflow modes without changing them", () => {
     const modes: AiWorkflowMode[] = ["fast", "standard", "strict"]
 

+ 12 - 2
src/lib/agent/workflow-mode.ts

@@ -4,8 +4,18 @@ export type OutlineWorkflowMode = Extract<AiWorkflowMode, "fast" | "standard">
 export const DEFAULT_AI_WORKFLOW_MODE: AiWorkflowMode = "standard"
 export const DEFAULT_OUTLINE_WORKFLOW_MODE: OutlineWorkflowMode = "standard"
 
-export function resolveAiWorkflowMode(value: AiWorkflowMode | null | undefined): AiWorkflowMode {
-  return value ?? DEFAULT_AI_WORKFLOW_MODE
+const AI_WORKFLOW_MODES: readonly AiWorkflowMode[] = ["fast", "standard", "strict"]
+
+export function isAiWorkflowMode(value: unknown): value is AiWorkflowMode {
+  return typeof value === "string" && (AI_WORKFLOW_MODES as readonly string[]).includes(value)
+}
+
+export function isOutlineWorkflowMode(value: unknown): value is OutlineWorkflowMode {
+  return value === "fast" || value === "standard"
+}
+
+export function resolveAiWorkflowMode(value: unknown): AiWorkflowMode {
+  return isAiWorkflowMode(value) ? value : DEFAULT_AI_WORKFLOW_MODE
 }
 
 export function resolveOutlineWorkflowMode(

+ 39 - 0
src/lib/project-store.spec.ts

@@ -15,9 +15,13 @@ vi.mock("@/lib/web-store", () => ({
 
 import {
   loadAiOutlineModel,
+  loadAiWorkflowMode,
   loadLastReadChapter,
+  loadOutlineWorkflowMode,
   saveAiOutlineModel,
+  saveAiWorkflowMode,
   saveLastReadChapter,
+  saveOutlineWorkflowMode,
 } from "@/lib/project-store"
 
 describe("AI outline model persistence", () => {
@@ -75,6 +79,41 @@ describe("AI outline model persistence", () => {
 
 })
 
+describe("workflow mode persistence", () => {
+  beforeEach(() => {
+    storeMocks.values.clear()
+    storeMocks.get.mockReset()
+    storeMocks.set.mockReset()
+    storeMocks.get.mockImplementation(async (key: string) => storeMocks.values.get(key))
+    storeMocks.set.mockImplementation(async (key: string, value: unknown) => {
+      storeMocks.values.set(key, value)
+    })
+  })
+
+  it("saves body and outline modes under separate keys", async () => {
+    await saveAiWorkflowMode("strict")
+    await saveOutlineWorkflowMode("fast")
+
+    expect(storeMocks.values.get("aiWorkflowMode")).toBe("strict")
+    expect(storeMocks.values.get("outlineWorkflowMode")).toBe("fast")
+    expect(storeMocks.values.has("aiChatModel")).toBe(false)
+    expect(storeMocks.values.has("aiOutlineModel")).toBe(false)
+    await expect(loadAiWorkflowMode()).resolves.toBe("strict")
+    await expect(loadOutlineWorkflowMode()).resolves.toBe("fast")
+  })
+
+  it("treats missing or invalid stored modes as unset", async () => {
+    await expect(loadAiWorkflowMode()).resolves.toBeNull()
+    await expect(loadOutlineWorkflowMode()).resolves.toBeNull()
+
+    storeMocks.values.set("aiWorkflowMode", "normal")
+    storeMocks.values.set("outlineWorkflowMode", "strict")
+
+    await expect(loadAiWorkflowMode()).resolves.toBeNull()
+    await expect(loadOutlineWorkflowMode()).resolves.toBeNull()
+  })
+})
+
 describe("last read chapter persistence", () => {
   beforeEach(() => {
     storeMocks.values.clear()

+ 32 - 0
src/lib/project-store.ts

@@ -21,6 +21,14 @@ import {
 import { migrateLegacyCodexCliTimeoutMinutes } from "@/lib/codex-cli-timeout"
 import { migrateLegacyDefaultCodexCliModel } from "@/lib/codex-cli-model"
 import { CHAPTER_TARGET_CHARS_MAX, CHAPTER_TARGET_CHARS_MIN } from "@/lib/novel/deep-chapter-prompts"
+import {
+  isAiWorkflowMode,
+  isOutlineWorkflowMode,
+  resolveAiWorkflowMode,
+  resolveOutlineWorkflowMode,
+  type AiWorkflowMode,
+  type OutlineWorkflowMode,
+} from "@/lib/agent/workflow-mode"
 
 const RECENT_PROJECTS_KEY = "recentProjects"
 const LAST_PROJECT_KEY = "lastProject"
@@ -136,6 +144,8 @@ async function markCodexModelMigrationDone(
 }
 const AI_CHAT_MODEL_KEY = "aiChatModel"
 const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
+const AI_WORKFLOW_MODE_KEY = "aiWorkflowMode"
+const OUTLINE_WORKFLOW_MODE_KEY = "outlineWorkflowMode"
 let aiOutlineModelSaveRevision = 0
 let latestAiOutlineModel = ""
 const DEFAULT_LLM_MODEL_KEY = "defaultLlmModel"
@@ -211,6 +221,28 @@ export async function loadAiOutlineModel(): Promise<string | null> {
   return (await store.get<string>(AI_OUTLINE_MODEL_KEY)) ?? null
 }
 
+export async function saveAiWorkflowMode(mode: AiWorkflowMode): Promise<void> {
+  const store = await getStore()
+  await store.set(AI_WORKFLOW_MODE_KEY, resolveAiWorkflowMode(mode))
+}
+
+export async function loadAiWorkflowMode(): Promise<AiWorkflowMode | null> {
+  const store = await getStore()
+  const saved = await store.get<unknown>(AI_WORKFLOW_MODE_KEY)
+  return isAiWorkflowMode(saved) ? saved : null
+}
+
+export async function saveOutlineWorkflowMode(mode: OutlineWorkflowMode): Promise<void> {
+  const store = await getStore()
+  await store.set(OUTLINE_WORKFLOW_MODE_KEY, resolveOutlineWorkflowMode(mode))
+}
+
+export async function loadOutlineWorkflowMode(): Promise<OutlineWorkflowMode | null> {
+  const store = await getStore()
+  const saved = await store.get<unknown>(OUTLINE_WORKFLOW_MODE_KEY)
+  return isOutlineWorkflowMode(saved) ? saved : null
+}
+
 export async function saveDefaultLlmModel(model: string): Promise<void> {
   const store = await getStore()
   await store.set(DEFAULT_LLM_MODEL_KEY, model)

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

@@ -141,6 +141,7 @@ vi.mock("@/lib/project-refresh", () => ({
 
 vi.mock("@/lib/project-store", () => ({
   saveAiChatModel: vi.fn(async () => {}),
+  saveAiWorkflowMode: vi.fn(async () => {}),
 }))
 
 import { ChatPanel } from "@/components/chat/chat-panel"