修复设置保存卡顿并优化诊断页 AI 校对状态显示(0.1.2)

- 修复保存润色模式/切换麦克风等设置后卡顿 2-4 秒:get_client_runtime_info 改用原生接口(GetUserDefaultLocaleName / GlobalMemoryStatusEx)替代 PowerShell,并把机器信息采集移出设置刷新热路径、启动时取一次复用

- 诊断页「AI 校对」未开启时不再显示绿色对勾,改为中性「未开启」状态并温和提示

- 通用设置无标题卡片补充「偏好设置」标题

- 更新 CHANGELOG 与关于页发布亮点,标注 0.1.2 发布日期
This commit is contained in:
crosswk committed 2026-07-24 12:12:13 +08:00
1 parent fc1eb4b0e7
commit d9ae553d3c
7 files changed
+68 -28

No files matched your search

+3 -1
View File
@@ -9,14 +9,16 @@
---
## [0.1.2] - 未发布
## [0.1.2] - 2026-07-24
### 优化
- 优化「备份与迁移」:导出不再弹出保存位置对话框,「配置」与「全部数据」都直接保存到默认下载目录,导出完成后就地显示文件路径与「打开所在文件夹」按钮,和其他下载体验保持一致。同时优化了页面文案,更清楚地说明两种备份分别包含什么、适合什么场景(「全部数据」在「配置」基础上额外包含历史记录与录音)。
- 优化诊断页面「AI 校对」的状态显示:未开启时不再显示为绿色对勾(易被误解为已启用),改为中性的「未开启」状态,并温和提示依赖 AI 的功能当前不会生效、以及在哪里开启。
### 修复
- 修复长时间后台运行后偶发「一直显示处理中、最后超时且无结果」的问题:SayIt 主窗口常驻后台隐藏时,系统会节流其心跳定时器,导致与服务器的连接被判为空闲而每隔约 5 分钟被回收;若录音恰好在连接被回收的瞬间开始,本次识别就会丢失并超时。现已让后台定时器不再被节流,保持心跳正常、连接稳定。
- 识别超时时不再丢失录音:即使这次识别超时没有结果,录音也会保存到历史记录中,可随时点「重新识别」重试。
- 修复保存润色模式(以及切换麦克风、静音、剪贴板保护等设置)后软件卡顿 2-4 秒的问题:此前每次保存设置都会重新采集一次机器信息(系统区域、物理内存等),采集时会临时启动 PowerShell,其冷启动耗时叠加导致卡顿。现改为启动时采集一次并复用,且换用系统原生接口获取,保存设置不再卡顿。
---
+2
View File
@@ -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",
+24 -19
View File
@@ -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::<f64>().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::<MEMORYSTATUSEX>() as u32,
..Default::default()
};
if unsafe { GlobalMemoryStatusEx(&mut status) }.is_ok() {
status.ullTotalPhys / (1024 * 1024)
} else {
0
}
}
#[cfg(not(target_os = "windows"))]
{
@@ -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 <CheckCircle2 className="h-4 w-4 text-green-500" />
if (status === 'error') return <XCircle className="h-4 w-4 text-red-500" />
// 未开启:灰色「关闭」图标,明确区别于「正常(绿勾)」和「失败(红叉)」
if (status === 'disabled') return <CircleSlash className="h-4 w-4 text-muted-foreground/50" />
return <MinusCircle className="h-4 w-4 text-muted-foreground/50" />
}
@@ -80,6 +82,9 @@ export default function DiagnosticsPage() {
const [showFileLog, setShowFileLog] = useState(true)
const [openFaq, setOpenFaq] = useState<number | null>(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() {
</div>
))}
</div>
{aiProofreadDisabled && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>AI 校对未开启,润色模式等依赖 AI 的功能不会生效。可在主页右上角,或主页侧边栏「AI 整理」页中开启。</span>
</div>
)}
</CardContent>
</Card>
@@ -143,6 +143,7 @@ export default function GeneralSettingsPage() {
<Card>
<CardContent className="space-y-4 p-6">
<h2 className="text-lg font-semibold">偏好设置</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">录音就绪提示音</p>
@@ -14,5 +14,6 @@ export const RELEASE_HIGHLIGHTS: ReleaseHighlights = {
items: [
'修复长时间后台运行后偶发「一直处理中、超时无结果」的问题:后台连接更稳定,不再被误判空闲而断开',
'识别超时不再丢录音:即使这次超时没结果,录音也会保存到历史,可随时「重新识别」',
'修复保存润色模式等设置后卡顿 2-4 秒的问题:机器信息改为启动时采集一次,日常改设置不再卡顿',
],
}
@@ -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。