settings-view.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. import { useCallback, useEffect, useMemo, useState } from "react"
  2. import {
  3. Bot,
  4. BookOpen,
  5. Database,
  6. ListFilter,
  7. Palette,
  8. Network,
  9. History,
  10. Wrench,
  11. HelpCircle,
  12. MessageCircle,
  13. HeartHandshake,
  14. Archive,
  15. } from "lucide-react"
  16. import { useTranslation } from "react-i18next"
  17. import i18n from "@/i18n"
  18. import { Button } from "@/components/ui/button"
  19. import { PanelHeaderWithHelp } from "@/components/layout/panel-header-with-help"
  20. import { useWikiStore } from "@/stores/wiki-store"
  21. import { isTauri } from "@/lib/platform"
  22. import { useChatStore } from "@/stores/chat-store"
  23. import { loadSourceWatchConfig, saveLanguage, loadNovelConfig, loadRerankConfig } from "@/lib/project-store"
  24. import type { SettingsDraft, DraftSetter } from "./settings-types"
  25. import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
  26. import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
  27. import type { UiFontFamily } from "@/lib/font-settings"
  28. import { LlmProviderSection } from "./sections/llm-provider-section"
  29. import { EmbeddingSection } from "./sections/embedding-section"
  30. import { RerankSection } from "./sections/rerank-section"
  31. import { InterfaceSection } from "./sections/interface-section"
  32. import { NovelSection } from "./sections/novel-section"
  33. import { NetworkSection } from "./sections/network-section"
  34. import { ChangelogSection } from "./sections/changelog-section"
  35. import { MaintenanceSection } from "./sections/maintenance-section"
  36. import { FeedbackSection } from "./sections/feedback-section"
  37. import { UsageGuideSection } from "./sections/usage-guide-section"
  38. import { ContactSupportSection } from "./sections/contact-support-section"
  39. import { DataManagementSection } from "./sections/data-management-section"
  40. type CategoryId =
  41. | "llm"
  42. | "rerank"
  43. | "embedding"
  44. | "network"
  45. | "interface"
  46. | "novel"
  47. | "usage-guide"
  48. | "maintenance"
  49. | "data-management"
  50. | "feedback"
  51. | "contact-support"
  52. | "changelog"
  53. interface Category {
  54. id: CategoryId
  55. /** i18n key under settings.categories — resolved at render time so
  56. * switching language in Settings → Interface takes effect without
  57. * remounting this component (Bug #53). */
  58. labelKey: string
  59. icon: typeof Bot
  60. }
  61. const CATEGORIES: Category[] = [
  62. { id: "llm", labelKey: "settings.categories.llm", icon: Bot },
  63. { id: "rerank", labelKey: "settings.categories.rerank", icon: ListFilter },
  64. { id: "embedding", labelKey: "settings.categories.embedding", icon: Database },
  65. { id: "network", labelKey: "settings.categories.network", icon: Network },
  66. { id: "interface", labelKey: "settings.categories.interface", icon: Palette },
  67. { id: "novel", labelKey: "settings.categories.novel", icon: BookOpen },
  68. { id: "usage-guide", labelKey: "settings.categories.usageGuide", icon: HelpCircle },
  69. { id: "maintenance", labelKey: "settings.categories.maintenance", icon: Wrench },
  70. { id: "data-management", labelKey: "settings.categories.dataManagement", icon: Archive },
  71. { id: "feedback", labelKey: "settings.categories.feedback", icon: MessageCircle },
  72. { id: "contact-support", labelKey: "settings.categories.contactSupport", icon: HeartHandshake },
  73. { id: "changelog", labelKey: "settings.categories.changelog", icon: History },
  74. ]
  75. /** Settings tabs that edit the shared draft and need the global Save footer. */
  76. const CATEGORIES_WITH_SAVE_FOOTER: CategoryId[] = [
  77. "rerank",
  78. "embedding",
  79. "network",
  80. "interface",
  81. "novel",
  82. ]
  83. function initialDraft(
  84. llm: ReturnType<typeof useWikiStore.getState>["llmConfig"],
  85. embed: ReturnType<typeof useWikiStore.getState>["embeddingConfig"],
  86. rerank: ReturnType<typeof useWikiStore.getState>["rerankConfig"],
  87. multimodal: ReturnType<typeof useWikiStore.getState>["multimodalConfig"],
  88. outputLanguage: ReturnType<typeof useWikiStore.getState>["outputLanguage"],
  89. proxy: ReturnType<typeof useWikiStore.getState>["proxyConfig"],
  90. scheduledImport: ReturnType<typeof useWikiStore.getState>["scheduledImportConfig"],
  91. sourceWatch: ReturnType<typeof useWikiStore.getState>["sourceWatchConfig"],
  92. revisionFeedbackWindowConfig: ReturnType<typeof useWikiStore.getState>["revisionFeedbackWindowConfig"],
  93. novelConfig: ReturnType<typeof useWikiStore.getState>["novelConfig"],
  94. maxHistoryMessages: number,
  95. uiLanguage: string,
  96. uiFontSizeScale: number,
  97. uiFontFamily: UiFontFamily,
  98. visualStyle: SettingsDraft["visualStyle"],
  99. sidebarNavConfig: SidebarNavConfig,
  100. projectPath?: string,
  101. ): SettingsDraft {
  102. // Show absolute path: if stored path is empty, show default using project path
  103. // If stored path is relative (legacy), prepend project path
  104. // If stored path is absolute, show as-is
  105. let displayPath = scheduledImport.path || ""
  106. if (!displayPath && projectPath) {
  107. displayPath = `${projectPath}/raw/sources`
  108. } else if (displayPath && projectPath && !displayPath.startsWith("/") && !displayPath.match(/^[a-zA-Z]:[/\\]/)) {
  109. // Legacy relative path - prepend project path for display
  110. displayPath = `${projectPath}/${displayPath}`
  111. }
  112. return {
  113. provider: llm.provider,
  114. apiKey: llm.apiKey,
  115. model: llm.model,
  116. ollamaUrl: llm.ollamaUrl,
  117. customEndpoint: llm.customEndpoint,
  118. azureApiVersion: llm.azureApiVersion ?? "2024-10-21",
  119. azureModelFamily: llm.azureModelFamily ?? "auto",
  120. maxContextSize: llm.maxContextSize ?? 204800,
  121. apiMode: llm.apiMode,
  122. reasoning: llm.reasoning,
  123. localCliIsolation: llm.localCliIsolation === true,
  124. embeddingEnabled: embed.enabled,
  125. embeddingEndpoint: embed.endpoint,
  126. embeddingApiKey: embed.apiKey,
  127. embeddingModel: embed.model,
  128. embeddingOutputDimensionality: embed.outputDimensionality,
  129. embeddingMaxChunkChars: embed.maxChunkChars,
  130. embeddingOverlapChunkChars: embed.overlapChunkChars,
  131. rerankConfig: rerank,
  132. multimodalEnabled: multimodal.enabled,
  133. multimodalUseMainLlm: multimodal.useMainLlm,
  134. multimodalProvider: multimodal.provider,
  135. multimodalApiKey: multimodal.apiKey,
  136. multimodalModel: multimodal.model,
  137. multimodalOllamaUrl: multimodal.ollamaUrl,
  138. multimodalCustomEndpoint: multimodal.customEndpoint,
  139. multimodalAzureApiVersion: multimodal.azureApiVersion ?? "2024-10-21",
  140. multimodalAzureModelFamily: multimodal.azureModelFamily ?? "auto",
  141. multimodalApiMode: multimodal.apiMode,
  142. multimodalConcurrency: multimodal.concurrency,
  143. outputLanguage,
  144. maxHistoryMessages,
  145. proxyEnabled: proxy.enabled,
  146. proxyUrl: proxy.url,
  147. proxyBypassLocal: proxy.bypassLocal,
  148. scheduledImportEnabled: scheduledImport.enabled,
  149. scheduledImportPath: displayPath,
  150. scheduledImportInterval: scheduledImport.interval,
  151. sourceWatchConfig: normalizeSourceWatchConfig(sourceWatch),
  152. revisionFeedbackWindowConfig,
  153. novelConfig,
  154. uiLanguage,
  155. uiFontSizeScale,
  156. uiFontFamily,
  157. visualStyle,
  158. sidebarNavConfig,
  159. }
  160. }
  161. export function SettingsView() {
  162. const { t } = useTranslation()
  163. const project = useWikiStore((s) => s.project)
  164. const activeSettingsCategory = useWikiStore((s) => s.activeSettingsCategory)
  165. const setActiveSettingsCategory = useWikiStore((s) => s.setActiveSettingsCategory)
  166. const llmConfig = useWikiStore((s) => s.llmConfig)
  167. const setLlmConfig = useWikiStore((s) => s.setLlmConfig)
  168. const embeddingConfig = useWikiStore((s) => s.embeddingConfig)
  169. const setEmbeddingConfig = useWikiStore((s) => s.setEmbeddingConfig)
  170. const rerankConfig = useWikiStore((s) => s.rerankConfig)
  171. const setRerankConfig = useWikiStore((s) => s.setRerankConfig)
  172. const multimodalConfig = useWikiStore((s) => s.multimodalConfig)
  173. const setMultimodalConfig = useWikiStore((s) => s.setMultimodalConfig)
  174. const outputLanguage = useWikiStore((s) => s.outputLanguage)
  175. const setOutputLanguage = useWikiStore((s) => s.setOutputLanguage)
  176. const proxyConfig = useWikiStore((s) => s.proxyConfig)
  177. const setProxyConfig = useWikiStore((s) => s.setProxyConfig)
  178. const scheduledImportConfig = useWikiStore((s) => s.scheduledImportConfig)
  179. const setScheduledImportConfig = useWikiStore((s) => s.setScheduledImportConfig)
  180. const sourceWatchConfig = useWikiStore((s) => s.sourceWatchConfig)
  181. const setSourceWatchConfig = useWikiStore((s) => s.setSourceWatchConfig)
  182. const revisionFeedbackWindowConfig = useWikiStore((s) => s.revisionFeedbackWindowConfig)
  183. const setRevisionFeedbackWindowConfig = useWikiStore((s) => s.setRevisionFeedbackWindowConfig)
  184. const novelConfig = useWikiStore((s) => s.novelConfig)
  185. const setNovelConfig = useWikiStore((s) => s.setNovelConfig)
  186. const maxHistoryMessages = useChatStore((s) => s.maxHistoryMessages)
  187. const setMaxHistoryMessages = useChatStore((s) => s.setMaxHistoryMessages)
  188. const uiFontSizeScale = useWikiStore((s) => s.uiFontSizeScale)
  189. const setUiFontSizeScale = useWikiStore((s) => s.setUiFontSizeScale)
  190. const uiFontFamily = useWikiStore((s) => s.uiFontFamily)
  191. const setUiFontFamily = useWikiStore((s) => s.setUiFontFamily)
  192. const visualStyle = useWikiStore((s) => s.visualStyle)
  193. const setVisualStyle = useWikiStore((s) => s.setVisualStyle)
  194. const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)
  195. const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)
  196. const [active, setActive] = useState<CategoryId>("llm")
  197. const [saved, setSaved] = useState(false)
  198. const [draft, setDraftState] = useState<SettingsDraft>(() =>
  199. initialDraft(
  200. llmConfig,
  201. embeddingConfig,
  202. rerankConfig,
  203. multimodalConfig,
  204. outputLanguage,
  205. proxyConfig,
  206. scheduledImportConfig,
  207. sourceWatchConfig,
  208. revisionFeedbackWindowConfig,
  209. novelConfig,
  210. maxHistoryMessages,
  211. i18n.language,
  212. uiFontSizeScale,
  213. uiFontFamily,
  214. visualStyle,
  215. sidebarNavConfig,
  216. project?.path,
  217. ),
  218. )
  219. useEffect(() => {
  220. if (!activeSettingsCategory) return
  221. if (CATEGORIES.some((category) => category.id === activeSettingsCategory)) {
  222. setActive(activeSettingsCategory)
  223. }
  224. setActiveSettingsCategory(null)
  225. }, [activeSettingsCategory, setActiveSettingsCategory])
  226. useEffect(() => {
  227. let cancelled = false
  228. loadSourceWatchConfig(project?.id).then((config) => {
  229. if (cancelled) return
  230. const normalized = normalizeSourceWatchConfig(config)
  231. setSourceWatchConfig(normalized)
  232. setDraftState((prev) => ({ ...prev, sourceWatchConfig: normalized }))
  233. }).catch(() => {
  234. if (cancelled) return
  235. const fallback = normalizeSourceWatchConfig()
  236. setSourceWatchConfig(fallback)
  237. setDraftState((prev) => ({ ...prev, sourceWatchConfig: fallback }))
  238. })
  239. return () => {
  240. cancelled = true
  241. }
  242. }, [project?.id, setSourceWatchConfig])
  243. useEffect(() => {
  244. let cancelled = false
  245. loadNovelConfig(project?.id, project?.path).then((config) => {
  246. if (cancelled || !config) return
  247. setNovelConfig(config)
  248. setDraftState((prev) => ({ ...prev, novelConfig: config }))
  249. }).catch(() => {})
  250. return () => {
  251. cancelled = true
  252. }
  253. }, [project?.id, project?.path, setNovelConfig])
  254. useEffect(() => {
  255. let cancelled = false
  256. loadRerankConfig(project?.id, project?.path).then((config) => {
  257. if (cancelled || !config) return
  258. setRerankConfig(config)
  259. setDraftState((prev) => ({ ...prev, rerankConfig: config }))
  260. }).catch(() => {})
  261. return () => {
  262. cancelled = true
  263. }
  264. }, [project?.id, project?.path, setRerankConfig])
  265. // Resync draft from store if it changes out-of-band (e.g. project switch).
  266. // IMPORTANT: keep the current draft.uiLanguage instead of re-reading
  267. // `i18n.language`. handleSave calls multiple zustand setters before it
  268. // calls `i18n.changeLanguage` at the end, and each setter triggers this
  269. // effect mid-save — which used to clobber the user's pending language
  270. // pick with the still-stale `i18n.language`. The next save would then
  271. // see draft.uiLanguage out of sync with i18n.language and silently
  272. // revert the UI to the previous language.
  273. useEffect(() => {
  274. setDraftState((prev) =>
  275. initialDraft(
  276. llmConfig,
  277. embeddingConfig,
  278. rerankConfig,
  279. multimodalConfig,
  280. outputLanguage,
  281. proxyConfig,
  282. scheduledImportConfig,
  283. sourceWatchConfig,
  284. revisionFeedbackWindowConfig,
  285. novelConfig,
  286. maxHistoryMessages,
  287. prev.uiLanguage,
  288. uiFontSizeScale,
  289. uiFontFamily,
  290. visualStyle,
  291. sidebarNavConfig,
  292. project?.path,
  293. ),
  294. )
  295. }, [
  296. llmConfig,
  297. embeddingConfig,
  298. rerankConfig,
  299. multimodalConfig,
  300. outputLanguage,
  301. proxyConfig,
  302. scheduledImportConfig,
  303. sourceWatchConfig,
  304. revisionFeedbackWindowConfig,
  305. novelConfig,
  306. maxHistoryMessages,
  307. uiFontSizeScale,
  308. uiFontFamily,
  309. visualStyle,
  310. sidebarNavConfig,
  311. project,
  312. ])
  313. const setDraft: DraftSetter = useCallback((key, value) => {
  314. setDraftState((prev) => ({ ...prev, [key]: value }))
  315. }, [])
  316. const handleSave = useCallback(async () => {
  317. const {
  318. saveLlmConfig,
  319. saveEmbeddingConfig,
  320. saveRerankConfig,
  321. saveMultimodalConfig,
  322. saveProxyConfig,
  323. saveScheduledImportConfig,
  324. saveSourceWatchConfig,
  325. saveRevisionFeedbackWindowConfig,
  326. saveNovelConfig,
  327. saveOutputLanguage,
  328. saveMaxHistoryMessages,
  329. saveUiFontSizeScale,
  330. saveUiFontFamily,
  331. saveVisualStyle,
  332. } = await import("@/lib/project-store")
  333. const newLlm = {
  334. provider: draft.provider,
  335. apiKey: draft.apiKey,
  336. model: draft.model,
  337. ollamaUrl: draft.ollamaUrl,
  338. customEndpoint: draft.customEndpoint,
  339. azureApiVersion: draft.provider === "azure" ? draft.azureApiVersion.trim() : undefined,
  340. azureModelFamily: draft.provider === "azure" ? draft.azureModelFamily : undefined,
  341. maxContextSize: draft.maxContextSize,
  342. apiMode: draft.provider === "custom" ? draft.apiMode : undefined,
  343. reasoning: draft.reasoning,
  344. localCliIsolation: draft.localCliIsolation,
  345. }
  346. const newEmbed = {
  347. enabled: draft.embeddingEnabled,
  348. endpoint: draft.embeddingEndpoint,
  349. apiKey: draft.embeddingApiKey,
  350. model: draft.embeddingModel,
  351. outputDimensionality: draft.embeddingOutputDimensionality,
  352. maxChunkChars: draft.embeddingMaxChunkChars,
  353. overlapChunkChars: draft.embeddingOverlapChunkChars,
  354. }
  355. const newRerank = {
  356. ...draft.rerankConfig,
  357. apiMode: draft.rerankConfig.provider === "custom" ? draft.rerankConfig.apiMode : undefined,
  358. maxCandidates: Math.max(3, Math.min(30, draft.rerankConfig.maxCandidates || 12)),
  359. }
  360. const newMultimodal = {
  361. enabled: draft.multimodalEnabled,
  362. useMainLlm: draft.multimodalUseMainLlm,
  363. provider: draft.multimodalProvider,
  364. apiKey: draft.multimodalApiKey,
  365. model: draft.multimodalModel,
  366. ollamaUrl: draft.multimodalOllamaUrl,
  367. customEndpoint: draft.multimodalCustomEndpoint,
  368. azureApiVersion: draft.multimodalProvider === "azure" ? draft.multimodalAzureApiVersion.trim() : undefined,
  369. azureModelFamily: draft.multimodalProvider === "azure" ? draft.multimodalAzureModelFamily : undefined,
  370. apiMode: draft.multimodalProvider === "custom" ? draft.multimodalApiMode : undefined,
  371. // Clamp at save time so a hand-edited persisted store with a
  372. // ridiculous concurrency value (e.g. someone setting 1000 in
  373. // the JSON) doesn't blow up the captioning pipeline. Caption
  374. // calls already share the LLM endpoint with everything else;
  375. // going wider than ~16 just queues behind the server's batch
  376. // slot.
  377. concurrency: Math.max(1, Math.min(16, draft.multimodalConcurrency || 4)),
  378. }
  379. const newProxy = {
  380. enabled: draft.proxyEnabled,
  381. url: draft.proxyUrl.trim(),
  382. bypassLocal: draft.proxyBypassLocal,
  383. }
  384. setLlmConfig(newLlm)
  385. await saveLlmConfig(newLlm)
  386. setEmbeddingConfig(newEmbed)
  387. await saveEmbeddingConfig(newEmbed)
  388. setRerankConfig(newRerank)
  389. await saveRerankConfig(newRerank, project?.id, project?.path)
  390. setMultimodalConfig(newMultimodal)
  391. await saveMultimodalConfig(newMultimodal)
  392. setProxyConfig(newProxy)
  393. await saveProxyConfig(newProxy)
  394. const newSourceWatch = normalizeSourceWatchConfig(draft.sourceWatchConfig)
  395. setSourceWatchConfig(newSourceWatch)
  396. await saveSourceWatchConfig(newSourceWatch, project?.id, project?.path)
  397. if (project) {
  398. const { startProjectFileSync, stopProjectFileSync } = await import("@/lib/project-file-sync")
  399. if (newSourceWatch.enabled) {
  400. await startProjectFileSync(project, newSourceWatch).catch((err) =>
  401. console.error("Failed to start project file sync:", err)
  402. )
  403. } else {
  404. await stopProjectFileSync()
  405. }
  406. }
  407. // Apply the proxy env vars LIVE so the next outbound request
  408. // picks them up — no app restart needed. tauri-plugin-http
  409. // builds a fresh reqwest client per fetch and reqwest reads
  410. // env vars at build time, so changing them here is enough.
  411. try {
  412. if (isTauri()) {
  413. const { invoke } = await import("@tauri-apps/api/core")
  414. await invoke<string>("set_proxy_env", { config: newProxy })
  415. }
  416. } catch (err) {
  417. console.warn("[settings] live network update failed; restart will still apply:", err)
  418. }
  419. const newScheduledImport = {
  420. enabled: draft.scheduledImportEnabled,
  421. path: draft.scheduledImportPath,
  422. interval: Math.max(1, Math.min(1440, draft.scheduledImportInterval || 60)),
  423. lastScan: scheduledImportConfig.lastScan,
  424. }
  425. setScheduledImportConfig(newScheduledImport)
  426. if (project) {
  427. await saveScheduledImportConfig(project.path, newScheduledImport)
  428. const { startScheduledImport, stopScheduledImport } = await import("@/lib/scheduled-import")
  429. if (
  430. newScheduledImport.enabled &&
  431. newScheduledImport.path &&
  432. newScheduledImport.interval > 0
  433. ) {
  434. startScheduledImport(project, newScheduledImport)
  435. } else {
  436. stopScheduledImport()
  437. }
  438. }
  439. setRevisionFeedbackWindowConfig(draft.revisionFeedbackWindowConfig)
  440. await saveRevisionFeedbackWindowConfig(draft.revisionFeedbackWindowConfig, project?.id, project?.path)
  441. setNovelConfig(draft.novelConfig)
  442. await saveNovelConfig(draft.novelConfig, project?.id, project?.path)
  443. setOutputLanguage(draft.outputLanguage)
  444. await saveOutputLanguage(draft.outputLanguage, project?.id)
  445. setMaxHistoryMessages(draft.maxHistoryMessages)
  446. await saveMaxHistoryMessages(draft.maxHistoryMessages, project?.id, project?.path)
  447. setUiFontSizeScale(draft.uiFontSizeScale)
  448. await saveUiFontSizeScale(draft.uiFontSizeScale, project?.id, project?.path)
  449. setUiFontFamily(draft.uiFontFamily)
  450. await saveUiFontFamily(draft.uiFontFamily)
  451. setVisualStyle(draft.visualStyle)
  452. await saveVisualStyle(draft.visualStyle)
  453. const { applyVisualStyle } = await import("@/lib/visual-style-settings")
  454. applyVisualStyle(draft.visualStyle)
  455. setSidebarNavConfig(draft.sidebarNavConfig)
  456. if (draft.uiLanguage !== i18n.language) {
  457. await i18n.changeLanguage(draft.uiLanguage)
  458. await saveLanguage(draft.uiLanguage)
  459. }
  460. setSaved(true)
  461. setTimeout(() => setSaved(false), 2000)
  462. }, [
  463. draft,
  464. project,
  465. setLlmConfig,
  466. setEmbeddingConfig,
  467. setRerankConfig,
  468. setOutputLanguage,
  469. setProxyConfig,
  470. setScheduledImportConfig,
  471. setSourceWatchConfig,
  472. setRevisionFeedbackWindowConfig,
  473. setNovelConfig,
  474. scheduledImportConfig,
  475. setMaxHistoryMessages,
  476. outputLanguage,
  477. setUiFontSizeScale,
  478. setUiFontFamily,
  479. setVisualStyle,
  480. setSidebarNavConfig,
  481. ])
  482. const body = useMemo(() => {
  483. switch (active) {
  484. case "llm":
  485. return <LlmProviderSection />
  486. case "rerank":
  487. return <RerankSection draft={draft} setDraft={setDraft} />
  488. case "embedding":
  489. return <EmbeddingSection draft={draft} setDraft={setDraft} />
  490. case "network":
  491. return <NetworkSection draft={draft} setDraft={setDraft} />
  492. case "interface":
  493. return <InterfaceSection draft={draft} setDraft={setDraft} />
  494. case "novel":
  495. return <NovelSection draft={draft} setDraft={setDraft} />
  496. case "usage-guide":
  497. return <UsageGuideSection />
  498. case "maintenance":
  499. return <MaintenanceSection />
  500. case "data-management":
  501. return <DataManagementSection />
  502. case "feedback":
  503. return <FeedbackSection />
  504. case "contact-support":
  505. return <ContactSupportSection />
  506. case "changelog":
  507. return <ChangelogSection />
  508. }
  509. }, [active, draft, setDraft])
  510. const showSaveFooter = CATEGORIES_WITH_SAVE_FOOTER.includes(active)
  511. return (
  512. <div className="flex h-full overflow-hidden">
  513. {/* Sidebar — category nav. Matches the IconSidebar's pill-on-accent
  514. pattern so the two navigational surfaces feel like one app. */}
  515. <aside className="flex w-56 shrink-0 flex-col border-r bg-muted/30">
  516. <div className="flex items-center gap-1.5 px-4 pb-2 pt-4 text-[11px] font-semibold uppercase tracking-wider">
  517. <PanelHeaderWithHelp
  518. title={t("settings.title")}
  519. helpKey="settings"
  520. className="cursor-pointer text-muted-foreground transition-colors hover:text-primary"
  521. />
  522. </div>
  523. <nav className="flex-1 overflow-y-auto px-2 pb-3">
  524. {CATEGORIES.map((c) => {
  525. const Icon = c.icon
  526. const isActive = c.id === active
  527. return (
  528. <button
  529. key={c.id}
  530. type="button"
  531. onClick={() => setActive(c.id)}
  532. aria-current={isActive ? "page" : undefined}
  533. className={`group mb-0.5 flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
  534. isActive
  535. ? "bg-foreground/[0.08] font-medium text-foreground ring-1 ring-border/70"
  536. : "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
  537. }`}
  538. >
  539. <Icon
  540. className={`h-4 w-4 shrink-0 transition-colors ${
  541. isActive ? "text-primary" : "text-muted-foreground/80 group-hover:text-accent-foreground"
  542. }`}
  543. />
  544. <span className="truncate">{t(c.labelKey)}</span>
  545. </button>
  546. )
  547. })}
  548. </nav>
  549. </aside>
  550. {/* Content */}
  551. <div className="flex flex-1 flex-col overflow-hidden">
  552. <div className="flex-1 overflow-y-auto px-8 py-6">
  553. <div className="mx-auto max-w-2xl">{body}</div>
  554. </div>
  555. {showSaveFooter && (
  556. <div className="shrink-0 border-t bg-background/80 backdrop-blur px-8 py-3">
  557. <div className="mx-auto flex max-w-2xl items-center justify-between gap-4">
  558. <p className="text-xs text-muted-foreground">
  559. {saved ? t("settings.savedTick") : t("settings.changeHint")}
  560. </p>
  561. <Button onClick={handleSave}>
  562. {saved ? t("settings.saved") : t("settings.save")}
  563. </Button>
  564. </div>
  565. </div>
  566. )}
  567. </div>
  568. </div>
  569. )
  570. }