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": [
|
||||
"dmg"
|
||||
],
|
||||
"category": "public.app-category.productivity"
|
||||
"category": "public.app-category.productivity",
|
||||
"extendInfo": {
|
||||
"NSMicrophoneUsageDescription": "GoodBuddy 需要访问麦克风,将语音转换为可编辑文字。"
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"icon": "build/icon.png",
|
||||
|
||||
@@ -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(
|
||||
'<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 () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-context-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -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<ReturnType<typeof parseDocument>>['sections']
|
||||
): string {
|
||||
return sections
|
||||
.map(
|
||||
(section) =>
|
||||
`[${section.locator}]\n${section.content}`
|
||||
)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export class ContextManager {
|
||||
private readonly contexts = new Map<string, StoredContext>()
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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(<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 () => {
|
||||
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(<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 () => {
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
|
||||
+77
-73
@@ -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<void> => {
|
||||
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<void> => {
|
||||
@@ -2913,36 +2925,28 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
<div
|
||||
className="conversation-title"
|
||||
title={activeConversation?.title}
|
||||
>
|
||||
<span>
|
||||
{view === 'knowledge'
|
||||
? '知识库'
|
||||
: view === 'heartbeat'
|
||||
? '智能心跳'
|
||||
: view === 'activity'
|
||||
? '任务与活动'
|
||||
: view === 'settings'
|
||||
? '设置中心'
|
||||
: activeConversation?.title ?? '新对话'}
|
||||
</span>
|
||||
</div>
|
||||
{view === 'chat' && (
|
||||
<ScopeBadge
|
||||
scope={
|
||||
activeProject
|
||||
? {
|
||||
kind: 'project',
|
||||
projectName: activeProject.name
|
||||
}
|
||||
: {
|
||||
kind: 'unavailable',
|
||||
explanation: '当前项目尚未加载。'
|
||||
}
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className="conversation-title"
|
||||
title={activeConversation?.title}
|
||||
>
|
||||
<span>{activeConversation?.title ?? '新对话'}</span>
|
||||
</div>
|
||||
<ScopeBadge
|
||||
scope={
|
||||
activeProject
|
||||
? {
|
||||
kind: 'project',
|
||||
projectName: activeProject.name
|
||||
}
|
||||
: {
|
||||
kind: 'unavailable',
|
||||
explanation: '当前项目尚未加载。'
|
||||
}
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="topbar__actions">
|
||||
<span
|
||||
@@ -3545,7 +3549,7 @@ function App(): React.JSX.Element {
|
||||
<button
|
||||
aria-label={voiceListening ? '正在听写' : '语音输入'}
|
||||
disabled={voiceListening}
|
||||
onClick={startVoiceInput}
|
||||
onClick={() => void startVoiceInput()}
|
||||
title="语音转文字,转写后可编辑再发送"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -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<string>()
|
||||
const createButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(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<void> => {
|
||||
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 (
|
||||
<div className="project-switcher">
|
||||
<div className="project-switcher__row">
|
||||
@@ -120,7 +156,10 @@ export function ProjectSwitcher({
|
||||
<button
|
||||
aria-label="新建项目"
|
||||
className="icon-button"
|
||||
onClick={() => setCreating(true)}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setCreating(true)
|
||||
}}
|
||||
ref={createButtonRef}
|
||||
type="button"
|
||||
>
|
||||
@@ -131,7 +170,12 @@ export function ProjectSwitcher({
|
||||
<div
|
||||
className="project-create-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target && !saving) {
|
||||
if (
|
||||
event.currentTarget === event.target &&
|
||||
!saving &&
|
||||
!archiving
|
||||
) {
|
||||
setError(undefined)
|
||||
setCreating(false)
|
||||
}
|
||||
}}
|
||||
@@ -148,7 +192,11 @@ export function ProjectSwitcher({
|
||||
<button
|
||||
aria-label="关闭新建项目"
|
||||
className="icon-button"
|
||||
onClick={() => setCreating(false)}
|
||||
disabled={saving || archiving}
|
||||
onClick={() => {
|
||||
setError(undefined)
|
||||
setCreating(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -189,16 +237,8 @@ export function ProjectSwitcher({
|
||||
<button
|
||||
aria-label="选择项目根目录"
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
void onSelectRoot().then((rootPath) => {
|
||||
if (rootPath) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
rootPath
|
||||
}))
|
||||
}
|
||||
})
|
||||
}}
|
||||
disabled={saving || archiving}
|
||||
onClick={() => void selectRoot()}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
@@ -223,23 +263,28 @@ export function ProjectSwitcher({
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
{projects.length > 1 && activeProjectId && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
void onArchive(activeProjectId)
|
||||
}}
|
||||
disabled={saving || archiving}
|
||||
onClick={() => void archive()}
|
||||
type="button"
|
||||
>
|
||||
<Archive size={13} />
|
||||
归档当前
|
||||
{archiving ? '归档中' : '归档当前'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={saving || !draft.name.trim()}
|
||||
disabled={
|
||||
saving || archiving || !draft.name.trim()
|
||||
}
|
||||
onClick={() => void create()}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -18,11 +18,13 @@ beforeEach(() => {
|
||||
function renderSidebar({
|
||||
tasks = [],
|
||||
experts = [],
|
||||
tab = 'context'
|
||||
tab = 'context',
|
||||
onCreateSchedule = vi.fn(async () => undefined)
|
||||
}: {
|
||||
tasks?: AssistantTask[]
|
||||
experts?: AssistantExpert[]
|
||||
tab?: 'tasks' | 'context'
|
||||
onCreateSchedule?: () => Promise<void>
|
||||
} = {}): HTMLElement {
|
||||
render(
|
||||
<RightAssistantSidebar
|
||||
@@ -38,7 +40,7 @@ function renderSidebar({
|
||||
onClose={vi.fn()}
|
||||
onCreateHeartbeat={vi.fn(async () => undefined)}
|
||||
onCreateMemory={vi.fn(async () => undefined)}
|
||||
onCreateSchedule={vi.fn(async () => undefined)}
|
||||
onCreateSchedule={onCreateSchedule}
|
||||
onImportArtifacts={vi.fn(async () => undefined)}
|
||||
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
|
||||
path,
|
||||
@@ -208,4 +210,34 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(taskButtons[1]).toHaveClass('assistant-sidebar__row--subtask')
|
||||
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<
|
||||
ScheduleCreateInput['recurrence']
|
||||
>('once')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const recentTasks = useMemo(
|
||||
() =>
|
||||
activities
|
||||
@@ -421,6 +422,7 @@ export function RightAssistantSidebar({
|
||||
workspacePreviewRequest.current = requestId
|
||||
const projectId = workspaceProjectId
|
||||
setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||
setActionError('')
|
||||
onTabChange('preview')
|
||||
void onLoadWorkspaceFile(path)
|
||||
.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 = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
tabId: AssistantSidebarTab
|
||||
@@ -472,6 +489,7 @@ export function RightAssistantSidebar({
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setActionError('')
|
||||
onTabChange(target.id)
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
||||
@@ -567,7 +585,10 @@ export function RightAssistantSidebar({
|
||||
}
|
||||
id={`assistant-sidebar-tab-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
onClick={() => {
|
||||
setActionError('')
|
||||
onTabChange(item.id)
|
||||
}}
|
||||
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
||||
role="tab"
|
||||
tabIndex={tab === item.id ? 0 : -1}
|
||||
@@ -590,6 +611,11 @@ export function RightAssistantSidebar({
|
||||
id="assistant-sidebar-panel"
|
||||
role="tabpanel"
|
||||
>
|
||||
{actionError ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{tab === 'tasks' && (
|
||||
<section className="assistant-sidebar__section">
|
||||
<p className="assistant-sidebar__section-description">
|
||||
@@ -737,17 +763,24 @@ export function RightAssistantSidebar({
|
||||
!scheduleTime
|
||||
}
|
||||
onClick={() => {
|
||||
void onCreateSchedule({
|
||||
title: scheduleTitle.trim(),
|
||||
prompt: schedulePrompt.trim(),
|
||||
workMode: 'ask',
|
||||
recurrence: scheduleRecurrence,
|
||||
nextRunAt: new Date(scheduleTime).toISOString()
|
||||
}).then(() => {
|
||||
setScheduleTitle('')
|
||||
setSchedulePrompt('')
|
||||
setScheduleTime('')
|
||||
})
|
||||
runAction(
|
||||
() =>
|
||||
onCreateSchedule({
|
||||
title: scheduleTitle.trim(),
|
||||
prompt: schedulePrompt.trim(),
|
||||
workMode: 'ask',
|
||||
recurrence: scheduleRecurrence,
|
||||
nextRunAt: new Date(
|
||||
scheduleTime
|
||||
).toISOString()
|
||||
}),
|
||||
'添加定时任务失败',
|
||||
() => {
|
||||
setScheduleTitle('')
|
||||
setSchedulePrompt('')
|
||||
setScheduleTime('')
|
||||
}
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
@@ -768,13 +801,23 @@ export function RightAssistantSidebar({
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => void onRunSchedule(schedule.id)}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => onRunSchedule(schedule.id),
|
||||
'运行定时任务失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
立即运行
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void onRemoveSchedule(schedule.id)}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => onRemoveSchedule(schedule.id),
|
||||
'删除定时任务失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
删除
|
||||
@@ -859,8 +902,11 @@ export function RightAssistantSidebar({
|
||||
disabled={!memoryDraft.trim()}
|
||||
onClick={() => {
|
||||
const content = memoryDraft.trim()
|
||||
setMemoryDraft('')
|
||||
void onCreateMemory(content)
|
||||
runAction(
|
||||
() => onCreateMemory(content),
|
||||
'保存长期记忆失败',
|
||||
() => setMemoryDraft('')
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
@@ -888,9 +934,13 @@ export function RightAssistantSidebar({
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
void onSetMemoryStatus(
|
||||
memory.id,
|
||||
'confirmed'
|
||||
runAction(
|
||||
() =>
|
||||
onSetMemoryStatus(
|
||||
memory.id,
|
||||
'confirmed'
|
||||
),
|
||||
'确认长期记忆失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
@@ -899,9 +949,13 @@ export function RightAssistantSidebar({
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
void onSetMemoryStatus(
|
||||
memory.id,
|
||||
'rejected'
|
||||
runAction(
|
||||
() =>
|
||||
onSetMemoryStatus(
|
||||
memory.id,
|
||||
'rejected'
|
||||
),
|
||||
'忽略长期记忆失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
@@ -913,7 +967,12 @@ export function RightAssistantSidebar({
|
||||
<button
|
||||
aria-label={`删除记忆 ${memory.content.slice(0, 24)}`}
|
||||
className="icon-button"
|
||||
onClick={() => void onRemoveMemory(memory.id)}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => onRemoveMemory(memory.id),
|
||||
'删除长期记忆失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<X size={13} />
|
||||
@@ -971,7 +1030,12 @@ export function RightAssistantSidebar({
|
||||
</h3>
|
||||
<button
|
||||
className="secondary-button assistant-sidebar__import"
|
||||
onClick={() => void onImportArtifacts()}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
onImportArtifacts,
|
||||
'导入成果失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload size={13} />
|
||||
@@ -990,8 +1054,12 @@ export function RightAssistantSidebar({
|
||||
workspacePreviewRequest.current += 1
|
||||
setWorkspacePreview(undefined)
|
||||
setSelectedArtifactId(artifact.id)
|
||||
setActionError('')
|
||||
onTabChange('preview')
|
||||
void onLoadArtifact(artifact.id)
|
||||
runAction(
|
||||
() => onLoadArtifact(artifact.id),
|
||||
'加载成果失败'
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
@@ -1022,7 +1090,10 @@ export function RightAssistantSidebar({
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
setWorkspaceRefreshVersion((current) => current + 1)
|
||||
void onRefreshChanges()
|
||||
runAction(
|
||||
onRefreshChanges,
|
||||
'刷新工作区文件失败'
|
||||
)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
@@ -1093,7 +1164,12 @@ export function RightAssistantSidebar({
|
||||
browserState.status !== 'stopped' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void onStopBrowser()}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
onStopBrowser,
|
||||
'停止浏览器失败'
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
停止浏览器
|
||||
|
||||
@@ -315,6 +315,46 @@ describe('SettingsPanel runtime files', () => {
|
||||
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 () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
@@ -45,6 +45,17 @@ type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
|
||||
clearApiKey: boolean
|
||||
}
|
||||
|
||||
const settingsTabs: readonly SettingsTab[] = [
|
||||
'appearance',
|
||||
'model',
|
||||
'runtime',
|
||||
'security',
|
||||
'automation',
|
||||
'roles',
|
||||
'skills',
|
||||
'mcp'
|
||||
]
|
||||
|
||||
type SettingsPanelProps = {
|
||||
open: boolean
|
||||
presentation?: 'modal' | 'page'
|
||||
@@ -175,6 +186,36 @@ export function SettingsPanel({
|
||||
activeTab === 'runtime' ||
|
||||
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(() => {
|
||||
if (!open) {
|
||||
return
|
||||
@@ -561,82 +602,135 @@ export function SettingsPanel({
|
||||
</header>
|
||||
|
||||
<div className="settings-panel__body">
|
||||
<nav aria-label="设置分类" className="settings-tabs">
|
||||
<nav
|
||||
aria-label="设置分类"
|
||||
aria-orientation="vertical"
|
||||
className="settings-tabs"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
aria-controls="settings-panel-appearance"
|
||||
aria-label="外观"
|
||||
aria-selected={activeTab === 'appearance'}
|
||||
id="settings-tab-appearance"
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'appearance')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'appearance' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>外观</strong>
|
||||
<small>亮色、暗色与系统主题</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-model"
|
||||
aria-label="模型连接"
|
||||
aria-selected={activeTab === 'model'}
|
||||
id="settings-tab-model"
|
||||
onClick={() => setActiveTab('model')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'model')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'model' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>模型连接</strong>
|
||||
<small>LLM、向量模型与凭据</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-runtime"
|
||||
aria-label="Agent Runtime"
|
||||
aria-selected={activeTab === 'runtime'}
|
||||
id="settings-tab-runtime"
|
||||
onClick={() => setActiveTab('runtime')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'runtime')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'runtime' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>Agent Runtime</strong>
|
||||
<small>OpenCode、Continue 与工作区</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-security"
|
||||
aria-label="安全与数据"
|
||||
aria-selected={activeTab === 'security'}
|
||||
id="settings-tab-security"
|
||||
onClick={() => setActiveTab('security')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'security')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'security' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>安全与数据</strong>
|
||||
<small>工具策略与本地隐私</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-automation"
|
||||
aria-label="自动化"
|
||||
aria-selected={activeTab === 'automation'}
|
||||
id="settings-tab-automation"
|
||||
onClick={() => setActiveTab('automation')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'automation')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'automation' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>自动化</strong>
|
||||
<small>智能心跳与周期回顾</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-roles"
|
||||
aria-label="角色与提示词"
|
||||
aria-selected={activeTab === 'roles'}
|
||||
id="settings-tab-roles"
|
||||
onClick={() => setActiveTab('roles')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'roles')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'roles' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>角色与提示词</strong>
|
||||
<small>角色、说明与系统提示词</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-skills"
|
||||
aria-label="Skills"
|
||||
aria-selected={activeTab === 'skills'}
|
||||
id="settings-tab-skills"
|
||||
onClick={() => setActiveTab('skills')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'skills')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'skills' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>Skills</strong>
|
||||
<small>内置与自定义能力</small>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="settings-panel-mcp"
|
||||
aria-label="MCP"
|
||||
aria-selected={activeTab === 'mcp'}
|
||||
id="settings-tab-mcp"
|
||||
onClick={() => setActiveTab('mcp')}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, 'mcp')
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={activeTab === 'mcp' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<strong>MCP</strong>
|
||||
@@ -644,7 +738,12 @@ export function SettingsPanel({
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="settings-panel__content">
|
||||
<div
|
||||
aria-labelledby={`settings-tab-${activeTab}`}
|
||||
className="settings-panel__content"
|
||||
id={`settings-panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
>
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="settings-section appearance-settings">
|
||||
<div className="settings-section__title">
|
||||
@@ -740,6 +839,13 @@ export function SettingsPanel({
|
||||
setWorkspacePath(selected)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '选择工作区目录失败'
|
||||
)
|
||||
})
|
||||
}}
|
||||
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