diff --git a/CHANGELOG.md b/CHANGELOG.md index 828798f..a8206d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,15 @@ ### 新增 - 「按住说话」与「免提模式」快捷键支持绑定鼠标中键(原有的鼠标侧键继续支持)。注意:绑定后鼠标中键的「打开新标签页 / 自动滚动」等原功能在 SayIt 运行时会被占用。 +### 变更 +- 数字规范化默认开启(把中文数字改写成阿拉伯数字,如 三点一四 → 3.14、百分之十五 → 15%)。已保存过该设置的用户不受影响。 + ### 修复 - 修复悬浮窗可能被其他置顶程序遮挡的问题:此前悬浮窗只在弹出瞬间置顶一次,当有其他「总在最前」的窗口(如某些播放器、会议共享工具条、置顶小工具)存在或后出现时,会盖住悬浮窗。现已在显示、前台窗口切换、以及显示期间周期性地主动重申置顶(不抢占焦点),确保悬浮窗稳定显示在最上层。(独占全屏程序仍会覆盖,属系统限制) +- 修复数字规范化把时间误当小数的问题:如「上午九点三十二分」曾被错转成「9.312」,现在正确处理为「9点32分」(「九点二十分」→「9点20分」);同时修复句末小数(如 5.4、0.1.0)偶发不生效的问题。 ### 优化 +- 历史记录搜索命中的关键词现在会高亮显示(正文与 ASR 原文),更容易定位。 - 优化热词管理:分类内热词过多时自动折叠(默认显示前 30 个,可一键展开全部);热词总数过多时给出可关闭的温和提示(热词并非越多越好);热词说明的 ⓘ 改为表格,清晰列出各模型的热词支持情况与上限。 - 键盘快捷键设置新增信息说明(标题旁的 ⓘ):集中说明可绑定的按键类型、选键建议,以及绑定鼠标侧键 / 中键会占用其原功能等注意事项。 - 优化低音量提醒(悬浮窗「请靠近麦克风」):文案缩短并保证单行不换行;不再被瞬间的小声音清掉而闪烁(需持续说话约 0.5 秒才消失);提醒不再过度频繁——还没听到过说话时静音约 2 秒提醒、已经正常说过话后停顿约 5 秒才提醒,避免正常思考停顿被反复催促。 diff --git a/client/src/components/history/HistoryRecordList.tsx b/client/src/components/history/HistoryRecordList.tsx index a0f0afc..18a8e25 100644 --- a/client/src/components/history/HistoryRecordList.tsx +++ b/client/src/components/history/HistoryRecordList.tsx @@ -26,6 +26,8 @@ interface HistoryRecordListProps { onToggleFavorite?: (id: string, nextFavorite: boolean) => Promise | void onReprocess?: (record: HistoryRecord) => Promise | void emptyText?: string + /** 搜索关键词:在正文与 ASR 原文里高亮命中处 */ + highlight?: string } function getDayLabel(ts: number): string { @@ -47,16 +49,42 @@ function formatTime(ts: number): string { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } +/** 把 text 中命中 keyword 的子串用 高亮(大小写不敏感,用 indexOf 避免正则特殊字符问题)。 */ +function highlightText(text: string, keyword: string) { + const kw = keyword.trim() + if (!kw) return text + const lower = text.toLowerCase() + const kwLower = kw.toLowerCase() + const parts: Array = [] + let from = 0 + let idx = lower.indexOf(kwLower, from) + let key = 0 + while (idx !== -1) { + if (idx > from) parts.push(text.slice(from, idx)) + parts.push( + + {text.slice(idx, idx + kw.length)} + , + ) + from = idx + kw.length + idx = lower.indexOf(kwLower, from) + } + if (from < text.length) parts.push(text.slice(from)) + return parts +} + function HistoryItem({ record, onDelete, onToggleFavorite, onReprocess, + highlight = '', }: { record: HistoryRecord onDelete: () => void onToggleFavorite?: (nextFavorite: boolean) => void onReprocess?: () => Promise | void + highlight?: string }) { const [expanded, setExpanded] = useState(false) const [audioPlaying, setAudioPlaying] = useState(false) @@ -261,10 +289,10 @@ function HistoryItem({ > {text.includes('\n') ? ( text.split(/\n{2,}/).map((para, idx) => ( -

0 ? 'mt-1.5' : undefined}>{para}

+

0 ? 'mt-1.5' : undefined}>{highlightText(para, highlight)}

)) ) : ( -

{text}

+

{highlightText(text, highlight)}

)} )} @@ -279,7 +307,7 @@ function HistoryItem({ {!isEmpty && record.asrText && (
ASR 原文: - {record.asrText} + {highlightText(record.asrText, highlight)}
)}
@@ -488,12 +516,14 @@ function DayGroup({ onDelete, onToggleFavorite, onReprocess, + highlight, }: { label: string records: HistoryRecord[] onDelete: (id: string) => void onToggleFavorite?: (id: string, nextFavorite: boolean) => Promise | void onReprocess?: (record: HistoryRecord) => Promise | void + highlight?: string }) { return ( @@ -507,6 +537,7 @@ function DayGroup({ onDelete={() => onDelete(record.id)} onToggleFavorite={onToggleFavorite ? (next) => onToggleFavorite(record.id, next) : undefined} onReprocess={onReprocess ? () => onReprocess(record) : undefined} + highlight={highlight} /> ))}
@@ -521,6 +552,7 @@ export default function HistoryRecordList({ onToggleFavorite, onReprocess, emptyText = '还没有记录,去语音工作台试试吧', + highlight, }: HistoryRecordListProps) { const grouped = useMemo(() => { return records.reduce((acc, record) => { @@ -559,6 +591,7 @@ export default function HistoryRecordList({ onDelete={onDelete} onToggleFavorite={onToggleFavorite} onReprocess={onReprocess} + highlight={highlight} /> ))} diff --git a/client/src/features/update/releaseHighlights.ts b/client/src/features/update/releaseHighlights.ts index 3a05d00..d63a1c1 100644 --- a/client/src/features/update/releaseHighlights.ts +++ b/client/src/features/update/releaseHighlights.ts @@ -10,15 +10,14 @@ export interface ReleaseHighlights { } export const RELEASE_HIGHLIGHTS: ReleaseHighlights = { - version: '0.1.0', + version: '0.1.1', items: [ - '新增「流式实时字幕」:说话时悬浮窗像输入法一样边说边出字,说完仍交给 AI 整理(支持豆包、千问实时)', - '快捷键支持鼠标侧键:按住说话与免提模式都能绑定鼠标的前进 / 后退键', - '新增「口语化整理」AI 预设:把语音整理成微信、Teams 那样自然口语、可直接发送的短消息', - '应用 Prompt 规则内置 Codex、微信、QQ:在对应软件里自动套用更合适的整理风格', - '诊断页新增「常见问题」:收录「文字插不进输入框」等问题的自助排查步骤', - '增强热词识别:优化豆包、千问的热词传递,并可选择把热词注入 AI 提示词进一步提升准确率', - '优化剪贴板:插入的文本不再进入剪贴板历史(Win+V)与云剪贴板,你原来复制的内容也不受影响', - '修复千问短音频偶发把热词原样吐出、诊断报告时间显示等小问题', + '修复悬浮窗被其他置顶程序(如播放器、会议共享工具条)遮挡的问题,现在稳定显示在最上层', + '「按住说话」「免提模式」新增支持绑定鼠标中键', + '历史记录搜索:命中的关键词现在会高亮显示,更容易定位', + '数字规范化默认开启,并修复「九点三十二分」等时间被误当小数的问题', + '优化热词管理:过多时自动折叠、给出温和提示,并用表格说明各模型的热词支持', + '优化低音量提醒与「未检测到有效声音」提示,减少打扰、更清晰', + '键盘快捷键设置新增使用说明(ⓘ)', ], } diff --git a/client/src/pages/History.tsx b/client/src/pages/History.tsx index 11adebf..f53878b 100644 --- a/client/src/pages/History.tsx +++ b/client/src/pages/History.tsx @@ -526,6 +526,7 @@ export default function History() { onDelete={handleDelete} onToggleFavorite={handleToggleFavorite} onReprocess={handleReprocess} + highlight={debouncedKeyword} emptyText={keyword.trim() ? '没有匹配的历史记录' : favoriteOnly ? '还没有收藏记录,去历史记录里点一下星标吧。' : '还没有记录,去语音工作台试试吧'} /> diff --git a/client/src/services/__tests__/textPostProcess.test.ts b/client/src/services/__tests__/textPostProcess.test.ts index 09b916e..d03c8f6 100644 --- a/client/src/services/__tests__/textPostProcess.test.ts +++ b/client/src/services/__tests__/textPostProcess.test.ts @@ -109,8 +109,17 @@ describe('convertChineseNumbers', () => { expect(convertChineseNumbers('一二三四五')).toBe('一二三四五') }) - it('不误伤时间「两点半」', () => { - expect(convertChineseNumbers('下午两点半开会')).toBe('下午两点半开会') + it('时间:数字转阿拉伯,保留点/分/半', () => { + expect(convertChineseNumbers('上午九点三十二分')).toBe('上午9点32分') + expect(convertChineseNumbers('九点二十分开会')).toBe('9点20分开会') + expect(convertChineseNumbers('下午两点半开会')).toBe('下午2点半开会') + expect(convertChineseNumbers('上午九点开会')).toBe('上午9点开会') + expect(convertChineseNumbers('十九点三十分')).toBe('19点30分') + }) + + it('时间:无「分」的复合分钟也按时间处理(不再出现 9.3十 之类乱码)', () => { + expect(convertChineseNumbers('九点三十')).toBe('9点30') + expect(convertChineseNumbers('九点二十开会')).toBe('9点20开会') }) it('空文本安全', () => { diff --git a/client/src/services/defaults.ts b/client/src/services/defaults.ts index 487a1f2..1d0afa5 100644 --- a/client/src/services/defaults.ts +++ b/client/src/services/defaults.ts @@ -76,7 +76,7 @@ export const DEFAULTS: Record = { // ── 文本后处理(不依赖 AI 的客户端文本规范化)── textPostProcess: { autoSegment: true, // 智能分段(仅极速模式生效),默认开启 - normalizeNumbers: false, // 数字规范化:百分之/小数/分之/结构化整数 + normalizeNumbers: true, // 数字规范化:百分之/小数/分之/结构化整数,默认开启 stripTrailingPunctuation: false, // 去除句末标点 punctuationToSpace: false, // 标点符号替换为空格 }, diff --git a/client/src/services/textPostProcess.ts b/client/src/services/textPostProcess.ts index 913def0..410d519 100644 --- a/client/src/services/textPostProcess.ts +++ b/client/src/services/textPostProcess.ts @@ -142,11 +142,59 @@ export function convertChineseNumbers(text: string): string { }, ) + // 2.5 时间表达:必须在小数规则之前处理,避免「点」被当成小数点。 + // 时/分数字转阿拉伯,保留「点/分/半」标记(如 九点三十二分 → 9点32分)。 + const TIME_HOUR = '零一二两三四五六七八九十' + const TIME_MIN = '零一二两三四五六七八九十' + const DIGITS_NO_TEN = '零一二两三四五六七八九' + + // 合法小时:单字数字,或含「十」的复合(十 / 十二 / 二十三…),范围 0–24。 + // 借「含十」排除「一二三」这类逐位串(parseChineseInteger 对它们会给出误导性结果)。 + const validHour = (s: string): number | null => { + if (s.length > 1 && !s.includes('十')) return null + const h = parseChineseInteger(s) + return h === null || h < 0 || h > 24 ? null : h + } + + // 2.5a 时间:X点半 / X点Y分 / X点[含十的分钟](九点半→9点半、九点三十二分→9点32分、九点三十→9点30) + result = result.replace( + new RegExp( + `([${TIME_HOUR}]+)点(半|[${TIME_MIN}]+分(?!之)|[${DIGITS_NO_TEN}]*十[${DIGITS_NO_TEN}]*)`, + 'g', + ), + (m: string, hourStr: string, tail: string) => { + const hour = validHour(hourStr) + if (hour === null) return m + if (tail === '半') return `${hour}点半` + if (tail.endsWith('分')) { + const min = parseChineseInteger(tail.slice(0, -1)) + return min === null || min < 0 || min > 59 ? m : `${hour}点${min}分` + } + // 含「十」的分钟、无「分」(九点三十 → 9点30) + const min = parseChineseInteger(tail) + return min === null || min < 0 || min > 59 ? m : `${hour}点${min}` + }, + ) + + // 2.5b 整点:X点(后面不跟数字/点)→ 9点(如 上午九点 → 上午9点)。 + // 「点」后跟数字的(三点一四)留给小数规则,避免误伤。 + 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}点`) + }, + ) + // 3. 小数 / 多段点分数字:数字点数字(点数字)* → N.N 或 N.N.N // 单段是普通小数(三点一四 → 3.14);多段是版本号等(零点一点零 → 0.1.0),每段逐位读。 result = result.replace( new RegExp(`([${NUM_CHARS}]+)((?:点[${DIGIT_CHARS}]+)+)`, 'g'), (m: string, intp: string, rest: string, offset: number, str: string) => { + // 护栏:小数尾数若紧跟位值词/时间词(说明被截断,或其实是时间/量词),不转,保留中文。 + // 例:九点三十(无「分」)→ 匹配到「九点三」,其后是「十」→ 跳过,宁留中文也不产出 9.3十。 + const after = str[offset + m.length] || '' + if (after && '十百千万亿分时点刻秒'.includes(after)) return m const intVal = parseChineseInteger(intp) if (intVal === null) return m const groups = rest.split('点').filter((g: string) => g.length > 0) @@ -249,7 +297,7 @@ const STORAGE_KEY = 'textPostProcess' export const DEFAULT_POST_PROCESS: TextPostProcessOptions = { autoSegment: true, - normalizeNumbers: false, + normalizeNumbers: true, stripTrailingPunctuation: false, punctuationToSpace: false, } diff --git a/docs/images/readme/SayIt 用户反馈微信群.jpg b/docs/images/readme/SayIt 用户反馈微信群.jpg index 755b724..b790b02 100644 Binary files a/docs/images/readme/SayIt 用户反馈微信群.jpg and b/docs/images/readme/SayIt 用户反馈微信群.jpg differ