fix: harden speech and document interactions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
This commit is contained in:
+4
-1
@@ -110,7 +110,10 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"dmg"
|
"dmg"
|
||||||
],
|
],
|
||||||
"category": "public.app-category.productivity"
|
"category": "public.app-category.productivity",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "GoodBuddy 需要访问麦克风,将语音转换为可编辑文字。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"icon": "build/icon.png",
|
"icon": "build/icon.png",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { basename, join } from 'node:path'
|
import { basename, join } from 'node:path'
|
||||||
|
import { strToU8, zipSync } from 'fflate'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const { createFromBuffer, getSources, showOpenDialog } = vi.hoisted(() => ({
|
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(
|
||||||
|
'<w:document><w:p><w:t>Word 需求正文</w:t></w:p></w:document>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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 () => {
|
it('keeps all five explicitly selected images', async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-'))
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-'))
|
||||||
temporaryDirectories.push(directory)
|
temporaryDirectories.push(directory)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
AgentImage
|
AgentImage
|
||||||
} from './agent/runtime'
|
} from './agent/runtime'
|
||||||
import { encodeBoundedJpeg } from './bounded-jpeg'
|
import { encodeBoundedJpeg } from './bounded-jpeg'
|
||||||
|
import { parseDocument } from './knowledge/document-parser'
|
||||||
|
|
||||||
type StoredTextContext = ContextAttachment & {
|
type StoredTextContext = ContextAttachment & {
|
||||||
kind: 'text'
|
kind: 'text'
|
||||||
@@ -35,6 +36,7 @@ type StoredImageContext = ContextAttachment & {
|
|||||||
type StoredContext = StoredTextContext | StoredImageContext
|
type StoredContext = StoredTextContext | StoredImageContext
|
||||||
|
|
||||||
const maximumFileSize = 256 * 1024
|
const maximumFileSize = 256 * 1024
|
||||||
|
const maximumDocumentFileSize = 20 * 1024 * 1024
|
||||||
const maximumContextBytes = 12 * 1024 * 1024
|
const maximumContextBytes = 12 * 1024 * 1024
|
||||||
const maximumContextCount = 16
|
const maximumContextCount = 16
|
||||||
const maximumAttachmentsPerMessage = 8
|
const maximumAttachmentsPerMessage = 8
|
||||||
@@ -68,6 +70,36 @@ const supportedImageExtensions = new Set([
|
|||||||
'.png',
|
'.png',
|
||||||
'.webp'
|
'.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<ReturnType<typeof parseDocument>>['sections']
|
||||||
|
): string {
|
||||||
|
return sections
|
||||||
|
.map(
|
||||||
|
(section) =>
|
||||||
|
`[${section.locator}]\n${section.content}`
|
||||||
|
)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
export class ContextManager {
|
export class ContextManager {
|
||||||
private readonly contexts = new Map<string, StoredContext>()
|
private readonly contexts = new Map<string, StoredContext>()
|
||||||
@@ -161,6 +193,12 @@ export class ContextManager {
|
|||||||
extensions: [...supportedImageExtensions].map((extension) =>
|
extensions: [...supportedImageExtensions].map((extension) =>
|
||||||
extension.slice(1)
|
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()
|
const extension = extname(canonicalPath).toLowerCase()
|
||||||
if (
|
if (
|
||||||
!supportedExtensions.has(extension) &&
|
!supportedExtensions.has(extension) &&
|
||||||
!supportedImageExtensions.has(extension)
|
!supportedImageExtensions.has(extension) &&
|
||||||
|
!supportedDocumentExtensions.has(extension)
|
||||||
) {
|
) {
|
||||||
throw new Error(`不支持的文件类型:${extension || '未知'}`)
|
throw new Error(`不支持的文件类型:${extension || '未知'}`)
|
||||||
}
|
}
|
||||||
@@ -204,6 +243,33 @@ export class ContextManager {
|
|||||||
}
|
}
|
||||||
continue
|
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
|
let content: string
|
||||||
try {
|
try {
|
||||||
const fileStat = await handle.stat()
|
const fileStat = await handle.stat()
|
||||||
|
|||||||
@@ -2,6 +2,33 @@ import { strToU8, zipSync } from 'fflate'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { chunkDocument, parseDocument } from './document-parser'
|
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', () => {
|
describe('document parser', () => {
|
||||||
it('parses text and creates overlapping bounded chunks', async () => {
|
it('parses text and creates overlapping bounded chunks', async () => {
|
||||||
const parsed = await parseDocument(
|
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 () => {
|
it('rejects unsupported or oversized content', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
parseDocument('archive.zip', Buffer.from('not supported'))
|
parseDocument('archive.zip', Buffer.from('not supported'))
|
||||||
|
|||||||
@@ -614,7 +614,12 @@ describe('App', () => {
|
|||||||
agentListener?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
delta: '这是回答内容'
|
delta: '这是'
|
||||||
|
})
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: '回答内容'
|
||||||
})
|
})
|
||||||
agentListener?.({
|
agentListener?.({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
@@ -626,6 +631,29 @@ describe('App', () => {
|
|||||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
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(<App />)
|
||||||
|
|
||||||
|
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 () => {
|
it('keeps sent documents and images in conversation history', async () => {
|
||||||
const documentAttachment = {
|
const documentAttachment = {
|
||||||
id: '00000000-0000-4000-8000-000000000301',
|
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(<App />)
|
||||||
|
|
||||||
|
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 () => {
|
it('marks an image model and renders its generated artifact', async () => {
|
||||||
const anchorClick = vi
|
const anchorClick = vi
|
||||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
|
|||||||
+77
-73
@@ -103,6 +103,11 @@ import {
|
|||||||
saveAppearanceTheme,
|
saveAppearanceTheme,
|
||||||
type AppearanceTheme
|
type AppearanceTheme
|
||||||
} from './theme'
|
} from './theme'
|
||||||
|
import {
|
||||||
|
describeSpeechRecognitionError,
|
||||||
|
getSpeechRecognitionConstructor,
|
||||||
|
prepareSpeechRecognition
|
||||||
|
} from './speech-recognition'
|
||||||
|
|
||||||
function isAgentRuntime(
|
function isAgentRuntime(
|
||||||
runtime: AgentRuntimeStatus | undefined
|
runtime: AgentRuntimeStatus | undefined
|
||||||
@@ -1194,6 +1199,7 @@ function App(): React.JSX.Element {
|
|||||||
mergeArtifacts(current, artifacts)
|
mergeArtifacts(current, artifacts)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.catch(() => setNotice('成果列表刷新失败'))
|
||||||
} else if (event.type === 'artifact') {
|
} else if (event.type === 'artifact') {
|
||||||
hydratingArtifactIds.current.add(event.artifactId)
|
hydratingArtifactIds.current.add(event.artifactId)
|
||||||
void window.goodbuddy.artifacts
|
void window.goodbuddy.artifacts
|
||||||
@@ -2366,7 +2372,11 @@ function App(): React.JSX.Element {
|
|||||||
([, run]) => run.conversationId === activeId
|
([, run]) => run.conversationId === activeId
|
||||||
)?.[0]
|
)?.[0]
|
||||||
if (requestId) {
|
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 => {
|
const startVoiceInput = async (): Promise<void> => {
|
||||||
type Recognition = {
|
const SpeechRecognition =
|
||||||
lang: string
|
getSpeechRecognitionConstructor(window)
|
||||||
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
|
|
||||||
if (!SpeechRecognition) {
|
if (!SpeechRecognition) {
|
||||||
setNotice('当前系统不支持内置语音识别,可继续使用键盘输入')
|
setNotice('当前系统不支持内置语音识别,可继续使用键盘输入')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const recognition = new SpeechRecognition()
|
setVoiceListening(true)
|
||||||
recognition.lang = 'zh-CN'
|
let started = false
|
||||||
recognition.interimResults = false
|
try {
|
||||||
recognition.continuous = false
|
const prepared = await prepareSpeechRecognition(
|
||||||
recognition.onresult = (event) => {
|
SpeechRecognition,
|
||||||
const transcript = event.results[0]?.[0]?.transcript?.trim()
|
'zh-CN',
|
||||||
if (transcript) {
|
() => {
|
||||||
setInput((current) =>
|
setNotice('正在下载中文离线语音包,完成后将自动开始听写')
|
||||||
current ? `${current} ${transcript}` : transcript
|
}
|
||||||
)
|
)
|
||||||
|
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<void> => {
|
const refreshSelectedKnowledge = async (): Promise<void> => {
|
||||||
@@ -2913,36 +2925,28 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<PanelLeft size={18} />
|
<PanelLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div
|
|
||||||
className="conversation-title"
|
|
||||||
title={activeConversation?.title}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{view === 'knowledge'
|
|
||||||
? '知识库'
|
|
||||||
: view === 'heartbeat'
|
|
||||||
? '智能心跳'
|
|
||||||
: view === 'activity'
|
|
||||||
? '任务与活动'
|
|
||||||
: view === 'settings'
|
|
||||||
? '设置中心'
|
|
||||||
: activeConversation?.title ?? '新对话'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{view === 'chat' && (
|
{view === 'chat' && (
|
||||||
<ScopeBadge
|
<>
|
||||||
scope={
|
<div
|
||||||
activeProject
|
className="conversation-title"
|
||||||
? {
|
title={activeConversation?.title}
|
||||||
kind: 'project',
|
>
|
||||||
projectName: activeProject.name
|
<span>{activeConversation?.title ?? '新对话'}</span>
|
||||||
}
|
</div>
|
||||||
: {
|
<ScopeBadge
|
||||||
kind: 'unavailable',
|
scope={
|
||||||
explanation: '当前项目尚未加载。'
|
activeProject
|
||||||
}
|
? {
|
||||||
}
|
kind: 'project',
|
||||||
/>
|
projectName: activeProject.name
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: 'unavailable',
|
||||||
|
explanation: '当前项目尚未加载。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="topbar__actions">
|
<div className="topbar__actions">
|
||||||
<span
|
<span
|
||||||
@@ -3545,7 +3549,7 @@ function App(): React.JSX.Element {
|
|||||||
<button
|
<button
|
||||||
aria-label={voiceListening ? '正在听写' : '语音输入'}
|
aria-label={voiceListening ? '正在听写' : '语音输入'}
|
||||||
disabled={voiceListening}
|
disabled={voiceListening}
|
||||||
onClick={startVoiceInput}
|
onClick={() => void startVoiceInput()}
|
||||||
title="语音转文字,转写后可编辑再发送"
|
title="语音转文字,转写后可编辑再发送"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function ProjectSwitcher({
|
|||||||
}: ProjectSwitcherProps): React.JSX.Element {
|
}: ProjectSwitcherProps): React.JSX.Element {
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [archiving, setArchiving] = useState(false)
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -53,7 +54,8 @@ export function ProjectSwitcher({
|
|||||||
}
|
}
|
||||||
restoreCreateButtonFocus.current = true
|
restoreCreateButtonFocus.current = true
|
||||||
const onKeyDown = (event: KeyboardEvent): void => {
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (event.key === 'Escape' && !saving) {
|
if (event.key === 'Escape' && !saving && !archiving) {
|
||||||
|
setError(undefined)
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,7 @@ export function ProjectSwitcher({
|
|||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
}, [creating, saving])
|
}, [archiving, creating, saving])
|
||||||
|
|
||||||
const create = async (): Promise<void> => {
|
const create = async (): Promise<void> => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -103,6 +105,40 @@ export function ProjectSwitcher({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectRoot = async (): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
setArchiving(true)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
await onArchive(activeProjectId)
|
||||||
|
setCreating(false)
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : '归档项目失败'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setArchiving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="project-switcher">
|
<div className="project-switcher">
|
||||||
<div className="project-switcher__row">
|
<div className="project-switcher__row">
|
||||||
@@ -120,7 +156,10 @@ export function ProjectSwitcher({
|
|||||||
<button
|
<button
|
||||||
aria-label="新建项目"
|
aria-label="新建项目"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => setCreating(true)}
|
onClick={() => {
|
||||||
|
setError(undefined)
|
||||||
|
setCreating(true)
|
||||||
|
}}
|
||||||
ref={createButtonRef}
|
ref={createButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -131,7 +170,12 @@ export function ProjectSwitcher({
|
|||||||
<div
|
<div
|
||||||
className="project-create-backdrop"
|
className="project-create-backdrop"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.currentTarget === event.target && !saving) {
|
if (
|
||||||
|
event.currentTarget === event.target &&
|
||||||
|
!saving &&
|
||||||
|
!archiving
|
||||||
|
) {
|
||||||
|
setError(undefined)
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -148,7 +192,11 @@ export function ProjectSwitcher({
|
|||||||
<button
|
<button
|
||||||
aria-label="关闭新建项目"
|
aria-label="关闭新建项目"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => setCreating(false)}
|
disabled={saving || archiving}
|
||||||
|
onClick={() => {
|
||||||
|
setError(undefined)
|
||||||
|
setCreating(false)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
@@ -189,16 +237,8 @@ export function ProjectSwitcher({
|
|||||||
<button
|
<button
|
||||||
aria-label="选择项目根目录"
|
aria-label="选择项目根目录"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => {
|
disabled={saving || archiving}
|
||||||
void onSelectRoot().then((rootPath) => {
|
onClick={() => void selectRoot()}
|
||||||
if (rootPath) {
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
rootPath
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} />
|
<FolderOpen size={14} />
|
||||||
@@ -223,23 +263,28 @@ export function ProjectSwitcher({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="project-create-card__error">{error}</p>}
|
{error && (
|
||||||
|
<p className="project-create-card__error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="project-create-card__actions">
|
<div className="project-create-card__actions">
|
||||||
{projects.length > 1 && activeProjectId && (
|
{projects.length > 1 && activeProjectId && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => {
|
disabled={saving || archiving}
|
||||||
void onArchive(activeProjectId)
|
onClick={() => void archive()}
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Archive size={13} />
|
<Archive size={13} />
|
||||||
归档当前
|
{archiving ? '归档中' : '归档当前'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={saving || !draft.name.trim()}
|
disabled={
|
||||||
|
saving || archiving || !draft.name.trim()
|
||||||
|
}
|
||||||
onClick={() => void create()}
|
onClick={() => void create()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ beforeEach(() => {
|
|||||||
function renderSidebar({
|
function renderSidebar({
|
||||||
tasks = [],
|
tasks = [],
|
||||||
experts = [],
|
experts = [],
|
||||||
tab = 'context'
|
tab = 'context',
|
||||||
|
onCreateSchedule = vi.fn(async () => undefined)
|
||||||
}: {
|
}: {
|
||||||
tasks?: AssistantTask[]
|
tasks?: AssistantTask[]
|
||||||
experts?: AssistantExpert[]
|
experts?: AssistantExpert[]
|
||||||
tab?: 'tasks' | 'context'
|
tab?: 'tasks' | 'context'
|
||||||
|
onCreateSchedule?: () => Promise<void>
|
||||||
} = {}): HTMLElement {
|
} = {}): HTMLElement {
|
||||||
render(
|
render(
|
||||||
<RightAssistantSidebar
|
<RightAssistantSidebar
|
||||||
@@ -38,7 +40,7 @@ function renderSidebar({
|
|||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onCreateHeartbeat={vi.fn(async () => undefined)}
|
onCreateHeartbeat={vi.fn(async () => undefined)}
|
||||||
onCreateMemory={vi.fn(async () => undefined)}
|
onCreateMemory={vi.fn(async () => undefined)}
|
||||||
onCreateSchedule={vi.fn(async () => undefined)}
|
onCreateSchedule={onCreateSchedule}
|
||||||
onImportArtifacts={vi.fn(async () => undefined)}
|
onImportArtifacts={vi.fn(async () => undefined)}
|
||||||
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
|
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
|
||||||
path,
|
path,
|
||||||
@@ -208,4 +210,34 @@ describe('RightAssistantSidebar resizing', () => {
|
|||||||
expect(taskButtons[1]).toHaveClass('assistant-sidebar__row--subtask')
|
expect(taskButtons[1]).toHaveClass('assistant-sidebar__row--subtask')
|
||||||
expect(taskButtons[1]).toHaveTextContent('子专家:研究专家 · 智能路由')
|
expect(taskButtons[1]).toHaveTextContent('子专家:研究专家 · 智能路由')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves schedule input and reports a failed action', async () => {
|
||||||
|
const onCreateSchedule = vi.fn(async () => {
|
||||||
|
throw new Error('定时服务不可用')
|
||||||
|
})
|
||||||
|
renderSidebar({ tab: 'tasks', onCreateSchedule })
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('定时任务标题'), {
|
||||||
|
target: { value: '每日摘要' }
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('定时任务内容'), {
|
||||||
|
target: { value: '总结今天的工作' }
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('定时任务时间'), {
|
||||||
|
target: { value: '2026-08-06T09:00' }
|
||||||
|
})
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '添加定时任务' })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'定时服务不可用'
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('定时任务标题')).toHaveValue(
|
||||||
|
'每日摘要'
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText('定时任务内容')).toHaveValue(
|
||||||
|
'总结今天的工作'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ export function RightAssistantSidebar({
|
|||||||
const [scheduleRecurrence, setScheduleRecurrence] = useState<
|
const [scheduleRecurrence, setScheduleRecurrence] = useState<
|
||||||
ScheduleCreateInput['recurrence']
|
ScheduleCreateInput['recurrence']
|
||||||
>('once')
|
>('once')
|
||||||
|
const [actionError, setActionError] = useState('')
|
||||||
const recentTasks = useMemo(
|
const recentTasks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
activities
|
activities
|
||||||
@@ -421,6 +422,7 @@ export function RightAssistantSidebar({
|
|||||||
workspacePreviewRequest.current = requestId
|
workspacePreviewRequest.current = requestId
|
||||||
const projectId = workspaceProjectId
|
const projectId = workspaceProjectId
|
||||||
setWorkspacePreview({ projectId, path, state: 'loading' })
|
setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||||
|
setActionError('')
|
||||||
onTabChange('preview')
|
onTabChange('preview')
|
||||||
void onLoadWorkspaceFile(path)
|
void onLoadWorkspaceFile(path)
|
||||||
.then((file) => {
|
.then((file) => {
|
||||||
@@ -449,6 +451,21 @@ export function RightAssistantSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runAction = (
|
||||||
|
action: () => Promise<void>,
|
||||||
|
fallback: string,
|
||||||
|
onSuccess?: () => void
|
||||||
|
): void => {
|
||||||
|
setActionError('')
|
||||||
|
void action()
|
||||||
|
.then(onSuccess)
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
setActionError(
|
||||||
|
reason instanceof Error ? reason.message : fallback
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const moveTabFocus = (
|
const moveTabFocus = (
|
||||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||||
tabId: AssistantSidebarTab
|
tabId: AssistantSidebarTab
|
||||||
@@ -472,6 +489,7 @@ export function RightAssistantSidebar({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setActionError('')
|
||||||
onTabChange(target.id)
|
onTabChange(target.id)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
||||||
@@ -567,7 +585,10 @@ export function RightAssistantSidebar({
|
|||||||
}
|
}
|
||||||
id={`assistant-sidebar-tab-${item.id}`}
|
id={`assistant-sidebar-tab-${item.id}`}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onTabChange(item.id)}
|
onClick={() => {
|
||||||
|
setActionError('')
|
||||||
|
onTabChange(item.id)
|
||||||
|
}}
|
||||||
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
||||||
role="tab"
|
role="tab"
|
||||||
tabIndex={tab === item.id ? 0 : -1}
|
tabIndex={tab === item.id ? 0 : -1}
|
||||||
@@ -590,6 +611,11 @@ export function RightAssistantSidebar({
|
|||||||
id="assistant-sidebar-panel"
|
id="assistant-sidebar-panel"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
>
|
>
|
||||||
|
{actionError ? (
|
||||||
|
<p className="settings-error" role="alert">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{tab === 'tasks' && (
|
{tab === 'tasks' && (
|
||||||
<section className="assistant-sidebar__section">
|
<section className="assistant-sidebar__section">
|
||||||
<p className="assistant-sidebar__section-description">
|
<p className="assistant-sidebar__section-description">
|
||||||
@@ -737,17 +763,24 @@ export function RightAssistantSidebar({
|
|||||||
!scheduleTime
|
!scheduleTime
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void onCreateSchedule({
|
runAction(
|
||||||
title: scheduleTitle.trim(),
|
() =>
|
||||||
prompt: schedulePrompt.trim(),
|
onCreateSchedule({
|
||||||
workMode: 'ask',
|
title: scheduleTitle.trim(),
|
||||||
recurrence: scheduleRecurrence,
|
prompt: schedulePrompt.trim(),
|
||||||
nextRunAt: new Date(scheduleTime).toISOString()
|
workMode: 'ask',
|
||||||
}).then(() => {
|
recurrence: scheduleRecurrence,
|
||||||
setScheduleTitle('')
|
nextRunAt: new Date(
|
||||||
setSchedulePrompt('')
|
scheduleTime
|
||||||
setScheduleTime('')
|
).toISOString()
|
||||||
})
|
}),
|
||||||
|
'添加定时任务失败',
|
||||||
|
() => {
|
||||||
|
setScheduleTitle('')
|
||||||
|
setSchedulePrompt('')
|
||||||
|
setScheduleTime('')
|
||||||
|
}
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -768,13 +801,23 @@ export function RightAssistantSidebar({
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => void onRunSchedule(schedule.id)}
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
() => onRunSchedule(schedule.id),
|
||||||
|
'运行定时任务失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
立即运行
|
立即运行
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => void onRemoveSchedule(schedule.id)}
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
() => onRemoveSchedule(schedule.id),
|
||||||
|
'删除定时任务失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -859,8 +902,11 @@ export function RightAssistantSidebar({
|
|||||||
disabled={!memoryDraft.trim()}
|
disabled={!memoryDraft.trim()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const content = memoryDraft.trim()
|
const content = memoryDraft.trim()
|
||||||
setMemoryDraft('')
|
runAction(
|
||||||
void onCreateMemory(content)
|
() => onCreateMemory(content),
|
||||||
|
'保存长期记忆失败',
|
||||||
|
() => setMemoryDraft('')
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -888,9 +934,13 @@ export function RightAssistantSidebar({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void onSetMemoryStatus(
|
runAction(
|
||||||
memory.id,
|
() =>
|
||||||
'confirmed'
|
onSetMemoryStatus(
|
||||||
|
memory.id,
|
||||||
|
'confirmed'
|
||||||
|
),
|
||||||
|
'确认长期记忆失败'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -899,9 +949,13 @@ export function RightAssistantSidebar({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void onSetMemoryStatus(
|
runAction(
|
||||||
memory.id,
|
() =>
|
||||||
'rejected'
|
onSetMemoryStatus(
|
||||||
|
memory.id,
|
||||||
|
'rejected'
|
||||||
|
),
|
||||||
|
'忽略长期记忆失败'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -913,7 +967,12 @@ export function RightAssistantSidebar({
|
|||||||
<button
|
<button
|
||||||
aria-label={`删除记忆 ${memory.content.slice(0, 24)}`}
|
aria-label={`删除记忆 ${memory.content.slice(0, 24)}`}
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => void onRemoveMemory(memory.id)}
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
() => onRemoveMemory(memory.id),
|
||||||
|
'删除长期记忆失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<X size={13} />
|
<X size={13} />
|
||||||
@@ -971,7 +1030,12 @@ export function RightAssistantSidebar({
|
|||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
className="secondary-button assistant-sidebar__import"
|
className="secondary-button assistant-sidebar__import"
|
||||||
onClick={() => void onImportArtifacts()}
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
onImportArtifacts,
|
||||||
|
'导入成果失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Upload size={13} />
|
<Upload size={13} />
|
||||||
@@ -990,8 +1054,12 @@ export function RightAssistantSidebar({
|
|||||||
workspacePreviewRequest.current += 1
|
workspacePreviewRequest.current += 1
|
||||||
setWorkspacePreview(undefined)
|
setWorkspacePreview(undefined)
|
||||||
setSelectedArtifactId(artifact.id)
|
setSelectedArtifactId(artifact.id)
|
||||||
|
setActionError('')
|
||||||
onTabChange('preview')
|
onTabChange('preview')
|
||||||
void onLoadArtifact(artifact.id)
|
runAction(
|
||||||
|
() => onLoadArtifact(artifact.id),
|
||||||
|
'加载成果失败'
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1022,7 +1090,10 @@ export function RightAssistantSidebar({
|
|||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWorkspaceRefreshVersion((current) => current + 1)
|
setWorkspaceRefreshVersion((current) => current + 1)
|
||||||
void onRefreshChanges()
|
runAction(
|
||||||
|
onRefreshChanges,
|
||||||
|
'刷新工作区文件失败'
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1093,7 +1164,12 @@ export function RightAssistantSidebar({
|
|||||||
browserState.status !== 'stopped' && (
|
browserState.status !== 'stopped' && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() => void onStopBrowser()}
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
onStopBrowser,
|
||||||
|
'停止浏览器失败'
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
停止浏览器
|
停止浏览器
|
||||||
|
|||||||
@@ -315,6 +315,46 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports keyboard navigation between settings tabs', () => {
|
||||||
|
render(
|
||||||
|
<SettingsPanel
|
||||||
|
{...heartbeatSettingsProps}
|
||||||
|
open
|
||||||
|
onClearLocalData={vi.fn(async () => {})}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSaved={vi.fn()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const runtimeTab = screen.getByRole('tab', {
|
||||||
|
name: 'Agent Runtime'
|
||||||
|
})
|
||||||
|
expect(runtimeTab).toHaveAttribute('tabindex', '0')
|
||||||
|
runtimeTab.focus()
|
||||||
|
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
|
||||||
|
|
||||||
|
const securityTab = screen.getByRole('tab', {
|
||||||
|
name: '安全与数据'
|
||||||
|
})
|
||||||
|
expect(securityTab).toHaveFocus()
|
||||||
|
expect(securityTab).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(securityTab).toHaveAttribute('tabindex', '0')
|
||||||
|
expect(runtimeTab).toHaveAttribute('tabindex', '-1')
|
||||||
|
|
||||||
|
fireEvent.keyDown(securityTab, { key: 'End' })
|
||||||
|
const mcpTab = screen.getByRole('tab', { name: 'MCP' })
|
||||||
|
expect(mcpTab).toHaveFocus()
|
||||||
|
expect(mcpTab).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(
|
||||||
|
screen.getByRole('tabpanel')
|
||||||
|
).toHaveAttribute('aria-labelledby', 'settings-tab-mcp')
|
||||||
|
|
||||||
|
fireEvent.keyDown(mcpTab, { key: 'Home' })
|
||||||
|
expect(
|
||||||
|
screen.getByRole('tab', { name: '外观' })
|
||||||
|
).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
it('explains automatic Execute authorization and the deny-all policy', async () => {
|
it('explains automatic Execute authorization and the deny-all policy', async () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
|||||||
clearApiKey: boolean
|
clearApiKey: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settingsTabs: readonly SettingsTab[] = [
|
||||||
|
'appearance',
|
||||||
|
'model',
|
||||||
|
'runtime',
|
||||||
|
'security',
|
||||||
|
'automation',
|
||||||
|
'roles',
|
||||||
|
'skills',
|
||||||
|
'mcp'
|
||||||
|
]
|
||||||
|
|
||||||
type SettingsPanelProps = {
|
type SettingsPanelProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
presentation?: 'modal' | 'page'
|
presentation?: 'modal' | 'page'
|
||||||
@@ -175,6 +186,36 @@ export function SettingsPanel({
|
|||||||
activeTab === 'runtime' ||
|
activeTab === 'runtime' ||
|
||||||
activeTab === 'security'
|
activeTab === 'security'
|
||||||
|
|
||||||
|
const handleTabKeyDown = (
|
||||||
|
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||||
|
tab: SettingsTab
|
||||||
|
): void => {
|
||||||
|
const currentIndex = settingsTabs.indexOf(tab)
|
||||||
|
let nextIndex: number | undefined
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||||
|
nextIndex = (currentIndex + 1) % settingsTabs.length
|
||||||
|
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||||
|
nextIndex =
|
||||||
|
(currentIndex - 1 + settingsTabs.length) %
|
||||||
|
settingsTabs.length
|
||||||
|
} else if (event.key === 'Home') {
|
||||||
|
nextIndex = 0
|
||||||
|
} else if (event.key === 'End') {
|
||||||
|
nextIndex = settingsTabs.length - 1
|
||||||
|
}
|
||||||
|
if (nextIndex === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
const nextTab = settingsTabs[nextIndex]!
|
||||||
|
setActiveTab(nextTab)
|
||||||
|
event.currentTarget.parentElement
|
||||||
|
?.querySelector<HTMLButtonElement>(
|
||||||
|
`#settings-tab-${nextTab}`
|
||||||
|
)
|
||||||
|
?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return
|
return
|
||||||
@@ -561,82 +602,135 @@ export function SettingsPanel({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="settings-panel__body">
|
<div className="settings-panel__body">
|
||||||
<nav aria-label="设置分类" className="settings-tabs">
|
<nav
|
||||||
|
aria-label="设置分类"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
className="settings-tabs"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-appearance"
|
||||||
aria-label="外观"
|
aria-label="外观"
|
||||||
aria-selected={activeTab === 'appearance'}
|
aria-selected={activeTab === 'appearance'}
|
||||||
|
id="settings-tab-appearance"
|
||||||
onClick={() => setActiveTab('appearance')}
|
onClick={() => setActiveTab('appearance')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'appearance')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'appearance' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>外观</strong>
|
<strong>外观</strong>
|
||||||
<small>亮色、暗色与系统主题</small>
|
<small>亮色、暗色与系统主题</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-model"
|
||||||
aria-label="模型连接"
|
aria-label="模型连接"
|
||||||
aria-selected={activeTab === 'model'}
|
aria-selected={activeTab === 'model'}
|
||||||
|
id="settings-tab-model"
|
||||||
onClick={() => setActiveTab('model')}
|
onClick={() => setActiveTab('model')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'model')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'model' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>模型连接</strong>
|
<strong>模型连接</strong>
|
||||||
<small>LLM、向量模型与凭据</small>
|
<small>LLM、向量模型与凭据</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-runtime"
|
||||||
aria-label="Agent Runtime"
|
aria-label="Agent Runtime"
|
||||||
aria-selected={activeTab === 'runtime'}
|
aria-selected={activeTab === 'runtime'}
|
||||||
|
id="settings-tab-runtime"
|
||||||
onClick={() => setActiveTab('runtime')}
|
onClick={() => setActiveTab('runtime')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'runtime')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'runtime' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>Agent Runtime</strong>
|
<strong>Agent Runtime</strong>
|
||||||
<small>OpenCode、Continue 与工作区</small>
|
<small>OpenCode、Continue 与工作区</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-security"
|
||||||
aria-label="安全与数据"
|
aria-label="安全与数据"
|
||||||
aria-selected={activeTab === 'security'}
|
aria-selected={activeTab === 'security'}
|
||||||
|
id="settings-tab-security"
|
||||||
onClick={() => setActiveTab('security')}
|
onClick={() => setActiveTab('security')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'security')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'security' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>安全与数据</strong>
|
<strong>安全与数据</strong>
|
||||||
<small>工具策略与本地隐私</small>
|
<small>工具策略与本地隐私</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-automation"
|
||||||
aria-label="自动化"
|
aria-label="自动化"
|
||||||
aria-selected={activeTab === 'automation'}
|
aria-selected={activeTab === 'automation'}
|
||||||
|
id="settings-tab-automation"
|
||||||
onClick={() => setActiveTab('automation')}
|
onClick={() => setActiveTab('automation')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'automation')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'automation' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>自动化</strong>
|
<strong>自动化</strong>
|
||||||
<small>智能心跳与周期回顾</small>
|
<small>智能心跳与周期回顾</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-roles"
|
||||||
aria-label="角色与提示词"
|
aria-label="角色与提示词"
|
||||||
aria-selected={activeTab === 'roles'}
|
aria-selected={activeTab === 'roles'}
|
||||||
|
id="settings-tab-roles"
|
||||||
onClick={() => setActiveTab('roles')}
|
onClick={() => setActiveTab('roles')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'roles')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'roles' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>角色与提示词</strong>
|
<strong>角色与提示词</strong>
|
||||||
<small>角色、说明与系统提示词</small>
|
<small>角色、说明与系统提示词</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-skills"
|
||||||
aria-label="Skills"
|
aria-label="Skills"
|
||||||
aria-selected={activeTab === 'skills'}
|
aria-selected={activeTab === 'skills'}
|
||||||
|
id="settings-tab-skills"
|
||||||
onClick={() => setActiveTab('skills')}
|
onClick={() => setActiveTab('skills')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'skills')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'skills' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>Skills</strong>
|
<strong>Skills</strong>
|
||||||
<small>内置与自定义能力</small>
|
<small>内置与自定义能力</small>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="settings-panel-mcp"
|
||||||
aria-label="MCP"
|
aria-label="MCP"
|
||||||
aria-selected={activeTab === 'mcp'}
|
aria-selected={activeTab === 'mcp'}
|
||||||
|
id="settings-tab-mcp"
|
||||||
onClick={() => setActiveTab('mcp')}
|
onClick={() => setActiveTab('mcp')}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
handleTabKeyDown(event, 'mcp')
|
||||||
|
}
|
||||||
role="tab"
|
role="tab"
|
||||||
|
tabIndex={activeTab === 'mcp' ? 0 : -1}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>MCP</strong>
|
<strong>MCP</strong>
|
||||||
@@ -644,7 +738,12 @@ export function SettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="settings-panel__content">
|
<div
|
||||||
|
aria-labelledby={`settings-tab-${activeTab}`}
|
||||||
|
className="settings-panel__content"
|
||||||
|
id={`settings-panel-${activeTab}`}
|
||||||
|
role="tabpanel"
|
||||||
|
>
|
||||||
{activeTab === 'appearance' && (
|
{activeTab === 'appearance' && (
|
||||||
<div className="settings-section appearance-settings">
|
<div className="settings-section appearance-settings">
|
||||||
<div className="settings-section__title">
|
<div className="settings-section__title">
|
||||||
@@ -740,6 +839,13 @@ export function SettingsPanel({
|
|||||||
setWorkspacePath(selected)
|
setWorkspacePath(selected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: '选择工作区目录失败'
|
||||||
|
)
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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<never>(() => 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<never>(() => 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<LocalAvailability>
|
||||||
|
install?: (options: LocalSpeechOptions) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreparedSpeechRecognition = {
|
||||||
|
recognition: SpeechRecognitionInstance
|
||||||
|
local: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeechPreparationTimeouts = {
|
||||||
|
availabilityMs?: number
|
||||||
|
installMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
message: string
|
||||||
|
): Promise<T> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_, 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<PreparedSpeechRecognition> {
|
||||||
|
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 '语音识别失败,请检查麦克风和系统语音设置'
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user