App.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import { useState, useEffect } from "react"
  2. import i18n from "@/i18n"
  3. import { useWikiStore } from "@/stores/wiki-store"
  4. import { useReviewStore } from "@/stores/review-store"
  5. import { isTauri, pickDirectory } from "@/lib/platform"
  6. import { useChatStore } from "@/stores/chat-store"
  7. import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
  8. 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"
  9. import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
  10. import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
  11. import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
  12. import { checkForAppUpdate } from "@/lib/app-updater"
  13. import { initAnalytics } from "@/lib/analytics"
  14. import { AppLayout } from "@/components/layout/app-layout"
  15. import { WelcomeScreen } from "@/components/project/welcome-screen"
  16. import { CreateProjectDialog } from "@/components/project/create-project-dialog"
  17. import { formatAppTitle } from "@/lib/app-title"
  18. import { resetProjectState } from "@/lib/reset-project-state"
  19. import { LLM_PRESETS } from "@/components/settings/llm-presets"
  20. import { resolveConfig } from "@/components/settings/preset-resolver"
  21. import { toast } from "@/lib/toast"
  22. import type { WikiProject } from "@/types/wiki"
  23. import { applyTheme, watchSystemTheme } from "@/lib/theme-utils"
  24. import { applyUiFontFamily } from "@/lib/font-settings"
  25. import { applyVisualStyle } from "@/lib/visual-style-settings"
  26. import { normalizePath } from "@/lib/path-utils"
  27. import { countChapterBodyWords } from "@/lib/chapter-word-count"
  28. import { flattenMdFiles } from "@/lib/novel/chapter-utils"
  29. import { runUserMemoryMaintenance } from "@/lib/user-memory/maintenance"
  30. function App() {
  31. const project = useWikiStore((s) => s.project)
  32. const setProject = useWikiStore((s) => s.setProject)
  33. const setFileTree = useWikiStore((s) => s.setFileTree)
  34. const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
  35. const setActiveView = useWikiStore((s) => s.setActiveView)
  36. const uiFontSizeScale = useWikiStore((s) => s.uiFontSizeScale)
  37. const uiFontFamily = useWikiStore((s) => s.uiFontFamily)
  38. const visualStyle = useWikiStore((s) => s.visualStyle)
  39. const communitySummaryError = useWikiStore((s) => s.communitySummaryError)
  40. const setCommunitySummaryError = useWikiStore((s) => s.setCommunitySummaryError)
  41. const dataVersion = useWikiStore((s) => s.dataVersion)
  42. const [showCreateDialog, setShowCreateDialog] = useState(false)
  43. const [loading, setLoading] = useState(true)
  44. const [appTitleTotalWordCount, setAppTitleTotalWordCount] = useState<number | null>(null)
  45. useEffect(() => {
  46. runUserMemoryMaintenance()
  47. }, [])
  48. function isCurrentProject(proj: WikiProject): boolean {
  49. const current = useWikiStore.getState().project
  50. if (!current || current.id !== proj.id) return false
  51. return normalizePath(current.path) === normalizePath(proj.path)
  52. }
  53. async function hydrateProjectSideStores(proj: WikiProject): Promise<void> {
  54. try {
  55. const savedReview = await loadReviewItems(proj.path)
  56. if (savedReview.length > 0 && isCurrentProject(proj)) {
  57. useReviewStore.getState().setItems(savedReview)
  58. }
  59. } catch (err) {
  60. console.warn("[startup] 加载审查项失败:", err)
  61. }
  62. try {
  63. const savedChat = await loadChatHistory(proj.path)
  64. if (!isCurrentProject(proj)) return
  65. useChatStore.getState().setLoadedRunStates(savedChat.runStates)
  66. if (savedChat.conversations.length > 0) {
  67. useChatStore.getState().setConversations(savedChat.conversations)
  68. useChatStore.getState().setMessages(savedChat.messages)
  69. const sorted = [...savedChat.conversations].sort((a, b) => b.updatedAt - a.updatedAt)
  70. if (sorted[0]) {
  71. useChatStore.getState().setActiveConversation(sorted[0].id)
  72. }
  73. }
  74. } catch (err) {
  75. console.warn("[startup] 加载聊天历史失败:", err)
  76. }
  77. }
  78. async function hydrateScheduledImportAfterOpen(proj: WikiProject): Promise<void> {
  79. try {
  80. const savedScheduledImport = await loadScheduledImportConfig(proj.path)
  81. if (!isCurrentProject(proj)) return
  82. if (savedScheduledImport) {
  83. let path = savedScheduledImport.path
  84. if (path && !path.startsWith("/") && !path.match(/^[a-zA-Z]:[/\\]/)) {
  85. path = `${proj.path}/${path}`
  86. }
  87. useWikiStore.getState().setScheduledImportConfig({
  88. ...savedScheduledImport,
  89. path,
  90. })
  91. }
  92. if (!isTauri()) return
  93. const scheduledImportConfig = useWikiStore.getState().scheduledImportConfig
  94. if (!isCurrentProject(proj)) return
  95. if (scheduledImportConfig.enabled && scheduledImportConfig.path && scheduledImportConfig.interval > 0) {
  96. const { startScheduledImport } = await import("@/lib/scheduled-import")
  97. if (!isCurrentProject(proj)) return
  98. startScheduledImport(proj, scheduledImportConfig)
  99. }
  100. } catch (err) {
  101. console.warn("[startup] 加载定时导入配置失败:", err)
  102. }
  103. }
  104. async function hydrateProjectBackgroundServices(proj: WikiProject): Promise<void> {
  105. if (!isTauri()) return
  106. if (!isCurrentProject(proj)) return
  107. try {
  108. const { restoreQueue } = await import("@/lib/ingest-queue")
  109. if (!isCurrentProject(proj)) return
  110. await restoreQueue(proj.id, proj.path)
  111. } catch (err) {
  112. console.error("恢复摄取队列失败:", err)
  113. }
  114. if (!isCurrentProject(proj)) return
  115. try {
  116. const { restoreQueue: restoreDedupQueue } = await import("@/lib/dedup-queue")
  117. await restoreDedupQueue(proj.id, proj.path)
  118. } catch (err) {
  119. console.error("恢复去重队列失败:", err)
  120. }
  121. if (!isCurrentProject(proj)) return
  122. try {
  123. const { startProjectFileSync, stopProjectFileSync } = await import("@/lib/project-file-sync")
  124. const config = await loadSourceWatchConfig(proj.id, proj.path)
  125. if (!isCurrentProject(proj)) return
  126. useWikiStore.getState().setSourceWatchConfig(config)
  127. if (config.enabled) {
  128. startProjectFileSync(proj, config).catch((err) =>
  129. console.error("启动项目文件同步失败:", err)
  130. )
  131. } else {
  132. stopProjectFileSync().catch(() => {})
  133. }
  134. } catch (err) {
  135. console.error("配置项目文件同步失败:", err)
  136. }
  137. }
  138. async function hydrateDeferredProjectState(proj: WikiProject): Promise<void> {
  139. await hydrateProjectBackgroundServices(proj)
  140. if (!isCurrentProject(proj)) return
  141. await hydrateScheduledImportAfterOpen(proj)
  142. if (!isCurrentProject(proj)) return
  143. await hydrateProjectSideStores(proj)
  144. }
  145. useEffect(() => {
  146. document.documentElement.style.fontSize = `${Math.round(uiFontSizeScale * 100)}%`
  147. }, [uiFontSizeScale])
  148. useEffect(() => {
  149. applyUiFontFamily(uiFontFamily)
  150. }, [uiFontFamily])
  151. useEffect(() => {
  152. applyVisualStyle(visualStyle)
  153. }, [visualStyle])
  154. // 监听社区摘要生成错误,弹窗提示
  155. useEffect(() => {
  156. if (communitySummaryError) {
  157. toast.error(i18n.t("novel.settings.communitySummaryFailed", { message: communitySummaryError }))
  158. setCommunitySummaryError(null)
  159. }
  160. }, [communitySummaryError, setCommunitySummaryError])
  161. // Set up auto-save once on mount
  162. useEffect(() => {
  163. setupAutoSave()
  164. // 注册 Tauri 窗口关闭前保存
  165. let unlisten: (() => void) | undefined
  166. let isClosing = false // 防止递归关闭
  167. if (isTauri()) {
  168. import("@tauri-apps/api/window").then(({ getCurrentWindow }) => {
  169. getCurrentWindow().onCloseRequested(async (event) => {
  170. // 防止递归:close() 会再次触发 onCloseRequested
  171. if (isClosing) return
  172. isClosing = true
  173. // 阻止窗口立即关闭,等待保存完成
  174. event.preventDefault()
  175. // 关闭前执行最终保存,防止丢失最后几秒的数据
  176. const project = useWikiStore.getState().project
  177. if (project) {
  178. const chatState = useChatStore.getState()
  179. if (chatState.conversations.length > 0) {
  180. await saveChatHistory(
  181. project.path,
  182. chatState.conversations,
  183. chatState.messages,
  184. chatState.maxHistoryMessages,
  185. chatState.runStates,
  186. ).catch((err) => console.error("关闭前保存聊天历史失败:", err))
  187. }
  188. const reviewState = useReviewStore.getState()
  189. if (reviewState.items.length > 0) {
  190. await saveReviewItems(project.path, reviewState.items)
  191. .catch((err) => console.error("关闭前保存审查项失败:", err))
  192. }
  193. }
  194. // 保存完成后手动关闭窗口
  195. await getCurrentWindow().close()
  196. }).then((fn) => { unlisten = fn })
  197. })
  198. }
  199. return () => {
  200. teardownAutoSave()
  201. unlisten?.()
  202. }
  203. }, [])
  204. // Auto-open last project on startup
  205. useEffect(() => {
  206. async function init() {
  207. try {
  208. // 先加载和应用主题
  209. const savedTheme = await loadTheme()
  210. const themeToUse = savedTheme ?? "system"
  211. useWikiStore.getState().setTheme(themeToUse)
  212. applyTheme(themeToUse)
  213. const savedVisualStyle = await loadVisualStyle()
  214. const visualStyleToUse = savedVisualStyle ?? useWikiStore.getState().visualStyle
  215. useWikiStore.getState().setVisualStyle(visualStyleToUse)
  216. applyVisualStyle(visualStyleToUse)
  217. const savedUiFontFamily = await loadUiFontFamily()
  218. if (savedUiFontFamily) {
  219. useWikiStore.getState().setUiFontFamily(savedUiFontFamily)
  220. applyUiFontFamily(savedUiFontFamily)
  221. }
  222. const savedConfig = await loadLlmConfig()
  223. if (savedConfig) {
  224. useWikiStore.getState().setLlmConfig(savedConfig)
  225. }
  226. const savedAiChatModel = await loadAiChatModel()
  227. if (savedAiChatModel) {
  228. useWikiStore.getState().setAiChatModel(savedAiChatModel)
  229. }
  230. await initializeAiOutlineModelFromStorage()
  231. const savedDefaultLlmModel = await loadDefaultLlmModel()
  232. if (savedDefaultLlmModel) {
  233. useWikiStore.getState().setDefaultLlmModel(savedDefaultLlmModel)
  234. }
  235. const savedProviderConfigs = await loadProviderConfigs()
  236. if (savedProviderConfigs) {
  237. useWikiStore.getState().setProviderConfigs(savedProviderConfigs)
  238. }
  239. const savedActivePreset = await loadActivePresetId()
  240. if (savedActivePreset) {
  241. useWikiStore.getState().setActivePresetId(savedActivePreset)
  242. // Re-resolve the active preset's LlmConfig from (preset defaults
  243. // + saved overrides). Without this, preset default updates
  244. // (e.g. a corrected Anthropic model ID shipped in a release)
  245. // never reach users who are relying on defaults — their stored
  246. // `llmConfig` snapshot from a previous launch would keep the
  247. // old value. Overrides still win, so an explicit user choice
  248. // is preserved.
  249. const preset = LLM_PRESETS.find((p) => p.id === savedActivePreset)
  250. if (preset) {
  251. const currentFallback = useWikiStore.getState().llmConfig
  252. const override = (savedProviderConfigs ?? {})[savedActivePreset]
  253. const resolved = resolveConfig(preset, override, currentFallback)
  254. useWikiStore.getState().setLlmConfig(resolved)
  255. await saveLlmConfig(resolved)
  256. }
  257. }
  258. const savedEmbeddingConfig = await loadEmbeddingConfig()
  259. if (savedEmbeddingConfig) {
  260. useWikiStore.getState().setEmbeddingConfig(savedEmbeddingConfig)
  261. }
  262. const savedMcpConfig = await loadMcpConfig()
  263. useWikiStore.getState().setMcpConfig(savedMcpConfig)
  264. const savedProxy = await loadProxyConfig()
  265. if (savedProxy) {
  266. useWikiStore.getState().setProxyConfig(savedProxy)
  267. }
  268. const savedLang = await loadLanguage()
  269. if (savedLang) {
  270. await i18n.changeLanguage(savedLang)
  271. }
  272. const savedNovelMode = await loadNovelMode()
  273. if (savedNovelMode !== null) {
  274. useWikiStore.getState().setNovelMode(savedNovelMode)
  275. }
  276. const savedMaxHistoryMessages = await loadMaxHistoryMessages()
  277. if (savedMaxHistoryMessages !== null) {
  278. useChatStore.getState().setMaxHistoryMessages(savedMaxHistoryMessages)
  279. }
  280. const savedRevisionFeedbackWindowConfig = await loadRevisionFeedbackWindowConfig()
  281. useWikiStore.getState().setRevisionFeedbackWindowConfig(savedRevisionFeedbackWindowConfig)
  282. const lastProject = await getLastProject()
  283. if (lastProject) {
  284. try {
  285. const proj = await openProject(lastProject.path)
  286. await handleProjectOpened(proj)
  287. } catch (err) {
  288. console.error("打开上次项目失败:", err)
  289. }
  290. }
  291. } catch (err) {
  292. console.error("应用初始化失败:", err)
  293. } finally {
  294. setLoading(false)
  295. void checkForAppUpdate()
  296. void initAnalytics()
  297. }
  298. }
  299. init()
  300. }, [])
  301. // 监听系统主题变化,当设置为跟随系统时自动切换
  302. const theme = useWikiStore((s) => s.theme)
  303. useEffect(() => {
  304. if (theme === "system") {
  305. applyTheme("system")
  306. const unwatch = watchSystemTheme(() => {
  307. applyTheme("system")
  308. })
  309. return unwatch
  310. } else {
  311. applyTheme(theme)
  312. }
  313. }, [theme])
  314. useEffect(() => {
  315. if (!project?.path) {
  316. setAppTitleTotalWordCount(null)
  317. return
  318. }
  319. let cancelled = false
  320. const loadAppTitleTotalWordCount = async () => {
  321. try {
  322. const chapterNodes = await listDirectory(`${normalizePath(project.path)}/wiki/chapters`)
  323. const files = flattenMdFiles(chapterNodes)
  324. const contents = await Promise.all(
  325. files.map((file) => readFile(file.path).catch(() => "")),
  326. )
  327. const total = contents.reduce(
  328. (sum, markdown) => sum + countChapterBodyWords(markdown),
  329. 0,
  330. )
  331. if (!cancelled) setAppTitleTotalWordCount(total)
  332. } catch {
  333. if (!cancelled) setAppTitleTotalWordCount(null)
  334. }
  335. }
  336. void loadAppTitleTotalWordCount()
  337. return () => {
  338. cancelled = true
  339. }
  340. }, [dataVersion, project?.path])
  341. useEffect(() => {
  342. const title = formatAppTitle(project?.name, appTitleTotalWordCount)
  343. document.title = title
  344. if (isTauri()) {
  345. import("@tauri-apps/api/window")
  346. .then(({ getCurrentWindow }) => getCurrentWindow().setTitle(title))
  347. .catch(() => {})
  348. }
  349. }, [appTitleTotalWordCount, project?.name])
  350. async function handleProjectOpened(proj: WikiProject) {
  351. await resetProjectState()
  352. setProject(proj)
  353. useWikiStore.getState().clearTransientTaskState()
  354. // 默认开启小说模式
  355. useWikiStore.getState().setNovelMode(true)
  356. const projectNovelConfig = await loadNovelConfig(proj.id, proj.path)
  357. if (projectNovelConfig && isCurrentProject(proj)) {
  358. useWikiStore.getState().setNovelConfig(projectNovelConfig)
  359. }
  360. const projectRevisionFeedbackWindowConfig = await loadRevisionFeedbackWindowConfig(proj.id, proj.path)
  361. if (isCurrentProject(proj)) {
  362. useWikiStore.getState().setRevisionFeedbackWindowConfig(projectRevisionFeedbackWindowConfig)
  363. }
  364. setSelectedFile(null)
  365. setActiveView("wiki")
  366. useWikiStore.getState().setScheduledImportConfig({
  367. enabled: false,
  368. path: `${proj.path}/raw/sources`,
  369. interval: 60,
  370. lastScan: null,
  371. })
  372. useWikiStore.getState().bumpDataVersion()
  373. await saveLastProject(proj)
  374. // 自动打开最后阅读的章节和AI会话窗口
  375. try {
  376. if (isCurrentProject(proj)) {
  377. const lastChapterPath = await loadLastReadChapter()
  378. if (isCurrentProject(proj) && lastChapterPath) {
  379. const normalizedPath = lastChapterPath.replace(/\\/g, "/")
  380. if (normalizedPath.includes("/wiki/chapters/")) {
  381. const exists = await fileExists(lastChapterPath)
  382. if (exists && isCurrentProject(proj)) {
  383. setSelectedFile(lastChapterPath)
  384. }
  385. }
  386. }
  387. }
  388. } catch (err) {
  389. console.error("加载最后阅读章节失败:", err)
  390. }
  391. if (isCurrentProject(proj)) {
  392. useWikiStore.getState().setChatExpanded(true)
  393. }
  394. // 文件树由 AppLayout 通过 refreshProjectFileTree 加载;重队列/定时导入/审查/聊天后置 hydration。
  395. void hydrateDeferredProjectState(proj)
  396. }
  397. async function handleSelectRecent(proj: WikiProject) {
  398. try {
  399. const validated = await openProject(proj.path)
  400. await handleProjectOpened(validated)
  401. } catch (err) {
  402. window.alert(`打开项目失败:${err}`)
  403. }
  404. }
  405. async function handleOpenProject() {
  406. const path = await pickDirectory()
  407. if (!path) return
  408. try {
  409. const proj = await openProject(path)
  410. await handleProjectOpened(proj)
  411. } catch (err) {
  412. window.alert(`打开项目失败:${err}`)
  413. }
  414. }
  415. async function handleSwitchProject() {
  416. // Stop scheduled import before switching projects
  417. import("@/lib/scheduled-import").then(({ stopScheduledImport }) => {
  418. stopScheduledImport()
  419. }).catch(() => {})
  420. // Save current project's scheduled import config before clearing
  421. const currentProject = useWikiStore.getState().project
  422. if (currentProject) {
  423. const currentConfig = useWikiStore.getState().scheduledImportConfig
  424. saveScheduledImportConfig(currentProject.path, currentConfig).catch(() => {})
  425. }
  426. // Clear all per-project state BEFORE flipping back to the welcome screen
  427. // so old data cannot leak in via any async render pass.
  428. await resetProjectState()
  429. setProject(null)
  430. setFileTree([])
  431. setSelectedFile(null)
  432. }
  433. if (loading) {
  434. return (
  435. <div className="flex h-screen items-center justify-center bg-background text-muted-foreground">
  436. Loading...
  437. </div>
  438. )
  439. }
  440. if (!project) {
  441. return (
  442. <>
  443. <WelcomeScreen
  444. onCreateProject={() => setShowCreateDialog(true)}
  445. onOpenProject={handleOpenProject}
  446. onSelectProject={handleSelectRecent}
  447. />
  448. <CreateProjectDialog
  449. open={showCreateDialog}
  450. onOpenChange={setShowCreateDialog}
  451. onCreated={handleProjectOpened}
  452. />
  453. </>
  454. )
  455. }
  456. return (
  457. <>
  458. <AppLayout onSwitchProject={handleSwitchProject} />
  459. <CreateProjectDialog
  460. open={showCreateDialog}
  461. onOpenChange={setShowCreateDialog}
  462. onCreated={handleProjectOpened}
  463. />
  464. </>
  465. )
  466. }
  467. export default App