数字规范化时间修复 + 历史搜索高亮 + 默认开启(0.1.1)

- 数字规范化:修复时间(如「九点三十二分」)被误当小数、以及句末小数偶发不生效;默认改为开启
- 历史记录搜索:命中的关键词在正文与 ASR 原文里高亮显示
- 更新 CHANGELOG 与关于页更新摘要至 0.1.1
- 更新 README 用户反馈微信群二维码图片
This commit is contained in:
crosswk committed 2026-07-22 09:28:54 +08:00
1 parent b120593bdc
commit d57db341dd
8 files changed
+111 -16

No files matched your search

+5
View File
@@ -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 秒才提醒,避免正常思考停顿被反复催促。
@@ -26,6 +26,8 @@ interface HistoryRecordListProps {
onToggleFavorite?: (id: string, nextFavorite: boolean) => Promise<void> | void
onReprocess?: (record: HistoryRecord) => Promise<void> | 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 的子串用 <mark> 高亮(大小写不敏感,用 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<string | JSX.Element> = []
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(
<mark key={key++} className="rounded-sm bg-amber-200/70 px-0.5 text-inherit dark:bg-amber-500/30">
{text.slice(idx, idx + kw.length)}
</mark>,
)
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> | 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) => (
<p key={idx} className={idx > 0 ? 'mt-1.5' : undefined}>{para}</p>
<p key={idx} className={idx > 0 ? 'mt-1.5' : undefined}>{highlightText(para, highlight)}</p>
))
) : (
<p>{text}</p>
<p>{highlightText(text, highlight)}</p>
)}
</div>
)}
@@ -279,7 +307,7 @@ function HistoryItem({
{!isEmpty && record.asrText && (
<div className="text-muted-foreground">
<span className="font-medium">ASR 原文:</span>
<span className="whitespace-pre-line">{record.asrText}</span>
<span className="whitespace-pre-line">{highlightText(record.asrText, highlight)}</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
@@ -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> | void
onReprocess?: (record: HistoryRecord) => Promise<void> | void
highlight?: string
}) {
return (
<Card>
@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -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)与云剪贴板,你原来复制的内容也不受影响',
'修复千问短音频偶发把热词原样吐出、诊断报告时间显示等小问题',
'修复悬浮窗被其他置顶程序(如播放器、会议共享工具条)遮挡的问题,现在稳定显示在最上层',
'「按住说话」「免提模式」新增支持绑定鼠标中键',
'历史记录搜索:命中的关键词现在会高亮显示,更容易定位',
'数字规范化默认开启,并修复「九点三十二分」等时间被误当小数的问题',
'优化热词管理:过多时自动折叠、给出温和提示,并用表格说明各模型的热词支持',
'优化低音量提醒与「未检测到有效声音」提示,减少打扰、更清晰',
'键盘快捷键设置新增使用说明(ⓘ)',
],
}
+1
View File
@@ -526,6 +526,7 @@ export default function History() {
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
onReprocess={handleReprocess}
highlight={debouncedKeyword}
emptyText={keyword.trim() ? '没有匹配的历史记录' : favoriteOnly ? '还没有收藏记录,去历史记录里点一下星标吧。' : '还没有记录,去语音工作台试试吧'}
/>
@@ -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('空文本安全', () => {
+1 -1
View File
@@ -76,7 +76,7 @@ export const DEFAULTS: Record<string, unknown> = {
// ── 文本后处理(不依赖 AI 的客户端文本规范化)──
textPostProcess: {
autoSegment: true, // 智能分段(仅极速模式生效),默认开启
normalizeNumbers: false, // 数字规范化:百分之/小数/分之/结构化整数
normalizeNumbers: true, // 数字规范化:百分之/小数/分之/结构化整数,默认开启
stripTrailingPunctuation: false, // 去除句末标点
punctuationToSpace: false, // 标点符号替换为空格
},
+49 -1
View File
@@ -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,
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 252 KiB