0.1.3: 修复右Alt热键抢焦点、数字规范化误转「一点」、诊断页AI未开启;新增模型自定义存储位置与历史手工编辑

- 键盘钩子:Alt 类单键热键(按住说话/免提)连 keyup 一起吞,避免松键呼出目标程序菜单栏抢走输入框焦点、导致插入落空但日志显示成功
- 数字规范化:整点仅在有明确时间信号时才转,口语「一点」不再被误转为「1点」
- 诊断页:所有模式(含默认的服务器模式)关闭「AI 整理」后均显示「未开启(极速模式)」
- 新增本地模型自定义存储位置:可改存到其它磁盘,更换目录后自动迁移已下载模型
- 历史记录支持手工编辑整理结果:失焦/离开页面自动保存,编辑过的记录加小图标标记
- 版本号 0.1.2 → 0.1.3,同步更新 CHANGELOG 与关于页「本次更新」亮点
This commit is contained in:
crosswk committed 2026-07-29 13:40:14 +08:00
1 parent 73c8fa2a08
commit 7d5eade190
17 files changed
+654 -222

No files matched your search

+13
View File
@@ -9,6 +9,19 @@
---
## [0.1.3] - 2026-07-29
### 新增
- 本地识别模型支持自定义存储位置:模型文件较大,若系统盘空间紧张,可在「本地模式」设置里把模型改存到其它磁盘;更换目录后,已下载的模型会自动迁移到新位置。
- 历史记录支持手工修改:可直接编辑整理后的文本,改完点击其它地方即自动保存(无需保存按钮,按 Esc 取消);编辑过的记录会用小图标标记,便于日后辨认。
### 修复
- 修复用「右 Alt」等 Alt 键作为「按住说话」快捷键时,松开按键会呼出目标程序(如记事本)的菜单栏、使光标离开输入框,导致识别文本插不进去、日志却显示插入成功的问题。现已在系统按键层面正确拦截 Alt 键抬起,插入恢复正常。
- 修复数字规范化把口语里的「一点」(表示"稍微、一点点")误当成时间「1 点」的问题:如「调宽一点」不再被改成「调宽 1 点」。整点时间仅在有明确时间信号时才转换(如「下午一点」「一点钟」「十二点」)。
- 修复关闭「AI 整理」后,默认的服务器模式下诊断页仍把「AI 校对」显示为正常的问题:现在无论哪种模式,关闭后都会明确显示为「未开启(极速模式)」。
---
## [0.1.2] - 2026-07-24
### 新增
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "sayit-tauri",
"version": "0.0.2",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sayit-tauri",
"version": "0.0.2",
"version": "0.1.3",
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
@@ -4574,4 +4574,4 @@
}
}
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "sayit-tauri",
"version": "0.1.2",
"version": "0.1.3",
"description": "SayIt - Voice to Text Desktop App (Tauri)",
"type": "module",
"scripts": {
@@ -49,4 +49,4 @@
"vite": "^6.0.3",
"vitest": "^4.1.4"
}
}
}
+1 -1
View File
@@ -4024,7 +4024,7 @@ dependencies = [
[[package]]
name = "sayit"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"base64 0.22.1",
"bzip2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sayit"
version = "0.1.2"
version = "0.1.3"
authors = ["Liu Qianglong", "Claude"]
edition = "2021"
+16 -3
View File
@@ -860,6 +860,14 @@ unsafe extern "system" fn low_level_keyboard_proc(
&& state.hf_vk_codes.contains(&vk)
&& !is_ptt_key; // PTT takes priority if same key
// Alt 键(VK_LMENU=0xA4 / VK_RMENU=0xA5)作为单键热键时,keyup 也必须吞掉。
// 否则被放行的 Alt 抬起会被 Windows 判为「单击了一下 Alt」→ 激活前台程序的
// 菜单栏(如记事本显示「文件(F)」等加速键下划线),抢走输入框焦点,导致随后
// 插字落到菜单上、实际没插进去(而 SendInput 仍上报成功,形成假成功)。
// keydown 既已被吞、系统自始至终没见过这个 Alt,连 keyup 一起吞不会造成
// 「修饰键卡住」。非 Alt 单键(F 键、Space、侧键等)维持原状:只吞 keydown。
let is_alt_key = vk == 0xA4 || vk == 0xA5;
// ── 诊断:仅在「当前配置的 PTT/免提热键」被按下时记录 flags/scanCode ──
// 只记热键本身(不记正常打字的 Ctrl/Shift 等),量可控;用于抓远程桌面
// 场景下误触发热键的那次事件特征,和真实按键对比后再对症过滤幻影键。
@@ -879,9 +887,10 @@ unsafe extern "system" fn low_level_keyboard_proc(
let is_down = msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN;
let is_up = msg == WM_KEYUP || msg == WM_SYSKEYUP;
// 只吞掉 keydown,keyup 必须放行让系统更新键盘状态
// 否则 Windows 会认为修饰键一直按着
if is_down {
// 吞掉 keydown。keyup 默认放行(让系统更新键盘状态,否则 Windows 会
// 认为修饰键一直按着);但 Alt 类键的 keyup 必须一并吞掉,见上方
// is_alt_key 处的说明(防止激活前台菜单栏抢焦点)。
if is_down || (is_up && is_alt_key) {
consumed = true;
}
@@ -935,6 +944,10 @@ unsafe extern "system" fn low_level_keyboard_proc(
state.hf_key_down.store(true, Ordering::SeqCst);
}
if is_up {
// Alt 类免提键的 keyup 也吞掉,避免激活前台菜单栏(与 PTT 同理)。
if is_alt_key {
consumed = true;
}
// 只有配对过真实 keydown 才 toggle;孤儿 keyup(幻影)直接忽略。
let had_down = state.hf_key_down.swap(false, Ordering::SeqCst);
if had_down {
+10
View File
@@ -249,6 +249,14 @@ fn main() {
log::info!("PTT setting from DB: raw={:?} parsed={:?}", ptt_setting_val, ptt_str);
log::info!("HF setting from DB: raw={:?} parsed={:?}", hf_setting_val, hf_str);
// 自定义模型存储目录:启动时读回并设为进程内生效路径(空串 = 用默认)。
// 必须在任何取模型路径的调用之前设定,且要在 storage 被 move 进 manage 之前读。
let models_dir_val = storage.get("localAsr.modelsDir", None);
if let Some(custom_dir) = models_dir_val.as_str().map(str::trim).filter(|s| !s.is_empty()) {
models::downloader::set_custom_models_dir(Some(std::path::PathBuf::from(custom_dir)));
log::info!("Custom models dir from DB: {}", custom_dir);
}
let window_state = WindowState::new();
let keyboard_hook = KeyboardHookManager::new();
let context_detector = ContextDetector::new();
@@ -518,6 +526,8 @@ fn main() {
models::registry::delete_model,
models::registry::open_models_folder,
models::registry::open_model_folder,
models::registry::get_models_dir,
models::registry::set_models_dir,
models::local_asr::local_transcribe,
models::local_asr::preload_local_model,
models::local_asr::unload_local_model,
+25 -2
View File
@@ -2,6 +2,7 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use tauri::{AppHandle, Emitter};
#[derive(Debug, Clone, Serialize)]
@@ -22,14 +23,36 @@ pub struct DownloadProgress {
pub error: Option<String>,
}
/// 获取模型存储根目录
pub fn models_dir() -> PathBuf {
/// 用户自定义的模型存储根目录(进程级)。None = 用默认路径。
/// 启动时由 main.rs 从设置 `localAsr.modelsDir` 灌入;用户在设置里更改时同步更新。
/// 所有取模型路径的地方都走 `models_dir()`,改这一处即全链路生效。
static CUSTOM_MODELS_DIR: RwLock<Option<PathBuf>> = RwLock::new(None);
/// 默认模型存储根目录(未自定义时使用)。
pub fn default_models_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("com.sayit.app")
.join("models")
}
/// 设置/清除自定义模型根目录。传 None 恢复默认。
pub fn set_custom_models_dir(dir: Option<PathBuf>) {
if let Ok(mut guard) = CUSTOM_MODELS_DIR.write() {
*guard = dir;
}
}
/// 获取模型存储根目录:优先自定义路径,否则默认路径。
pub fn models_dir() -> PathBuf {
if let Ok(guard) = CUSTOM_MODELS_DIR.read() {
if let Some(ref dir) = *guard {
return dir.clone();
}
}
default_models_dir()
}
/// 获取指定模型的目录
pub fn model_dir(model_id: &str) -> PathBuf {
models_dir().join(model_id)
+124
View File
@@ -2,6 +2,9 @@
use super::catalog::{self, LocalModelInfo, ModelInfo};
use super::downloader;
use crate::storage::Storage;
use serde::Serialize;
use std::path::Path;
use tauri::AppHandle;
/// 列出所有可用模型
@@ -198,6 +201,127 @@ pub fn open_models_folder() -> Result<String, String> {
Ok(path)
}
/// 模型存储位置信息(供设置页展示)
#[derive(Debug, Clone, Serialize)]
pub struct ModelsDirInfo {
/// 当前生效的模型根目录
pub current: String,
/// 默认模型根目录
pub default_dir: String,
/// 当前是否为用户自定义(非默认)
pub is_custom: bool,
}
/// 查询当前模型存储位置
#[tauri::command]
pub fn get_models_dir() -> ModelsDirInfo {
let current = downloader::models_dir();
let default_dir = downloader::default_models_dir();
ModelsDirInfo {
is_custom: current != default_dir,
current: current.to_string_lossy().to_string(),
default_dir: default_dir.to_string_lossy().to_string(),
}
}
/// 递归复制目录/文件(跨盘 rename 失败时的兜底)。
fn copy_path_recursive(src: &Path, dst: &Path) -> Result<(), String> {
if src.is_dir() {
std::fs::create_dir_all(dst).map_err(|e| format!("创建目录失败: {}", e))?;
for entry in std::fs::read_dir(src).map_err(|e| format!("读取目录失败: {}", e))? {
let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?;
copy_path_recursive(&entry.path(), &dst.join(entry.file_name()))?;
}
} else {
std::fs::copy(src, dst).map_err(|e| format!("复制文件失败: {}", e))?;
}
Ok(())
}
/// 把旧模型根目录下的每个条目移动到新根目录。
/// 优先 rename(同盘瞬时);跨盘失败则递归复制后删除源。
/// 目标已存在同名条目则跳过(不覆盖新目录已有内容)。
fn move_dir_contents(from: &Path, to: &Path) -> Result<(), String> {
for entry in std::fs::read_dir(from).map_err(|e| format!("读取旧目录失败: {}", e))? {
let entry = entry.map_err(|e| format!("读取条目失败: {}", e))?;
let src = entry.path();
let dst = to.join(entry.file_name());
if dst.exists() {
continue;
}
if std::fs::rename(&src, &dst).is_ok() {
continue;
}
// 跨盘:递归复制后删除源
copy_path_recursive(&src, &dst)?;
if src.is_dir() {
let _ = std::fs::remove_dir_all(&src);
} else {
let _ = std::fs::remove_file(&src);
}
}
Ok(())
}
/// 设置模型存储位置。
/// - `dir`:新目录(绝对路径);传 None 或空串 = 恢复默认路径。
/// - `move_existing`:为 true 时把旧目录下已下载的模型一并移动到新目录。
///
/// 会校验新目录可创建且可写;随后更新进程内生效路径并持久化到设置,
/// 使重启后仍生效(main.rs 启动时会读取 `localAsr.modelsDir`)。
#[tauri::command]
pub async fn set_models_dir(
storage: tauri::State<'_, Storage>,
dir: Option<String>,
move_existing: bool,
) -> Result<String, String> {
let trimmed = dir
.as_deref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let old_dir = downloader::models_dir();
let new_dir = match &trimmed {
Some(d) => std::path::PathBuf::from(d),
None => downloader::default_models_dir(),
};
// 目标与当前一致:仅确保持久化的设置与之同步,不做迁移。
if new_dir != old_dir {
let old_for_task = old_dir.clone();
let new_for_task = new_dir.clone();
// fs 操作(可能是跨盘大文件复制)放到阻塞线程池,避免占用异步运行时线程。
tokio::task::spawn_blocking(move || -> Result<(), String> {
std::fs::create_dir_all(&new_for_task)
.map_err(|e| format!("无法创建目录: {}", e))?;
// 可写性探测
let probe = new_for_task.join(".sayit_write_test");
std::fs::write(&probe, b"ok").map_err(|e| format!("目录不可写: {}", e))?;
let _ = std::fs::remove_file(&probe);
if move_existing && old_for_task.exists() {
move_dir_contents(&old_for_task, &new_for_task)?;
}
Ok(())
})
.await
.map_err(|e| format!("迁移任务异常: {}", e))??;
}
// 更新进程内生效路径:自定义 → Some,恢复默认 → None
downloader::set_custom_models_dir(trimmed.as_ref().map(|_| new_dir.clone()));
// 持久化(重启后由 main.rs 读回)。恢复默认时存空串。
let value = match &trimmed {
Some(d) => serde_json::json!(d),
None => serde_json::json!(""),
};
storage
.set("localAsr.modelsDir", &value)
.map_err(|e| format!("保存设置失败: {}", e))?;
Ok(new_dir.to_string_lossy().to_string())
}
/// 打开指定模型的存储目录(不存在则创建)
#[tauri::command]
pub fn open_model_folder(model_id: String) -> Result<String, String> {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"productName": "SayIt",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "com.sayit.app",
"build": {
"frontendDist": "../dist",
@@ -69,4 +69,4 @@
"open": true
}
}
}
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Trash2, ChevronDown, ChevronUp, VolumeX, Star, Play, Pause, RotateCcw, Loader2, Download, Check, Copy, X, FolderOpen } from 'lucide-react'
import { Trash2, ChevronDown, ChevronUp, VolumeX, Star, Play, Pause, RotateCcw, Loader2, Download, Check, Copy, X, FolderOpen, Pencil } from 'lucide-react'
import { Tooltip } from '@/components/ui/tooltip'
import { Card, CardContent } from '@/components/ui/card'
import { type HistoryRecord } from '@/services/store'
@@ -25,6 +25,8 @@ interface HistoryRecordListProps {
onDelete: (id: string) => Promise<void> | void
onToggleFavorite?: (id: string, nextFavorite: boolean) => Promise<void> | void
onReprocess?: (record: HistoryRecord) => Promise<void> | void
/** 手工编辑转换结果并保存 */
onEdit?: (id: string, nextText: string) => Promise<void> | void
emptyText?: string
/** 搜索关键词:在正文与 ASR 原文里高亮命中处 */
highlight?: string
@@ -78,15 +80,19 @@ function HistoryItem({
onDelete,
onToggleFavorite,
onReprocess,
onEdit,
highlight = '',
}: {
record: HistoryRecord
onDelete: () => void
onToggleFavorite?: (nextFavorite: boolean) => void
onReprocess?: () => Promise<void> | void
onEdit?: (nextText: string) => Promise<void> | void
highlight?: string
}) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState('')
const [audioPlaying, setAudioPlaying] = useState(false)
const [audioLoading, setAudioLoading] = useState(false)
const [reprocessing, setReprocessing] = useState(false)
@@ -202,6 +208,41 @@ function HistoryItem({
}
}, [onReprocess, reprocessing])
// ── 编辑:自动保存(失焦 / 离开页面即存,无需按钮)──
// 用 ref 保存最新值,供失焦回调与卸载清理读取,避免闭包拿到旧值。
const editTextRef = useRef('')
const editingRef = useRef(false)
const textRef = useRef(text)
const onEditRef = useRef(onEdit)
textRef.current = text
onEditRef.current = onEdit
const startEdit = useCallback(() => {
editTextRef.current = text
editingRef.current = true
setEditText(text)
setEditing(true)
}, [text])
// 提交:仅当仍在编辑且内容有变化时写回。幂等——重复调用(失焦后又卸载)不会重复保存。
const commitEdit = useCallback(async () => {
if (!editingRef.current) return
editingRef.current = false
setEditing(false)
const next = editTextRef.current
if (!onEditRef.current || next === textRef.current) return
await onEditRef.current(next)
}, [])
// 取消:不保存,直接退出(后续失焦/卸载因 editingRef=false 而跳过)。
const cancelEdit = useCallback(() => {
editingRef.current = false
setEditing(false)
}, [])
// 离开页面(组件卸载)时若仍在编辑,自动保存。
useEffect(() => () => { void commitEdit() }, [commitEdit])
const handleDownloadAudio = useCallback(async () => {
if (!record.audioFilePath || downloading) return
setDownloading(true)
@@ -270,7 +311,24 @@ function HistoryItem({
</span>
<div className="min-w-0 flex-1">
{isEmpty ? (
{editing ? (
<div onClick={(e) => e.stopPropagation()}>
<textarea
value={editText}
onChange={(e) => { setEditText(e.target.value); editTextRef.current = e.target.value }}
autoFocus
rows={Math.min(Math.max(editText.split('\n').length, 2), 12)}
className="w-full resize-y rounded-md border border-input-border bg-input-bg px-2.5 py-1.5 text-sm leading-relaxed focus:border-input-focus-border focus:outline-none"
onBlur={() => { void commitEdit() }}
onKeyDown={(e) => {
// Esc 取消(不保存);Ctrl/Cmd+Enter 立即保存(失焦触发提交)
if (e.key === 'Escape') { e.preventDefault(); cancelEdit() }
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); (e.target as HTMLTextAreaElement).blur() }
}}
/>
<span className="mt-1 inline-block text-[11px] text-muted-foreground/50">改完点击其它地方即自动保存 · Esc 取消</span>
</div>
) : isEmpty ? (
<div className="flex items-center gap-2">
<VolumeX className="h-3.5 w-3.5 text-muted-foreground/40" />
<p className="text-sm italic text-muted-foreground/60">无有效声音</p>
@@ -287,13 +345,24 @@ function HistoryItem({
})
}}
>
{text.includes('\n') ? (
text.split(/\n{2,}/).map((para, idx) => (
<p key={idx} className={idx > 0 ? 'mt-1.5' : undefined}>{highlightText(para, highlight)}</p>
))
) : (
<p>{highlightText(text, highlight)}</p>
)}
{(() => {
// 编辑过的记录:在正文末尾内联一个小铅笔图标(hover 提示「已编辑」),不占额外行、不混入正文文本
const editedMark = record.manualEditedAt ? (
<Tooltip content="已编辑">
<Pencil className="ml-1 inline-block h-3 w-3 translate-y-[1px] text-muted-foreground/40" />
</Tooltip>
) : null
if (text.includes('\n')) {
const paras = text.split(/\n{2,}/)
return paras.map((para, idx) => (
<p key={idx} className={idx > 0 ? 'mt-1.5' : undefined}>
{highlightText(para, highlight)}
{idx === paras.length - 1 && editedMark}
</p>
))
}
return <p>{highlightText(text, highlight)}{editedMark}</p>
})()}
</div>
)}
@@ -304,151 +373,150 @@ function HistoryItem({
<div className="overflow-hidden">
{(expanded || true) && (
<div className="mt-2 space-y-2 text-xs">
{!isEmpty && record.asrText && (
<div className="text-muted-foreground">
<span className="font-medium">ASR 原文:</span>
<span className="whitespace-pre-line">{highlightText(record.asrText, highlight)}</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
{record.workMode && (
<>
<span className="rounded border border-border px-1.5 py-0.5 text-xs">
{record.workMode === 'server' ? '服务器' : record.workMode === 'cloud_api' ? '云 API' : '本地'}
</span>
{record.asrProvider && (
<span className="text-xs">ASR: {ASR_PROVIDER_DISPLAY[record.asrProvider] || record.asrProvider}</span>
)}
{record.aiProvider && record.aiProvider !== 'server' && record.llmMs > 0 && (
<span className="text-xs">
AI: {record.aiProvider}{record.aiModel ? ` (${record.aiModel})` : ''}
</span>
{!isEmpty && record.asrText && (
<div className="text-muted-foreground">
<span className="font-medium">ASR 原文:</span>
<span className="whitespace-pre-line">{highlightText(record.asrText, highlight)}</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
{record.workMode && (
<>
<span className="rounded border border-border px-1.5 py-0.5 text-xs">
{record.workMode === 'server' ? '服务器' : record.workMode === 'cloud_api' ? '云 API' : '本地'}
</span>
{record.asrProvider && (
<span className="text-xs">ASR: {ASR_PROVIDER_DISPLAY[record.asrProvider] || record.asrProvider}</span>
)}
{record.aiProvider && record.aiProvider !== 'server' && record.llmMs > 0 && (
<span className="text-xs">
AI: {record.aiProvider}{record.aiModel ? ` (${record.aiModel})` : ''}
</span>
)}
<span className="text-border">|</span>
</>
)}
<span>语音长度 {voiceDurationSec.toFixed(1)}s</span>
<span className="text-border">|</span>
</>
)}
<span>语音长度 {voiceDurationSec.toFixed(1)}s</span>
<span className="text-border">|</span>
<span>识别 {((record.asrMs + record.llmMs) / 1000).toFixed(1)}s (ASR {record.asrMs}ms + LLM {record.llmMs}ms)</span>
{record.audioFilePath && (
<Tooltip content={audioPlaying ? '暂停播放' : '播放录音'}>
<button
type="button"
onClick={() => { void handleTogglePlayback() }}
disabled={audioLoading}
className="relative top-[0.5px] flex h-7 w-7 items-center justify-center rounded p-1.5 hover:bg-accent disabled:opacity-50"
aria-label={audioPlaying ? '暂停播放' : '播放录音'}
>
{audioLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : audioPlaying ? (
<Pause className="h-3.5 w-3.5 text-primary" />
) : (
<Play className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</Tooltip>
)}
{record.audioFilePath && (
<Tooltip content={downloadStatus === 'ok' ? `已保存到 ${downloadPath}` : downloadStatus === 'fail' ? `下载失败: ${downloadPath}` : '下载音频'}>
<button
type="button"
onClick={() => { void handleDownloadAudio() }}
disabled={downloading}
className="relative top-[0.5px] flex h-7 w-7 items-center justify-center rounded p-1.5 hover:bg-accent disabled:opacity-50"
aria-label="下载音频"
>
{downloading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : downloadStatus === 'ok' ? (
<Check className="h-3.5 w-3.5 text-success" />
) : downloadStatus === 'fail' ? (
<X className="h-3.5 w-3.5 text-destructive" />
) : (
<Download className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</Tooltip>
)}
{record.audioFilePath && onReprocess && (
<Tooltip content="重新识别">
<button
type="button"
onClick={() => { void handleReprocess() }}
disabled={reprocessing}
className="relative top-[0.5px] flex h-7 w-7 items-center justify-center rounded p-1.5 hover:bg-accent disabled:opacity-50"
aria-label="重新识别"
>
{reprocessing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
<RotateCcw className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</Tooltip>
)}
</div>
{downloadStatus === 'ok' && downloadPath && (
<div className="mt-1 flex items-center gap-2 text-xs text-success break-all">
<span className="min-w-0 truncate">已保存到 {downloadPath}</span>
<button
onClick={() => void invoke('reveal_file_in_folder', { filePath: downloadPath })}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="打开文件所在目录"
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</div>
)}
{downloadStatus === 'fail' && downloadPath && (
<div className="mt-1 text-xs text-destructive break-all">
下载失败: {downloadPath}
</div>
)}
{/* 音频进度条 + 倍速 */}
{audioReady && (
<div className="mt-2 flex items-center gap-2">
<span className="w-[72px] shrink-0 text-[11px] tabular-nums text-muted-foreground">
{formatElapsed(currentTime)} / {formatElapsed(duration)}
</span>
<div className="relative h-3 min-w-0 flex-1 overflow-visible">
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-border" />
<div className="absolute left-0 top-1/2 h-px -translate-y-1/2 bg-foreground" style={{ width: `${progress * 100}%` }} />
<div
className="absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-foreground bg-card shadow-sm"
style={{ left: `${progress * 100}%` }}
/>
<input
type="range"
min={0}
max={Math.max(duration, 0.1)}
step={0.1}
value={Math.min(currentTime, duration || 0)}
onChange={(e) => handleSeek(Number(e.target.value))}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
<span>识别 {((record.asrMs + record.llmMs) / 1000).toFixed(1)}s (ASR {record.asrMs}ms + LLM {record.llmMs}ms)</span>
{record.audioFilePath && (
<Tooltip content={audioPlaying ? '暂停播放' : '播放录音'}>
<button
type="button"
onClick={() => { void handleTogglePlayback() }}
disabled={audioLoading}
className="relative top-[0.5px] flex h-7 w-7 items-center justify-center rounded p-1.5 hover:bg-accent disabled:opacity-50"
aria-label={audioPlaying ? '暂停播放' : '播放录音'}
>
{audioLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : audioPlaying ? (
<Pause className="h-3.5 w-3.5 text-primary" />
) : (
<Play className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</Tooltip>
)}
{record.audioFilePath && (
<Tooltip content={downloadStatus === 'ok' ? `已保存到 ${downloadPath}` : downloadStatus === 'fail' ? `下载失败: ${downloadPath}` : '下载音频'}>
<button
type="button"
onClick={() => { void handleDownloadAudio() }}
disabled={downloading}
className="relative top-[0.5px] flex h-7 w-7 items-center justify-center rounded p-1.5 hover:bg-accent disabled:opacity-50"
aria-label="下载音频"
>
{downloading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : downloadStatus === 'ok' ? (
<Check className="h-3.5 w-3.5 text-success" />
) : downloadStatus === 'fail' ? (
<X className="h-3.5 w-3.5 text-destructive" />
) : (
<Download className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</Tooltip>
)}
{record.audioFilePath && onReprocess && (
<Tooltip content="重新识别">
<button
type="button"
onClick={() => { void handleReprocess() }}
disabled={reprocessing}
className="relative top-[0.5px] flex h-7 w-7 items-center justify-center rounded p-1.5 hover:bg-accent disabled:opacity-50"
aria-label="重新识别"
>
{reprocessing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : (
<RotateCcw className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
</Tooltip>
)}
</div>
<div className="flex shrink-0 gap-0.5">
{[0.75, 1, 1.5, 2, 2.5].map((rate) => (
{downloadStatus === 'ok' && downloadPath && (
<div className="mt-1 flex items-center gap-2 text-xs text-success break-all">
<span className="min-w-0 truncate">已保存到 {downloadPath}</span>
<button
key={rate}
type="button"
onClick={() => handleRateChange(rate)}
className={`rounded px-1.5 py-0.5 text-[11px] transition-colors ${
playbackRate === rate
? 'bg-foreground text-background font-medium'
: 'text-muted-foreground hover:bg-accent'
}`}
onClick={() => void invoke('reveal_file_in_folder', { filePath: downloadPath })}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="打开文件所在目录"
>
{rate}x
<FolderOpen className="h-3.5 w-3.5" />
</button>
))}
</div>
</div>
)}
{downloadStatus === 'fail' && downloadPath && (
<div className="mt-1 text-xs text-destructive break-all">
下载失败: {downloadPath}
</div>
)}
{/* 音频进度条 + 倍速 */}
{audioReady && (
<div className="mt-2 flex items-center gap-2">
<span className="w-[72px] shrink-0 text-[11px] tabular-nums text-muted-foreground">
{formatElapsed(currentTime)} / {formatElapsed(duration)}
</span>
<div className="relative h-3 min-w-0 flex-1 overflow-visible">
<div className="absolute left-0 right-0 top-1/2 h-px -translate-y-1/2 bg-border" />
<div className="absolute left-0 top-1/2 h-px -translate-y-1/2 bg-foreground" style={{ width: `${progress * 100}%` }} />
<div
className="absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-foreground bg-card shadow-sm"
style={{ left: `${progress * 100}%` }}
/>
<input
type="range"
min={0}
max={Math.max(duration, 0.1)}
step={0.1}
value={Math.min(currentTime, duration || 0)}
onChange={(e) => handleSeek(Number(e.target.value))}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
/>
</div>
<div className="flex shrink-0 gap-0.5">
{[0.75, 1, 1.5, 2, 2.5].map((rate) => (
<button
key={rate}
type="button"
onClick={() => handleRateChange(rate)}
className={`rounded px-1.5 py-0.5 text-[11px] transition-colors ${playbackRate === rate
? 'bg-foreground text-background font-medium'
: 'text-muted-foreground hover:bg-accent'
}`}
>
{rate}x
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
@@ -474,6 +542,18 @@ function HistoryItem({
</Tooltip>
)}
{!isEmpty && onEdit && !editing && (
<Tooltip content="编辑文本">
<button
onClick={startEdit}
className="inline-flex items-center rounded p-1 transition-colors hover:bg-accent"
aria-label="编辑"
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</Tooltip>
)}
{onToggleFavorite && (
<Tooltip content={record.favorite ? '取消收藏' : '收藏'}>
<button
@@ -487,18 +567,18 @@ function HistoryItem({
)}
<Tooltip content={expanded ? '收起详情' : '展开详情'}>
<button
onClick={() => setExpanded(!expanded)}
className="rounded p-1 hover:bg-accent"
<button
onClick={() => setExpanded(!expanded)}
className="rounded p-1 hover:bg-accent"
aria-label="详情"
>
{expanded ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
</Tooltip>
<Tooltip content="删除记录">
<button
onClick={onDelete}
className="rounded p-1 hover:bg-accent"
<button
onClick={onDelete}
className="rounded p-1 hover:bg-accent"
aria-label="删除"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
@@ -516,6 +596,7 @@ function DayGroup({
onDelete,
onToggleFavorite,
onReprocess,
onEdit,
highlight,
}: {
label: string
@@ -523,6 +604,7 @@ function DayGroup({
onDelete: (id: string) => void
onToggleFavorite?: (id: string, nextFavorite: boolean) => Promise<void> | void
onReprocess?: (record: HistoryRecord) => Promise<void> | void
onEdit?: (id: string, nextText: string) => Promise<void> | void
highlight?: string
}) {
return (
@@ -537,6 +619,7 @@ function DayGroup({
onDelete={() => onDelete(record.id)}
onToggleFavorite={onToggleFavorite ? (next) => onToggleFavorite(record.id, next) : undefined}
onReprocess={onReprocess ? () => onReprocess(record) : undefined}
onEdit={onEdit ? (nextText) => onEdit(record.id, nextText) : undefined}
highlight={highlight}
/>
))}
@@ -551,6 +634,7 @@ export default function HistoryRecordList({
onDelete,
onToggleFavorite,
onReprocess,
onEdit,
emptyText = '还没有记录,去语音工作台试试吧',
highlight,
}: HistoryRecordListProps) {
@@ -591,6 +675,7 @@ export default function HistoryRecordList({
onDelete={onDelete}
onToggleFavorite={onToggleFavorite}
onReprocess={onReprocess}
onEdit={onEdit}
highlight={highlight}
/>
))}
@@ -125,8 +125,8 @@ export default function DiagnosticsPage() {
const isQwenOmni = asrProvider.startsWith('qwen_omni')
const qwenOmniModel = asrProvider === 'qwen_omni_35_plus' ? 'qwen3.5-omni-plus-realtime'
: asrProvider === 'qwen_omni_35_flash' ? 'qwen3.5-omni-flash-realtime'
: asrProvider === 'qwen_omni_flash' ? 'qwen3-omni-flash-realtime'
: asrProvider === 'qwen_omni_turbo' ? 'qwen-omni-turbo-realtime' : undefined
: asrProvider === 'qwen_omni_flash' ? 'qwen3-omni-flash-realtime'
: asrProvider === 'qwen_omni_turbo' ? 'qwen-omni-turbo-realtime' : undefined
const result = await invoke<{ ok: boolean; message: string }>('test_asr_connection', {
config: {
provider: isQwenOmni ? 'qwen_omni' : asrProvider,
@@ -148,34 +148,34 @@ export default function DiagnosticsPage() {
items.push({ label: 'ASR', status: 'ok', detail: '由服务器提供' })
}
// AI 检查
if (workMode !== 'server') {
const aiEnabled = await getSetting('aiEnabled', false) as boolean
if (!aiEnabled) {
items.push({ label: 'AI 校对', status: 'disabled', detail: '未开启(极速模式)' })
} else {
const aiProvider = await getSetting('cloudAi.provider', '') as string
const aiApiKey = await getSetting('cloudAi.apiKey', '') as string
const aiApiUrl = await getSetting('cloudAi.apiUrl', '') as string
const aiModel = await getSetting('cloudAi.model', '') as string
const displayName = AI_DISPLAY[aiProvider] || aiProvider
// AI 检查:所有模式都先看「AI 整理」总开关。关闭即极速模式(不经 AI),
// 无论服务器/云 API/本地都应显示「未开启」。此前服务器模式跳过该判断、
// 固定显示「由服务器提供」,导致用户关掉 AI 整理后诊断页看不出未开启。
const aiEnabled = await getSetting('aiEnabled', false) as boolean
if (!aiEnabled) {
items.push({ label: 'AI 校对', status: 'disabled', detail: '未开启(极速模式)' })
} else if (workMode === 'server') {
items.push({ label: 'AI 校对', status: 'ok', detail: '由服务器提供' })
} else {
const aiProvider = await getSetting('cloudAi.provider', '') as string
const aiApiKey = await getSetting('cloudAi.apiKey', '') as string
const aiApiUrl = await getSetting('cloudAi.apiUrl', '') as string
const aiModel = await getSetting('cloudAi.model', '') as string
const displayName = AI_DISPLAY[aiProvider] || aiProvider
if (!aiProvider || (!aiApiKey && aiProvider !== 'ollama') || !aiApiUrl) {
items.push({ label: 'AI 校对', status: 'error', detail: '未完整配置' })
} else {
// 实际测试连通性
try {
const result = await invoke<{ ok: boolean; message: string }>('test_ai_connection', {
config: { provider: aiProvider, api_url: aiApiUrl, api_key: aiApiKey, model: aiModel },
})
items.push({ label: 'AI 校对', status: result.ok ? 'ok' : 'error', detail: result.ok ? `${displayName}(${aiModel})` : `${displayName} — ${result.message}` })
} catch (err) {
items.push({ label: 'AI 校对', status: 'error', detail: `${displayName} — ${String(err)}` })
}
if (!aiProvider || (!aiApiKey && aiProvider !== 'ollama') || !aiApiUrl) {
items.push({ label: 'AI 校对', status: 'error', detail: '未完整配置' })
} else {
// 实际测试连通性
try {
const result = await invoke<{ ok: boolean; message: string }>('test_ai_connection', {
config: { provider: aiProvider, api_url: aiApiUrl, api_key: aiApiKey, model: aiModel },
})
items.push({ label: 'AI 校对', status: result.ok ? 'ok' : 'error', detail: result.ok ? `${displayName}(${aiModel})` : `${displayName} — ${result.message}` })
} catch (err) {
items.push({ label: 'AI 校对', status: 'error', detail: `${displayName} — ${String(err)}` })
}
}
} else {
items.push({ label: 'AI 校对', status: 'ok', detail: '由服务器提供' })
}
setHealth(items)
@@ -299,11 +299,10 @@ export default function DiagnosticsPage() {
<button
key={tab.value}
onClick={() => setLogFilter(tab.value)}
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
logFilter === tab.value
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${logFilter === tab.value
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
}`}
>
{tab.label}{tab.count > 0 ? ` (${tab.count})` : ''}
</button>
+140 -21
View File
@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { FolderOpen, Copy, Check, Zap, CheckCircle2, X, type LucideIcon } from 'lucide-react'
import { open } from '@tauri-apps/plugin-dialog'
import { FolderOpen, Copy, Check, Zap, CheckCircle2, X, HardDrive, Loader2, Info, type LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tooltip } from '@/components/ui/tooltip'
@@ -56,6 +57,12 @@ interface DownloadProgress {
error: string | null
}
interface ModelsDirInfo {
current: string
default_dir: string
is_custom: boolean
}
function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`
@@ -137,11 +144,10 @@ function OfflineGuideDialog({ models, onClose }: { models: ModelInfo[]; onClose:
<button
key={name}
onClick={() => setSelectedSource(i)}
className={`flex-1 rounded-md px-2 py-1.5 text-xs transition-colors ${
selectedSource === i
? 'bg-accent font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`flex-1 rounded-md px-2 py-1.5 text-xs transition-colors ${selectedSource === i
? 'bg-accent font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{name === 'HuggingFace Mirror' ? 'HF Mirror (China)' : name}
</button>
@@ -158,8 +164,8 @@ function OfflineGuideDialog({ models, onClose }: { models: ModelInfo[]; onClose:
// GitHub 地址用国内代理加速手动下载
const archiveUrl = model.archive_url
? (model.archive_url.startsWith('https://github.com/')
? `https://gh-proxy.com/${model.archive_url}`
: model.archive_url)
? `https://gh-proxy.com/${model.archive_url}`
: model.archive_url)
: ''
return (
<div key={model.id}>
@@ -216,6 +222,120 @@ function OfflineGuide({ models }: { models: ModelInfo[] }) {
)
}
/** 模型存储位置:查看 / 更改 / 恢复默认。更改时可选把已下载模型一并迁移。 */
function ModelsDirSection({ onChanged }: { onChanged: () => void }) {
const [info, setInfo] = useState<ModelsDirInfo | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
// 待确认的目录变更:null=无。dir=null 表示恢复默认。
const [pending, setPending] = useState<{ dir: string | null } | null>(null)
const load = async () => {
try { setInfo(await invoke<ModelsDirInfo>('get_models_dir')) } catch { /* ignore */ }
}
useEffect(() => { void load() }, [])
async function pickDir() {
setError('')
try {
const selected = await open({ directory: true, multiple: false, title: '选择模型存储位置' })
if (typeof selected !== 'string') return // 取消
if (info && selected === info.current) return // 未变化
setPending({ dir: selected })
} catch (err) {
setError(String(err))
}
}
function requestResetDefault() {
if (!info || !info.is_custom) return
setPending({ dir: null })
}
async function applyChange() {
if (!pending) return
setBusy(true)
setError('')
try {
// 一律自动迁移已下载的模型到新目录
await invoke<string>('set_models_dir', { dir: pending.dir, moveExisting: true })
setPending(null)
await load()
onChanged() // 路径变了,刷新已下载模型列表
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
return (
<Card>
<CardContent className="p-6">
<div className="mb-2 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<h2 className="text-base font-semibold">模型存储位置</h2>
<Tooltip variant="light" content="本地模型体积较大,可以选择其他路径存放。已下载的模型在更换目录后会自动迁移。">
<Info className="h-3.5 w-3.5 cursor-help text-muted-foreground/50 transition-colors hover:text-muted-foreground" />
</Tooltip>
</div>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md bg-muted/30 px-3 py-2">
<code className="min-w-0 flex-1 truncate text-[11px] text-foreground/70 select-all" title={info?.current}>
{info?.current || '加载中…'}
</code>
{info?.is_custom && (
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">自定义</span>
)}
<Tooltip content="打开目录">
<button
type="button"
onClick={() => void invoke<string>('open_models_folder')}
className="shrink-0 rounded p-1 text-muted-foreground/50 transition-colors hover:bg-accent hover:text-muted-foreground"
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
<Button size="sm" variant="outline" onClick={() => void pickDir()} disabled={busy}>
更改位置
</Button>
{info?.is_custom && (
<Button size="sm" variant="ghost" onClick={requestResetDefault} disabled={busy}>
恢复默认
</Button>
)}
</div>
{error && <p className="mt-2 text-xs text-destructive break-all">操作失败:{error}</p>}
</CardContent>
{pending && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => !busy && setPending(null)}>
<div className="w-[420px] rounded-xl border bg-card p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold">
{pending.dir === null ? '恢复默认模型位置' : '更改模型存储位置'}
</h3>
<p className="mt-2 text-sm text-muted-foreground break-all">
新位置:{pending.dir === null ? (info?.default_dir || '默认目录') : pending.dir}
</p>
<p className="mt-3 text-xs text-muted-foreground">
已下载的模型会自动迁移到新位置,跨磁盘移动大文件可能需要一些时间,请勿中途退出。
</p>
<div className="mt-5 flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setPending(null)} disabled={busy}>取消</Button>
<Button size="sm" onClick={() => void applyChange()} disabled={busy}>
{busy ? (<><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />处理中…</>) : '确定'}
</Button>
</div>
</div>
</div>
)}
</Card>
)
}
export default function LocalModeSection() {
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([])
const [downloadedModels, setDownloadedModels] = useState<LocalModelInfo[]>([])
@@ -327,11 +447,10 @@ export default function LocalModeSection() {
key={lang.value}
type="button"
onClick={() => { setAsrLanguage(lang.value); void setSetting('localAsr.language', lang.value) }}
className={`rounded-md border px-4 py-1.5 text-sm transition-colors ${
asrLanguage === lang.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card text-foreground hover:bg-accent'
}`}
className={`rounded-md border px-4 py-1.5 text-sm transition-colors ${asrLanguage === lang.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card text-foreground hover:bg-accent'
}`}
>
{lang.label}
</button>
@@ -380,11 +499,10 @@ export default function LocalModeSection() {
key={src.value}
type="button"
onClick={() => { setDownloadSource(src.value); void setSetting('localAsr.downloadSource', src.value) }}
className={`rounded-md border px-3 py-1 text-xs transition-colors ${
downloadSource === src.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card text-foreground hover:bg-accent'
}`}
className={`rounded-md border px-3 py-1 text-xs transition-colors ${downloadSource === src.value
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card text-foreground hover:bg-accent'
}`}
>
{src.label}
</button>
@@ -402,9 +520,8 @@ export default function LocalModeSection() {
return (
<div
key={model.id}
className={`flex items-center justify-between rounded-lg border p-3 ${
isSelected ? 'border-primary bg-primary/5' : 'border-border'
}`}
className={`flex items-center justify-between rounded-lg border p-3 ${isSelected ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2">
@@ -486,6 +603,8 @@ export default function LocalModeSection() {
</CardContent>
</Card>
<ModelsDirSection onChanged={() => void refreshDownloaded()} />
{/* 删除确认对话框 */}
{confirmDeleteId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setConfirmDeleteId(null)}>
@@ -10,11 +10,11 @@ export interface ReleaseHighlights {
}
export const RELEASE_HIGHLIGHTS: ReleaseHighlights = {
version: '0.1.2',
version: '0.1.3',
items: [
'新增「备份与迁移」:可导出 / 导入「配置」或「全部数据」,方便换机、重装与定期备份',
'修复长时间后台运行后偶发「一直处理中、超时无结果」的问题:后台连接更稳定,不再被误判空闲而断开',
'识别超时不再丢录音:即使这次超时没结果,录音也会保存到历史,可随时「重新识别」',
'修复保存润色模式等设置后卡顿 2-4 秒的问题:机器信息改为启动时采集一次,日常改设置不再卡顿',
'本地识别模型支持自定义存储位置:系统盘空间紧张时可改存到其它磁盘,已下载的模型会自动迁移',
'历史记录支持手工修改:可直接编辑整理后的文本,改完点其它地方即自动保存',
'修复用「右 Alt」等 Alt 键做「按住说话」时会呼出记事本等程序菜单、导致文本插不进去的问题',
'修复数字规范化把口语「一点」误当成时间「1 点」的问题,如「调宽一点」不再变成「调宽 1 点」',
],
}
+14
View File
@@ -353,6 +353,19 @@ export default function History() {
setRecords((prev) => prev.map((r) => (r.id === id ? { ...r, favorite: nextFavorite } : r)))
}
const handleEdit = async (id: string, nextText: string) => {
const editedAt = Date.now()
const patch = {
llmText: nextText,
charCount: nextText.length,
isEmpty: !nextText.trim(),
manualEditedAt: editedAt,
}
await updateHistoryRecord(id, patch)
// 局部更新,避免整页刷新丢失展开态/滚动位置
setRecords((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)))
}
const handleExport = async () => {
const result = await exportHistory({ keyword: debouncedKeyword })
setExportResult(result)
@@ -526,6 +539,7 @@ export default function History() {
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
onReprocess={handleReprocess}
onEdit={handleEdit}
highlight={debouncedKeyword}
emptyText={keyword.trim() ? '没有匹配的历史记录' : favoriteOnly ? '还没有收藏记录,去历史记录里点一下星标吧。' : '还没有记录,去语音工作台试试吧'}
/>
@@ -122,6 +122,24 @@ describe('convertChineseNumbers', () => {
expect(convertChineseNumbers('九点二十开会')).toBe('9点20开会')
})
it('不误伤口语「一点」(a little bit):无时间信号的裸整点保留中文', () => {
expect(convertChineseNumbers('把页面调宽一点')).toBe('把页面调宽一点')
expect(convertChineseNumbers('有一点担心')).toBe('有一点担心')
expect(convertChineseNumbers('再快一点')).toBe('再快一点')
expect(convertChineseNumbers('声音大一点')).toBe('声音大一点')
expect(convertChineseNumbers('一点点小事')).toBe('一点点小事')
// 无时段词的裸整点也保守不转(宁可不转,交给 AI 整理)
expect(convertChineseNumbers('九点开会')).toBe('九点开会')
})
it('整点:有时间信号才转(时段词 / 钟 / 含十两位)', () => {
expect(convertChineseNumbers('下午一点开会')).toBe('下午1点开会')
expect(convertChineseNumbers('晚上八点')).toBe('晚上8点')
expect(convertChineseNumbers('一点钟到')).toBe('1点钟到')
expect(convertChineseNumbers('十二点了')).toBe('12点了')
expect(convertChineseNumbers('十点休息')).toBe('10点休息')
})
it('空文本安全', () => {
expect(convertChineseNumbers('')).toBe('')
})
+15 -1
View File
@@ -178,11 +178,25 @@ export function convertChineseNumbers(text: string): string {
// 2.5b 整点:X点(后面不跟数字/点)→ 9点(如 上午九点 → 上午9点)。
// 「点」后跟数字的(三点一四)留给小数规则,避免误伤。
//
// 收紧:裸整点必须有明确「时间信号」才转,否则保留中文。目的是不把口语里
// 表「a little bit」的「一点」误当成「1 点钟」——如「调宽一点」「有一点」
// 「快一点」。时间信号(满足其一即转):
// ① 前面紧邻时段词(上午 / 下午 / 晚上 …);
// ② 后面紧跟「钟」(一点钟);
// ③ 小时是含「十」的两位(十点 / 十二点,几乎只作时间用)。
// 无信号时宁可不转,交给 AI 整理处理歧义场景(与整体「尽力而为」取向一致)。
const TIME_OF_DAY_PREFIX = /(凌晨|清晨|早晨|早上|上午|中午|下午|傍晚|晚上|夜里|半夜|今早|今晚|明早|明晚|昨晚)$/
result = result.replace(
new RegExp(`([${TIME_HOUR}]+)点(?![${DIGIT_CHARS}点])`, 'g'),
(m: string, hourStr: string, offset: number, str: string) => {
const hour = validHour(hourStr)
return hour === null ? m : maybeSpace(str, offset, `${hour}点`)
if (hour === null) return m
const hasTenUnit = hourStr.includes('十')
const hasClockSuffix = (str[offset + m.length] || '') === '钟'
const hasTimePrefix = TIME_OF_DAY_PREFIX.test(str.slice(0, offset))
if (!hasTenUnit && !hasClockSuffix && !hasTimePrefix) return m
return maybeSpace(str, offset, `${hour}点`)
},
)