diff --git a/package.json b/package.json index 559aeda..00da38d 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,10 @@ "target": [ "dmg" ], - "category": "public.app-category.productivity" + "category": "public.app-category.productivity", + "extendInfo": { + "NSMicrophoneUsageDescription": "GoodBuddy 需要访问麦克风,将语音转换为可编辑文字。" + } }, "linux": { "icon": "build/icon.png", diff --git a/src/main/context-manager.test.ts b/src/main/context-manager.test.ts index 6e2a84f..2b374a7 100644 --- a/src/main/context-manager.test.ts +++ b/src/main/context-manager.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' +import { strToU8, zipSync } from 'fflate' import { afterEach, describe, expect, it, vi } from 'vitest' const { createFromBuffer, getSources, showOpenDialog } = vi.hoisted(() => ({ @@ -175,6 +176,59 @@ describe('ContextManager', () => { ) }) + it('extracts explicitly selected Office documents into bounded text context', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-')) + temporaryDirectories.push(directory) + const filePath = join(directory, '需求说明.docx') + await writeFile( + filePath, + Buffer.from( + zipSync({ + 'word/document.xml': strToU8( + 'Word 需求正文' + ) + }) + ) + ) + showOpenDialog.mockResolvedValue({ + canceled: false, + filePaths: [filePath] + }) + const manager = new ContextManager() + + const [attachment] = await manager.selectFiles({} as BrowserWindow) + + expect(attachment).toMatchObject({ + name: '需求说明.docx', + kind: 'text', + preview: '[正文] Word 需求正文' + }) + expect(showOpenDialog).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + filters: expect.arrayContaining([ + expect.objectContaining({ + name: 'PDF 和 Office 文档', + extensions: expect.arrayContaining([ + 'docx', + 'pdf', + 'pptx', + 'xlsx' + ]) + }) + ]) + }) + ) + const prompt = manager.enrichRequest({ + requestId: '1f6a37b6-e0a3-449f-8878-b10d353fbfb4', + conversationId: 'conversation-1', + prompt: '总结文档', + contextIds: [attachment!.id] + }).prompt + expect(prompt).toContain('Word 需求正文') + expect(prompt).toContain('"content":"[正文]\\nWord 需求正文"') + }) + it('keeps all five explicitly selected images', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-')) temporaryDirectories.push(directory) diff --git a/src/main/context-manager.ts b/src/main/context-manager.ts index d2ab81f..1f95432 100644 --- a/src/main/context-manager.ts +++ b/src/main/context-manager.ts @@ -20,6 +20,7 @@ import type { AgentImage } from './agent/runtime' import { encodeBoundedJpeg } from './bounded-jpeg' +import { parseDocument } from './knowledge/document-parser' type StoredTextContext = ContextAttachment & { kind: 'text' @@ -35,6 +36,7 @@ type StoredImageContext = ContextAttachment & { type StoredContext = StoredTextContext | StoredImageContext const maximumFileSize = 256 * 1024 +const maximumDocumentFileSize = 20 * 1024 * 1024 const maximumContextBytes = 12 * 1024 * 1024 const maximumContextCount = 16 const maximumAttachmentsPerMessage = 8 @@ -68,6 +70,36 @@ const supportedImageExtensions = new Set([ '.png', '.webp' ]) +const supportedDocumentExtensions = new Set([ + '.docx', + '.pdf', + '.pptx', + '.xlsx' +]) + +function truncateUtf8(value: string, maximumBytes: number): string { + const buffer = Buffer.from(value) + if (buffer.byteLength <= maximumBytes) { + return value + } + const marker = '\n\n[文档内容过长,已截断]' + const markerBytes = Buffer.byteLength(marker) + return `${buffer + .subarray(0, maximumBytes - markerBytes) + .toString('utf8') + .replace(/\uFFFD$/u, '')}${marker}` +} + +function formatParsedDocument( + sections: Awaited>['sections'] +): string { + return sections + .map( + (section) => + `[${section.locator}]\n${section.content}` + ) + .join('\n\n') +} export class ContextManager { private readonly contexts = new Map() @@ -161,6 +193,12 @@ export class ContextManager { extensions: [...supportedImageExtensions].map((extension) => extension.slice(1) ) + }, + { + name: 'PDF 和 Office 文档', + extensions: [...supportedDocumentExtensions].map((extension) => + extension.slice(1) + ) } ] }) @@ -178,7 +216,8 @@ export class ContextManager { const extension = extname(canonicalPath).toLowerCase() if ( !supportedExtensions.has(extension) && - !supportedImageExtensions.has(extension) + !supportedImageExtensions.has(extension) && + !supportedDocumentExtensions.has(extension) ) { throw new Error(`不支持的文件类型:${extension || '未知'}`) } @@ -204,6 +243,33 @@ export class ContextManager { } continue } + if (supportedDocumentExtensions.has(extension)) { + try { + const fileStat = await handle.stat() + if ( + !fileStat.isFile() || + fileStat.size > maximumDocumentFileSize + ) { + throw new Error('PDF 或 Office 文档必须小于 20MB 且不能是目录') + } + const parsed = await parseDocument( + basename(canonicalPath), + await handle.readFile() + ) + attachments.push( + this.storeText( + basename(canonicalPath), + truncateUtf8( + formatParsedDocument(parsed.sections), + maximumFileSize + ) + ) + ) + } finally { + await handle.close() + } + continue + } let content: string try { const fileStat = await handle.stat() diff --git a/src/main/knowledge/document-parser.test.ts b/src/main/knowledge/document-parser.test.ts index 319fe7a..9172a59 100644 --- a/src/main/knowledge/document-parser.test.ts +++ b/src/main/knowledge/document-parser.test.ts @@ -2,6 +2,33 @@ import { strToU8, zipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { chunkDocument, parseDocument } from './document-parser' +function createPdfFixture(text: string): Buffer { + const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET` + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream` + ] + let content = '%PDF-1.4\n' + const offsets = [0] + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(content)) + content += `${index + 1} 0 obj\n${object}\nendobj\n` + } + const xrefOffset = Buffer.byteLength(content) + content += `xref\n0 ${objects.length + 1}\n` + content += '0000000000 65535 f \n' + content += offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`) + .join('') + content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` + content += `startxref\n${xrefOffset}\n%%EOF\n` + return Buffer.from(content) +} + describe('document parser', () => { it('parses text and creates overlapping bounded chunks', async () => { const parsed = await parseDocument( @@ -66,6 +93,21 @@ describe('document parser', () => { } }) + it('extracts page text and locators from PDF files', async () => { + const parsed = await parseDocument( + 'sample.pdf', + createPdfFixture('PDF body text') + ) + + expect(parsed.content).toContain('PDF body text') + expect(parsed.sections).toEqual([ + { + locator: '第 1 页', + content: 'PDF body text' + } + ]) + }) + it('rejects unsupported or oversized content', async () => { await expect( parseDocument('archive.zip', Buffer.from('not supported')) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e644f23..4aa7968 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -614,7 +614,12 @@ describe('App', () => { agentListener?.({ requestId: request.requestId, type: 'text', - delta: '这是回答内容' + delta: '这是' + }) + agentListener?.({ + requestId: request.requestId, + type: 'text', + delta: '回答内容' }) agentListener?.({ requestId: request.requestId, @@ -626,6 +631,29 @@ describe('App', () => { expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge') }) + it('keeps a running response visible when cancellation fails', async () => { + vi.mocked(api.agent.cancel).mockRejectedValueOnce( + new Error('cancel failed') + ) + render() + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '开始一个长任务' } + }) + fireEvent.click(await screen.findByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + + fireEvent.click(await screen.findByLabelText('停止生成')) + + await waitFor(() => + expect(api.agent.cancel).toHaveBeenCalledOnce() + ) + expect( + await screen.findByText(/停止生成失败,请重试/u) + ).toBeInTheDocument() + expect(screen.getByLabelText('停止生成')).toBeInTheDocument() + }) + it('keeps sent documents and images in conversation history', async () => { const documentAttachment = { id: '00000000-0000-4000-8000-000000000301', @@ -1455,6 +1483,31 @@ describe('App', () => { ) }) + it('keeps project input when creation fails', async () => { + vi.mocked(api.projects.create).mockRejectedValueOnce( + new Error('项目目录不可用') + ) + render() + + fireEvent.click(await screen.findByLabelText('新建项目')) + const dialog = screen.getByRole('dialog', { name: '新建项目' }) + const nameInput = within(dialog).getByLabelText('名称') + fireEvent.change(nameInput, { + target: { value: '保留的项目名称' } + }) + fireEvent.click( + within(dialog).getByRole('button', { name: '创建' }) + ) + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + '项目目录不可用' + ) + expect(nameInput).toHaveValue('保留的项目名称') + expect( + screen.getByRole('dialog', { name: '新建项目' }) + ).toBeInTheDocument() + }) + it('marks an image model and renders its generated artifact', async () => { const anchorClick = vi .spyOn(HTMLAnchorElement.prototype, 'click') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0603cb5..dcecd9c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -103,6 +103,11 @@ import { saveAppearanceTheme, type AppearanceTheme } from './theme' +import { + describeSpeechRecognitionError, + getSpeechRecognitionConstructor, + prepareSpeechRecognition +} from './speech-recognition' function isAgentRuntime( runtime: AgentRuntimeStatus | undefined @@ -1194,6 +1199,7 @@ function App(): React.JSX.Element { mergeArtifacts(current, artifacts) ) ) + .catch(() => setNotice('成果列表刷新失败')) } else if (event.type === 'artifact') { hydratingArtifactIds.current.add(event.artifactId) void window.goodbuddy.artifacts @@ -2366,7 +2372,11 @@ function App(): React.JSX.Element { ([, run]) => run.conversationId === activeId )?.[0] if (requestId) { - await window.goodbuddy.agent.cancel(requestId) + try { + await window.goodbuddy.agent.cancel(requestId) + } catch { + setNotice('停止生成失败,请重试') + } } } @@ -2481,54 +2491,56 @@ function App(): React.JSX.Element { ) } - const startVoiceInput = (): void => { - type Recognition = { - lang: string - interimResults: boolean - continuous: boolean - start: () => void - stop: () => void - onresult?: (event: { - results: ArrayLike<{ - 0?: { transcript?: string } - }> - }) => void - onerror?: () => void - onend?: () => void - } - const SpeechRecognition = ( - window as unknown as { - webkitSpeechRecognition?: new () => Recognition - SpeechRecognition?: new () => Recognition - } - ).SpeechRecognition ?? ( - window as unknown as { - webkitSpeechRecognition?: new () => Recognition - } - ).webkitSpeechRecognition + const startVoiceInput = async (): Promise => { + const SpeechRecognition = + getSpeechRecognitionConstructor(window) if (!SpeechRecognition) { setNotice('当前系统不支持内置语音识别,可继续使用键盘输入') return } - const recognition = new SpeechRecognition() - recognition.lang = 'zh-CN' - recognition.interimResults = false - recognition.continuous = false - recognition.onresult = (event) => { - const transcript = event.results[0]?.[0]?.transcript?.trim() - if (transcript) { - setInput((current) => - current ? `${current} ${transcript}` : transcript - ) + setVoiceListening(true) + let started = false + try { + const prepared = await prepareSpeechRecognition( + SpeechRecognition, + 'zh-CN', + () => { + setNotice('正在下载中文离线语音包,完成后将自动开始听写') + } + ) + const { recognition } = prepared + recognition.onresult = (event) => { + const transcript = event.results[0]?.[0]?.transcript?.trim() + if (transcript) { + setInput((current) => + current ? `${current} ${transcript}` : transcript + ) + setNotice('语音已转为文字,可编辑后发送') + } + } + recognition.onerror = (event) => { + setNotice(describeSpeechRecognitionError(event)) + setVoiceListening(false) + } + recognition.onend = () => setVoiceListening(false) + recognition.start() + started = true + setNotice( + prepared.local + ? '正在使用本地语音识别听写' + : '正在使用系统语音服务听写' + ) + } catch (reason) { + setNotice( + reason instanceof Error + ? reason.message + : '无法启动语音识别,请检查系统语音设置' + ) + } finally { + if (!started) { + setVoiceListening(false) } } - recognition.onerror = () => { - setNotice('语音识别失败,请检查麦克风权限') - setVoiceListening(false) - } - recognition.onend = () => setVoiceListening(false) - setVoiceListening(true) - recognition.start() } const refreshSelectedKnowledge = async (): Promise => { @@ -2913,36 +2925,28 @@ function App(): React.JSX.Element { > -
- - {view === 'knowledge' - ? '知识库' - : view === 'heartbeat' - ? '智能心跳' - : view === 'activity' - ? '任务与活动' - : view === 'settings' - ? '设置中心' - : activeConversation?.title ?? '新对话'} - -
{view === 'chat' && ( - + <> +
+ {activeConversation?.title ?? '新对话'} +
+ + )}
void startVoiceInput()} title="语音转文字,转写后可编辑再发送" type="button" > diff --git a/src/renderer/src/ProjectSwitcher.tsx b/src/renderer/src/ProjectSwitcher.tsx index ee5c980..fa0bc39 100644 --- a/src/renderer/src/ProjectSwitcher.tsx +++ b/src/renderer/src/ProjectSwitcher.tsx @@ -32,6 +32,7 @@ export function ProjectSwitcher({ }: ProjectSwitcherProps): React.JSX.Element { const [creating, setCreating] = useState(false) const [saving, setSaving] = useState(false) + const [archiving, setArchiving] = useState(false) const [error, setError] = useState() const createButtonRef = useRef(null) const dialogRef = useRef(null) @@ -53,7 +54,8 @@ export function ProjectSwitcher({ } restoreCreateButtonFocus.current = true const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape' && !saving) { + if (event.key === 'Escape' && !saving && !archiving) { + setError(undefined) setCreating(false) return } @@ -81,7 +83,7 @@ export function ProjectSwitcher({ } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) - }, [creating, saving]) + }, [archiving, creating, saving]) const create = async (): Promise => { setSaving(true) @@ -103,6 +105,40 @@ export function ProjectSwitcher({ } } + const selectRoot = async (): Promise => { + setError(undefined) + try { + const rootPath = await onSelectRoot() + if (rootPath) { + setDraft((current) => ({ + ...current, + rootPath + })) + } + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : '选择项目根目录失败' + ) + } + } + + const archive = async (): Promise => { + setArchiving(true) + setError(undefined) + try { + await onArchive(activeProjectId) + setCreating(false) + } catch (reason) { + setError( + reason instanceof Error ? reason.message : '归档项目失败' + ) + } finally { + setArchiving(false) + } + } + return (
@@ -120,7 +156,10 @@ export function ProjectSwitcher({ )} -
+
{activeTab === 'appearance' && (
@@ -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 '语音识别失败,请检查麦克风和系统语音设置' + } +}