@@ -740,6 +839,13 @@ export function SettingsPanel({
setWorkspacePath(selected)
}
})
+ .catch((reason: unknown) => {
+ setError(
+ reason instanceof Error
+ ? reason.message
+ : '选择工作区目录失败'
+ )
+ })
}}
type="button"
>
diff --git a/src/renderer/src/speech-recognition.test.ts b/src/renderer/src/speech-recognition.test.ts
new file mode 100644
index 0000000..288b16b
--- /dev/null
+++ b/src/renderer/src/speech-recognition.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ describeSpeechRecognitionError,
+ getSpeechRecognitionConstructor,
+ isElectronUserAgent,
+ prepareSpeechRecognition,
+ type SpeechRecognitionConstructor,
+ type SpeechRecognitionInstance
+} from './speech-recognition'
+
+function createRecognitionConstructor(): {
+ Recognition: SpeechRecognitionConstructor
+ instance: SpeechRecognitionInstance
+} {
+ const instance: SpeechRecognitionInstance = {
+ lang: '',
+ interimResults: true,
+ continuous: true,
+ start: vi.fn(),
+ stop: vi.fn()
+ }
+ const Recognition = vi.fn(function RecognitionMock() {
+ return instance
+ }) as unknown as SpeechRecognitionConstructor
+ return { Recognition, instance }
+}
+
+describe('speech recognition', () => {
+ it('prefers the standard constructor over the prefixed constructor', () => {
+ const standard = createRecognitionConstructor().Recognition
+ const prefixed = createRecognitionConstructor().Recognition
+
+ expect(
+ getSpeechRecognitionConstructor({
+ SpeechRecognition: standard,
+ webkitSpeechRecognition: prefixed
+ } as unknown as Window)
+ ).toBe(standard)
+ })
+
+ it('enables local processing when the language pack is available', async () => {
+ const { Recognition, instance } =
+ createRecognitionConstructor()
+ Recognition.available = vi.fn(
+ async () => 'available' as const
+ )
+
+ await expect(
+ prepareSpeechRecognition(Recognition, 'zh-CN')
+ ).resolves.toEqual({
+ recognition: instance,
+ local: true
+ })
+ expect(instance).toMatchObject({
+ processLocally: true,
+ lang: 'zh-CN',
+ interimResults: false,
+ continuous: false
+ })
+ })
+
+ it('installs a downloadable local language pack before listening', async () => {
+ const { Recognition, instance } =
+ createRecognitionConstructor()
+ const onDownload = vi.fn()
+ Recognition.available = vi.fn(
+ async () => 'downloadable' as const
+ )
+ Recognition.install = vi.fn(async () => true)
+
+ await expect(
+ prepareSpeechRecognition(
+ Recognition,
+ 'zh-CN',
+ onDownload
+ )
+ ).resolves.toEqual({
+ recognition: instance,
+ local: true
+ })
+ expect(onDownload).toHaveBeenCalledOnce()
+ expect(Recognition.install).toHaveBeenCalledWith({
+ langs: ['zh-CN'],
+ processLocally: true
+ })
+ })
+
+ it('does not claim local processing when local APIs are unavailable', async () => {
+ const { Recognition, instance } =
+ createRecognitionConstructor()
+
+ await expect(
+ prepareSpeechRecognition(Recognition, 'zh-CN')
+ ).resolves.toEqual({
+ recognition: instance,
+ local: false
+ })
+ expect(instance.processLocally).toBeUndefined()
+ })
+
+ it('avoids Electron speech APIs that can freeze the renderer', async () => {
+ const { Recognition } = createRecognitionConstructor()
+ Recognition.available = vi.fn(
+ async () => 'available' as const
+ )
+
+ await expect(
+ prepareSpeechRecognition(
+ Recognition,
+ 'zh-CN',
+ undefined,
+ {},
+ 'Mozilla/5.0 Electron/43.2.0'
+ )
+ ).rejects.toThrow('不支持可靠的语音识别')
+ expect(Recognition).not.toHaveBeenCalled()
+ expect(Recognition.available).not.toHaveBeenCalled()
+ })
+
+ it('reports a language pack that is still downloading', async () => {
+ const { Recognition } = createRecognitionConstructor()
+ Recognition.available = vi.fn(
+ async () => 'downloading' as const
+ )
+
+ await expect(
+ prepareSpeechRecognition(Recognition, 'zh-CN')
+ ).rejects.toThrow('中文离线语音包正在下载')
+ })
+
+ it('bounds a stalled local language availability check', async () => {
+ const { Recognition } = createRecognitionConstructor()
+ Recognition.available = vi.fn(
+ () => new Promise
(() => undefined)
+ )
+
+ await expect(
+ prepareSpeechRecognition(
+ Recognition,
+ 'zh-CN',
+ undefined,
+ { availabilityMs: 5 }
+ )
+ ).rejects.toThrow('检查中文离线语音包超时')
+ })
+
+ it('bounds a stalled local language pack installation', async () => {
+ const { Recognition } = createRecognitionConstructor()
+ Recognition.available = vi.fn(
+ async () => 'downloadable' as const
+ )
+ Recognition.install = vi.fn(
+ () => new Promise(() => undefined)
+ )
+
+ await expect(
+ prepareSpeechRecognition(
+ Recognition,
+ 'zh-CN',
+ undefined,
+ { installMs: 5 }
+ )
+ ).rejects.toThrow('中文离线语音包下载超时')
+ })
+
+ it.each([
+ ['GoodBuddy Electron/43.2.0', true],
+ [
+ 'Mozilla/5.0 Chrome/144.0.0.0 Electron/43.2.0 Safari/537.36',
+ true
+ ],
+ ['Mozilla/5.0 Chrome/144.0.0.0 Safari/537.36', false]
+ ])('detects Electron user agent %s', (userAgent, expected) => {
+ expect(isElectronUserAgent(userAgent)).toBe(expected)
+ })
+
+ it.each([
+ ['audio-capture', '未检测到可用麦克风'],
+ ['language-not-supported', '中文语音识别包'],
+ ['network', 'Electron 在线语音服务不可用'],
+ ['no-speech', '没有检测到语音'],
+ ['not-allowed', '麦克风权限被拒绝'],
+ ['service-not-allowed', '麦克风权限被拒绝']
+ ])('maps %s errors to actionable copy', (error, copy) => {
+ expect(describeSpeechRecognitionError({ error })).toContain(copy)
+ })
+})
diff --git a/src/renderer/src/speech-recognition.ts b/src/renderer/src/speech-recognition.ts
new file mode 100644
index 0000000..dff8a6d
--- /dev/null
+++ b/src/renderer/src/speech-recognition.ts
@@ -0,0 +1,179 @@
+export type SpeechRecognitionErrorCode =
+ | 'aborted'
+ | 'audio-capture'
+ | 'bad-grammar'
+ | 'language-not-supported'
+ | 'network'
+ | 'no-speech'
+ | 'not-allowed'
+ | 'phrases-not-supported'
+ | 'service-not-allowed'
+
+export type SpeechRecognitionResultEvent = {
+ results: ArrayLike<{
+ 0?: { transcript?: string }
+ }>
+}
+
+export type SpeechRecognitionErrorEvent = {
+ error?: SpeechRecognitionErrorCode | string
+ message?: string
+}
+
+export type SpeechRecognitionInstance = {
+ lang: string
+ interimResults: boolean
+ continuous: boolean
+ processLocally?: boolean
+ start: () => void
+ stop: () => void
+ onresult?: (event: SpeechRecognitionResultEvent) => void
+ onerror?: (event: SpeechRecognitionErrorEvent) => void
+ onend?: () => void
+}
+
+type LocalAvailability =
+ | 'available'
+ | 'downloadable'
+ | 'downloading'
+ | 'unavailable'
+
+type LocalSpeechOptions = {
+ langs: string[]
+ processLocally: true
+}
+
+export type SpeechRecognitionConstructor = {
+ new (): SpeechRecognitionInstance
+ available?: (
+ options: LocalSpeechOptions
+ ) => Promise
+ install?: (options: LocalSpeechOptions) => Promise
+}
+
+export type PreparedSpeechRecognition = {
+ recognition: SpeechRecognitionInstance
+ local: boolean
+}
+
+type SpeechPreparationTimeouts = {
+ availabilityMs?: number
+ installMs?: number
+}
+
+async function withTimeout(
+ operation: Promise,
+ timeoutMs: number,
+ message: string
+): Promise {
+ let timeout: ReturnType | undefined
+ try {
+ return await Promise.race([
+ operation,
+ new Promise((_, reject) => {
+ timeout = setTimeout(
+ () => reject(new Error(message)),
+ timeoutMs
+ )
+ })
+ ])
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout)
+ }
+ }
+}
+
+export function getSpeechRecognitionConstructor(
+ target: Window
+): SpeechRecognitionConstructor | undefined {
+ const speechWindow = target as unknown as {
+ SpeechRecognition?: SpeechRecognitionConstructor
+ webkitSpeechRecognition?: SpeechRecognitionConstructor
+ }
+ return (
+ speechWindow.SpeechRecognition ??
+ speechWindow.webkitSpeechRecognition
+ )
+}
+
+export async function prepareSpeechRecognition(
+ Recognition: SpeechRecognitionConstructor,
+ lang: string,
+ onDownload?: () => void,
+ timeouts: SpeechPreparationTimeouts = {},
+ userAgent = navigator.userAgent
+): Promise {
+ if (isElectronUserAgent(userAgent)) {
+ throw new Error(
+ '当前 Electron 版本不支持可靠的语音识别,请改用系统听写功能输入文字'
+ )
+ }
+ const recognition = new Recognition()
+ const options: LocalSpeechOptions = {
+ langs: [lang],
+ processLocally: true
+ }
+ let local = false
+
+ if (Recognition.available) {
+ const availability = await withTimeout(
+ Recognition.available(options),
+ timeouts.availabilityMs ?? 5_000,
+ '检查中文离线语音包超时,请确认网络后重试'
+ )
+ if (availability === 'available') {
+ local = true
+ } else if (
+ availability === 'downloadable' &&
+ Recognition.install
+ ) {
+ onDownload?.()
+ local = await withTimeout(
+ Recognition.install(options),
+ timeouts.installMs ?? 120_000,
+ '中文离线语音包下载超时,请检查网络后重试'
+ )
+ } else if (availability === 'downloading') {
+ throw new Error('中文离线语音包正在下载,请稍后重试')
+ }
+ }
+
+ if (local) {
+ recognition.processLocally = true
+ }
+ recognition.lang = lang
+ recognition.interimResults = false
+ recognition.continuous = false
+ return { recognition, local }
+}
+
+export function isElectronUserAgent(userAgent: string): boolean {
+ return /\bElectron\/[\d.]+\b/u.test(userAgent)
+}
+
+export function describeSpeechRecognitionError(
+ event: SpeechRecognitionErrorEvent
+): string {
+ switch (event.error) {
+ case 'aborted':
+ return '语音识别已取消'
+ case 'audio-capture':
+ return '未检测到可用麦克风,请检查设备连接和系统输入设置'
+ case 'language-not-supported':
+ return '当前系统没有可用的中文语音识别包'
+ case 'network':
+ return 'Electron 在线语音服务不可用,请安装中文离线语音包后重试'
+ case 'no-speech':
+ return '没有检测到语音,请靠近麦克风后重试'
+ case 'not-allowed':
+ case 'service-not-allowed':
+ return '麦克风权限被拒绝,请在系统隐私设置中允许 GoodBuddy 使用麦克风'
+ case 'phrases-not-supported':
+ return '当前语音识别服务不支持短语增强'
+ case 'bad-grammar':
+ return '当前语音识别服务无法处理语法配置'
+ default:
+ return '语音识别失败,请检查麦克风和系统语音设置'
+ }
+}