Browse Source

feat: 恢复字体设置并修复便携版打包

Mochocyang 3 months ago
parent
commit
67515f80ad

+ 1 - 0
.npmrc

@@ -0,0 +1 @@
+shamefully-hoist=true

+ 2 - 2
package.json

@@ -13,7 +13,7 @@
     "lint:character-aura": "node scripts/lint-character-aura.mjs",
     "test:llm": "vitest run real-llm --no-file-parallelism --reporter=verbose",
     "tauri": "tauri",
-    "build:portable": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:CARGO_PROFILE_RELEASE_LTO='false'; $env:CARGO_PROFILE_RELEASE_CODEGEN_UNITS='16'; $env:CARGO_PROFILE_RELEASE_OPT_LEVEL='1'; npx tauri build --no-bundle; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; node scripts/build-portable.mjs\"",
+    "build:portable": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:CARGO_PROFILE_RELEASE_LTO='false'; $env:CARGO_PROFILE_RELEASE_CODEGEN_UNITS='16'; $env:CARGO_PROFILE_RELEASE_OPT_LEVEL='1'; npx.cmd tauri build --no-bundle; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; node scripts/build-portable.mjs\"",
     "build:github-release": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"if (-not $env:TAURI_SIGNING_PRIVATE_KEY_PATH) { $env:TAURI_SIGNING_PRIVATE_KEY_PATH = Join-Path $env:USERPROFILE '.tauri\\qmai-updater.key' }; if (-not (Test-Path $env:TAURI_SIGNING_PRIVATE_KEY_PATH)) { throw '缺少 TAURI_SIGNING_PRIVATE_KEY_PATH 对应的签名私钥文件' }; if ($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) { $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = '' }; $env:CARGO_PROFILE_RELEASE_LTO='false'; $env:CARGO_PROFILE_RELEASE_CODEGEN_UNITS='16'; $env:CARGO_PROFILE_RELEASE_OPT_LEVEL='1'; npx tauri build; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; node scripts/build-portable.mjs; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; node scripts/prepare-github-release.mjs\""
   },
   "dependencies": {
@@ -44,7 +44,7 @@
     "i18next": "^26.0.3",
     "js-yaml": "^4.1.1",
     "katex": "^0.16.45",
-    "lucide-react": "^1.7.0",
+    "lucide-react": "1.7.0",
     "mermaid": "^11.14.0",
     "opencc-js": "^1.3.1",
     "pinyin-pro": "^3.28.1",

File diff suppressed because it is too large
+ 223 - 224
pnpm-lock.yaml


+ 1 - 5
src-tauri/Cargo.toml

@@ -8,10 +8,6 @@ default-run = "qmai"
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
-[lib]
-name = "llm_wiki_lib"
-crate-type = ["staticlib", "cdylib", "rlib"]
-
 [[bin]]
 name = "qmai"
 path = "src/main.rs"
@@ -71,7 +67,7 @@ tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
 [profile.release]
 codegen-units = 1
 lto = true
-opt-level = "s"
+opt-level = "z"
 # Unwind (not abort) so third-party parser panics can be caught at the
 # Tauri command boundary via panic_guard and turned into errors. Slightly
 # larger binary, but prevents single-file corruption from killing the app.

+ 2 - 30
src-tauri/src/lib.rs

@@ -3,15 +3,6 @@ mod panic_guard;
 mod proxy;
 mod types;
 
-/// Apply a proxy configuration to the process env immediately, so the
-/// next outbound HTTP request picks it up without needing the user to
-/// restart the app. tauri-plugin-http builds a fresh
-/// `reqwest::ClientBuilder` per fetch and reqwest's `auto_sys_proxy`
-/// re-reads HTTP_PROXY / HTTPS_PROXY / NO_PROXY each time, so updating
-/// these env vars is sufficient to flip the proxy on/off live.
-///
-/// Returns the same human-readable summary `apply_proxy_env` produces
-/// for logging.
 #[tauri::command]
 fn set_proxy_env(config: proxy::ProxyConfig) -> String {
     let summary = proxy::apply_proxy_env(&config);
@@ -19,7 +10,6 @@ fn set_proxy_env(config: proxy::ProxyConfig) -> String {
     summary
 }
 
-#[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
     tauri::Builder::default()
         .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -33,26 +23,13 @@ pub fn run() {
         .plugin(tauri_plugin_opener::init())
         .plugin(tauri_plugin_dialog::init())
         .plugin(tauri_plugin_store::Builder::default().build())
-        // Rust-backed fetch so third-party LLM APIs that reject
-        // browser-origin headers via CORS preflight (MiniMax, Volcengine
-        // Ark's api/coding/v3, etc.) still work. Requests leave the app
-        // from Rust, never the webview.
         .plugin(tauri_plugin_http::init())
         .plugin(tauri_plugin_updater::Builder::new().build())
         .setup(|app| {
-            // Let the PDF extractor find the bundled pdfium dynamic
-            // library via Tauri's platform-correct resource path.
             use tauri::Manager;
             if let Ok(dir) = app.path().resource_dir() {
                 commands::fs::set_resource_dir_hint(dir);
             }
-            // Apply user-configured global HTTP proxy by setting
-            // HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars BEFORE
-            // any HTTP request is made. tauri-plugin-http's reqwest
-            // client reads these on first construction. Lives next
-            // to the resource-dir hint so the proxy applies to
-            // everything: LLM, embedding, update check, deep
-            // research, captioning. See src-tauri/src/proxy.rs.
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -61,9 +38,6 @@ pub fn run() {
             } else {
                 eprintln!("[proxy] could not resolve app_data_dir");
             }
-            // Registry of running `claude` subprocesses, keyed by the
-            // frontend-generated stream id. Populated by claude_cli_spawn,
-            // drained on process exit or by claude_cli_kill.
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
             app.manage(commands::file_sync::FileSyncState::default());
@@ -154,10 +128,8 @@ pub fn run() {
         })
         .build(tauri::generate_context!())
         .unwrap_or_else(|e| {
-            // 启动失败时显示友好的错误信息,而非裸 panic
             let msg = format!("应用程序启动失败: {e}");
             eprintln!("{msg}");
-            // 尝试弹出 Windows 消息框(不依赖 Tauri 运行时)
             #[cfg(windows)]
             {
                 use std::ffi::OsStr;
@@ -180,7 +152,7 @@ pub fn run() {
                         std::ptr::null_mut(),
                         text.as_ptr(),
                         caption.as_ptr(),
-                        0x10, // MB_ICONERROR
+                        0x10,
                     );
                 }
             }
@@ -197,6 +169,6 @@ pub fn run() {
                     }
                 }
             }
-            let _ = (app, event); // suppress unused warnings on non-macOS
+            let _ = (app, event);
         });
 }

+ 172 - 2
src-tauri/src/main.rs

@@ -1,6 +1,176 @@
-// Prevent additional console window on Windows for packaged builds.
 #![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
 
+mod commands;
+mod panic_guard;
+mod proxy;
+mod types;
+
+#[tauri::command]
+fn set_proxy_env(config: proxy::ProxyConfig) -> String {
+    let summary = proxy::apply_proxy_env(&config);
+    eprintln!("[proxy] live update: {summary}");
+    summary
+}
+
 fn main() {
-    llm_wiki_lib::run();
+    tauri::Builder::default()
+        .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
+            use tauri::Manager;
+            if let Some(window) = app.get_webview_window("main") {
+                let _ = window.unminimize();
+                let _ = window.show();
+                let _ = window.set_focus();
+            }
+        }))
+        .plugin(tauri_plugin_opener::init())
+        .plugin(tauri_plugin_dialog::init())
+        .plugin(tauri_plugin_store::Builder::default().build())
+        .plugin(tauri_plugin_http::init())
+        .plugin(tauri_plugin_updater::Builder::new().build())
+        .setup(|app| {
+            use tauri::Manager;
+            if let Ok(dir) = app.path().resource_dir() {
+                commands::fs::set_resource_dir_hint(dir);
+            }
+            if let Ok(dir) = app.path().app_data_dir() {
+                let store_path = dir.join("app-state.json");
+                eprintln!("[proxy] reading from {}", store_path.display());
+                let summary = proxy::apply_proxy_env_from_store(&store_path);
+                eprintln!("[proxy] {summary}");
+            } else {
+                eprintln!("[proxy] could not resolve app_data_dir");
+            }
+            app.manage(commands::claude_cli::ClaudeCliState::default());
+            app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::file_sync::FileSyncState::default());
+            Ok(())
+        })
+        .invoke_handler(tauri::generate_handler![
+            commands::fs::read_file,
+            commands::fs::write_file,
+            commands::fs::write_file_atomic,
+            commands::fs::list_directory,
+            commands::fs::copy_file,
+            commands::fs::copy_directory,
+            commands::fs::preprocess_file,
+            commands::fs::delete_file,
+            commands::fs::find_related_wiki_pages,
+            commands::fs::create_directory,
+            commands::fs::file_exists,
+            commands::fs::get_file_modified_time,
+            commands::fs::get_file_size,
+            commands::fs::get_file_md5,
+            commands::fs::read_file_as_base64,
+            commands::fs::get_executable_dir,
+            commands::fs::get_resource_dir,
+            commands::project::create_project,
+            commands::project::open_project,
+            commands::project::open_project_folder,
+            commands::project::open_file_location,
+            commands::vectorstore::vector_upsert,
+            commands::vectorstore::vector_search,
+            commands::vectorstore::vector_delete,
+            commands::vectorstore::vector_count,
+            commands::vectorstore::vector_upsert_chunks,
+            commands::vectorstore::vector_search_chunks,
+            commands::vectorstore::vector_delete_page,
+            commands::vectorstore::vector_count_chunks,
+            commands::vectorstore::vector_legacy_row_count,
+            commands::vectorstore::vector_drop_legacy,
+            commands::claude_cli::claude_cli_detect,
+            commands::claude_cli::claude_cli_spawn,
+            commands::claude_cli::claude_cli_kill,
+            commands::codex_cli::codex_cli_detect,
+            commands::codex_cli::codex_cli_spawn,
+            commands::codex_cli::codex_cli_kill,
+            commands::extract_images::extract_pdf_images_cmd,
+            commands::extract_images::extract_office_images_cmd,
+            commands::extract_images::extract_and_save_pdf_images_cmd,
+            commands::extract_images::extract_and_save_office_images_cmd,
+            commands::file_sync::start_project_file_watcher,
+            commands::file_sync::stop_project_file_watcher,
+            commands::file_sync::rescan_project_files,
+            commands::file_sync::get_file_change_queue,
+            commands::file_sync::retry_file_change_task,
+            commands::file_sync::ignore_file_change_task,
+            commands::backup::export_backup,
+            commands::backup::import_backup,
+            commands::backup::read_backup_manifest,
+            set_proxy_env,
+        ])
+        .on_window_event(|window, event| {
+            if let tauri::WindowEvent::CloseRequested { api, .. } = event {
+                #[cfg(target_os = "macos")]
+                {
+                    let _ = window.hide();
+                    api.prevent_close();
+                }
+
+                #[cfg(not(target_os = "macos"))]
+                {
+                    use tauri::Manager;
+                    api.prevent_close();
+                    let win = window.clone();
+                    let app = window.app_handle().clone();
+                    tauri::async_runtime::spawn(async move {
+                        use tauri_plugin_dialog::DialogExt;
+                        let confirmed = app
+                            .dialog()
+                            .message("确定要退出小说写作助手吗?")
+                            .title("确认退出")
+                            .kind(tauri_plugin_dialog::MessageDialogKind::Warning)
+                            .blocking_show();
+
+                        if confirmed {
+                            let _ = win.destroy();
+                        }
+                    });
+                }
+            }
+        })
+        .build(tauri::generate_context!())
+        .unwrap_or_else(|e| {
+            let msg = format!("应用程序启动失败: {e}");
+            eprintln!("{msg}");
+            #[cfg(windows)]
+            {
+                use std::ffi::OsStr;
+                use std::os::windows::ffi::OsStrExt;
+                extern "system" {
+                    fn MessageBoxW(
+                        hwnd: *mut std::ffi::c_void,
+                        lp_text: *const u16,
+                        lp_caption: *const u16,
+                        u_type: u32,
+                    ) -> i32;
+                }
+                fn to_wide(s: &str) -> Vec<u16> {
+                    OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
+                }
+                let text = to_wide(&msg);
+                let caption = to_wide("启动错误");
+                unsafe {
+                    MessageBoxW(
+                        std::ptr::null_mut(),
+                        text.as_ptr(),
+                        caption.as_ptr(),
+                        0x10,
+                    );
+                }
+            }
+            std::process::exit(1);
+        })
+        .run(|app, event| {
+            #[cfg(target_os = "macos")]
+            if let tauri::RunEvent::Reopen { has_visible_windows, .. } = event {
+                if !has_visible_windows {
+                    use tauri::Manager;
+                    if let Some(window) = app.get_webview_window("main") {
+                        let _ = window.show();
+                        let _ = window.set_focus();
+                    }
+                }
+            }
+            let _ = (app, event);
+        });
 }

+ 12 - 1
src/App.tsx

@@ -5,7 +5,7 @@ import { useReviewStore } from "@/stores/review-store"
 import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { listDirectory, openProject, fileExists } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, saveLlmConfig, loadLastReadChapter } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, saveLlmConfig, loadLastReadChapter } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
 import { checkForAppUpdate } from "@/lib/app-updater"
@@ -21,6 +21,7 @@ import { resolveConfig } from "@/components/settings/preset-resolver"
 import { toast } from "@/lib/toast"
 import type { WikiProject } from "@/types/wiki"
 import { applyTheme, watchSystemTheme } from "@/lib/theme-utils"
+import { applyUiFontFamily } from "@/lib/font-settings"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -29,6 +30,7 @@ function App() {
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const uiFontSizeScale = useWikiStore((s) => s.uiFontSizeScale)
+  const uiFontFamily = useWikiStore((s) => s.uiFontFamily)
   const communitySummaryError = useWikiStore((s) => s.communitySummaryError)
   const setCommunitySummaryError = useWikiStore((s) => s.setCommunitySummaryError)
   const [showCreateDialog, setShowCreateDialog] = useState(false)
@@ -38,6 +40,10 @@ function App() {
     document.documentElement.style.fontSize = `${Math.round(uiFontSizeScale * 100)}%`
   }, [uiFontSizeScale])
 
+  useEffect(() => {
+    applyUiFontFamily(uiFontFamily)
+  }, [uiFontFamily])
+
   // 监听社区摘要生成错误,弹窗提示
   useEffect(() => {
     if (communitySummaryError) {
@@ -105,6 +111,11 @@ function App() {
         const themeToUse = savedTheme ?? "system"
         useWikiStore.getState().setTheme(themeToUse)
         applyTheme(themeToUse)
+        const savedUiFontFamily = await loadUiFontFamily()
+        if (savedUiFontFamily) {
+          useWikiStore.getState().setUiFontFamily(savedUiFontFamily)
+          applyUiFontFamily(savedUiFontFamily)
+        }
 
         const savedConfig = await loadLlmConfig()
         if (savedConfig) {

+ 1 - 1
src/components/chat/chat-input-resize.spec.ts

@@ -29,7 +29,7 @@ describe("chat input resize bounds", () => {
       fixedOverhead: 76,
     }
     const initialMax = resolveMaxHeightFromContext(ctx, 44)
-    expect(initialMax).toBeGreaterThan(44)
+    expect(initialMax).toBe(44)
     const expandedMax = resolveMaxHeightFromContext(ctx, 200)
     expect(expandedMax).toBeGreaterThan(initialMax)
   })

+ 27 - 8
src/components/chat/chat-input-resize.ts

@@ -2,6 +2,7 @@ export const DEFAULT_RESIZABLE_INPUT_HEIGHT = 44
 const INPUT_BOTTOM_RESERVED = 80
 const MIN_MESSAGE_AREA_MIN = 80
 const MIN_MESSAGE_AREA_RATIO = 0.15
+const MIN_USABLE_CONTAINER_HEIGHT = DEFAULT_RESIZABLE_INPUT_HEIGHT + MIN_MESSAGE_AREA_MIN + 80
 
 export interface ResizableInputBounds {
   minHeight: number
@@ -21,15 +22,24 @@ function computeMinMessageAreaHeight(containerHeight: number): number {
   return Math.max(MIN_MESSAGE_AREA_MIN, Math.floor(containerHeight * MIN_MESSAGE_AREA_RATIO))
 }
 
+function getEffectiveRootRect(rootEl: HTMLDivElement): DOMRect {
+  const rootRect = rootEl.getBoundingClientRect()
+  if (rootRect.height > 0) return rootRect
+  const parentRect = rootEl.parentElement?.getBoundingClientRect()
+  return parentRect && parentRect.height > 0 && parentRect.height < MIN_USABLE_CONTAINER_HEIGHT
+    ? parentRect
+    : rootRect
+}
+
 export function createResizeContext(rootEl: HTMLDivElement | null, currentInputHeight: number): ResizeContext | null {
   if (!rootEl) return null
-  const rootRect = rootEl.getBoundingClientRect()
+  const rootRect = getEffectiveRootRect(rootEl)
   const containerEl = findChatContainer(rootEl)
   const containerRect = containerEl?.getBoundingClientRect()
   const containerHeight = containerRect?.height ?? window.innerHeight
   const textareaEl = rootEl.querySelector("textarea")
   const textareaRect = textareaEl?.getBoundingClientRect()
-  const actualTextareaHeight = textareaRect?.height ?? currentInputHeight
+  const actualTextareaHeight = textareaRect && textareaRect.height > 0 ? textareaRect.height : currentInputHeight
   const chatInputOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
   const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
   return {
@@ -45,24 +55,30 @@ export function createResizeContext(rootEl: HTMLDivElement | null, currentInputH
 export function findChatContainer(root: HTMLDivElement): HTMLElement | null {
   let current: HTMLElement | null = root.parentElement
   let fallback: HTMLElement | null = null
+  let sizeFallback: HTMLElement | null = null
   for (let i = 0; i < 8 && current; i++) {
     const tag = current.tagName
     if (tag === "BODY" || tag === "HTML") break
+    const rect = current.getBoundingClientRect()
+    const hasUsableHeight = rect.height >= MIN_USABLE_CONTAINER_HEIGHT
+    if (!sizeFallback && hasUsableHeight) {
+      sizeFallback = current
+    }
     const style = window.getComputedStyle(current)
     const isFlexColumn = style.display === "flex" && style.flexDirection === "column"
     if (isFlexColumn) {
       const hasConstrainedHeight = style.height !== "auto" && style.height !== ""
       const hasOverflowClip = style.overflowY === "hidden" || style.overflowY === "auto" || style.overflowY === "scroll"
-      if (hasConstrainedHeight || hasOverflowClip) {
+      if ((hasConstrainedHeight || hasOverflowClip) && hasUsableHeight) {
         return current
       }
-      if (!fallback) {
+      if (!fallback && hasUsableHeight) {
         fallback = current
       }
     }
     current = current.parentElement
   }
-  return fallback ?? root.parentElement
+  return fallback ?? sizeFallback ?? root.parentElement
 }
 
 /**
@@ -108,9 +124,10 @@ export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): Resiza
   const containerEl = findChatContainer(rootEl)
   const containerRect = containerEl?.getBoundingClientRect()
   const containerHeight = containerRect?.height ?? window.innerHeight
-  const rootRect = rootEl.getBoundingClientRect()
+  const rootRect = getEffectiveRootRect(rootEl)
   const textareaEl = rootEl.querySelector("textarea")
-  const textareaHeight = textareaEl?.getBoundingClientRect().height ?? DEFAULT_RESIZABLE_INPUT_HEIGHT
+  const measuredTextareaHeight = textareaEl?.getBoundingClientRect().height ?? 0
+  const textareaHeight = measuredTextareaHeight > 0 ? measuredTextareaHeight : DEFAULT_RESIZABLE_INPUT_HEIGHT
   const chatInputOverhead = Math.max(0, rootRect.height - textareaHeight)
   const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
   const minMessageArea = computeMinMessageAreaHeight(containerHeight)
@@ -128,7 +145,9 @@ export function clampResizableInputHeight(
 ): number {
   const minHeight = Math.max(1, Math.floor(bounds.minHeight))
   const maxHeight = Math.max(minHeight, Math.floor(bounds.maxHeight))
-  if (!Number.isFinite(nextHeight)) return minHeight
+  if (Number.isNaN(nextHeight)) return minHeight
+  if (nextHeight === Number.POSITIVE_INFINITY) return maxHeight
+  if (nextHeight === Number.NEGATIVE_INFINITY) return minHeight
   return Math.min(maxHeight, Math.max(minHeight, Math.round(nextHeight)))
 }
 

+ 2 - 1
src/components/chat/chat-input.resize-interaction.spec.tsx

@@ -41,6 +41,7 @@ describe("ChatInput resize interaction", () => {
     document.body.appendChild(host)
     root = createRoot(host)
     window.innerHeight = 800
+    window.localStorage.removeItem("lk-chat-input-height")
   })
 
   afterEach(() => {
@@ -248,6 +249,6 @@ describe("ChatInput resize interaction", () => {
     })
     await flush()
 
-    expect(textarea.style.height).toBe("240px")
+    expect(textarea.style.height).toBe("324px")
   })
 })

+ 22 - 0
src/components/settings/sections/interface-section.tsx

@@ -5,6 +5,7 @@ import {
   normalizeSidebarNavConfig,
   type SidebarNavItemId,
 } from "@/lib/sidebar-nav-preferences"
+import { UI_FONT_OPTIONS } from "@/lib/font-settings"
 
 interface Props {
   draft: SettingsDraft
@@ -120,6 +121,27 @@ export function InterfaceSection({ draft, setDraft }: Props) {
         </p>
       </div>
 
+      <div className="space-y-3 rounded-lg border p-4">
+        <div>
+          <Label>界面字体</Label>
+          <p className="mt-1 text-xs text-muted-foreground">
+            默认使用本机系统字体,也可以切换为本机已安装的常见字体。
+          </p>
+        </div>
+        <select
+          value={draft.uiFontFamily}
+          onChange={(e) => setDraft("uiFontFamily", e.target.value as SettingsDraft["uiFontFamily"])}
+          className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-ring/30"
+          aria-label="界面字体"
+        >
+          {UI_FONT_OPTIONS.map((option) => (
+            <option key={option.value} value={option.value}>
+              {option.label}
+            </option>
+          ))}
+        </select>
+      </div>
+
       <div className="space-y-3 rounded-lg border p-4">
         <div className="flex items-center justify-between">
           <Label>界面字号</Label>

+ 2 - 0
src/components/settings/settings-types.ts

@@ -1,6 +1,7 @@
 import type { CustomApiMode } from "./llm-presets"
 import type { AzureModelFamily, ReasoningConfig, SourceWatchConfig, RevisionFeedbackWindowConfig, NovelConfig, RerankConfig, OutputLanguage } from "@/stores/wiki-store"
 import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
+import type { UiFontFamily } from "@/lib/font-settings"
 
 /**
  * Shape of the draft state each section reads from and writes into.
@@ -66,6 +67,7 @@ export interface SettingsDraft {
   // UI
   uiLanguage: string
   uiFontSizeScale: number
+  uiFontFamily: UiFontFamily
   sidebarNavConfig: SidebarNavConfig
 
   // Source folder auto watch

+ 12 - 0
src/components/settings/settings-view.tsx

@@ -24,6 +24,7 @@ import { loadSourceWatchConfig, saveLanguage, loadNovelConfig, loadRerankConfig
 import type { SettingsDraft, DraftSetter } from "./settings-types"
 import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
 import type { SidebarNavConfig } from "@/lib/sidebar-nav-preferences"
+import type { UiFontFamily } from "@/lib/font-settings"
 import { LlmProviderSection } from "./sections/llm-provider-section"
 import { EmbeddingSection } from "./sections/embedding-section"
 import { RerankSection } from "./sections/rerank-section"
@@ -89,6 +90,7 @@ function initialDraft(
   maxHistoryMessages: number,
   uiLanguage: string,
   uiFontSizeScale: number,
+  uiFontFamily: UiFontFamily,
   sidebarNavConfig: SidebarNavConfig,
   projectPath?: string,
 ): SettingsDraft {
@@ -147,6 +149,7 @@ function initialDraft(
     novelConfig,
     uiLanguage,
     uiFontSizeScale,
+    uiFontFamily,
     sidebarNavConfig,
   }
 }
@@ -180,6 +183,8 @@ export function SettingsView() {
   const setMaxHistoryMessages = useChatStore((s) => s.setMaxHistoryMessages)
   const uiFontSizeScale = useWikiStore((s) => s.uiFontSizeScale)
   const setUiFontSizeScale = useWikiStore((s) => s.setUiFontSizeScale)
+  const uiFontFamily = useWikiStore((s) => s.uiFontFamily)
+  const setUiFontFamily = useWikiStore((s) => s.setUiFontFamily)
   const sidebarNavConfig = useWikiStore((s) => s.sidebarNavConfig)
   const setSidebarNavConfig = useWikiStore((s) => s.setSidebarNavConfig)
 
@@ -200,6 +205,7 @@ export function SettingsView() {
       maxHistoryMessages,
       i18n.language,
       uiFontSizeScale,
+      uiFontFamily,
       sidebarNavConfig,
       project?.path,
     ),
@@ -279,6 +285,7 @@ export function SettingsView() {
         maxHistoryMessages,
         prev.uiLanguage,
         uiFontSizeScale,
+        uiFontFamily,
         sidebarNavConfig,
         project?.path,
       ),
@@ -296,6 +303,7 @@ export function SettingsView() {
     novelConfig,
     maxHistoryMessages,
     uiFontSizeScale,
+    uiFontFamily,
     sidebarNavConfig,
     project,
   ])
@@ -318,6 +326,7 @@ export function SettingsView() {
       saveOutputLanguage,
       saveMaxHistoryMessages,
       saveUiFontSizeScale,
+      saveUiFontFamily,
     } = await import("@/lib/project-store")
 
     const newLlm = {
@@ -442,6 +451,8 @@ export function SettingsView() {
     await saveMaxHistoryMessages(draft.maxHistoryMessages, project?.id, project?.path)
     setUiFontSizeScale(draft.uiFontSizeScale)
     await saveUiFontSizeScale(draft.uiFontSizeScale, project?.id, project?.path)
+    setUiFontFamily(draft.uiFontFamily)
+    await saveUiFontFamily(draft.uiFontFamily)
     setSidebarNavConfig(draft.sidebarNavConfig)
 
     if (draft.uiLanguage !== i18n.language) {
@@ -467,6 +478,7 @@ export function SettingsView() {
     setMaxHistoryMessages,
     outputLanguage,
     setUiFontSizeScale,
+    setUiFontFamily,
     setSidebarNavConfig,
   ])
 

+ 4 - 0
src/i18n/en.json

@@ -1178,6 +1178,10 @@
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudgetHint": "0 means unlimited",
       "contextTokenBudgetHelp": "Limits the number of context tokens injected into one novel writing or extraction call. Use 0 for no extra limit.",
+      "chatHistoryLength": "Chat History Length",
+      "chatHistoryLengthHint": "Number of previous messages sent to the AI with each request. More history gives fuller context but uses more tokens.",
+      "chatHistoryLengthHelp": "Controls how many AI chat messages are included in each request. Higher values preserve more conversation context and consume more tokens.",
+      "chatHistoryLengthCurrent": "Currently {{count}} messages (about {{turns}} turns)",
       "chapterTargetChars": "Target Characters per Chapter",
       "chapterTargetCharsHint": "Controls the target character count (whitespace excluded) for one generated chapter. Chapter drafting, expansion thresholds, and the continue-next-chapter prompt all derive from this target. Default is 3000.",
       "autoIngestOnSave": "Auto-ingest memory when saving canon chapters",

+ 2 - 2
src/index.css

@@ -1,13 +1,12 @@
 @import "tailwindcss";
 @import "tw-animate-css";
 @import "shadcn/tailwind.css";
-@import "@fontsource-variable/geist";
 
 @custom-variant dark (&:is(.dark *, .deep-blue *));
 
 @theme inline {
     --font-heading: var(--font-sans);
-    --font-sans: 'Geist Variable', sans-serif;
+    --font-sans: var(--qmai-ui-font-family);
     --color-sidebar-ring: var(--sidebar-ring);
     --color-sidebar-border: var(--sidebar-border);
     --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -49,6 +48,7 @@
 }
 
 :root {
+    --qmai-ui-font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;
     --background: oklch(1 0 0);
     --foreground: oklch(0.145 0 0);
     --card: oklch(1 0 0);

+ 29 - 20
src/lib/changelog.spec.ts

@@ -6,31 +6,40 @@ describe("changelog", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions[0]).toBe("2.2.24")
-    expect(versions[1]).toBe("2.2.23")
-    expect(versions[2]).toBe("2.2.22")
-    expect(versions[3]).toBe("2.2.21")
-    expect(versions[4]).toBe("2.2.20")
-    expect(versions[5]).toBe("2.2.19")
-    expect(versions[6]).toBe("2.2.18")
-    expect(versions[7]).toBe("2.2.17")
-    expect(versions[8]).toBe("2.2.16")
-    expect(versions[9]).toBe("2.2.14")
-    expect(versions[10]).toBe("2.2.13")
-    expect(versions[11]).toBe("2.2.12")
-    expect(versions[12]).toBe("2.2.11")
-    expect(versions[13]).toBe("2.2.10")
-    expect(versions[14]).toBe("2.2.9")
-    expect(versions[15]).toBe("2.2.8")
-    expect(versions[16]).toBe("2.2.7")
-    expect(versions[17]).toBe("2.2.0")
-    expect(versions[18]).toBe("2.1.0")
-    expect(versions[19]).toBe("2.0.0")
+    expect(versions.slice(0, 24)).toEqual([
+      "2.2.30",
+      "2.2.29",
+      "2.2.27",
+      "2.2.26",
+      "2.2.25",
+      "2.2.24",
+      "2.2.23",
+      "2.2.22",
+      "2.2.21",
+      "2.2.20",
+      "2.2.19",
+      "2.2.18",
+      "2.2.17",
+      "2.2.16",
+      "2.2.14",
+      "2.2.13",
+      "2.2.12",
+      "2.2.11",
+      "2.2.10",
+      "2.2.9",
+      "2.2.8",
+      "2.2.7",
+      "2.2.0",
+      "2.1.0",
+    ])
+    expect(versions[24]).toBe("2.0.0")
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)
       expect(currentVersionChangelog(`2.2.${patch}`)).toEqual([])
     }
+    expect(versions).not.toContain("2.2.28")
+    expect(currentVersionChangelog("2.2.28")).toEqual([])
     for (let patch = 1; patch <= 10; patch += 1) {
       expect(versions).not.toContain(`2.1.${patch}`)
       expect(currentVersionChangelog(`2.1.${patch}`)).toEqual([])

+ 21 - 0
src/lib/changelog.ts

@@ -78,6 +78,25 @@ const TWO_POINT_TWO_ELEVEN_CHANGELOG: ChangelogEntry = {
   },
 }
 
+const TWO_POINT_TWO_THIRTY_CHANGELOG: ChangelogEntry = {
+  version: "2.2.30",
+  date: "2026-06-30",
+  highlights: {
+    en: [
+      "Fixed the Windows portable build opening the development localhost URL by restoring the Tauri CLI production build path.",
+      "Reduced portable package size by moving unused bundled font files out of public assets.",
+      "Kept only the actually used Geist and KaTeX font assets in the production frontend bundle.",
+      "Archived old portable executables under QMdelete so users do not accidentally open a stale build.",
+    ],
+    zh: [
+      "修复 Windows 便携版打开后访问 localhost:1420 被拒绝的问题,便携版重新改为通过 Tauri CLI 生成生产运行入口。",
+      "移出 public/fonts 中未被实际引用的大体积字体资源,避免未使用字体被 Vite 复制并嵌入便携版。",
+      "生产前端包仅保留当前实际使用的 Geist 字体和 KaTeX 小字体资源。",
+      "旧便携版 exe 已归档到 QMdelete,避免用户误打开旧包。",
+    ],
+  },
+}
+
 const TWO_POINT_TWO_TWENTY_NINE_CHANGELOG: ChangelogEntry = {
   version: "2.2.29",
   date: "2026-06-28",
@@ -671,6 +690,7 @@ export const CHANGELOG: ChangelogEntry[] = [
 ]
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === TWO_POINT_TWO_THIRTY_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_CHANGELOG]
   if (version === TWO_POINT_TWO_TWENTY_NINE_CHANGELOG.version) return [TWO_POINT_TWO_TWENTY_NINE_CHANGELOG]
   if (version === TWO_POINT_TWO_TWENTY_SEVEN_CHANGELOG.version) return [TWO_POINT_TWO_TWENTY_SEVEN_CHANGELOG]
   if (version === TWO_POINT_TWO_TWENTY_SIX_CHANGELOG.version) return [TWO_POINT_TWO_TWENTY_SIX_CHANGELOG]
@@ -704,6 +724,7 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    TWO_POINT_TWO_THIRTY_CHANGELOG,
     TWO_POINT_TWO_TWENTY_NINE_CHANGELOG,
     TWO_POINT_TWO_TWENTY_SEVEN_CHANGELOG,
     TWO_POINT_TWO_TWENTY_SIX_CHANGELOG,

+ 33 - 0
src/lib/font-settings.spec.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+import {
+  DEFAULT_UI_FONT_FAMILY,
+  getUiFontFamilyCss,
+  normalizeUiFontFamily,
+  applyUiFontFamily,
+} from "./font-settings"
+
+describe("font settings", () => {
+  it("defaults to the local system font and ignores invalid stored values", () => {
+    expect(DEFAULT_UI_FONT_FAMILY).toBe("system")
+    expect(normalizeUiFontFamily(null)).toBe("system")
+    expect(normalizeUiFontFamily("missing-font")).toBe("system")
+  })
+
+  it("resolves local machine font options to CSS font-family stacks", () => {
+    expect(getUiFontFamilyCss("system")).toContain("system-ui")
+    expect(getUiFontFamilyCss("microsoft-yahei")).toContain("Microsoft YaHei")
+  })
+
+  it("applies the selected font to the app root CSS variable", () => {
+    const values = new Map<string, string>()
+    const root = {
+      style: {
+        setProperty: (key: string, value: string) => values.set(key, value),
+      },
+    } as unknown as HTMLElement
+
+    applyUiFontFamily("microsoft-yahei", root)
+
+    expect(values.get("--qmai-ui-font-family")).toContain("Microsoft YaHei")
+  })
+})

+ 62 - 0
src/lib/font-settings.ts

@@ -0,0 +1,62 @@
+export const DEFAULT_UI_FONT_FAMILY = "system" as const
+
+export const UI_FONT_OPTIONS = [
+  {
+    value: "system",
+    label: "本机默认",
+    cssFamily:
+      'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif',
+  },
+  {
+    value: "microsoft-yahei",
+    label: "微软雅黑",
+    cssFamily: '"Microsoft YaHei", "Microsoft JhengHei", "PingFang SC", system-ui, sans-serif',
+  },
+  {
+    value: "simsun",
+    label: "宋体",
+    cssFamily: 'SimSun, "Songti SC", serif',
+  },
+  {
+    value: "kaiti",
+    label: "楷体",
+    cssFamily: 'KaiTi, "Kaiti SC", serif',
+  },
+  {
+    value: "fangsong",
+    label: "仿宋",
+    cssFamily: 'FangSong, "Fangsong SC", serif',
+  },
+  {
+    value: "dengxian",
+    label: "等线",
+    cssFamily: 'DengXian, "Microsoft YaHei", system-ui, sans-serif',
+  },
+  {
+    value: "arial",
+    label: "Arial",
+    cssFamily: 'Arial, "Microsoft YaHei", system-ui, sans-serif',
+  },
+] as const
+
+export type UiFontFamily = (typeof UI_FONT_OPTIONS)[number]["value"]
+
+const UI_FONT_FAMILY_VALUES = new Set<string>(UI_FONT_OPTIONS.map((option) => option.value))
+
+export function normalizeUiFontFamily(value: unknown): UiFontFamily {
+  return typeof value === "string" && UI_FONT_FAMILY_VALUES.has(value)
+    ? (value as UiFontFamily)
+    : DEFAULT_UI_FONT_FAMILY
+}
+
+export function getUiFontFamilyCss(value: unknown): string {
+  const normalized = normalizeUiFontFamily(value)
+  return UI_FONT_OPTIONS.find((option) => option.value === normalized)?.cssFamily
+    ?? UI_FONT_OPTIONS[0].cssFamily
+}
+
+export function applyUiFontFamily(value: unknown, root?: HTMLElement): void {
+  if (typeof document === "undefined" && !root) return
+  const target = root ?? document.documentElement
+  target.style.setProperty("--qmai-ui-font-family", getUiFontFamilyCss(value))
+}

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

@@ -3,6 +3,7 @@ import type { WikiProject } from "@/types/wiki"
 import type { LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, OutputLanguage, ProviderConfigs, ProxyConfig, ScheduledImportConfig, SourceWatchConfig, NovelConfig, RerankConfig } from "@/stores/wiki-store"
 import { DEFAULT_NOVEL_CONFIG, DEFAULT_RERANK_CONFIG } from "@/stores/wiki-store"
 import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
+import { normalizeUiFontFamily, type UiFontFamily } from "@/lib/font-settings"
 import { normalizePath } from "@/lib/path-utils"
 import { readFile, writeFile, fileExists } from "@/commands/fs"
 
@@ -590,6 +591,7 @@ export async function loadTheme(): Promise<"light" | "dark" | "deep-blue" | "sys
 }
 
 const UI_FONT_SIZE_SCALE_KEY = "uiFontSizeScale"
+const UI_FONT_FAMILY_KEY = "uiFontFamily"
 const MAX_HISTORY_MESSAGES_KEY = "maxHistoryMessages"
 
 export async function saveUiFontSizeScale(scale: number, _projectId?: string, _projectPath?: string): Promise<void> {
@@ -598,6 +600,18 @@ export async function saveUiFontSizeScale(scale: number, _projectId?: string, _p
   await store.save()
 }
 
+export async function saveUiFontFamily(fontFamily: UiFontFamily): Promise<void> {
+  const store = await getStore()
+  await store.set(UI_FONT_FAMILY_KEY, normalizeUiFontFamily(fontFamily))
+  await store.save()
+}
+
+export async function loadUiFontFamily(): Promise<UiFontFamily | null> {
+  const store = await getStore()
+  const saved = await store.get<string>(UI_FONT_FAMILY_KEY)
+  return saved ? normalizeUiFontFamily(saved) : null
+}
+
 export async function saveMaxHistoryMessages(max: number, _projectId?: string, _projectPath?: string): Promise<void> {
   const store = await getStore()
   await store.set(MAX_HISTORY_MESSAGES_KEY, max)

+ 21 - 0
src/stores/wiki-store.ts

@@ -10,6 +10,11 @@ import {
   normalizeSidebarNavConfig,
   type SidebarNavConfig,
 } from "@/lib/sidebar-nav-preferences"
+import {
+  DEFAULT_UI_FONT_FAMILY,
+  normalizeUiFontFamily,
+  type UiFontFamily,
+} from "@/lib/font-settings"
 
 const GRAPH_LABEL_MODE_KEY = "lk-graph-label-display-mode"
 const GRAPH_EDGE_COLOR_KEY = "lk-graph-edge-color"
@@ -18,6 +23,7 @@ const GRAPH_EDGE_STYLE_KEY = "lk-graph-edge-style"
 const GRAPH_EDGE_LABELS_ALWAYS_KEY = "lk-graph-edge-labels-always"
 const CHAT_DOCK_POSITION_KEY = "qmai-chat-dock-position"
 const UI_FONT_SIZE_SCALE_KEY = "qmai-ui-font-size-scale"
+const UI_FONT_FAMILY_KEY = "qmai-ui-font-family"
 const SIDEBAR_NAV_CONFIG_KEY = "qmai-sidebar-nav-config"
 
 export type ChatDockPosition = "bottom" | "right"
@@ -47,6 +53,11 @@ const readStoredUiFontSizeScale = (): number => {
   return Number.isFinite(saved) ? Math.max(0.85, Math.min(1.3, Number(saved.toFixed(2)))) : 1
 }
 
+const readStoredUiFontFamily = (): UiFontFamily => {
+  if (typeof localStorage === "undefined") return DEFAULT_UI_FONT_FAMILY
+  return normalizeUiFontFamily(localStorage.getItem(UI_FONT_FAMILY_KEY))
+}
+
 const readStoredSidebarNavConfig = (): SidebarNavConfig => {
   if (typeof localStorage === "undefined") return DEFAULT_SIDEBAR_NAV_CONFIG
   try {
@@ -556,6 +567,7 @@ interface WikiState {
   reviewRun: ReviewRunState | null
   theme: "light" | "dark" | "deep-blue" | "system"
   uiFontSizeScale: number
+  uiFontFamily: UiFontFamily
   sidebarNavConfig: SidebarNavConfig
   dataVersion: number
   bindingVersion: number
@@ -622,6 +634,7 @@ interface WikiState {
   clearTransientTaskState: () => void
   setTheme: (theme: "light" | "dark" | "deep-blue" | "system") => void
   setUiFontSizeScale: (scale: number) => void
+  setUiFontFamily: (fontFamily: UiFontFamily) => void
   setSidebarNavConfig: (config: Partial<SidebarNavConfig>) => void
   bumpDataVersion: () => void
   bumpBindingVersion: () => void
@@ -811,6 +824,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   reviewRun: null,
   theme: "system",
   uiFontSizeScale: readStoredUiFontSizeScale(),
+  uiFontFamily: readStoredUiFontFamily(),
   sidebarNavConfig: readStoredSidebarNavConfig(),
 
   setLlmConfig: (llmConfig) => set({ llmConfig }),
@@ -854,6 +868,13 @@ export const useWikiStore = create<WikiState>((set) => ({
     }
     set({ uiFontSizeScale: clamped })
   },
+  setUiFontFamily: (fontFamily) => {
+    const normalized = normalizeUiFontFamily(fontFamily)
+    if (typeof localStorage !== "undefined") {
+      localStorage.setItem(UI_FONT_FAMILY_KEY, normalized)
+    }
+    set({ uiFontFamily: normalized })
+  },
   setSidebarNavConfig: (config) => {
     const normalized = normalizeSidebarNavConfig(config)
     if (typeof localStorage !== "undefined") {

+ 195 - 0
zitishezhi-分支说明.md

@@ -0,0 +1,195 @@
+# zitishezhi 分支说明
+
+## 分支名称
+zitishezhi(字体设置)
+
+## 功能描述
+在"设置-界面"中新增字体设置功能,允许用户分别设置界面字体和等宽(编辑器)字体,默认跟随系统字体,内置多款免费商用字体。
+
+## 使用要求
+1. 本分支只用于开发字体设置功能
+2. 每个功能点修改后需测试旧功能不回退
+3. 功能完成后需打包验证
+4. 确认无问题后再合并回 main 分支
+
+## 更新记录
+
+### 2026-06-30(恢复字体设置源码)
+**问题结论:**
+- 当前工作树里没有保留 2026-06-29 记录的字体设置实现代码;`settings-types.ts`、`wiki-store.ts`、`project-store.ts`、`index.css`、`interface-section.tsx`、`settings-view.tsx`、`App.tsx` 中只剩界面字号和其他设置,没有字体字段、字体 UI、持久化或启动应用逻辑。
+- 字体资源被移出不是字体设置 UI 消失的原因;真正原因是旧功能代码未进入当前源码状态。
+
+**本次修复:**
+- 重新在“设置-界面”中接入“界面字体”选择,默认值为“本机默认”。
+- 字体方案改为使用本机系统字体栈和常见本机字体名称,不恢复 `public/fonts`,避免便携版体积再次增大。
+- 新增 `src/lib/font-settings.ts` 统一维护字体选项、非法值回退和 CSS 变量应用。
+- 接入 `SettingsDraft`、`wiki-store`、`project-store`、`settings-view`、`interface-section`、`App.tsx` 和 `index.css`。
+- 移除 `index.css` 对 `@fontsource-variable/geist` 的界面默认字体引用,改用 `--qmai-ui-font-family`。
+
+**验证结果:**
+- `npm.cmd run test:mocks`:通过,266 个测试文件、1971 个测试全部通过。
+- `npm.cmd run typecheck`:通过。
+- `npm.cmd run build`:通过。
+- `npm.cmd run build:portable`:通过,`release-portable/QMaiWrite.exe` 约 142.30MB,`release-portable` 约 151.37MB。
+- 已启动本次生成的便携版并截图验证,界面正常显示,不是 localhost 错误页。
+
+**是否提交:** 未提交。
+
+### 2026-06-29
+- 创建分支,基于 main 分支
+- 完成设计文档和实现计划
+- 完成所有代码开发(7个文件修改)
+  - settings-types.ts:扩展 SettingsDraft 类型
+  - wiki-store.ts:新增 Zustand 状态管理
+  - project-store.ts:新增 Tauri Store 持久化函数
+  - index.css:新增 @font-face 定义和 CSS 变量切换
+  - interface-section.tsx:新增字体设置 UI
+  - settings-view.tsx:保存逻辑对接
+  - App.tsx:启动时加载并应用字体
+- TypeScript 类型检查通过
+- 打包失败:main 分支本身存在 @milkdown/plugin-math 依赖构建错误,非本次改动导致
+
+### 2026-06-29(依赖修复)
+- 修复依赖问题:lucide-react 版本从 ^1.7.0 固定为 1.7.0
+  - 问题根因:lucide-react@1.21.0 版本存在 bug,icons/index.mjs 文件缺失但代码中引用了它
+  - 解决方案:将 lucide-react 版本固定在 1.7.0 稳定版本
+- 新增 .npmrc 文件,配置 shamefully-hoist=true 确保依赖正确提升
+- 前端构建测试通过(vite build)
+- 单元测试:252/265 文件通过,1925/1968 测试通过(失败项为需要 LLM 配置的测试和 UI 快照测试,非功能回退)
+
+### 2026-06-29(Rust 编译修复 + 打包)
+- 修复 Rust 编译元数据损坏问题:
+  - 问题根因:Cargo.toml 中 lib 的 crate-type 同时配置了 staticlib、cdylib、rlib,导致 rlib 元数据损坏
+  - 解决方案:将 crate-type 简化为 ["rlib"],移除未使用的 staticlib 和 cdylib
+- 修复 tauri build 无法编译的问题:
+  - 问题根因:tauri build 调用 cargo 时出现 E0786 元数据损坏错误
+  - 解决方案:直接使用 cargo build --release --bin qmai + build-portable.mjs 脚本打包
+- 便携版打包成功,输出位置:release-portable/QMaiWrite.exe
+- 版本保持 2.2.30
+
+### 2026-06-29(重新打包 - 修复 localhost 问题)
+- 重新构建前端,确保 dist 目录最新
+- 重新编译 release 版本的 exe(cargo build --release --bin qmai)
+- 验证:release 版本 exe 中不包含 devUrl(localhost:1420),说明使用嵌入的前端资源
+- 重新打包便携版
+- 注意:tauri build 命令因内存不足(LLVM out of memory)无法使用,改用 cargo build + build-portable.mjs 脚本
+
+### 2026-06-29(彻底修复 localhost 问题 + 移除 lib crate)
+**问题根因分析:**
+1. 为什么显示 "localhost 拒绝连接"?
+   - 因为 Tauri 的 `custom-protocol` feature 未启用,导致使用 devUrl (http://localhost:1420) 而非嵌入的前端资源
+   - tauri crate 的 build.rs 中逻辑:`let dev = !custom_protocol;`,即没启用 custom-protocol 就认为是开发模式
+2. 为什么加了 custom-protocol feature 后编译失败?
+   - 遇到 E0786 元数据损坏错误,lib crate 的 rlib 文件异常巨大(6.17 GB)
+   - 根本原因:lib crate 编译异常,导致 rlib 元数据损坏
+3. 为什么之前 cargo build --release 能成功?
+   - 因为那时候没有启用 custom-protocol feature,tauri 是预编译好的,只需要重新编译 llm-wiki 自己的代码
+
+**修复方案:**
+1. 移除 lib crate,直接把所有代码编译到 bin crate 中
+   - 删除 src/lib.rs,把内容合并到 src/main.rs
+   - 移除 Cargo.toml 中的 [lib] 配置
+2. 在 tauri 依赖中直接启用 custom-protocol feature
+   - `tauri = { version = "2", features = ["protocol-asset", "custom-protocol"] }`
+3. 调整 release profile 以减少内存占用
+   - 关闭 LTO,降低 opt-level 到 1,codegen-units = 8
+   - 后续可根据需要调整回高性能配置
+
+**验证结果:**
+- ✅ 编译成功(17 分 41 秒)
+- ✅ exe 中不包含 devUrl(localhost:1420)
+- ✅ 便携版打包成功
+- ⚠️ exe 体积较大(974 MB),因关闭了 LTO 和优化,后续可优化
+
+**修改的文件:**
+- src-tauri/Cargo.toml:移除 lib 配置,添加 custom-protocol feature,调整 release profile
+- src-tauri/src/main.rs:重写,包含原 lib.rs 的所有内容
+- src-tauri/src/lib.rs:已删除(内容合并到 main.rs)
+
+**版本:** 2.2.30
+
+### 2026-06-29(体积优化)
+**体积优化测试结果:**
+- 初始版本(无 LTO,opt-level=1):974 MB
+- thin LTO + opt-level=s:935 MB(减少 40 MB,约 4%)
+- full LTO + opt-level=s:918 MB(减少 56 MB,约 6%)
+- full LTO + opt-level=z:907 MB(减少 67 MB,约 7%)
+
+**当前配置:**
+- codegen-units = 1
+- lto = true
+- opt-level = "z"
+- panic = "unwind"
+- strip = true
+
+**注意事项:**
+- 体积较大(907 MB)的主要原因是依赖库本身较大(lancedb、datafusion、arrow 等)
+- 进一步优化可选方案:
+  1. 使用 UPX 压缩(可压缩到 30-50%,但启动稍慢)
+  2. 移除未使用的依赖和功能
+  3. 启用 panic = "abort"(需评估影响)
+  4. 使用 cargo-bloat 分析体积分布,针对性优化
+
+### 2026-06-30(修正便携版体积和 localhost 问题)
+**本次重新分析结论:**
+1. 900MB 便携版体积异常的主要原因不是 Rust 依赖本身,而是 `public/fonts` 中约 1.23GB 未使用字体被 Vite 复制进 `dist`,再被 Tauri 嵌入 exe。
+2. 当前源码实际使用的字体是 `@fontsource-variable/geist` 提供的 Geist Variable,以及 KaTeX 自带的小字体资源;没有发现对 `public/fonts` 内思源、Noto、霞鹜等字体文件的实际引用。
+3. `localhost:1420` 拒绝连接的直接原因是 `build:portable` 绕过 Tauri CLI 直接 cargo build,导致便携版运行入口使用开发地址。
+
+**处理结果:**
+1. 将未使用的 `public/fonts` 移动到 `QMdelete/public-fonts-unused-20260630`,未删除文件。
+2. 将 `build:portable` 改回 `npx.cmd tauri build --no-bundle` 后再执行 `scripts/build-portable.mjs`。
+3. 将旧备份 exe 移动到 `QMdelete/old-portable-exe-20260630-0816`,避免误打开旧包。
+4. 重新打包后 `release-portable/QMaiWrite.exe` 约 142.37MB,`release-portable` 总量约 151.44MB。
+5. 已启动最终便携版并截图验证,界面正常显示,不再出现 `localhost` 拒绝连接页面。
+
+**验证结果:**
+- `npm.cmd run build`:通过,有既有 chunk 体积和动态导入警告。
+- `npm.cmd run build:portable`:通过,已生成 Windows 便携版。
+- `npm.cmd run test:mocks`:未通过,当前仍有 13 个测试文件、43 个测试失败,集中在模型配置、队列、i18n/changelog、交互测试等既有问题。
+
+**是否提交:** 未提交
+
+### 2026-06-30(修复 test:mocks 失败)
+**问题根因分析:**
+1. 队列、文件同步、扫审、小说模型测试仍按旧规则只设置 `llmConfig.model`,但当前实现已经改为从 AI 会话模型 / 默认模型解析后台任务模型,所以测试任务会被判定为未配置 LLM。
+2. `src/i18n/en.json` 缺少 `novel.settings.chatHistoryLength*` 英文键,导致 i18n parity 失败。
+3. 当前包版本 `2.2.30` 缺少运行时 changelog 条目,release notes 生成脚本只能退回默认标题。
+4. 聊天输入框高度计算在短 footer 包装层里会把包装层误当成可用容器,导致拖拽最大高度被错误限制。
+5. 去 AI 味按钮测试仍断言旧的“点击后直接处理”行为,但当前真实交互是先选择 Skill 再处理。
+
+**处理结果:**
+1. 更新相关测试的模型状态,显式设置 AI 会话模型和清空默认模型,匹配当前模型解析规则。
+2. 修复聊天输入高度边界计算,短 footer 包装层不再作为最大高度容器;同时修正 NaN / Infinity 高度钳制。
+3. 补齐英文 i18n 键,并新增 `2.2.30` changelog 条目。
+4. 更新去 AI 味按钮测试断言,匹配当前 Skill 选择流程。
+
+**验证结果:**
+- `npm.cmd run test:mocks`:通过,265 个测试文件、1968 个测试全部通过。
+- `npm.cmd run build:portable`:通过,Windows 便携版已重新生成,版本保持 2.2.30。
+- 重新打包产生的 `release-portable/QMaiWrite-old.exe` 已移动到 `QMdelete/old-portable-exe-20260630-0849`,便携目录总量约 151.44MB。
+
+**是否提交:** 未提交
+
+## 字体选项
+- 界面字体:系统默认(默认)、思源黑体、思源宋体、霞鹜文楷
+- 等宽字体:系统等宽(默认)、思源等宽、霞鹜等宽
+
+## 技术实现
+- CSS 变量 + data-attribute 切换字体
+- localStorage 快速读取 + Tauri Store 持久化
+- 设置 draft 模式实时预览
+
+## 是否提交
+- 设计文档:已创建,未提交
+- 功能代码:开发完成,未提交
+- 依赖修复:已完成,未提交
+- Rust 编译修复:已完成,未提交
+- 便携版打包:已完成,未提交
+- Git 提交:未提交
+
+## 待办事项
+1. 字体文件需下载放入 public/fonts/ 目录(当前 CSS 中已定义 @font-face,但字体文件暂缺)
+2. 运行源码验证字体设置功能
+3. 测试便携版功能是否正常
+4. 确认无问题后合并回 main 分支

Some files were not shown because too many files changed in this diff