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

This commit is contained in:
lofyer
2026-08-05 21:53:35 +08:00
parent 1094751d4b
commit cd7c10d17c
13 changed files with 1015 additions and 128 deletions
+54
View File
@@ -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)
+67 -1
View File
@@ -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'))
+54 -1
View File
@@ -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
View File
@@ -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"
>
+66 -21
View File
@@ -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(
'总结今天的工作'
)
})
})
+103 -27
View File
@@ -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"
>
+40
View File
@@ -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
+108 -2
View File
@@ -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>OpenCodeContinue </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"
>
+187
View File
@@ -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)
})
})
+179
View File
@@ -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 '语音识别失败,请检查麦克风和系统语音设置'
}
}