diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a56842..7fe48ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ --- +## [0.1.3] - 2026-07-29 + +### 新增 +- 本地识别模型支持自定义存储位置:模型文件较大,若系统盘空间紧张,可在「本地模式」设置里把模型改存到其它磁盘;更换目录后,已下载的模型会自动迁移到新位置。 +- 历史记录支持手工修改:可直接编辑整理后的文本,改完点击其它地方即自动保存(无需保存按钮,按 Esc 取消);编辑过的记录会用小图标标记,便于日后辨认。 + +### 修复 +- 修复用「右 Alt」等 Alt 键作为「按住说话」快捷键时,松开按键会呼出目标程序(如记事本)的菜单栏、使光标离开输入框,导致识别文本插不进去、日志却显示插入成功的问题。现已在系统按键层面正确拦截 Alt 键抬起,插入恢复正常。 +- 修复数字规范化把口语里的「一点」(表示"稍微、一点点")误当成时间「1 点」的问题:如「调宽一点」不再被改成「调宽 1 点」。整点时间仅在有明确时间信号时才转换(如「下午一点」「一点钟」「十二点」)。 +- 修复关闭「AI 整理」后,默认的服务器模式下诊断页仍把「AI 校对」显示为正常的问题:现在无论哪种模式,关闭后都会明确显示为「未开启(极速模式)」。 + +--- + ## [0.1.2] - 2026-07-24 ### 新增 diff --git a/client/package-lock.json b/client/package-lock.json index e008940..c49b277 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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 @@ } } } -} +} \ No newline at end of file diff --git a/client/package.json b/client/package.json index 666bfbe..82f9161 100644 --- a/client/package.json +++ b/client/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index b169e0f..eb73c4e 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -4024,7 +4024,7 @@ dependencies = [ [[package]] name = "sayit" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64 0.22.1", "bzip2", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index ec22622..cf0b4b3 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sayit" -version = "0.1.2" +version = "0.1.3" authors = ["Liu Qianglong", "Claude"] edition = "2021" diff --git a/client/src-tauri/src/keyboard/mod.rs b/client/src-tauri/src/keyboard/mod.rs index ed94858..b6e1583 100644 --- a/client/src-tauri/src/keyboard/mod.rs +++ b/client/src-tauri/src/keyboard/mod.rs @@ -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 { diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index c0cff96..278113a 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -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, diff --git a/client/src-tauri/src/models/downloader.rs b/client/src-tauri/src/models/downloader.rs index b8e1052..12b989a 100644 --- a/client/src-tauri/src/models/downloader.rs +++ b/client/src-tauri/src/models/downloader.rs @@ -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, } -/// 获取模型存储根目录 -pub fn models_dir() -> PathBuf { +/// 用户自定义的模型存储根目录(进程级)。None = 用默认路径。 +/// 启动时由 main.rs 从设置 `localAsr.modelsDir` 灌入;用户在设置里更改时同步更新。 +/// 所有取模型路径的地方都走 `models_dir()`,改这一处即全链路生效。 +static CUSTOM_MODELS_DIR: RwLock> = 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) { + 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) diff --git a/client/src-tauri/src/models/registry.rs b/client/src-tauri/src/models/registry.rs index 0fcb367..e11abbb 100644 --- a/client/src-tauri/src/models/registry.rs +++ b/client/src-tauri/src/models/registry.rs @@ -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 { 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, + move_existing: bool, +) -> Result { + 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 { diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 3c2bd87..4831232 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -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 } } -} +} \ No newline at end of file diff --git a/client/src/components/history/HistoryRecordList.tsx b/client/src/components/history/HistoryRecordList.tsx index 18a8e25..6443579 100644 --- a/client/src/components/history/HistoryRecordList.tsx +++ b/client/src/components/history/HistoryRecordList.tsx @@ -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 onToggleFavorite?: (id: string, nextFavorite: boolean) => Promise | void onReprocess?: (record: HistoryRecord) => Promise | void + /** 手工编辑转换结果并保存 */ + onEdit?: (id: string, nextText: string) => Promise | 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 + onEdit?: (nextText: string) => Promise | 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({
- {isEmpty ? ( + {editing ? ( +
e.stopPropagation()}> +