0.1.3: 修复右Alt热键抢焦点、数字规范化误转「一点」、诊断页AI未开启;新增模型自定义存储位置与历史手工编辑
- 键盘钩子:Alt 类单键热键(按住说话/免提)连 keyup 一起吞,避免松键呼出目标程序菜单栏抢走输入框焦点、导致插入落空但日志显示成功 - 数字规范化:整点仅在有明确时间信号时才转,口语「一点」不再被误转为「1点」 - 诊断页:所有模式(含默认的服务器模式)关闭「AI 整理」后均显示「未开启(极速模式)」 - 新增本地模型自定义存储位置:可改存到其它磁盘,更换目录后自动迁移已下载模型 - 历史记录支持手工编辑整理结果:失焦/离开页面自动保存,编辑过的记录加小图标标记 - 版本号 0.1.2 → 0.1.3,同步更新 CHANGELOG 与关于页「本次更新」亮点
This commit is contained in:
1 parent
73c8fa2a08
commit
7d5eade190
17 files changed
+654
-222
No files matched your search
@@ -9,6 +9,19 @@
|
||||
|
||||
---
|
||||
|
||||
## [0.1.3] - 2026-07-29
|
||||
|
||||
### 新增
|
||||
- 本地识别模型支持自定义存储位置:模型文件较大,若系统盘空间紧张,可在「本地模式」设置里把模型改存到其它磁盘;更换目录后,已下载的模型会自动迁移到新位置。
|
||||
- 历史记录支持手工修改:可直接编辑整理后的文本,改完点击其它地方即自动保存(无需保存按钮,按 Esc 取消);编辑过的记录会用小图标标记,便于日后辨认。
|
||||
|
||||
### 修复
|
||||
- 修复用「右 Alt」等 Alt 键作为「按住说话」快捷键时,松开按键会呼出目标程序(如记事本)的菜单栏、使光标离开输入框,导致识别文本插不进去、日志却显示插入成功的问题。现已在系统按键层面正确拦截 Alt 键抬起,插入恢复正常。
|
||||
- 修复数字规范化把口语里的「一点」(表示"稍微、一点点")误当成时间「1 点」的问题:如「调宽一点」不再被改成「调宽 1 点」。整点时间仅在有明确时间信号时才转换(如「下午一点」「一点钟」「十二点」)。
|
||||
- 修复关闭「AI 整理」后,默认的服务器模式下诊断页仍把「AI 校对」显示为正常的问题:现在无论哪种模式,关闭后都会明确显示为「未开启(极速模式)」。
|
||||
|
||||
---
|
||||
|
||||
## [0.1.2] - 2026-07-24
|
||||
|
||||
### 新增
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -4024,7 +4024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sayit"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bzip2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "sayit"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
authors = ["Liu Qianglong", "Claude"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 点」',
|
||||
],
|
||||
}
|
||||
@@ -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('')
|
||||
})
|
||||
|
||||
@@ -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}点`)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user