From d9ae553d3c555bc8cb76f5fad18d143c94c96e79 Mon Sep 17 00:00:00 2001 From: crosswk Date: Fri, 24 Jul 2026 12:12:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=BE=E7=BD=AE=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=8D=A1=E9=A1=BF=E5=B9=B6=E4=BC=98=E5=8C=96=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E9=A1=B5=20AI=20=E6=A0=A1=E5=AF=B9=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=EF=BC=880.1.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复保存润色模式/切换麦克风等设置后卡顿 2-4 秒:get_client_runtime_info 改用原生接口(GetUserDefaultLocaleName / GlobalMemoryStatusEx)替代 PowerShell,并把机器信息采集移出设置刷新热路径、启动时取一次复用 - 诊断页「AI 校对」未开启时不再显示绿色对勾,改为中性「未开启」状态并温和提示 - 通用设置无标题卡片补充「偏好设置」标题 - 更新 CHANGELOG 与关于页发布亮点,标注 0.1.2 发布日期 --- CHANGELOG.md | 4 +- client/src-tauri/Cargo.toml | 2 + client/src-tauri/src/commands/system.rs | 43 +++++++++++-------- .../src/features/settings/DiagnosticsPage.tsx | 17 ++++++-- .../features/settings/GeneralSettingsPage.tsx | 1 + .../src/features/update/releaseHighlights.ts | 1 + .../services/recorder/RecorderOrchestrator.ts | 28 +++++++++--- 7 files changed, 68 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 761eef9..e19b196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,16 @@ --- -## [0.1.2] - 未发布 +## [0.1.2] - 2026-07-24 ### 优化 - 优化「备份与迁移」:导出不再弹出保存位置对话框,「配置」与「全部数据」都直接保存到默认下载目录,导出完成后就地显示文件路径与「打开所在文件夹」按钮,和其他下载体验保持一致。同时优化了页面文案,更清楚地说明两种备份分别包含什么、适合什么场景(「全部数据」在「配置」基础上额外包含历史记录与录音)。 +- 优化诊断页面「AI 校对」的状态显示:未开启时不再显示为绿色对勾(易被误解为已启用),改为中性的「未开启」状态,并温和提示依赖 AI 的功能当前不会生效、以及在哪里开启。 ### 修复 - 修复长时间后台运行后偶发「一直显示处理中、最后超时且无结果」的问题:SayIt 主窗口常驻后台隐藏时,系统会节流其心跳定时器,导致与服务器的连接被判为空闲而每隔约 5 分钟被回收;若录音恰好在连接被回收的瞬间开始,本次识别就会丢失并超时。现已让后台定时器不再被节流,保持心跳正常、连接稳定。 - 识别超时时不再丢失录音:即使这次识别超时没有结果,录音也会保存到历史记录中,可随时点「重新识别」重试。 +- 修复保存润色模式(以及切换麦克风、静音、剪贴板保护等设置)后软件卡顿 2-4 秒的问题:此前每次保存设置都会重新采集一次机器信息(系统区域、物理内存等),采集时会临时启动 PowerShell,其冷启动耗时叠加导致卡顿。现改为启动时采集一次并复用,且换用系统原生接口获取,保存设置不再卡顿。 --- diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index ac39bde..ec22622 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -44,12 +44,14 @@ sherpa-onnx = { version = "1.13.3", default-features = false, features = ["stati [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_Globalization", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Accessibility", "Win32_System_Threading", "Win32_System_DataExchange", "Win32_System_Memory", + "Win32_System_SystemInformation", "Win32_System_Com", "Win32_Graphics_Gdi", "Win32_Media_Audio", diff --git a/client/src-tauri/src/commands/system.rs b/client/src-tauri/src/commands/system.rs index 7c72a88..1ab40b8 100644 --- a/client/src-tauri/src/commands/system.rs +++ b/client/src-tauri/src/commands/system.rs @@ -116,17 +116,20 @@ fn get_local_ip() -> String { } fn get_system_locale() -> String { + // 用原生 Win32 API 取区域设置,避免拉起 PowerShell(冷启动 1-2 秒)。 + // GetUserDefaultLocaleName 返回形如 "zh-CN" 的 BCP-47 名称,与原 (Get-Culture).Name 一致。 #[cfg(target_os = "windows")] { - use std::process::Command; - Command::new("powershell") - .args(["-NoProfile", "-Command", "(Get-Culture).Name"]) - .creation_flags(0x08000000) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s: String| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()) + use windows::Win32::Globalization::GetUserDefaultLocaleName; + // LOCALE_NAME_MAX_LENGTH = 85 + let mut buf = [0u16; 85]; + let len = unsafe { GetUserDefaultLocaleName(&mut buf) }; + if len > 1 { + // 返回值含结尾的 NUL,切掉它。 + String::from_utf16_lossy(&buf[..(len as usize - 1)]) + } else { + "unknown".to_string() + } } #[cfg(not(target_os = "windows"))] { @@ -135,18 +138,20 @@ fn get_system_locale() -> String { } fn get_total_memory_mb() -> u64 { + // 用原生 GlobalMemoryStatusEx 取物理内存,避免拉起 PowerShell。 + // ullTotalPhys 为字节,/ 1MB 与原 TotalPhysicalMemory / 1MB 语义一致。 #[cfg(target_os = "windows")] { - use std::process::Command; - Command::new("powershell") - .args(["-NoProfile", "-Command", "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1MB"]) - .creation_flags(0x08000000) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s: String| s.trim().parse::().ok()) - .map(|v| v as u64) - .unwrap_or(0) + use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + let mut status = MEMORYSTATUSEX { + dwLength: std::mem::size_of::() as u32, + ..Default::default() + }; + if unsafe { GlobalMemoryStatusEx(&mut status) }.is_ok() { + status.ullTotalPhys / (1024 * 1024) + } else { + 0 + } } #[cfg(not(target_os = "windows"))] { diff --git a/client/src/features/settings/DiagnosticsPage.tsx b/client/src/features/settings/DiagnosticsPage.tsx index 55d222d..d648707 100644 --- a/client/src/features/settings/DiagnosticsPage.tsx +++ b/client/src/features/settings/DiagnosticsPage.tsx @@ -6,11 +6,11 @@ import { Card, CardContent } from '@/components/ui/card' import { getSetting } from '@/services/store' import { getRuntimeEvents, type RuntimeEvent } from '@/services/debugLog' import { getWorkMode } from '@/services/transcription' -import { FolderOpen, RefreshCw, CheckCircle2, XCircle, MinusCircle, HelpCircle, ChevronDown } from 'lucide-react' +import { FolderOpen, RefreshCw, CheckCircle2, XCircle, MinusCircle, CircleSlash, Info, HelpCircle, ChevronDown } from 'lucide-react' import { Tooltip } from '@/components/ui/tooltip' import DiagnosticsReportPanel from './DiagnosticsReportPanel' -type HealthStatus = 'ok' | 'error' | 'unknown' +type HealthStatus = 'ok' | 'error' | 'unknown' | 'disabled' interface HealthItem { label: string @@ -54,6 +54,8 @@ const FAQ_ITEMS: { q: string; answer: React.ReactNode }[] = [ function StatusIcon({ status }: { status: HealthStatus }) { if (status === 'ok') return if (status === 'error') return + // 未开启:灰色「关闭」图标,明确区别于「正常(绿勾)」和「失败(红叉)」 + if (status === 'disabled') return return } @@ -80,6 +82,9 @@ export default function DiagnosticsPage() { const [showFileLog, setShowFileLog] = useState(true) const [openFaq, setOpenFaq] = useState(0) + // AI 校对处于「未开启」状态时,给出温和提示(依赖 AI 的功能不会生效) + const aiProofreadDisabled = health.some((item) => item.label === 'AI 校对' && item.status === 'disabled') + useEffect(() => { void doHealthCheck() setEvents(getRuntimeEvents()) @@ -147,7 +152,7 @@ export default function DiagnosticsPage() { if (workMode !== 'server') { const aiEnabled = await getSetting('aiEnabled', false) as boolean if (!aiEnabled) { - items.push({ label: 'AI 校对', status: 'ok', detail: '已关闭(极速模式)' }) + items.push({ label: 'AI 校对', status: 'disabled', detail: '未开启(极速模式)' }) } else { const aiProvider = await getSetting('cloudAi.provider', '') as string const aiApiKey = await getSetting('cloudAi.apiKey', '') as string @@ -243,6 +248,12 @@ export default function DiagnosticsPage() { ))} + {aiProofreadDisabled && ( +
+ + AI 校对未开启,润色模式等依赖 AI 的功能不会生效。可在主页右上角,或主页侧边栏「AI 整理」页中开启。 +
+ )} diff --git a/client/src/features/settings/GeneralSettingsPage.tsx b/client/src/features/settings/GeneralSettingsPage.tsx index cfdd77c..79e7273 100644 --- a/client/src/features/settings/GeneralSettingsPage.tsx +++ b/client/src/features/settings/GeneralSettingsPage.tsx @@ -143,6 +143,7 @@ export default function GeneralSettingsPage() { +

偏好设置

录音就绪提示音

diff --git a/client/src/features/update/releaseHighlights.ts b/client/src/features/update/releaseHighlights.ts index d1574bc..b1a219b 100644 --- a/client/src/features/update/releaseHighlights.ts +++ b/client/src/features/update/releaseHighlights.ts @@ -14,5 +14,6 @@ export const RELEASE_HIGHLIGHTS: ReleaseHighlights = { items: [ '修复长时间后台运行后偶发「一直处理中、超时无结果」的问题:后台连接更稳定,不再被误判空闲而断开', '识别超时不再丢录音:即使这次超时没结果,录音也会保存到历史,可随时「重新识别」', + '修复保存润色模式等设置后卡顿 2-4 秒的问题:机器信息改为启动时采集一次,日常改设置不再卡顿', ], } diff --git a/client/src/services/recorder/RecorderOrchestrator.ts b/client/src/services/recorder/RecorderOrchestrator.ts index 883016f..7d17782 100644 --- a/client/src/services/recorder/RecorderOrchestrator.ts +++ b/client/src/services/recorder/RecorderOrchestrator.ts @@ -121,6 +121,9 @@ export class RecorderOrchestrator { private cachedActivePresetId = 'intent' private cachedAiEnabled = true private cachedClientRuntimeInfo: ClientRuntimeInfo | null = null + /** 客户端运行时信息是否已成功采集一次。它是进程内静态元数据(区域/内存/主机名等), + * 只需启动时取一次,不该挂在每次改设置都触发的刷新热路径上。失败时保持 false 以便重试。 */ + private clientRuntimeInfoLoaded = false private cachedAppPromptRules: AppPromptRule[] = [] private cachedUserStats: UserStats = createDefaultUserStats() private cachedHotwords: string[] = [] @@ -261,6 +264,8 @@ export class RecorderOrchestrator { startInsertionTargetTracking() await this.refreshRuntimeSettings() + // 静态机器信息只在启动采集一次;不 await,避免拖慢建连(clientMeta 可空,采集期间为 null 无碍)。 + void this.ensureClientRuntimeInfo() this.ensureConnection() // 快捷键切换润色模式(由 Rust global_shortcut 触发) @@ -434,9 +439,11 @@ export class RecorderOrchestrator { this.cachedStreamingDisplay = Boolean(streamingDisplay) await this.overlayService.refreshSettings() - // 热词 / 服务器语言 / 客户端运行时信息彼此独立,并行加载而非依次 await, - // 减少本函数的总耗时(每次切换 AI 整理开关等都会触发这里)。 - const [hotwordsResult, languageResult, runtimeInfoResult] = await Promise.allSettled([ + // 热词 / 服务器语言彼此独立,并行加载而非依次 await,减少本函数的总耗时 + // (每次切换 AI 整理开关等都会触发这里)。 + // 客户端运行时信息(区域/内存/主机名等)是进程内静态元数据,已移出此热路径, + // 仅在启动时经 ensureClientRuntimeInfo() 采集一次,避免改任意设置都重新采集。 + const [hotwordsResult, languageResult] = await Promise.allSettled([ Promise.all([ getSetting(BUILTIN_SET_WORDS_KEY, {}), getSetting(BUILTIN_SET_ACTIVE_KEY, {}), @@ -453,12 +460,23 @@ export class RecorderOrchestrator { const l = lang as string return l && l !== 'auto' ? l : '' }), - bridge.getClientRuntimeInfo(), ]) this.cachedHotwords = hotwordsResult.status === 'fulfilled' ? hotwordsResult.value : [] this.cachedLanguage = languageResult.status === 'fulfilled' ? languageResult.value : '' - this.cachedClientRuntimeInfo = runtimeInfoResult.status === 'fulfilled' ? runtimeInfoResult.value : null + } + + /** 采集一次客户端运行时信息并缓存。静态元数据,进程内只成功采集一次; + * 失败不置位 loaded,留待下次重试(避免把 unknown/0 永久缓存)。 + * clientMeta 本就可空,采集期间为 null 不影响业务。 */ + async ensureClientRuntimeInfo() { + if (this.clientRuntimeInfoLoaded) return + try { + this.cachedClientRuntimeInfo = await bridge.getClientRuntimeInfo() + this.clientRuntimeInfoLoaded = true + } catch { + this.cachedClientRuntimeInfo = null + } } /** 工作模式或服务地址变更后强制重连 provider。