1
0
Эх сурвалжийг харах

feat: 优化项目灵魂和预览标题栏

Mochocyang 2 сар өмнө
parent
commit
f038fcf107

+ 39 - 3
src/App.tsx

@@ -4,7 +4,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
 import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
-import { openProject, fileExists } from "@/commands/fs"
+import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
 import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
@@ -23,6 +23,8 @@ import { applyTheme, watchSystemTheme } from "@/lib/theme-utils"
 import { applyUiFontFamily } from "@/lib/font-settings"
 import { applyVisualStyle } from "@/lib/visual-style-settings"
 import { normalizePath } from "@/lib/path-utils"
+import { countChapterBodyWords } from "@/lib/chapter-word-count"
+import { flattenMdFiles } from "@/lib/novel/chapter-utils"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -35,8 +37,10 @@ function App() {
   const visualStyle = useWikiStore((s) => s.visualStyle)
   const communitySummaryError = useWikiStore((s) => s.communitySummaryError)
   const setCommunitySummaryError = useWikiStore((s) => s.setCommunitySummaryError)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
   const [showCreateDialog, setShowCreateDialog] = useState(false)
   const [loading, setLoading] = useState(true)
+  const [appTitleTotalWordCount, setAppTitleTotalWordCount] = useState<number | null>(null)
 
   function isCurrentProject(proj: WikiProject): boolean {
     const current = useWikiStore.getState().project
@@ -329,14 +333,46 @@ function App() {
   }, [theme])
 
   useEffect(() => {
-    const title = formatAppTitle(project?.name)
+    if (!project?.path) {
+      setAppTitleTotalWordCount(null)
+      return
+    }
+
+    let cancelled = false
+
+    const loadAppTitleTotalWordCount = async () => {
+      try {
+        const chapterNodes = await listDirectory(`${normalizePath(project.path)}/wiki/chapters`)
+        const files = flattenMdFiles(chapterNodes)
+        const contents = await Promise.all(
+          files.map((file) => readFile(file.path).catch(() => "")),
+        )
+        const total = contents.reduce(
+          (sum, markdown) => sum + countChapterBodyWords(markdown),
+          0,
+        )
+        if (!cancelled) setAppTitleTotalWordCount(total)
+      } catch {
+        if (!cancelled) setAppTitleTotalWordCount(null)
+      }
+    }
+
+    void loadAppTitleTotalWordCount()
+
+    return () => {
+      cancelled = true
+    }
+  }, [dataVersion, project?.path])
+
+  useEffect(() => {
+    const title = formatAppTitle(project?.name, appTitleTotalWordCount)
     document.title = title
     if (isTauri()) {
       import("@tauri-apps/api/window")
         .then(({ getCurrentWindow }) => getCurrentWindow().setTitle(title))
         .catch(() => {})
     }
-  }, [project?.name])
+  }, [appTitleTotalWordCount, project?.name])
 
   async function handleProjectOpened(proj: WikiProject) {
     await resetProjectState()

+ 161 - 0
src/components/layout/preview-panel.test.tsx

@@ -0,0 +1,161 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "preview-panel.tsx"), "utf8")
+
+describe("preview-panel final chapter save state", () => {
+  it("scopes saved state by projectPath and filePath", () => {
+    expect(source).toContain("finalChapterSave.projectPath === project?.path")
+    expect(source).toContain("finalChapterSave.filePath === selectedFile")
+  })
+
+  it("writes final chapter save progress through the shared store", () => {
+    expect(source).toContain("setFinalChapterSave")
+    expect(source).toContain("projectPath,")
+    expect(source).toContain("filePath:")
+  })
+
+  it("uses phase codes instead of translated labels for final save progress", () => {
+    expect(source).toContain("phase: FinalChapterSavePhase")
+    expect(source).toContain("phaseLabelMap")
+  })
+
+  it("flushes chapter filenames before leaving a chapter", () => {
+    expect(source).toContain("flushChapterBeforeLeave")
+    expect(source).toContain("previousFile !== selectedFile")
+    expect(source).toContain("syncChapterToCanonicalPath")
+  })
+
+  it("supports selected-text transform previews", () => {
+    expect(source).toContain("TextTransformPreviewDialog")
+    expect(source).toContain("handleSelectionAction")
+    expect(source).toContain("replaceChapterBodySelection")
+  })
+
+  it("shows the actual skill used by de-AI actions", () => {
+    expect(source).toContain("deAiSkillName")
+    expect(source).toContain("selectionTransformSkillName")
+    expect(source).toContain("本次使用 Skill")
+    expect(source).toContain("去AI味处理中,使用 Skill")
+  })
+
+  it("keeps the chapter de-AI toolbar button label short while preserving the skill picker state", () => {
+    expect(source).toContain("chapterDeAiOptions.effectiveName")
+    expect(source).toContain('const chapterDeAiButtonLabel = deAiProcessing ? "处理中" : "去AI味"')
+    expect(source).not.toContain("`去AI味:${chapterDeAiSkillName}`")
+    expect(source).toContain("currentSkillId={chapterDeAiOptions.currentSkillId}")
+    expect(source).toContain("defaultSkillId={chapterDeAiOptions.defaultSkillId}")
+  })
+
+  it("does not expose the cognition panel from the chapter toolbar", () => {
+    expect(source).not.toContain("setShowCognition(true)")
+    expect(source).not.toContain('t("preview.cognitionTitle")')
+  })
+
+  it("persists the last picked chapter de-AI skill in the project skill config", () => {
+    expect(source).toContain("setLastChapterDeAiSkill")
+    expect(source).toContain("saveDeAiSkillConfig(project.path")
+  })
+
+  it("uses the shared de-AI skill option loader for chapter skill state", () => {
+    expect(source).toContain("useDeAiSkillOptions")
+    expect(source).toContain("useLastChapterSkill: true")
+  })
+
+  it("keeps chapter de-AI processing clear when remembering the picked skill fails", () => {
+    expect(source).toContain("未能记住本次去AI味 Skill 选择,本次处理仍会继续")
+    expect(source).not.toContain("setSaveStatus(\"保存章节去AI味 Skill 选择失败\")")
+  })
+
+  it("resets the remembered chapter de-AI skill when switching projects", () => {
+    expect(source).toContain("setChapterDeAiSkillId(undefined)")
+    expect(source).toContain("}, [project?.path])")
+  })
+
+  it("keeps the remember-skill warning visible during de-AI processing", () => {
+    expect(source).toContain("deAiSkillMemoryWarning")
+    expect(source).toContain("formatDeAiStatus")
+    expect(source).toContain("setDeAiSkillMemoryWarning(\"未能记住本次去AI味 Skill 选择,本次处理仍会继续\")")
+  })
+
+  it("closes the chapter de-AI skill picker when clicking outside it", () => {
+    expect(source).toContain("deAiSkillPickerRef")
+    expect(source).toContain("handleDeAiSkillPickerMouseDown")
+    expect(source).toContain('document.addEventListener("mousedown", handleDeAiSkillPickerMouseDown)')
+    expect(source).toContain('document.removeEventListener("mousedown", handleDeAiSkillPickerMouseDown)')
+  })
+
+  it("anchors the chapter de-AI skill picker near the clicked toolbar button", () => {
+    expect(source).toContain("deAiSkillPickerPosition")
+    expect(source).toContain("getDeAiSkillPickerPosition")
+    expect(source).toContain("openDeAiSkillPicker(null, e.currentTarget)")
+    expect(source).not.toContain('className="fixed right-6 top-20')
+  })
+
+  it("uses a local draft for chapter title editing", () => {
+    expect(source).toContain("chapterTitleDraft")
+    expect(source).toContain("chapterTitleEditing")
+    expect(source).toContain("commitChapterTitleDraft")
+    expect(source).toContain("e.currentTarget.blur()")
+    expect(source).toContain("e.stopPropagation()")
+  })
+
+  it("measures chapter title width for header metadata layout", () => {
+    expect(source).toContain("titleMeasureRef")
+    expect(source).toContain("chapterTitleWidthPx")
+    expect(source).toContain("chapterStatusMeta")
+    expect(source).toContain("chapterWordCountMeta")
+    expect(source).toContain('chapterHeader.status === "final"')
+  })
+
+  it("lets the chapter toolbar span the preview header so actions stay right aligned", () => {
+    expect(source).toContain('ref={chapterToolbarRef} className="flex min-w-0 flex-1 items-center gap-2"')
+    expect(source).toContain('className="relative ml-auto flex shrink-0 items-center justify-end gap-1"')
+  })
+
+  it("keeps the draft badge lightweight in the chapter header", () => {
+    expect(source).toContain('chapterHeader.status === "draft"')
+    expect(source).toContain("rounded-full border border-border/70 bg-muted/60")
+  })
+
+  it("falls back to the filename when the chapter heading is missing", () => {
+    expect(source).toContain("chapterDisplayTitle")
+    expect(source).toContain("getChapterTitleFromPath")
+  })
+
+  it("does not render total chapter word count in the header", () => {
+    expect(source).not.toContain("chapterTotalWordCountMeta")
+    expect(source).not.toContain("buildChapterTotalWordCountLabel")
+    expect(source).not.toContain("totalChapterWords")
+  })
+
+  it("does not render the archived-draft action anymore", () => {
+    expect(source).not.toContain("canArchiveDraft")
+    expect(source).not.toContain("handleArchiveDraft")
+    expect(source).not.toContain('t("novel.chapter.archiveDraft")')
+  })
+
+  it("does not mount a newly selected markdown file before it loads", () => {
+    expect(source).toContain("loadedFilePath")
+    expect(source).toContain("loadedFilePath !== selectedFile")
+    expect(source).toContain("setLoadedFilePath(selectedFile)")
+  })
+
+  it("skips redundant chapter flushes when markdown is unchanged", () => {
+    expect(source).toContain("shouldSyncChapterOnLeave")
+    expect(source).toContain("if (!shouldSyncChapterOnLeave(path, markdown, lastLoadedForPath)) return")
+  })
+
+  it("persists outline ingest progress through the shared outline task flow", () => {
+    expect(source).toContain("startOutlineIngestTask")
+    expect(source).toContain("currentOutlineTask")
+    expect(source).toContain('task.status === "ingesting" || task.status === "done" || task.status === "error"')
+  })
+
+  it("strips frontmatter from trash markdown previews", () => {
+    expect(source).toContain('const trashPreviewBody = category === "markdown"')
+    expect(source).toContain("? parseFrontmatter(fileContent).body")
+    expect(source).toContain("<WikiReader body={trashPreviewBody} />")
+  })
+})

+ 3 - 26
src/components/layout/preview-panel.tsx

@@ -651,7 +651,7 @@ export function PreviewPanel() {
     </div>
   ) : null
   const chapterDeAiSkillName = chapterHeader ? chapterDeAiOptions.effectiveName : "未启用"
-  const chapterDeAiButtonLabel = deAiProcessing ? "处理中" : (chapterDeAiSkillName === "未启用" ? "去AI味" : `去AI味:${chapterDeAiSkillName}`)
+  const chapterDeAiButtonLabel = deAiProcessing ? "处理中" : "去AI味"
   const chapterDeAiButtonTitle = `当前去AI味 Skill:${chapterDeAiSkillName}`
 
   useEffect(() => {
@@ -1237,8 +1237,7 @@ export function PreviewPanel() {
     canIngestOutline ||
     canSaveAsFinal ||
     canFormatWriting ||
-    canViewSnapshot ||
-    (novelMode && project)
+    canViewSnapshot
   )
 
   if (loadedFilePath !== selectedFile) {
@@ -1252,7 +1251,7 @@ export function PreviewPanel() {
   return (
     <div className="flex h-full flex-col">
       <div className="flex h-12 shrink-0 items-center border-b px-3">
-        <div ref={chapterToolbarRef} className="flex min-w-0 items-center gap-2">
+        <div ref={chapterToolbarRef} className="flex min-w-0 flex-1 items-center gap-2">
           <div className="relative flex min-w-0 min-h-0 flex-1 items-center gap-1 overflow-hidden">
             {chapterHeader ? (
               <>
@@ -1418,18 +1417,6 @@ export function PreviewPanel() {
                       {t("novel.snapshot.viewButton")}
                     </button>
                   ) : null}
-                  {novelMode && project ? (
-                    <button
-                      type="button"
-                      onClick={() => {
-                        setChapterToolbarMoreOpen(false)
-                        setShowCognition(true)
-                      }}
-                      className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent"
-                    >
-                      {t("novel.cognition.title")}
-                    </button>
-                  ) : null}
                 </div>
               ) : null}
             </div>
@@ -1526,16 +1513,6 @@ export function PreviewPanel() {
               {t("novel.snapshot.viewButton")}
             </button>
           ) : null}
-          {!chapterToolbarCompact && novelMode && project ? (
-            <button
-              type="button"
-              onClick={() => setShowCognition(true)}
-              className="shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-accent"
-              title={t("preview.cognitionTitle")}
-            >
-              {t("novel.cognition.title")}
-            </button>
-          ) : null}
           <button
             onClick={() => setSelectedFile(null)}
             className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent"

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

@@ -50,8 +50,6 @@ import {
   readFile,
   writeFile,
 } from "@/commands/fs";
-import { countChapterBodyWords } from "@/lib/chapter-word-count";
-import { buildChapterTotalWordCountLabel } from "@/lib/chapter-display";
 import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils";
 import {
   loadDismantlingLibrary,
@@ -877,7 +875,6 @@ export function SidebarPanel() {
   const setFileTree = useWikiStore((s) => s.setFileTree);
   const setChatExpanded = useWikiStore((s) => s.setChatExpanded);
   const enqueueReferenceTokens = useChatStore((s) => s.enqueueReferenceTokens);
-  const dataVersion = useWikiStore((s) => s.dataVersion);
   const [mode, setMode] = useState<"knowledge" | "files">("knowledge");
   const [refreshKey, setRefreshKey] = useState(0);
   const [pendingCreate, setPendingCreate] =
@@ -888,9 +885,6 @@ export function SidebarPanel() {
   const [memoryData, setMemoryData] = useState<MemoryCenterData | null>(null);
   const [memoryLoading, setMemoryLoading] = useState(false);
   const [memoryError, setMemoryError] = useState<string | null>(null);
-  const [sidebarTotalWordCount, setSidebarTotalWordCount] = useState<
-    number | null
-  >(null);
   const [outlineImporting, setOutlineImporting] = useState(false);
   const [outlineImportMenuOpen, setOutlineImportMenuOpen] = useState(false);
   const outlineImportMenuRef = useRef<HTMLDivElement | null>(null);
@@ -931,44 +925,6 @@ export function SidebarPanel() {
 
   const isChapter = mode === "knowledge";
 
-  useEffect(() => {
-    if (!project || !isChapter) {
-      setSidebarTotalWordCount(null);
-      return;
-    }
-
-    let cancelled = false;
-
-    const loadSidebarTotalWordCount = async () => {
-      try {
-        const chapterNodes = await listDirectory(
-          `${normalizePath(project.path)}/wiki/chapters`,
-        );
-        const files = flattenMdFiles(chapterNodes);
-        const contents = await Promise.all(
-          files.map((file) => readFile(file.path).catch(() => "")),
-        );
-        const total = contents.reduce(
-          (sum, markdown) => sum + countChapterBodyWords(markdown),
-          0,
-        );
-        if (!cancelled) {
-          setSidebarTotalWordCount(total);
-        }
-      } catch {
-        if (!cancelled) {
-          setSidebarTotalWordCount(null);
-        }
-      }
-    };
-
-    void loadSidebarTotalWordCount();
-
-    return () => {
-      cancelled = true;
-    };
-  }, [dataVersion, isChapter, project]);
-
   useEffect(() => {
     if (!pendingCreate?.kind) return;
     if (
@@ -1718,11 +1674,6 @@ export function SidebarPanel() {
               helpKey={isChapter ? "chapter" : "outline"}
               helpTitle={isChapter ? "章节功能使用说明" : "大纲功能使用说明"}
             />
-            {isChapter && sidebarTotalWordCount !== null ? (
-              <span className="shrink-0 text-xs font-normal text-muted-foreground">
-                {buildChapterTotalWordCountLabel(sidebarTotalWordCount)}
-              </span>
-            ) : null}
           </div>
         </div>
         <div className="flex items-center gap-1">

+ 2 - 3
src/components/layout/workspace-top-bars.spec.ts

@@ -15,9 +15,8 @@ describe("workspace top bars", () => {
     expect(outlineChatSource).toContain('className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2"')
   })
 
-  it("keeps the total word count inline after the book title instead of as a second header line", () => {
-    expect(sidebarSource).toContain("buildChapterTotalWordCountLabel(sidebarTotalWordCount)")
+  it("does not keep the total word count in the chapter sidebar header", () => {
+    expect(sidebarSource).not.toContain("buildChapterTotalWordCountLabel(sidebarTotalWordCount)")
     expect(sidebarSource).toContain('className="flex min-w-0 items-center gap-1.5 text-sm font-semibold"')
-    expect(sidebarSource).toContain('className="shrink-0 text-xs font-normal text-muted-foreground"')
   })
 })

+ 25 - 0
src/components/novel/soul-doc-editor.test.tsx

@@ -0,0 +1,25 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "soul-doc-editor.tsx"), "utf8")
+
+describe("SoulDocEditor source", () => {
+  it("uses the structured project soul style store instead of editing only soul.md directly", () => {
+    expect(source).toContain("loadProjectSoulStyleStore")
+    expect(source).toContain("saveProjectSoulStyleStore")
+    expect(source).toContain("createEmptyProjectSoulStyle")
+  })
+
+  it("renders multiple style items with a single enabled switch", () => {
+    expect(source).toContain("styles.map")
+    expect(source).toContain("handleEnableStyle")
+    expect(source).toContain('aria-label={`启用写作风格:${style.name}`}')
+    expect(source).toContain("新增写作风格")
+  })
+
+  it("describes project soul as project rules rather than only writing style", () => {
+    expect(source).toContain("核心气质、创作边界、叙事原则和长期写作总则")
+    expect(source).not.toContain("定义整个写作 AI 的气质、叙事节奏和语言风格")
+  })
+})

+ 232 - 19
src/components/novel/soul-doc-editor.tsx

@@ -1,27 +1,148 @@
-import { useState, useEffect } from "react"
+import { useEffect, useMemo, useState } from "react"
+import { CheckCircle2, Plus, Trash2 } from "lucide-react"
 import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Textarea } from "@/components/ui/textarea"
 import { useWikiStore } from "@/stores/wiki-store"
-import { readSoulDoc, writeSoulDoc } from "@/lib/novel/soul-doc"
+import {
+  createEmptyProjectSoulStyle,
+  loadProjectSoulStyleStore,
+  saveProjectSoulStyleStore,
+  type ProjectSoulStyle,
+  type ProjectSoulStyleStore,
+} from "@/lib/novel/project-soul-style-store"
 import i18n from "@/i18n"
 
 export function SoulDocEditor() {
   const project = useWikiStore((s) => s.project)
-  const [content, setContent] = useState("")
+  const [store, setStore] = useState<ProjectSoulStyleStore | null>(null)
+  const [selectedStyleId, setSelectedStyleId] = useState<string | null>(null)
   const [saving, setSaving] = useState(false)
   const [message, setMessage] = useState("")
+  const styles = store?.styles ?? []
+  const selectedStyle = useMemo(
+    () => styles.find((style) => style.id === selectedStyleId) ?? styles[0] ?? null,
+    [selectedStyleId, styles],
+  )
 
   useEffect(() => {
     if (!project) return
-    readSoulDoc(project.path).then(setContent).catch(() => setContent(""))
+    let cancelled = false
+    loadProjectSoulStyleStore(project.path)
+      .then((loadedStore) => {
+        if (cancelled) return
+        setStore(loadedStore)
+        setSelectedStyleId(loadedStore.enabledStyleId ?? loadedStore.styles[0]?.id ?? null)
+      })
+      .catch(() => {
+        if (cancelled) return
+        setStore(null)
+        setSelectedStyleId(null)
+      })
+    return () => {
+      cancelled = true
+    }
   }, [project?.path])
 
+  function updateStyles(updater: (styles: ProjectSoulStyle[]) => ProjectSoulStyle[]) {
+    setStore((current) => {
+      if (!current) return current
+      const nextStyles = updater(current.styles)
+      const enabledStyleId = nextStyles.find((style) => style.enabled)?.id ?? nextStyles[0]?.id ?? null
+      return {
+        ...current,
+        enabledStyleId,
+        styles: nextStyles.map((style) => ({
+          ...style,
+          enabled: style.id === enabledStyleId,
+        })),
+      }
+    })
+  }
+
+  function handleAddStyle() {
+    const style = createEmptyProjectSoulStyle(`写作风格 ${styles.length + 1}`)
+    setStore((current) => {
+      if (!current) {
+        return {
+          version: 1,
+          enabledStyleId: null,
+          styles: [style],
+        }
+      }
+      return {
+        ...current,
+        styles: [...current.styles, style],
+      }
+    })
+    setSelectedStyleId(style.id)
+    setMessage("")
+  }
+
+  function handleEnableStyle(styleId: string) {
+    setSelectedStyleId(styleId)
+    setStore((current) => {
+      if (!current) return current
+      return {
+        ...current,
+        enabledStyleId: styleId,
+        styles: current.styles.map((style) => ({
+          ...style,
+          enabled: style.id === styleId,
+          updatedAt: style.id === styleId ? Date.now() : style.updatedAt,
+        })),
+      }
+    })
+    setMessage("")
+  }
+
+  function handleDeleteStyle(styleId: string) {
+    if (styles.length <= 1) {
+      setMessage("至少保留一个写作风格")
+      return
+    }
+    const deletingEnabled = store?.enabledStyleId === styleId
+    const remaining = styles.filter((style) => style.id !== styleId)
+    const nextSelectedId = selectedStyleId === styleId ? remaining[0]?.id ?? null : selectedStyleId
+    setStore((current) => {
+      if (!current) return current
+      const enabledStyleId = deletingEnabled ? remaining[0]?.id ?? null : current.enabledStyleId
+      return {
+        ...current,
+        enabledStyleId,
+        styles: remaining.map((style) => ({
+          ...style,
+          enabled: style.id === enabledStyleId,
+        })),
+      }
+    })
+    setSelectedStyleId(nextSelectedId)
+    setMessage("")
+  }
+
+  function handleStyleFieldChange(styleId: string, patch: Partial<Pick<ProjectSoulStyle, "name" | "content">>) {
+    updateStyles((currentStyles) =>
+      currentStyles.map((style) =>
+        style.id === styleId
+          ? {
+              ...style,
+              ...patch,
+              updatedAt: Date.now(),
+            }
+          : style,
+      ),
+    )
+    setMessage("")
+  }
+
   async function handleSave() {
-    if (!project) return
+    if (!project || !store) return
     setSaving(true)
     try {
-      await writeSoulDoc(project.path, content)
+      const savedStore = await saveProjectSoulStyleStore(project.path, store)
+      setStore(savedStore)
+      setSelectedStyleId(savedStore.enabledStyleId ?? savedStore.styles[0]?.id ?? null)
       setMessage(i18n.t("novel.soul.saveProjectSoulSuccess"))
     } catch {
       setMessage(i18n.t("novel.soul.saveProjectSoulFailed"))
@@ -31,25 +152,117 @@ export function SoulDocEditor() {
   }
 
   return (
-    <div className="flex flex-col gap-4 p-4">
-      <div>
-        <Label>{i18n.t("novel.soul.projectSoul")}</Label>
-        <p className="text-sm text-muted-foreground mt-1">
-          {i18n.t("novel.soul.projectSoulDesc")}
+    <div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-8 py-7">
+      <div className="space-y-1">
+        <Label className="text-base font-semibold">{i18n.t("novel.soul.projectSoul")}</Label>
+        <p className="max-w-2xl text-sm leading-6 text-muted-foreground">
+          定义本项目的核心气质、创作边界、叙事原则和长期写作总则。当前启用的写作风格会同步写入 soul.md,并进入 AI 会话、大纲和推演上下文。
         </p>
       </div>
-      <Textarea
-        className="min-h-[300px] font-mono text-sm"
-        placeholder={i18n.t("novel.soul.projectSoulPlaceholder")}
-        value={content}
-        onChange={(e) => setContent(e.target.value)}
-      />
+
+      <div className="grid min-h-[34rem] gap-4 lg:grid-cols-[16rem_minmax(0,1fr)]">
+        <div className="flex min-h-0 flex-col rounded-md border bg-background/35">
+          <div className="flex items-center justify-between border-b px-3 py-2">
+            <div className="text-sm font-medium">写作风格</div>
+            <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleAddStyle}>
+              <Plus className="mr-1 h-3.5 w-3.5" />
+              新增写作风格
+            </Button>
+          </div>
+          <div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
+            {styles.map((style) => (
+              <button
+                key={style.id}
+                type="button"
+                onClick={() => setSelectedStyleId(style.id)}
+                className={`w-full rounded-md border px-3 py-2 text-left transition-colors ${
+                  selectedStyle?.id === style.id ? "border-primary/70 bg-primary/10" : "border-border bg-background/70 hover:bg-accent"
+                }`}
+              >
+                <div className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    checked={style.enabled}
+                    onChange={(event) => {
+                      event.stopPropagation()
+                      handleEnableStyle(style.id)
+                    }}
+                    onClick={(event) => event.stopPropagation()}
+                    aria-label={`启用写作风格:${style.name}`}
+                    className="h-4 w-4 shrink-0 accent-primary"
+                  />
+                  <div className="min-w-0 flex-1 truncate text-sm font-medium">{style.name || "未命名风格"}</div>
+                  {style.enabled ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-primary" /> : null}
+                </div>
+                <div className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
+                  {style.content.trim() || "还没有填写这个写作风格"}
+                </div>
+              </button>
+            ))}
+          </div>
+        </div>
+
+        <div className="flex min-h-0 flex-col rounded-md border bg-background/35 p-4">
+          {selectedStyle ? (
+            <>
+              <div className="mb-3 flex items-start gap-3">
+                <div className="min-w-0 flex-1 space-y-1">
+                  <Label className="text-xs text-muted-foreground">风格名称</Label>
+                  <Input
+                    value={selectedStyle.name}
+                    onChange={(event) => handleStyleFieldChange(selectedStyle.id, { name: event.target.value })}
+                    placeholder="例如:冷峻写实、轻松吐槽、史诗感叙事"
+                  />
+                </div>
+                <Button
+                  type="button"
+                  variant={selectedStyle.enabled ? "default" : "outline"}
+                  size="sm"
+                  className="mt-5 shrink-0"
+                  onClick={() => handleEnableStyle(selectedStyle.id)}
+                >
+                  {selectedStyle.enabled ? "已启用" : "启用"}
+                </Button>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  className="mt-5 shrink-0 px-2 text-muted-foreground"
+                  onClick={() => handleDeleteStyle(selectedStyle.id)}
+                  title="删除写作风格"
+                >
+                  <Trash2 className="h-4 w-4" />
+                </Button>
+              </div>
+              <Textarea
+                className="min-h-0 flex-1 resize-none rounded-md bg-background/60 p-4 text-sm leading-7"
+                placeholder={[
+                  "在这里填写当前写作风格的规则...",
+                  "",
+                  "例如:",
+                  "- 核心气质:克制、冷静、现实压力强",
+                  "- 叙事原则:每个场景必须推动目标或制造代价",
+                  "- 语言边界:避免华丽堆砌,少用感叹和空泛比喻",
+                  "- 节奏控制:每 500 字至少出现一个新信息点",
+                ].join("\n")}
+                value={selectedStyle.content}
+                onChange={(event) => handleStyleFieldChange(selectedStyle.id, { content: event.target.value })}
+              />
+            </>
+          ) : (
+            <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
+              请选择或新增一个写作风格
+            </div>
+          )}
+        </div>
+      </div>
+
       <div className="flex items-center gap-2">
-        <Button onClick={handleSave} disabled={saving || content.trim() === ""}>
+        <Button onClick={handleSave} disabled={saving || !store}>
           {saving ? "..." : i18n.t("novel.soul.saveProjectSoul")}
         </Button>
         {message && <span className="text-sm text-muted-foreground">{message}</span>}
       </div>
     </div>
   )
-}
+}

+ 2 - 2
src/i18n/zh.json

@@ -1175,8 +1175,8 @@
     "soul": {
       "projectSoul": "项目灵魂",
       "characterSoul": "角色灵魂",
-      "projectSoulDesc": "定义整个写作 AI 的气质、叙事节奏和语言风格",
-      "projectSoulPlaceholder": "在此编写项目级的写作灵魂描述...\n\n例如:\n- 叙事节奏:快节奏,每章 3-5 个场景切换\n- 语言风格:简洁克制,避免华丽修辞\n- 叙述气质:冷静、客观、少用感叹号\n- 密度控制:每 500 字至少包含一个新信息点",
+      "projectSoulDesc": "定义本项目的核心气质、创作边界、叙事原则和长期写作总则",
+      "projectSoulPlaceholder": "在此编写项目级写作总则...\n\n例如:\n- 核心气质:克制、冷静、现实压力强\n- 叙事原则:每个场景必须推动目标或制造代价\n- 语言边界:避免华丽堆砌,少用感叹和空泛比喻\n- 节奏控制:每 500 字至少出现一个新信息点",
       "saveProjectSoul": "保存项目灵魂",
       "saveProjectSoulSuccess": "项目灵魂已保存",
       "saveProjectSoulFailed": "项目灵魂保存失败,请检查项目文件权限后重试",

+ 7 - 2
src/lib/app-title.ts

@@ -1,6 +1,11 @@
 export const APP_NAME = "青幕AI写作"
 
-export function formatAppTitle(projectName: string | null | undefined): string {
+export function formatAppTitle(projectName: string | null | undefined, totalWordCount?: number | null): string {
   const name = projectName?.trim()
-  return name ? `${APP_NAME}|${name}` : APP_NAME
+  if (!name) return APP_NAME
+  const totalWordCountLabel =
+    typeof totalWordCount === "number" && Number.isFinite(totalWordCount)
+      ? `|总字数:${totalWordCount}字`
+      : ""
+  return `${APP_NAME}|${name}${totalWordCountLabel}`
 }

+ 92 - 0
src/lib/novel/project-soul-style-store.test.ts

@@ -0,0 +1,92 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import * as fs from "@/commands/fs"
+import {
+  createEmptyProjectSoulStyle,
+  loadProjectSoulStyleStore,
+  saveProjectSoulStyleStore,
+} from "./project-soul-style-store"
+
+vi.mock("@/commands/fs", () => ({
+  createDirectory: vi.fn(),
+  readFile: vi.fn(),
+  writeFileAtomic: vi.fn(),
+}))
+
+const mockCreateDirectory = vi.mocked(fs.createDirectory)
+const mockReadFile = vi.mocked(fs.readFile)
+const mockWriteFileAtomic = vi.mocked(fs.writeFileAtomic)
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.setSystemTime(new Date("2026-07-06T12:00:00.000Z"))
+})
+
+describe("project soul style store", () => {
+  it("migrates existing soul.md into a default enabled style when the style store is missing", async () => {
+    mockReadFile
+      .mockRejectedValueOnce(new Error("missing style store"))
+      .mockResolvedValueOnce("冷峻克制,叙事推进快")
+
+    const store = await loadProjectSoulStyleStore("/project/path")
+
+    expect(mockReadFile).toHaveBeenNthCalledWith(1, "/project/path/.qmai/project-soul-styles.json")
+    expect(mockReadFile).toHaveBeenNthCalledWith(2, "/project/path/soul.md")
+    expect(store.enabledStyleId).toBe(store.styles[0]?.id)
+    expect(store.styles[0]).toMatchObject({
+      name: "默认项目灵魂",
+      content: "冷峻克制,叙事推进快",
+    })
+  })
+
+  it("normalizes loaded styles so only the enabled style is selected", async () => {
+    mockReadFile.mockResolvedValueOnce(JSON.stringify({
+      version: 1,
+      enabledStyleId: "style-2",
+      styles: [
+        { id: "style-1", name: "轻松", content: "轻松吐槽", enabled: true, createdAt: 1, updatedAt: 1 },
+        { id: "style-2", name: "冷峻", content: "冷峻写实", enabled: false, createdAt: 2, updatedAt: 2 },
+      ],
+    }))
+
+    const store = await loadProjectSoulStyleStore("/project/path")
+
+    expect(store.enabledStyleId).toBe("style-2")
+    expect(store.styles.map((style) => [style.id, style.enabled])).toEqual([
+      ["style-1", false],
+      ["style-2", true],
+    ])
+  })
+
+  it("saves the structured store and writes the enabled style content back to soul.md", async () => {
+    const store = {
+      version: 1 as const,
+      enabledStyleId: "style-2",
+      styles: [
+        { id: "style-1", name: "轻松", content: "轻松吐槽", enabled: false, createdAt: 1, updatedAt: 1 },
+        { id: "style-2", name: "冷峻", content: "冷峻写实", enabled: true, createdAt: 2, updatedAt: 2 },
+      ],
+    }
+
+    await saveProjectSoulStyleStore("/project/path", store)
+
+    expect(mockCreateDirectory).toHaveBeenCalledWith("/project/path/.qmai")
+    expect(mockWriteFileAtomic).toHaveBeenCalledWith(
+      "/project/path/.qmai/project-soul-styles.json",
+      expect.stringContaining('"enabledStyleId": "style-2"'),
+    )
+    expect(mockWriteFileAtomic).toHaveBeenCalledWith("/project/path/soul.md", "冷峻写实")
+  })
+
+  it("creates a blank disabled style with a stable default name", () => {
+    const style = createEmptyProjectSoulStyle("新写作风格")
+
+    expect(style).toMatchObject({
+      name: "新写作风格",
+      content: "",
+      enabled: false,
+      createdAt: Date.parse("2026-07-06T12:00:00.000Z"),
+      updatedAt: Date.parse("2026-07-06T12:00:00.000Z"),
+    })
+    expect(style.id).toMatch(/^project-soul-style-/)
+  })
+})

+ 107 - 0
src/lib/novel/project-soul-style-store.ts

@@ -0,0 +1,107 @@
+import { createDirectory, readFile, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { readSoulDoc, writeSoulDoc } from "./soul-doc"
+
+export const PROJECT_SOUL_STYLE_STORE_FILENAME = "project-soul-styles.json"
+
+export interface ProjectSoulStyle {
+  id: string
+  name: string
+  content: string
+  enabled: boolean
+  createdAt: number
+  updatedAt: number
+}
+
+export interface ProjectSoulStyleStore {
+  version: 1
+  enabledStyleId: string | null
+  styles: ProjectSoulStyle[]
+}
+
+function storePath(projectPath: string): string {
+  return `${normalizePath(projectPath)}/.qmai/${PROJECT_SOUL_STYLE_STORE_FILENAME}`
+}
+
+function storeDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/.qmai`
+}
+
+function makeId(): string {
+  return `project-soul-style-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+}
+
+export function createEmptyProjectSoulStyle(name = "新写作风格"): ProjectSoulStyle {
+  const now = Date.now()
+  return {
+    id: makeId(),
+    name,
+    content: "",
+    enabled: false,
+    createdAt: now,
+    updatedAt: now,
+  }
+}
+
+function createDefaultStore(content = ""): ProjectSoulStyleStore {
+  const style: ProjectSoulStyle = {
+    ...createEmptyProjectSoulStyle("默认项目灵魂"),
+    content,
+    enabled: true,
+  }
+  return {
+    version: 1,
+    enabledStyleId: style.id,
+    styles: [style],
+  }
+}
+
+export function normalizeProjectSoulStyleStore(input: Partial<ProjectSoulStyleStore> | null | undefined): ProjectSoulStyleStore {
+  const rawStyles = Array.isArray(input?.styles) ? input.styles : []
+  if (rawStyles.length === 0) return createDefaultStore("")
+
+  const styles: ProjectSoulStyle[] = rawStyles.map((style, index) => {
+    const now = Date.now()
+    return {
+      id: typeof style.id === "string" && style.id.trim() ? style.id : makeId(),
+      name: typeof style.name === "string" && style.name.trim() ? style.name.trim() : `写作风格 ${index + 1}`,
+      content: typeof style.content === "string" ? style.content : "",
+      enabled: Boolean(style.enabled),
+      createdAt: typeof style.createdAt === "number" ? style.createdAt : now,
+      updatedAt: typeof style.updatedAt === "number" ? style.updatedAt : now,
+    }
+  })
+
+  const requestedEnabledId = typeof input?.enabledStyleId === "string" ? input.enabledStyleId : null
+  const enabledStyleId = styles.some((style) => style.id === requestedEnabledId)
+    ? requestedEnabledId
+    : styles.find((style) => style.enabled)?.id ?? styles[0]?.id ?? null
+
+  return {
+    version: 1,
+    enabledStyleId,
+    styles: styles.map((style) => ({
+      ...style,
+      enabled: style.id === enabledStyleId,
+    })),
+  }
+}
+
+export async function loadProjectSoulStyleStore(projectPath: string): Promise<ProjectSoulStyleStore> {
+  try {
+    const raw = await readFile(storePath(projectPath))
+    return normalizeProjectSoulStyleStore(JSON.parse(raw) as Partial<ProjectSoulStyleStore>)
+  } catch {
+    const legacySoulDoc = await readSoulDoc(projectPath)
+    return createDefaultStore(legacySoulDoc)
+  }
+}
+
+export async function saveProjectSoulStyleStore(projectPath: string, store: ProjectSoulStyleStore): Promise<ProjectSoulStyleStore> {
+  const normalized = normalizeProjectSoulStyleStore(store)
+  await createDirectory(storeDir(projectPath))
+  await writeFileAtomic(storePath(projectPath), JSON.stringify(normalized, null, 2))
+  const enabledStyle = normalized.styles.find((style) => style.id === normalized.enabledStyleId)
+  await writeSoulDoc(projectPath, enabledStyle?.content ?? "")
+  return normalized
+}

+ 22 - 0
xiangmulinghunfengge-分支说明.md

@@ -0,0 +1,22 @@
+# xiangmulinghunfengge 分支说明
+
+## 分支目标
+
+将项目灵魂编辑器升级为多个写作风格项,每个风格项带启用开关,同一时间只允许启用一个写作风格,并继续兼容现有 `soul.md` 调用链。
+
+## 使用要求
+
+1. 旧项目已有的 `soul.md` 内容不能丢失。
+2. 打开的写作风格需要同步写回 `soul.md`,保证 AI 会话、大纲、推演等现有流程继续生效。
+3. 多个写作风格之间只能单选启用,未启用项不进入 AI 上下文。
+4. 项目灵魂说明文案要和拆书库文风区分开。
+5. 本次用户明确要求先不打包。
+
+## 更新记录
+
+- 20260706:创建分支说明,准备实现项目灵魂多写作风格。
+- 20260706-2044:新增项目灵魂多写作风格存储与编辑界面,启用项单选并同步写回 `soul.md`;按用户要求本次未打包。
+
+## Git 提交状态
+
+未提交。

+ 21 - 0
zongzishubiaotilan-分支说明.md

@@ -0,0 +1,21 @@
+# zongzishubiaotilan 分支说明
+
+## 分支目标
+
+将章节总字数从左侧章节栏标题区域移动到应用窗口标题栏,显示在项目名后面。
+
+## 使用要求
+
+1. 窗口标题显示格式为:`青幕AI写作|项目名|总字数:N字`。
+2. 左侧章节栏标题只保留“章节”和帮助入口,不再显示总字数。
+3. 总字数仍统计 `wiki/chapters` 下所有章节正文内容。
+4. 不修改无关 UI 和业务流程。
+
+## 更新记录
+
+- 20260706:创建分支说明,准备修复总字数显示位置。
+- 20260706-2007:修复章节预览标题栏右侧工具按钮贴近字数的问题,恢复全屏/宽屏时靠右显示。
+
+## Git 提交状态
+
+未提交。