数字规范化时间修复 + 历史搜索高亮 + 默认开启(0.1.1)
- 数字规范化:修复时间(如「九点三十二分」)被误当小数、以及句末小数偶发不生效;默认改为开启 - 历史记录搜索:命中的关键词在正文与 ASR 原文里高亮显示 - 更新 CHANGELOG 与关于页更新摘要至 0.1.1 - 更新 README 用户反馈微信群二维码图片
This commit is contained in:
1 parent
b120593bdc
commit
d57db341dd
8 files changed
+111
-16
No files matched your search
@@ -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)与云剪贴板,你原来复制的内容也不受影响',
|
||||
'修复千问短音频偶发把热词原样吐出、诊断报告时间显示等小问题',
|
||||
'修复悬浮窗被其他置顶程序(如播放器、会议共享工具条)遮挡的问题,现在稳定显示在最上层',
|
||||
'「按住说话」「免提模式」新增支持绑定鼠标中键',
|
||||
'历史记录搜索:命中的关键词现在会高亮显示,更容易定位',
|
||||
'数字规范化默认开启,并修复「九点三十二分」等时间被误当小数的问题',
|
||||
'优化热词管理:过多时自动折叠、给出温和提示,并用表格说明各模型的热词支持',
|
||||
'优化低音量提醒与「未检测到有效声音」提示,减少打扰、更清晰',
|
||||
'键盘快捷键设置新增使用说明(ⓘ)',
|
||||
],
|
||||
}
|
||||
@@ -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('空文本安全', () => {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const DEFAULTS: Record<string, unknown> = {
|
||||
// ── 文本后处理(不依赖 AI 的客户端文本规范化)──
|
||||
textPostProcess: {
|
||||
autoSegment: true, // 智能分段(仅极速模式生效),默认开启
|
||||
normalizeNumbers: false, // 数字规范化:百分之/小数/分之/结构化整数
|
||||
normalizeNumbers: true, // 数字规范化:百分之/小数/分之/结构化整数,默认开启
|
||||
stripTrailingPunctuation: false, // 去除句末标点
|
||||
punctuationToSpace: false, // 标点符号替换为空格
|
||||
},
|
||||
|
||||
@@ -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 |
Reference in new issue
Block a user