feat: expand model tools and document handling
This commit is contained in:
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentEvent,
|
||||
BrowserLiveState,
|
||||
ContextAttachment,
|
||||
DesktopApi
|
||||
} from '../../shared/contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
@@ -33,6 +34,11 @@ import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
||||
|
||||
let agentListener: ((event: AgentEvent) => void) | undefined
|
||||
let browserListener: ((state: BrowserLiveState) => void) | undefined
|
||||
let fileSelectionProgressListener:
|
||||
| Parameters<
|
||||
DesktopApi['context']['onFileSelectionProgress']
|
||||
>[0]
|
||||
| undefined
|
||||
let newConversationListener: (() => void) | undefined
|
||||
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
|
||||
const removeMaximizedChangedListener = vi.fn()
|
||||
@@ -438,6 +444,12 @@ const api: DesktopApi = {
|
||||
},
|
||||
context: {
|
||||
selectFiles: vi.fn(async () => []),
|
||||
onFileSelectionProgress: vi.fn((listener) => {
|
||||
fileSelectionProgressListener = listener
|
||||
return () => {
|
||||
fileSelectionProgressListener = undefined
|
||||
}
|
||||
}),
|
||||
addPastedImage: vi.fn(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
@@ -565,6 +577,7 @@ describe('App', () => {
|
||||
vi.clearAllMocks()
|
||||
newConversationListener = undefined
|
||||
browserListener = undefined
|
||||
fileSelectionProgressListener = undefined
|
||||
maximizedChangedListener = undefined
|
||||
speechRecognitionMocks.startPcmRecording.mockResolvedValue({
|
||||
result: Promise.resolve({
|
||||
@@ -1570,6 +1583,61 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows attachment parsing progress and prevents duplicate selection', async () => {
|
||||
const attachment = {
|
||||
id: '00000000-0000-4000-8000-000000000309',
|
||||
name: '扫描材料.pdf',
|
||||
size: 8_705_692,
|
||||
preview: '解析后的文档',
|
||||
kind: 'text' as const
|
||||
}
|
||||
let resolveSelection:
|
||||
| ((attachments: ContextAttachment[]) => void)
|
||||
| undefined
|
||||
vi.mocked(api.context.selectFiles).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSelection = resolve
|
||||
})
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
const addButton = await screen.findByLabelText('添加附件')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(addButton).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('progressbar', {
|
||||
name: '附件读取与解析进度'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('正在选择附件…')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
fileSelectionProgressListener?.({
|
||||
phase: 'parsing',
|
||||
fileName: '扫描材料.pdf',
|
||||
fileNumber: 1,
|
||||
fileCount: 1
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('正在解析 扫描材料.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('第 1 / 1 个文件')).toBeInTheDocument()
|
||||
fireEvent.click(addButton)
|
||||
expect(api.context.selectFiles).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => resolveSelection?.([attachment]))
|
||||
expect(await screen.findByText('扫描材料.pdf')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(addButton).toBeEnabled()
|
||||
expect(
|
||||
screen.queryByRole('progressbar', {
|
||||
name: '附件读取与解析进度'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('sends and renders five selected images together', async () => {
|
||||
const imageAttachments = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000031${index}`,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
HeartPulse,
|
||||
Info,
|
||||
Library,
|
||||
LoaderCircle,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
MessageSquare,
|
||||
@@ -54,6 +55,7 @@ import type {
|
||||
AppInfo,
|
||||
BrowserLiveState,
|
||||
ContextAttachment,
|
||||
ContextFileSelectionProgress,
|
||||
KnowledgeSearchReference,
|
||||
KnowledgeSnapshot,
|
||||
RuntimeSettings
|
||||
@@ -1493,11 +1495,25 @@ function App(): React.JSX.Element {
|
||||
[]
|
||||
)
|
||||
const [contextError, setContextError] = useState<string>()
|
||||
const [fileSelectionProgress, setFileSelectionProgress] =
|
||||
useState<ContextFileSelectionProgress>()
|
||||
const [selectingContextFiles, setSelectingContextFiles] =
|
||||
useState(false)
|
||||
const selectingContextFilesRef = useRef(false)
|
||||
const [imageViewerItem, setImageViewerItem] =
|
||||
useState<ImageViewerItem>()
|
||||
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
|
||||
undefined
|
||||
)
|
||||
useEffect(
|
||||
() =>
|
||||
window.goodbuddy.context.onFileSelectionProgress((progress) => {
|
||||
if (selectingContextFilesRef.current) {
|
||||
setFileSelectionProgress(progress)
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const [knowledgeSnapshot, setKnowledgeSnapshot] = useState<KnowledgeSnapshot>({
|
||||
libraries: [],
|
||||
sources: [],
|
||||
@@ -3922,6 +3938,13 @@ function App(): React.JSX.Element {
|
||||
if (!prompt || !activeConversation) {
|
||||
return
|
||||
}
|
||||
if (selectingContextFilesRef.current) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
message: t('composer.attachmentProgress.waitBeforeSending')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (activeConversation.remote) {
|
||||
notify({
|
||||
tone: 'info',
|
||||
@@ -4232,6 +4255,22 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const selectContextFiles = async (): Promise<void> => {
|
||||
if (selectingContextFilesRef.current) {
|
||||
return
|
||||
}
|
||||
selectingContextFilesRef.current = true
|
||||
setSelectingContextFiles(true)
|
||||
setFileSelectionProgress(undefined)
|
||||
try {
|
||||
await addContext(() => window.goodbuddy.context.selectFiles())
|
||||
} finally {
|
||||
selectingContextFilesRef.current = false
|
||||
setSelectingContextFiles(false)
|
||||
setFileSelectionProgress(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAttachment = (attachmentId: string): void => {
|
||||
void window.goodbuddy.context.remove(attachmentId)
|
||||
updateAttachments((current) =>
|
||||
@@ -5687,8 +5726,11 @@ function App(): React.JSX.Element {
|
||||
) : (
|
||||
<>
|
||||
<div className="composer">
|
||||
{attachments.length > 0 && (
|
||||
<div className="context-list">
|
||||
{(attachments.length > 0 || selectingContextFiles) && (
|
||||
<div
|
||||
aria-busy={selectingContextFiles}
|
||||
className="context-list"
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
className="context-chip"
|
||||
@@ -5729,6 +5771,53 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectingContextFiles && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="context-chip context-chip--processing"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="context-chip__spinner"
|
||||
size={16}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{fileSelectionProgress
|
||||
? t(
|
||||
`composer.attachmentProgress.${fileSelectionProgress.phase}`,
|
||||
{
|
||||
name: fileSelectionProgress.fileName
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'composer.attachmentProgress.selecting'
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{fileSelectionProgress
|
||||
? t(
|
||||
'composer.attachmentProgress.fileCount',
|
||||
{
|
||||
current:
|
||||
fileSelectionProgress.fileNumber,
|
||||
total:
|
||||
fileSelectionProgress.fileCount
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'composer.attachmentProgress.waiting'
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
<progress
|
||||
aria-label={t(
|
||||
'composer.attachmentProgress.progressLabel'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__input">
|
||||
@@ -5799,11 +5888,8 @@ function App(): React.JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('composer.addAttachment')}
|
||||
onClick={() =>
|
||||
void addContext(() =>
|
||||
window.goodbuddy.context.selectFiles()
|
||||
)
|
||||
}
|
||||
disabled={selectingContextFiles}
|
||||
onClick={() => void selectContextFiles()}
|
||||
title={t('composer.addAttachment')}
|
||||
>
|
||||
<Paperclip aria-hidden="true" size={18} />
|
||||
@@ -6161,6 +6247,7 @@ function App(): React.JSX.Element {
|
||||
aria-label={t('composer.send')}
|
||||
disabled={
|
||||
!input.trim() ||
|
||||
selectingContextFiles ||
|
||||
!runtime?.available ||
|
||||
runtimeSwitching ||
|
||||
runtimeStatusKey !== activeRuntimeSelectionKey
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('ChannelSettingsSection', () => {
|
||||
await screen.findByRole('tab', { name: '企业微信' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('checkbox', {
|
||||
await screen.findByRole('switch', {
|
||||
name: '启用企业微信通道'
|
||||
})
|
||||
)
|
||||
@@ -206,6 +206,11 @@ describe('ChannelSettingsSection', () => {
|
||||
fireEvent.change(screen.getByLabelText('企业微信允许的发送者 ID'), {
|
||||
target: { value: 'user-1\nuser-2\nuser-1' }
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: '允许群聊中被提及时响应'
|
||||
})
|
||||
).not.toBeChecked()
|
||||
fireEvent.change(screen.getByLabelText('企业微信 默认工作目录'), {
|
||||
target: { value: 'C:\\RemoteWorkspace' }
|
||||
})
|
||||
@@ -595,7 +600,7 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(wecomTab).toHaveAttribute('tabindex', '-1')
|
||||
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '启用企业微信通道' })
|
||||
screen.queryByRole('switch', { name: '启用企业微信通道' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(weixinTab, { key: 'ArrowRight' })
|
||||
@@ -607,7 +612,7 @@ describe('ChannelSettingsSection', () => {
|
||||
'channel-settings-tab-wecom'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: '启用企业微信通道' })
|
||||
screen.getByRole('switch', { name: '启用企业微信通道' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -482,6 +482,7 @@ function ChannelEditor({
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, enabled: event.target.checked })
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('channels.credential.enable', { channel: title })}</span>
|
||||
@@ -527,7 +528,7 @@ function ChannelEditor({
|
||||
</label>
|
||||
|
||||
{settings.secretConfigured && !settings.readOnly && (
|
||||
<label className="toggle-row">
|
||||
<label className="check-field">
|
||||
<input
|
||||
checked={draft.clearSecret}
|
||||
onChange={(event) =>
|
||||
@@ -578,6 +579,7 @@ function ChannelEditor({
|
||||
allowGroupMessages: event.target.checked
|
||||
})
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('channels.credential.groupMessages')}</span>
|
||||
@@ -899,6 +901,7 @@ function WeixinChannelEditor({
|
||||
onChange={(event) =>
|
||||
onEnabledChange(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('channels.weixin.enable')}</span>
|
||||
|
||||
@@ -212,6 +212,9 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
expect(screen.getByText('质量:基础')).toBeInTheDocument()
|
||||
expect(screen.getByText('速度:快')).toBeInTheDocument()
|
||||
expect(screen.getByText('旧版 Office 转换')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /启用本地 OCR/u })
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '本地模型' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
@@ -224,6 +227,9 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
expect(
|
||||
screen.queryByText('模型详情与手动导入')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('可从 ModelScope 下载')
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '打开 PP-OCRv6 Tiny 的 ModelScope 页面'
|
||||
|
||||
@@ -734,30 +734,30 @@ export function DocumentParsingSettingsSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="document-ocr-model__state">
|
||||
<span
|
||||
className={`document-ocr-model__status${
|
||||
installedModel
|
||||
? ' document-ocr-model__status--installed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{installedModel && (
|
||||
<CheckCircle2 aria-hidden="true" size={13} />
|
||||
)}
|
||||
{modelOperation
|
||||
? t(
|
||||
modelOperation.phase === 'installing'
|
||||
? 'documentParsing.ocr.operations.installing'
|
||||
: modelOperation.kind === 'import'
|
||||
? 'documentParsing.ocr.operations.importing'
|
||||
: 'documentParsing.ocr.operations.downloading'
|
||||
)
|
||||
: installedModel
|
||||
? t('documentParsing.ocr.installed')
|
||||
: t('documentParsing.ocr.availableToDownload')}
|
||||
</span>
|
||||
</div>
|
||||
{(modelOperation || installedModel) && (
|
||||
<div className="document-ocr-model__state">
|
||||
<span
|
||||
className={`document-ocr-model__status${
|
||||
installedModel
|
||||
? ' document-ocr-model__status--installed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{installedModel && (
|
||||
<CheckCircle2 aria-hidden="true" size={13} />
|
||||
)}
|
||||
{modelOperation
|
||||
? t(
|
||||
modelOperation.phase === 'installing'
|
||||
? 'documentParsing.ocr.operations.installing'
|
||||
: modelOperation.kind === 'import'
|
||||
? 'documentParsing.ocr.operations.importing'
|
||||
: 'documentParsing.ocr.operations.downloading'
|
||||
)
|
||||
: t('documentParsing.ocr.installed')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="document-ocr-model__actions">
|
||||
{modelOperation ? (
|
||||
@@ -909,15 +909,16 @@ export function DocumentParsingSettingsSection({
|
||||
)}
|
||||
|
||||
<div className="document-ocr-settings__options">
|
||||
<label className="settings-checkbox">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={draft.localOcrEnabled}
|
||||
onChange={(event) =>
|
||||
updateDraft('localOcrEnabled', event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="field">
|
||||
<strong>{t('documentParsing.ocr.enabled')}</strong>
|
||||
<small>
|
||||
{t('documentParsing.ocr.enabledDescription')}
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('KnowledgeWorkspace', () => {
|
||||
target: { value: '访谈与反馈' }
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText(/引用原文件/))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /启用知识图谱/u })
|
||||
).toBeChecked()
|
||||
fireEvent.change(screen.getByLabelText('图谱生成策略'), {
|
||||
target: { value: 'rules' }
|
||||
})
|
||||
@@ -269,11 +272,11 @@ describe('KnowledgeWorkspace', () => {
|
||||
expect(within(tabs).getAllByRole('tab').map((item) => item.textContent))
|
||||
.toEqual(['文档与来源', '知识图谱', '任务中心', '设置'])
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '知识图谱' })
|
||||
screen.queryByRole('switch', { name: '知识图谱' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /启用知识图谱/u }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: /启用知识图谱/u }))
|
||||
expect(onUpdateLibrary).toHaveBeenCalledWith('library-1', {
|
||||
graphEnabled: false
|
||||
})
|
||||
|
||||
@@ -635,6 +635,7 @@ function CreateLibraryWizard({
|
||||
))}
|
||||
</fieldset>
|
||||
<label
|
||||
className="toggle-row"
|
||||
style={{
|
||||
...styles.surface,
|
||||
display: 'flex',
|
||||
@@ -647,6 +648,7 @@ function CreateLibraryWizard({
|
||||
<input
|
||||
checked={graphEnabled}
|
||||
onChange={(event) => setGraphEnabled(event.currentTarget.checked)}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
@@ -1841,7 +1843,7 @@ function KnowledgeSettingsView({
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
className="knowledge-settings__toggle"
|
||||
className="knowledge-settings__toggle toggle-row"
|
||||
style={{ ...styles.surface, cursor: saving ? 'wait' : 'pointer' }}
|
||||
>
|
||||
<input
|
||||
@@ -1850,6 +1852,7 @@ function KnowledgeSettingsView({
|
||||
onChange={(event) =>
|
||||
void update({ graphEnabled: event.currentTarget.checked })
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -27,7 +27,8 @@ import type {
|
||||
McpServerSummary,
|
||||
McpServerTestResult,
|
||||
McpTransport,
|
||||
RuntimeTarget
|
||||
RuntimeTarget,
|
||||
WebSearchTestResult
|
||||
} from '../../shared/capability-contracts'
|
||||
import { trapTabFocus } from './dialog-focus'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
@@ -100,12 +101,15 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
disabled: t('mcp.diagnosticStatuses.disabled')
|
||||
}
|
||||
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
const [editor, setEditor] = useState<McpEditor>()
|
||||
const [busy, setBusy] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, McpServerTestResult>
|
||||
>({})
|
||||
const [webSearchTestResult, setWebSearchTestResult] =
|
||||
useState<WebSearchTestResult>()
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
@@ -147,6 +151,20 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const getSettings = window.goodbuddy.updates?.getSettings
|
||||
if (!getSettings) {
|
||||
return
|
||||
}
|
||||
void getSettings()
|
||||
.then((settings) => {
|
||||
setMagicNotesEnabled(settings.magicNotesEnabled)
|
||||
})
|
||||
.catch(() => {
|
||||
setMagicNotesEnabled(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorOpen) {
|
||||
return
|
||||
@@ -284,6 +302,27 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const testDirectModelWebSearch = async (): Promise<void> => {
|
||||
setBusy('test:web-search')
|
||||
setError(undefined)
|
||||
try {
|
||||
const testCapability =
|
||||
window.goodbuddy.capabilities.testWebSearch
|
||||
if (!testCapability) {
|
||||
throw new Error(t('mcp.webSearch.unsupported'))
|
||||
}
|
||||
setWebSearchTestResult(await testCapability())
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('mcp.webSearch.testFailed')
|
||||
)
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAssignment = (
|
||||
target: RuntimeTarget,
|
||||
checked: boolean
|
||||
@@ -335,6 +374,12 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
profiles: [],
|
||||
defaultProfileId: null
|
||||
}
|
||||
const webSearch = snapshot?.webSearch ?? {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
availableIn: ['ask', 'execute'] as const,
|
||||
tools: ['web_search', 'web_fetch'] as const
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -398,35 +443,36 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
: t('mcp.computer.disabled')}
|
||||
</small>
|
||||
</div>
|
||||
<label className="capability-switch">
|
||||
<input
|
||||
aria-label={t('mcp.computer.enableAriaLabel', {
|
||||
name: capability.name
|
||||
})}
|
||||
checked={capability.enabled}
|
||||
disabled={Boolean(busy) || !capability.supported}
|
||||
onChange={(event) =>
|
||||
void run(`computer:${capability.id}`, () =>
|
||||
window.goodbuddy.capabilities.setComputerCapabilityEnabled?.(
|
||||
capability.id,
|
||||
event.target.checked
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error(
|
||||
t('mcp.errors.unsupportedComputerControl')
|
||||
)
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
aria-label={t('mcp.computer.enableAriaLabel', {
|
||||
name: capability.name
|
||||
})}
|
||||
checked={capability.enabled}
|
||||
disabled={Boolean(busy) || !capability.supported}
|
||||
onChange={(event) =>
|
||||
void run(`computer:${capability.id}`, () =>
|
||||
window.goodbuddy.capabilities.setComputerCapabilityEnabled?.(
|
||||
capability.id,
|
||||
event.target.checked
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error(
|
||||
t('mcp.errors.unsupportedComputerControl')
|
||||
)
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{capability.enabled
|
||||
? t('mcp.computer.enabled')
|
||||
: t('mcp.computer.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{capability.enabled
|
||||
? t('mcp.computer.enabled')
|
||||
: t('mcp.computer.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
<p>{capability.description}</p>
|
||||
<p className="computer-capability-risk">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
@@ -679,8 +725,16 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
const expansionId = `builtin:${server.id}`
|
||||
const expanded = expandedItemIds.has(expansionId)
|
||||
const panelId = `mcp-server-tools-${server.id}`
|
||||
const enabled =
|
||||
!('requiresFeature' in server) ||
|
||||
magicNotesEnabled === true
|
||||
return (
|
||||
<article className="mcp-server-card" key={server.id}>
|
||||
<article
|
||||
className={`mcp-server-card${
|
||||
enabled ? '' : ' mcp-server-card--disabled'
|
||||
}`}
|
||||
key={server.id}
|
||||
>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
@@ -697,7 +751,9 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
<div>
|
||||
<strong>{server.name}</strong>
|
||||
<small>
|
||||
{server.access === 'mixed'
|
||||
{!enabled
|
||||
? t('mcp.builtin.serverSummaryDisabled')
|
||||
: server.access === 'mixed'
|
||||
? t('mcp.builtin.serverSummaryMixed')
|
||||
: t('mcp.builtin.serverSummaryReadOnly')}
|
||||
</small>
|
||||
@@ -719,6 +775,11 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mcp-server-card__body" id={panelId}>
|
||||
{!enabled && (
|
||||
<p className="mcp-server-card__disabled-notice">
|
||||
{t('mcp.builtin.featureDisabled')}
|
||||
</p>
|
||||
)}
|
||||
<p>{server.description}</p>
|
||||
<section
|
||||
aria-label={t('mcp.builtin.toolsAriaLabel', {
|
||||
@@ -771,7 +832,92 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
</small>
|
||||
</div>
|
||||
<div className="mcp-server-list">
|
||||
{builtinModelToolGroups.map((group) => {
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('mcp.webSearch.title')}</strong>
|
||||
<small>{t('mcp.webSearch.subtitle')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
aria-label={t('mcp.webSearch.enableAriaLabel')}
|
||||
checked={webSearch.enabled}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) =>
|
||||
void run('web-search:toggle', () =>
|
||||
window.goodbuddy.capabilities.setWebSearchEnabled?.(
|
||||
event.target.checked
|
||||
) ??
|
||||
Promise.reject(
|
||||
new Error(t('mcp.webSearch.unsupported'))
|
||||
)
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{webSearch.enabled
|
||||
? t('mcp.webSearch.enabled')
|
||||
: t('mcp.webSearch.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
<p>{t('mcp.webSearch.description')}</p>
|
||||
<p className="computer-capability-risk">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
{t('mcp.webSearch.privacy')}
|
||||
</p>
|
||||
<div className="capability-card__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => void testDirectModelWebSearch()}
|
||||
type="button"
|
||||
>
|
||||
<FlaskConical aria-hidden="true" size={13} />
|
||||
{busy === 'test:web-search'
|
||||
? t('mcp.webSearch.testing')
|
||||
: t('mcp.webSearch.test')}
|
||||
</button>
|
||||
</div>
|
||||
{webSearchTestResult && (
|
||||
<div
|
||||
aria-label={t('mcp.webSearch.resultAriaLabel')}
|
||||
className="capability-diagnostic__result"
|
||||
>
|
||||
<strong>
|
||||
{t('mcp.webSearch.result', {
|
||||
duration: webSearchTestResult.durationMs
|
||||
})}
|
||||
</strong>
|
||||
<p>{webSearchTestResult.preview}</p>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
aria-label={t('mcp.webSearch.toolsAriaLabel')}
|
||||
className="mcp-server-tools"
|
||||
>
|
||||
<ul>
|
||||
{builtinModelToolGroups
|
||||
.find((group) => group.id === 'web')
|
||||
?.tools.map((tool) => (
|
||||
<li key={tool.name}>
|
||||
<div>
|
||||
<code>{tool.name}</code>
|
||||
<span className="builtin-tool-badge">
|
||||
{t('mcp.builtin.readOnly')}
|
||||
</span>
|
||||
</div>
|
||||
<p>{tool.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
{builtinModelToolGroups
|
||||
.filter((group) => group.id !== 'web')
|
||||
.map((group) => {
|
||||
const expansionId = `model-tools:${group.id}`
|
||||
const expanded = expandedItemIds.has(expansionId)
|
||||
const panelId = `model-tool-group-${group.id}`
|
||||
@@ -1010,7 +1156,7 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="check-field">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={editor.enabled}
|
||||
onChange={(event) =>
|
||||
@@ -1019,6 +1165,7 @@ export function McpSettingsSection(): React.JSX.Element {
|
||||
enabled: event.target.checked
|
||||
})
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('mcp.editor.enable')}</span>
|
||||
|
||||
@@ -165,6 +165,12 @@ const capabilitySnapshot = {
|
||||
}
|
||||
],
|
||||
mcpServers: [] as CapabilitySnapshot['mcpServers'],
|
||||
webSearch: {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
availableIn: ['ask', 'execute'] as const,
|
||||
tools: ['web_search', 'web_fetch'] as const
|
||||
},
|
||||
computerCapabilities: [
|
||||
{
|
||||
id: 'host-browser-control' as const,
|
||||
@@ -201,6 +207,19 @@ const importSkill = vi.fn<DesktopApi['capabilities']['importSkill']>(
|
||||
async () => capabilitySnapshot
|
||||
)
|
||||
const saveMcpServer = vi.fn(async () => capabilitySnapshot)
|
||||
const setWebSearchEnabled = vi.fn(async (enabled: boolean) => ({
|
||||
...capabilitySnapshot,
|
||||
webSearch: {
|
||||
...capabilitySnapshot.webSearch,
|
||||
enabled
|
||||
}
|
||||
}))
|
||||
const testWebSearch = vi.fn(async () => ({
|
||||
provider: 'exa' as const,
|
||||
query: 'GoodBuddy desktop assistant',
|
||||
durationMs: 321,
|
||||
preview: 'GoodBuddy search result'
|
||||
}))
|
||||
const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
|
||||
...capabilitySnapshot,
|
||||
skills: capabilitySnapshot.skills.map((skill) => ({
|
||||
@@ -449,6 +468,8 @@ describe('SettingsPanel runtime files', () => {
|
||||
toolCount: 0,
|
||||
tools: []
|
||||
})),
|
||||
setWebSearchEnabled,
|
||||
testWebSearch,
|
||||
setComputerCapabilityEnabled,
|
||||
setComputerCapabilityBrowserProfile: vi.fn(
|
||||
async () => capabilitySnapshot
|
||||
@@ -784,15 +805,16 @@ describe('SettingsPanel runtime files', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '语音模型' })
|
||||
)
|
||||
const paraformer = await screen.findByRole('radio', {
|
||||
name: '选择 Paraformer 中英双语 INT8'
|
||||
const speechModelSelector = await screen.findByRole('combobox', {
|
||||
name: '当前语音模型'
|
||||
})
|
||||
|
||||
fireEvent.click(paraformer)
|
||||
fireEvent.change(speechModelSelector, {
|
||||
target: { value: 'paraformer-bilingual-zh-en-int8' }
|
||||
})
|
||||
|
||||
expect(selectSpeechModel).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('待保存')).toBeInTheDocument()
|
||||
expect(screen.getByText('正在使用')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存设置' })
|
||||
@@ -804,7 +826,10 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
)
|
||||
expect(screen.queryByText('待保存')).not.toBeInTheDocument()
|
||||
expect(paraformer).toBeChecked()
|
||||
expect(screen.getByText('正在使用')).toBeInTheDocument()
|
||||
expect(speechModelSelector).toHaveValue(
|
||||
'paraformer-bilingual-zh-en-int8'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a speech model draft when saving the selection fails', async () => {
|
||||
@@ -826,10 +851,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '语音模型' })
|
||||
)
|
||||
const paraformer = await screen.findByRole('radio', {
|
||||
name: '选择 Paraformer 中英双语 INT8'
|
||||
const speechModelSelector = await screen.findByRole('combobox', {
|
||||
name: '当前语音模型'
|
||||
})
|
||||
fireEvent.change(speechModelSelector, {
|
||||
target: { value: 'paraformer-bilingual-zh-en-int8' }
|
||||
})
|
||||
fireEvent.click(paraformer)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存设置' })
|
||||
)
|
||||
@@ -838,7 +865,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
await screen.findByText('语音模型切换失败')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('待保存')).toBeInTheDocument()
|
||||
expect(paraformer).toBeChecked()
|
||||
expect(speechModelSelector).toHaveValue(
|
||||
'paraformer-bilingual-zh-en-int8'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses one first-level heading for the settings page', () => {
|
||||
@@ -992,12 +1021,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
expect(
|
||||
screen.queryByRole('checkbox', {
|
||||
screen.queryByRole('switch', {
|
||||
name: '启用 Subagent 智能路由'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '角色与提示词' }))
|
||||
const smartRouting = await screen.findByRole('checkbox', {
|
||||
const smartRouting = await screen.findByRole('switch', {
|
||||
name: '启用 Subagent 智能路由'
|
||||
})
|
||||
expect(smartRouting).not.toBeChecked()
|
||||
@@ -1489,7 +1518,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
const imageInput = await screen.findByRole('checkbox', {
|
||||
const imageInput = await screen.findByRole('switch', {
|
||||
name: '支持图像输入'
|
||||
})
|
||||
expect(imageInput).not.toBeChecked()
|
||||
@@ -1925,7 +1954,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '启用向量模型' })
|
||||
screen.queryByRole('switch', { name: '启用向量模型' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
@@ -1939,7 +1968,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: '启用向量模型' })
|
||||
screen.getByRole('switch', { name: '启用向量模型' })
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('向量接口 URL'), {
|
||||
target: { value: 'https://vectors.example/v1/embeddings' }
|
||||
@@ -2003,7 +2032,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeDisabled()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: '启用向量模型' })
|
||||
screen.getByRole('switch', { name: '启用向量模型' })
|
||||
)
|
||||
fireEvent.click(
|
||||
within(section).getByRole('button', { name: '测试向量模型' })
|
||||
@@ -2220,7 +2249,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByRole('button', { name: '导入 Skill ZIP' })
|
||||
)
|
||||
await waitFor(() => expect(importSkill).toHaveBeenCalledWith('zip'))
|
||||
fireEvent.click(screen.getByLabelText('启用 文档写作'))
|
||||
fireEvent.click(
|
||||
screen.getByRole('switch', { name: '启用 文档写作' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(setSkillEnabled).toHaveBeenCalledWith(
|
||||
'document-writing',
|
||||
@@ -2240,8 +2271,14 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.getByText(/Runtime 自有 MCP 配置不在此处管理/)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByText('托管浏览器配置').length).toBeGreaterThan(0)
|
||||
expect(screen.getByLabelText('启用 Linux 桌面控制')).toBeDisabled()
|
||||
fireEvent.click(screen.getByLabelText('启用 浏览器控制'))
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: '启用 Linux 桌面控制'
|
||||
})
|
||||
).toBeDisabled()
|
||||
fireEvent.click(
|
||||
screen.getByRole('switch', { name: '启用 浏览器控制' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(setComputerCapabilityEnabled).toHaveBeenCalledWith(
|
||||
'host-browser-control',
|
||||
@@ -2286,19 +2323,41 @@ describe('SettingsPanel runtime files', () => {
|
||||
)
|
||||
expect(await screen.findByText('文件系统操作')).toBeInTheDocument()
|
||||
expect(screen.getByText('浏览器操作')).toBeInTheDocument()
|
||||
expect(screen.getByText('联网搜索')).toBeInTheDocument()
|
||||
expect(screen.getByText('web_search')).toBeInTheDocument()
|
||||
expect(screen.getByText('web_fetch')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/查询词和公开网页地址会发送给第三方 Exa/)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('switch', {
|
||||
name: '启用直连模型联网搜索'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(setWebSearchEnabled).toHaveBeenCalledWith(false)
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '测试真实搜索' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('真实搜索成功 · 321 毫秒')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('GoodBuddy search result')).toBeInTheDocument()
|
||||
expect(testWebSearch).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('读取工作区文本')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('知识库 MCP')).toBeInTheDocument()
|
||||
expect(screen.getByText('知识库')).toBeInTheDocument()
|
||||
expect(screen.queryByText('knowledge_list')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('knowledge_search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('note_search')).not.toBeInTheDocument()
|
||||
const knowledgeServerToggle = screen.getByRole('button', {
|
||||
name: '展开服务器 知识库 MCP'
|
||||
name: '展开服务器 知识库'
|
||||
})
|
||||
expect(knowledgeServerToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(knowledgeServerToggle)
|
||||
expect(knowledgeServerToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
const knowledgeTools = screen.getByRole('region', {
|
||||
name: '知识库 MCP 工具'
|
||||
name: '知识库 工具'
|
||||
})
|
||||
expect(knowledgeTools).toContainElement(
|
||||
screen.getByText('knowledge_list')
|
||||
@@ -2309,14 +2368,27 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(within(knowledgeTools).queryByText(/可用于:/u))
|
||||
.not.toBeInTheDocument()
|
||||
const noteServerToggle = screen.getByRole('button', {
|
||||
name: '展开服务器 笔记 MCP'
|
||||
name: '展开服务器 笔记'
|
||||
})
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'内置 MCP Server · 未启用 · 需要开启魔法笔记'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(noteServerToggle.closest('article')).toHaveClass(
|
||||
'mcp-server-card--disabled'
|
||||
)
|
||||
fireEvent.click(noteServerToggle)
|
||||
expect(
|
||||
screen.getByRole('region', { name: '笔记 MCP 工具' })
|
||||
screen.getByRole('region', { name: '笔记 工具' })
|
||||
).toContainElement(screen.getByText('note_search'))
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /服务器 .* MCP/u })
|
||||
screen.getByText(/此内置能力当前不会向任何 Runtime 提供工具/)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', {
|
||||
name: /(?:展开|收起)服务器 (?:知识库|笔记)/u
|
||||
})
|
||||
).toHaveLength(builtinMcpServers.length)
|
||||
expect(
|
||||
screen.getByText('可用于:模型、OpenCode、Continue')
|
||||
@@ -2338,7 +2410,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(screen.getByText('浏览器导航')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /工具组/u })
|
||||
).toHaveLength(builtinModelToolGroups.length)
|
||||
).toHaveLength(
|
||||
builtinModelToolGroups.filter((group) => group.id !== 'web').length
|
||||
)
|
||||
expect(
|
||||
await screen.findByText('尚未配置 MCP Server')
|
||||
).toBeInTheDocument()
|
||||
@@ -2353,6 +2427,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '添加 MCP Server'
|
||||
})
|
||||
expect(
|
||||
within(dialog).getByRole('switch', {
|
||||
name: '启用此 MCP Server'
|
||||
})
|
||||
).toBeChecked()
|
||||
expect(within(dialog).getByLabelText('模型')).toBeChecked()
|
||||
expect(
|
||||
within(dialog).queryByLabelText('OpenCode')
|
||||
|
||||
@@ -1987,7 +1987,7 @@ export function SettingsPanel({
|
||||
</label>
|
||||
{isAgentRuntimeModelProtocol(profile.protocol) && (
|
||||
<div className="field">
|
||||
<label className="check-field">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={profile.supportsImageInput}
|
||||
onChange={(event) =>
|
||||
@@ -1995,6 +1995,7 @@ export function SettingsPanel({
|
||||
supportsImageInput: event.target.checked
|
||||
})
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('model.profile.supportsImageInput')}</span>
|
||||
@@ -2132,12 +2133,13 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div className="runtime-note">
|
||||
<label className="check-field">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={knowledgeEmbeddingEnabled}
|
||||
onChange={(event) =>
|
||||
setKnowledgeEmbeddingEnabled(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('model.embedding.enabled')}</span>
|
||||
@@ -2398,13 +2400,14 @@ export function SettingsPanel({
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="check-field">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
aria-describedby="subagent-smart-routing-help"
|
||||
checked={subagentSmartRoutingEnabled}
|
||||
onChange={(event) =>
|
||||
setSubagentSmartRoutingEnabled(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('roles.smartRouting.enabled')}</span>
|
||||
|
||||
@@ -121,30 +121,31 @@ export function SkillsSettingsSection(): React.JSX.Element {
|
||||
· {skill.version ?? t('skills.versionMissing')}
|
||||
</small>
|
||||
</div>
|
||||
<label className="capability-switch">
|
||||
<input
|
||||
aria-label={t('skills.enableAria', {
|
||||
name: skill.name
|
||||
})}
|
||||
checked={skill.enabled}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) =>
|
||||
void run(`toggle:${skill.id}`, () =>
|
||||
window.goodbuddy.capabilities.setSkillEnabled(
|
||||
skill.id,
|
||||
event.target.checked
|
||||
)
|
||||
)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{skill.enabled
|
||||
? t('skills.enabled')
|
||||
: t('skills.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
aria-label={t('skills.enableAria', {
|
||||
name: skill.name
|
||||
})}
|
||||
checked={skill.enabled}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) =>
|
||||
void run(`toggle:${skill.id}`, () =>
|
||||
window.goodbuddy.capabilities.setSkillEnabled(
|
||||
skill.id,
|
||||
event.target.checked
|
||||
)
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{skill.enabled
|
||||
? t('skills.enabled')
|
||||
: t('skills.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
<p>{skill.description}</p>
|
||||
<div className="capability-tags">
|
||||
{skill.tags.map((tag) => (
|
||||
|
||||
@@ -66,6 +66,7 @@ afterEach(() => {
|
||||
describe('SpeechModelSettingsSection', () => {
|
||||
it('renders speech model controls and metadata in English', async () => {
|
||||
await changeUiLocale('en-US')
|
||||
const openRepository = vi.fn()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -77,7 +78,7 @@ describe('SpeechModelSettingsSection', () => {
|
||||
select: vi.fn(),
|
||||
importArchive: vi.fn(),
|
||||
exportArchive: vi.fn(),
|
||||
openRepository: vi.fn(),
|
||||
openRepository,
|
||||
openModelsDirectory: vi.fn()
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
@@ -88,6 +89,11 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(
|
||||
await screen.findByText('Speech models')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('combobox', {
|
||||
name: 'Current speech model'
|
||||
})
|
||||
).toHaveValue('sensevoice-small-int8')
|
||||
expect(screen.getByText('Recommended')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chinese / Cantonese')).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -95,7 +101,14 @@ describe('SpeechModelSettingsSection', () => {
|
||||
name: 'Download SenseVoiceSmall INT8'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/使用前请阅读许可。/u)).toBeInTheDocument()
|
||||
expect(screen.getByText('模型仓库自定义许可')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Model details')).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Open the SenseVoiceSmall INT8 model repository'
|
||||
})
|
||||
)
|
||||
expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
})
|
||||
|
||||
it('lists downloadable models and starts a verified download', async () => {
|
||||
@@ -393,12 +406,12 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('已安装')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1_000 }
|
||||
{ timeout: 1_500 }
|
||||
)
|
||||
expect(getSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('keeps a radio choice pending until the parent saves it', async () => {
|
||||
it('keeps a dropdown choice pending until the parent saves it', async () => {
|
||||
const installedSenseVoice = {
|
||||
id: entry.id,
|
||||
displayName: entry.displayName,
|
||||
@@ -449,15 +462,84 @@ describe('SpeechModelSettingsSection', () => {
|
||||
})
|
||||
|
||||
render(<SpeechModelSettingsSection />)
|
||||
const choice = await screen.findByRole('radio', {
|
||||
name: '选择 Paraformer 中英双语 INT8'
|
||||
const selector = await screen.findByRole('combobox', {
|
||||
name: '当前语音模型'
|
||||
})
|
||||
expect(choice).not.toBeChecked()
|
||||
expect(selector).toHaveValue('sensevoice-small-int8')
|
||||
expect(screen.getAllByRole('article')).toHaveLength(1)
|
||||
expect(screen.getByText('正在使用')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(choice)
|
||||
fireEvent.change(selector, {
|
||||
target: { value: 'paraformer-bilingual-zh-en-int8' }
|
||||
})
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('待保存')).toBeInTheDocument()
|
||||
expect(choice).toBeChecked()
|
||||
expect(selector).toHaveValue('paraformer-bilingual-zh-en-int8')
|
||||
expect(screen.getAllByRole('article')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('synchronizes the card when a controlled selection is reset', async () => {
|
||||
const paraformerEntry = {
|
||||
...entry,
|
||||
id: 'paraformer-bilingual-zh-en-int8',
|
||||
displayName: 'Paraformer 中英双语 INT8',
|
||||
family: 'paraformer' as const
|
||||
}
|
||||
const installed = [entry, paraformerEntry].map((model) => ({
|
||||
id: model.id,
|
||||
displayName: model.displayName,
|
||||
source: 'download' as const,
|
||||
installedAt: '2026-08-06T00:00:00.000Z',
|
||||
files: [
|
||||
{
|
||||
name: 'model.int8.onnx',
|
||||
role: 'model' as const,
|
||||
size: 1_000,
|
||||
sha256: 'a'.repeat(64)
|
||||
}
|
||||
]
|
||||
}))
|
||||
const installedSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
catalog: [entry, paraformerEntry],
|
||||
installed,
|
||||
selectedModelId: entry.id
|
||||
}
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
speechModels: {
|
||||
getSnapshot: vi.fn(async () => installedSnapshot),
|
||||
install: vi.fn(),
|
||||
cancel: vi.fn(async () => true),
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
importArchive: vi.fn(),
|
||||
exportArchive: vi.fn(),
|
||||
openRepository: vi.fn(),
|
||||
openModelsDirectory: vi.fn()
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
const view = render(
|
||||
<SpeechModelSettingsSection
|
||||
persistedSelectedModelId={entry.id}
|
||||
selectedModelId={paraformerEntry.id}
|
||||
/>
|
||||
)
|
||||
const selector = await screen.findByRole('combobox', {
|
||||
name: '当前语音模型'
|
||||
})
|
||||
expect(selector).toHaveValue(paraformerEntry.id)
|
||||
|
||||
view.rerender(
|
||||
<SpeechModelSettingsSection
|
||||
persistedSelectedModelId={entry.id}
|
||||
selectedModelId={entry.id}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(selector).toHaveValue(entry.id))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
@@ -85,10 +84,14 @@ export function SpeechModelSettingsSection({
|
||||
const [localSelectedModelId, setLocalSelectedModelId] = useState<
|
||||
string | null | undefined
|
||||
>()
|
||||
const [viewedModelId, setViewedModelId] = useState<string>()
|
||||
const [busyModelId, setBusyModelId] = useState<string>()
|
||||
const [confirmingRemove, setConfirmingRemove] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const mountedRef = useRef(false)
|
||||
const synchronizedSelectionRef = useRef<string | null | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
const api = window.goodbuddy.speechModels
|
||||
@@ -138,16 +141,28 @@ export function SpeechModelSettingsSection({
|
||||
if (!shouldPoll) {
|
||||
return
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
void refresh().catch(() => undefined)
|
||||
}, 300)
|
||||
return () => window.clearInterval(timer)
|
||||
let active = true
|
||||
let timer: number | undefined
|
||||
const poll = async (): Promise<void> => {
|
||||
await refresh().catch(() => undefined)
|
||||
if (active) {
|
||||
timer = window.setTimeout(poll, 750)
|
||||
}
|
||||
}
|
||||
timer = window.setTimeout(poll, 750)
|
||||
return () => {
|
||||
active = false
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [refresh, shouldPoll])
|
||||
|
||||
const run = async (
|
||||
modelId: string,
|
||||
operation: () => Promise<SpeechModelSnapshot | undefined>,
|
||||
successMessage: string
|
||||
successMessage: string,
|
||||
selectAfterSuccess = false
|
||||
): Promise<void> => {
|
||||
setBusyModelId(modelId)
|
||||
setError(undefined)
|
||||
@@ -160,6 +175,19 @@ export function SpeechModelSettingsSection({
|
||||
? localSelectedModelId
|
||||
: selectedModelId
|
||||
if (
|
||||
selectAfterSuccess &&
|
||||
next.installed.some((model) => model.id === modelId)
|
||||
) {
|
||||
const effectivePersistedModelId =
|
||||
persistedSelectedModelId === undefined
|
||||
? next.selectedModelId
|
||||
: persistedSelectedModelId
|
||||
setLocalSelectedModelId(modelId)
|
||||
onSelectedModelIdChange?.(
|
||||
modelId,
|
||||
modelId !== effectivePersistedModelId
|
||||
)
|
||||
} else if (
|
||||
draftSelectedModelId &&
|
||||
!next.installed.some(
|
||||
(model) => model.id === draftSelectedModelId
|
||||
@@ -207,6 +235,27 @@ export function SpeechModelSettingsSection({
|
||||
)
|
||||
}
|
||||
|
||||
const draftSelectedModelId =
|
||||
selectedModelId === undefined
|
||||
? localSelectedModelId
|
||||
: selectedModelId
|
||||
const effectiveSelectedModelId =
|
||||
draftSelectedModelId === undefined
|
||||
? snapshot?.selectedModelId
|
||||
: draftSelectedModelId
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!snapshot ||
|
||||
effectiveSelectedModelId === undefined ||
|
||||
synchronizedSelectionRef.current === effectiveSelectedModelId
|
||||
) {
|
||||
return
|
||||
}
|
||||
synchronizedSelectionRef.current = effectiveSelectedModelId
|
||||
setViewedModelId(effectiveSelectedModelId ?? undefined)
|
||||
}, [effectiveSelectedModelId, snapshot])
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
@@ -226,6 +275,54 @@ export function SpeechModelSettingsSection({
|
||||
operation
|
||||
])
|
||||
)
|
||||
const effectivePersistedModelId =
|
||||
persistedSelectedModelId === undefined
|
||||
? snapshot.selectedModelId
|
||||
: persistedSelectedModelId
|
||||
const model =
|
||||
snapshot.catalog.find((entry) => entry.id === viewedModelId) ??
|
||||
snapshot.catalog.find((entry) => operationsById.has(entry.id)) ??
|
||||
snapshot.catalog.find(
|
||||
(entry) => entry.id === effectiveSelectedModelId
|
||||
) ??
|
||||
snapshot.catalog[0]
|
||||
const displayName = model
|
||||
? t(`speech.catalog.${model.id}.displayName`, {
|
||||
defaultValue: model.displayName
|
||||
})
|
||||
: ''
|
||||
const description = model
|
||||
? t(`speech.catalog.${model.id}.description`, {
|
||||
defaultValue: model.description
|
||||
})
|
||||
: ''
|
||||
const installed = model
|
||||
? installedById.get(model.id)
|
||||
: undefined
|
||||
const operation = model
|
||||
? operationsById.get(model.id)
|
||||
: undefined
|
||||
const percent = operation
|
||||
? progressPercent(operation)
|
||||
: undefined
|
||||
const size = model ? catalogSize(model) : undefined
|
||||
const selected = model?.id === effectiveSelectedModelId
|
||||
const inUse = model?.id === effectivePersistedModelId
|
||||
const pendingSelection =
|
||||
Boolean(selected) &&
|
||||
draftSelectedModelId !== undefined &&
|
||||
draftSelectedModelId !== effectivePersistedModelId
|
||||
const status = operation
|
||||
? operationLabel(operation, t)
|
||||
: pendingSelection
|
||||
? t('speech.status.pendingSave')
|
||||
: inUse
|
||||
? t('speech.status.inUse')
|
||||
: installed
|
||||
? t('speech.status.installed')
|
||||
: model?.manualOnly
|
||||
? t('speech.status.manualImport')
|
||||
: t('speech.status.availableToDownload')
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -259,315 +356,277 @@ export function SpeechModelSettingsSection({
|
||||
</p>
|
||||
{error && <p className="settings-warning" role="alert">{error}</p>}
|
||||
|
||||
<div
|
||||
aria-label={t('speech.availableModels')}
|
||||
className="speech-model-settings__list"
|
||||
role="list"
|
||||
>
|
||||
{snapshot.catalog.map((entry) => {
|
||||
const displayName = t(
|
||||
`speech.catalog.${entry.id}.displayName`,
|
||||
{ defaultValue: entry.displayName }
|
||||
)
|
||||
const description = t(
|
||||
`speech.catalog.${entry.id}.description`,
|
||||
{ defaultValue: entry.description }
|
||||
)
|
||||
const installed = installedById.get(entry.id)
|
||||
const operation = operationsById.get(entry.id)
|
||||
const percent = operation
|
||||
? progressPercent(operation)
|
||||
: undefined
|
||||
const size = catalogSize(entry)
|
||||
const draftSelectedModelId =
|
||||
selectedModelId === undefined
|
||||
? localSelectedModelId
|
||||
: selectedModelId
|
||||
const effectiveSelectedModelId =
|
||||
draftSelectedModelId === undefined
|
||||
? snapshot.selectedModelId
|
||||
: draftSelectedModelId
|
||||
const selected = effectiveSelectedModelId === entry.id
|
||||
const effectivePersistedModelId =
|
||||
persistedSelectedModelId === undefined
|
||||
? snapshot.selectedModelId
|
||||
: persistedSelectedModelId
|
||||
const inUse = effectivePersistedModelId === entry.id
|
||||
const pendingSelection =
|
||||
selected &&
|
||||
draftSelectedModelId !== undefined &&
|
||||
draftSelectedModelId !== effectivePersistedModelId
|
||||
const status = operation
|
||||
? operationLabel(operation, t)
|
||||
: pendingSelection
|
||||
? t('speech.status.pendingSave')
|
||||
: inUse
|
||||
? t('speech.status.inUse')
|
||||
: installed
|
||||
? t('speech.status.installed')
|
||||
: entry.manualOnly
|
||||
? t('speech.status.manualImport')
|
||||
: t('speech.status.availableToDownload')
|
||||
return (
|
||||
<article
|
||||
className={`speech-model-row${selected ? ' speech-model-row--selected' : ''}`}
|
||||
key={entry.id}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="speech-model-row__selection">
|
||||
<input
|
||||
aria-label={
|
||||
installed
|
||||
? t('speech.accessibility.selectModel', {
|
||||
name: displayName
|
||||
})
|
||||
: t('speech.accessibility.notInstalled', {
|
||||
name: displayName
|
||||
})
|
||||
}
|
||||
checked={selected}
|
||||
disabled={!installed || operation !== undefined}
|
||||
name="selected-speech-model"
|
||||
onChange={() => {
|
||||
setLocalSelectedModelId(entry.id)
|
||||
onSelectedModelIdChange?.(
|
||||
entry.id,
|
||||
entry.id !== effectivePersistedModelId
|
||||
)
|
||||
}}
|
||||
type="radio"
|
||||
/>
|
||||
</div>
|
||||
<label className="field document-ocr-model-selector">
|
||||
<span>{t('speech.modelSelector')}</span>
|
||||
<select
|
||||
aria-label={t('speech.modelSelector')}
|
||||
onChange={(event) => {
|
||||
const modelId = event.target.value
|
||||
setViewedModelId(modelId)
|
||||
if (installedById.has(modelId)) {
|
||||
setLocalSelectedModelId(modelId)
|
||||
onSelectedModelIdChange?.(
|
||||
modelId,
|
||||
modelId !== effectivePersistedModelId
|
||||
)
|
||||
}
|
||||
}}
|
||||
value={model?.id ?? ''}
|
||||
>
|
||||
{snapshot.catalog.map((entry) => {
|
||||
const optionName = t(
|
||||
'speech.catalog.' + entry.id + '.displayName',
|
||||
{ defaultValue: entry.displayName }
|
||||
)
|
||||
return (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{optionName} ·{' '}
|
||||
{installedById.has(entry.id)
|
||||
? t('speech.status.installed')
|
||||
: t('speech.status.availableToDownload')}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<small>
|
||||
{pendingSelection
|
||||
? t('speech.pendingSelection')
|
||||
: installed
|
||||
? t('speech.modelSelectorDescription')
|
||||
: t('speech.modelSelectorDownloadDescription')}
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<div className="speech-model-row__summary">
|
||||
<div className="speech-model-row__name">
|
||||
<strong>{displayName}</strong>
|
||||
{entry.recommended && (
|
||||
<span className="speech-model-tag speech-model-tag--recommended">
|
||||
{t('speech.tags.recommended')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
<div className="speech-model-row__tags">
|
||||
<span className="speech-model-tag">
|
||||
{t(`speech.family.${entry.family}`)}
|
||||
{model ? (
|
||||
<article className="document-ocr-model speech-model-card">
|
||||
<div className="document-ocr-model__header">
|
||||
<div className="document-ocr-model__summary">
|
||||
<div className="document-ocr-model__name">
|
||||
<strong>{displayName}</strong>
|
||||
{model.recommended && (
|
||||
<span className="speech-model-tag speech-model-tag--recommended">
|
||||
{t('speech.tags.recommended')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{entry.languages
|
||||
.map((language) =>
|
||||
t(`speech.languages.${language}`, {
|
||||
defaultValue: language
|
||||
})
|
||||
)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{entry.quantization.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="speech-model-row__profile">
|
||||
<span>{t(`speech.quality.${entry.quality}`)}</span>
|
||||
<span>{t(`speech.speed.${entry.speed}`)}</span>
|
||||
<span>
|
||||
{size ? formatBytes(size) : t('speech.status.unknownSize')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="speech-model-row__state">
|
||||
<span
|
||||
className={`speech-model-status${
|
||||
selected || inUse
|
||||
? ' speech-model-status--selected'
|
||||
: installed
|
||||
? ' speech-model-status--installed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{inUse && <CheckCircle2 aria-hidden="true" size={13} />}
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="speech-model-row__actions">
|
||||
{operation ? (
|
||||
<button
|
||||
aria-label={t('speech.accessibility.cancelOperation', {
|
||||
name: displayName
|
||||
})}
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels
|
||||
?.cancel(entry.id)
|
||||
.then(() => refresh())
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Square aria-hidden="true" size={12} />
|
||||
{t('speech.actions.cancel')}
|
||||
</button>
|
||||
) : installed ? (
|
||||
<>
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.exportModelZip',
|
||||
{ name: displayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={busyModelId === entry.id}
|
||||
onClick={() =>
|
||||
void run(
|
||||
entry.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!
|
||||
.exportArchive(entry.id),
|
||||
t('speech.notifications.exportedZip', {
|
||||
name: displayName
|
||||
})
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t('speech.actions.exportZip')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('speech.accessibility.deleteModel', {
|
||||
name: displayName
|
||||
})}
|
||||
className={
|
||||
confirmingRemove === entry.id
|
||||
? 'danger-button'
|
||||
: 'danger-ghost'
|
||||
}
|
||||
disabled={busyModelId === entry.id}
|
||||
onClick={() => void remove(entry.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={12} />
|
||||
{confirmingRemove === entry.id
|
||||
? t('speech.actions.confirmDelete')
|
||||
: t('speech.actions.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!entry.manualOnly && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.downloadModel',
|
||||
{ name: displayName }
|
||||
)}
|
||||
className="primary-button"
|
||||
disabled={busyModelId === entry.id}
|
||||
onClick={() =>
|
||||
void run(
|
||||
entry.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.install(
|
||||
entry.id
|
||||
),
|
||||
t('speech.notifications.installed', {
|
||||
name: displayName
|
||||
})
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t('speech.actions.download')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t('speech.accessibility.importModelZip', {
|
||||
name: displayName
|
||||
})}
|
||||
className="secondary-button"
|
||||
disabled={busyModelId === entry.id}
|
||||
onClick={() =>
|
||||
void run(
|
||||
entry.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!
|
||||
.importArchive(entry.id),
|
||||
t('speech.notifications.importedZip', {
|
||||
name: displayName
|
||||
})
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t('speech.actions.importZip')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
)}
|
||||
className="icon-button speech-model-card__repository"
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels?.openRepository(
|
||||
model.id
|
||||
)
|
||||
}
|
||||
title={t(
|
||||
'speech.accessibility.openRepository',
|
||||
{ name: displayName }
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<p>{description}</p>
|
||||
<div className="document-ocr-model__tags">
|
||||
<span className="speech-model-tag">
|
||||
{t('speech.family.' + model.family)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.languages
|
||||
.map((language) =>
|
||||
t('speech.languages.' + language, {
|
||||
defaultValue: language
|
||||
})
|
||||
)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.quantization.toUpperCase()}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{t('speech.quality.' + model.quality)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{t('speech.speed.' + model.speed)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{size
|
||||
? formatBytes(size)
|
||||
: t('speech.status.unknownSize')}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.license.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{operation && (
|
||||
<div aria-live="polite" className="speech-model-operation">
|
||||
<progress
|
||||
aria-label={t(
|
||||
'speech.accessibility.downloadProgress',
|
||||
{ name: displayName }
|
||||
)}
|
||||
max={100}
|
||||
{...(percent === undefined ? {} : { value: percent })}
|
||||
/>
|
||||
<small>
|
||||
{operation.currentFile
|
||||
? t('speech.operations.processingFile', {
|
||||
file: operation.currentFile
|
||||
})
|
||||
: `${operationLabel(operation, t)}…`}
|
||||
{percent === undefined
|
||||
? ''
|
||||
: ` · ${percent.toFixed(0)}%`}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="document-ocr-model__state">
|
||||
<span
|
||||
className={
|
||||
'document-ocr-model__status' +
|
||||
(installed
|
||||
? ' document-ocr-model__status--installed'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
{installed && <CheckCircle2 aria-hidden="true" size={13} />}
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details className="speech-model-row__details">
|
||||
<summary>
|
||||
<ChevronDown aria-hidden="true" size={13} />
|
||||
{t('speech.actions.modelDetails')}
|
||||
</summary>
|
||||
<div>
|
||||
{entry.manualOnly &&
|
||||
entry.manualReason &&
|
||||
!installed && (
|
||||
<p>{entry.manualReason}</p>
|
||||
)}
|
||||
<p>
|
||||
{t('speech.details.license')}
|
||||
<strong>{entry.license.name}</strong>
|
||||
{t('speech.details.licenseSeparator')}
|
||||
{entry.license.notice}
|
||||
</p>
|
||||
<div className="document-ocr-model__actions">
|
||||
{operation ? (
|
||||
<button
|
||||
aria-label={t('speech.accessibility.cancelOperation', {
|
||||
name: displayName
|
||||
})}
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels
|
||||
?.cancel(model.id)
|
||||
.then(() => refresh())
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Square aria-hidden="true" size={12} />
|
||||
{t('speech.actions.cancel')}
|
||||
</button>
|
||||
) : installed ? (
|
||||
<>
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.exportModelZip',
|
||||
{ name: displayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void run(
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!
|
||||
.exportArchive(model.id),
|
||||
t('speech.notifications.exportedZip', {
|
||||
name: displayName
|
||||
})
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t('speech.actions.exportZip')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('speech.accessibility.deleteModel', {
|
||||
name: displayName
|
||||
})}
|
||||
className={
|
||||
confirmingRemove === model.id
|
||||
? 'danger-button'
|
||||
: 'danger-ghost'
|
||||
}
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() => void remove(model.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={12} />
|
||||
{confirmingRemove === model.id
|
||||
? t('speech.actions.confirmDelete')
|
||||
: t('speech.actions.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!model.manualOnly && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.openRepository',
|
||||
'speech.accessibility.downloadModel',
|
||||
{ name: displayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
className="primary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void window.goodbuddy.speechModels?.openRepository(
|
||||
entry.id
|
||||
void run(
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.install(
|
||||
model.id
|
||||
),
|
||||
t('speech.notifications.installed', {
|
||||
name: displayName
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ExternalLink aria-hidden="true" size={13} />
|
||||
{t('speech.actions.openRepository')}
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t('speech.actions.download')}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'speech.accessibility.importModelZip',
|
||||
{ name: displayName }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={busyModelId === model.id}
|
||||
onClick={() =>
|
||||
void run(
|
||||
model.id,
|
||||
() =>
|
||||
window.goodbuddy.speechModels!.importArchive(
|
||||
model.id
|
||||
),
|
||||
t('speech.notifications.importedZip', {
|
||||
name: displayName
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t('speech.actions.importZip')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{operation && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="document-ocr-model__operation"
|
||||
>
|
||||
<progress
|
||||
aria-label={t(
|
||||
'speech.accessibility.downloadProgress',
|
||||
{ name: displayName }
|
||||
)}
|
||||
max={100}
|
||||
{...(percent === undefined ? {} : { value: percent })}
|
||||
/>
|
||||
<small>
|
||||
{operation.currentFile
|
||||
? t('speech.operations.processingFile', {
|
||||
file: operation.currentFile
|
||||
})
|
||||
: operationLabel(operation, t) + '…'}
|
||||
{percent === undefined
|
||||
? ''
|
||||
: ' · ' + percent.toFixed(0) + '%'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<p className="settings-warning">
|
||||
{t('speech.catalogUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('UpdateSettingsSection', () => {
|
||||
})
|
||||
|
||||
render(<UpdateSettingsSection />)
|
||||
const startup = await screen.findByRole('checkbox', {
|
||||
const startup = await screen.findByRole('switch', {
|
||||
name: '启动时检查新版本'
|
||||
})
|
||||
expect(startup).toBeChecked()
|
||||
|
||||
@@ -161,6 +161,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
|
||||
onChange={(event) =>
|
||||
void changeStartupCheck(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('updates.checkOnStartup')}</span>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
createCanvas,
|
||||
DOMMatrix,
|
||||
Path2D,
|
||||
type Canvas
|
||||
} from '@napi-rs/canvas'
|
||||
import type {
|
||||
PDFDocumentLoadingTask,
|
||||
PDFPageProxy
|
||||
} from 'pdfjs-dist/types/src/display/api'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createWorkerPdfLoadingParameters,
|
||||
WorkerPdfCanvasFactory
|
||||
} from './document-ocr-pdf'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||
const length = chunks.reduce(
|
||||
(total, chunk) => total + chunk.byteLength,
|
||||
0
|
||||
)
|
||||
const result = new Uint8Array(length)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createScannedPdfFixture(): Uint8Array {
|
||||
const chunks: Uint8Array[] = []
|
||||
const offsets = [0]
|
||||
let byteLength = 0
|
||||
const append = (chunk: string | Uint8Array): void => {
|
||||
const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : chunk
|
||||
chunks.push(bytes)
|
||||
byteLength += bytes.byteLength
|
||||
}
|
||||
const image = Uint8Array.from([
|
||||
0b10101010,
|
||||
0b01010101,
|
||||
0b10101010,
|
||||
0b01010101,
|
||||
0b10101010,
|
||||
0b01010101,
|
||||
0b10101010,
|
||||
0b01010101
|
||||
])
|
||||
const content = 'q\n100 0 0 100 0 0 cm\n/Im0 Do\nQ'
|
||||
const objects: Array<string | Uint8Array[]> = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>',
|
||||
[
|
||||
encoder.encode(
|
||||
`<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ImageMask true /BitsPerComponent 1 /Decode [0 1] /Length ${image.byteLength} >>\nstream\n`
|
||||
),
|
||||
image,
|
||||
encoder.encode('\nendstream')
|
||||
],
|
||||
`<< /Length ${encoder.encode(content).byteLength} >>\nstream\n${content}\nendstream`
|
||||
]
|
||||
|
||||
append('%PDF-1.4\n')
|
||||
for (const [index, object] of objects.entries()) {
|
||||
offsets.push(byteLength)
|
||||
append(`${index + 1} 0 obj\n`)
|
||||
if (typeof object === 'string') {
|
||||
append(object)
|
||||
} else {
|
||||
for (const part of object) {
|
||||
append(part)
|
||||
}
|
||||
}
|
||||
append('\nendobj\n')
|
||||
}
|
||||
const xrefOffset = byteLength
|
||||
append(`xref\n0 ${objects.length + 1}\n`)
|
||||
append('0000000000 65535 f \n')
|
||||
for (const offset of offsets.slice(1)) {
|
||||
append(`${String(offset).padStart(10, '0')} 00000 n \n`)
|
||||
}
|
||||
append(
|
||||
`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
|
||||
)
|
||||
return concatBytes(chunks)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('OCR PDF rendering', () => {
|
||||
it('renders an image-only PDF without a DOM document', async () => {
|
||||
const documentDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'document'
|
||||
)
|
||||
const toHexDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Uint8Array.prototype,
|
||||
'toHex'
|
||||
)
|
||||
const mapInsertionDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Map.prototype,
|
||||
'getOrInsertComputed'
|
||||
)
|
||||
const weakMapInsertionDescriptor = Object.getOwnPropertyDescriptor(
|
||||
WeakMap.prototype,
|
||||
'getOrInsertComputed'
|
||||
)
|
||||
let canvasCount = 0
|
||||
vi.stubGlobal('DOMMatrix', DOMMatrix)
|
||||
vi.stubGlobal('Path2D', Path2D)
|
||||
vi.stubGlobal(
|
||||
'OffscreenCanvas',
|
||||
function TestOffscreenCanvas(width: number, height: number) {
|
||||
canvasCount += 1
|
||||
return createCanvas(width, height)
|
||||
} as unknown as typeof OffscreenCanvas
|
||||
)
|
||||
if (!toHexDescriptor) {
|
||||
Object.defineProperty(Uint8Array.prototype, 'toHex', {
|
||||
configurable: true,
|
||||
value(this: Uint8Array) {
|
||||
return Array.from(this, (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('')
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!mapInsertionDescriptor) {
|
||||
Object.defineProperty(Map.prototype, 'getOrInsertComputed', {
|
||||
configurable: true,
|
||||
value(
|
||||
this: Map<unknown, unknown>,
|
||||
key: unknown,
|
||||
callback: (key: unknown) => unknown
|
||||
) {
|
||||
if (this.has(key)) {
|
||||
return this.get(key)
|
||||
}
|
||||
const value = callback(key)
|
||||
this.set(key, value)
|
||||
return value
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!weakMapInsertionDescriptor) {
|
||||
Object.defineProperty(WeakMap.prototype, 'getOrInsertComputed', {
|
||||
configurable: true,
|
||||
value(
|
||||
this: WeakMap<object, unknown>,
|
||||
key: object,
|
||||
callback: (key: object) => unknown
|
||||
) {
|
||||
if (this.has(key)) {
|
||||
return this.get(key)
|
||||
}
|
||||
const value = callback(key)
|
||||
this.set(key, value)
|
||||
return value
|
||||
}
|
||||
})
|
||||
}
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
|
||||
let loadingTask: PDFDocumentLoadingTask | undefined
|
||||
let page: PDFPageProxy | undefined
|
||||
let output: ReturnType<WorkerPdfCanvasFactory['create']> | undefined
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist')
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pathToFileURL(
|
||||
join(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'pdfjs-dist',
|
||||
'build',
|
||||
'pdf.worker.mjs'
|
||||
)
|
||||
).href
|
||||
const fixture = createScannedPdfFixture()
|
||||
loadingTask = pdfjs.getDocument(
|
||||
createWorkerPdfLoadingParameters(
|
||||
fixture.buffer.slice(
|
||||
fixture.byteOffset,
|
||||
fixture.byteOffset + fixture.byteLength
|
||||
) as ArrayBuffer
|
||||
)
|
||||
)
|
||||
const pdf = await loadingTask.promise
|
||||
page = await pdf.getPage(1)
|
||||
const viewport = page.getViewport({ scale: 2 })
|
||||
const factory = new WorkerPdfCanvasFactory()
|
||||
output = factory.create(
|
||||
Math.ceil(viewport.width),
|
||||
Math.ceil(viewport.height)
|
||||
)
|
||||
const canvasCountBeforeRender = canvasCount
|
||||
|
||||
await page.render({
|
||||
canvas: output.canvas as unknown as HTMLCanvasElement,
|
||||
canvasContext:
|
||||
output.context as unknown as CanvasRenderingContext2D,
|
||||
viewport
|
||||
}).promise
|
||||
|
||||
expect(canvasCount).toBeGreaterThan(canvasCountBeforeRender)
|
||||
expect(
|
||||
await (output.canvas as unknown as Canvas).encode('png')
|
||||
).not.toHaveLength(0)
|
||||
} finally {
|
||||
page?.cleanup()
|
||||
if (output) {
|
||||
new WorkerPdfCanvasFactory().destroy(output)
|
||||
}
|
||||
await loadingTask?.destroy()
|
||||
if (documentDescriptor) {
|
||||
Object.defineProperty(
|
||||
globalThis,
|
||||
'document',
|
||||
documentDescriptor
|
||||
)
|
||||
}
|
||||
if (toHexDescriptor) {
|
||||
Object.defineProperty(
|
||||
Uint8Array.prototype,
|
||||
'toHex',
|
||||
toHexDescriptor
|
||||
)
|
||||
} else {
|
||||
Reflect.deleteProperty(Uint8Array.prototype, 'toHex')
|
||||
}
|
||||
if (mapInsertionDescriptor) {
|
||||
Object.defineProperty(
|
||||
Map.prototype,
|
||||
'getOrInsertComputed',
|
||||
mapInsertionDescriptor
|
||||
)
|
||||
} else {
|
||||
Reflect.deleteProperty(Map.prototype, 'getOrInsertComputed')
|
||||
}
|
||||
if (weakMapInsertionDescriptor) {
|
||||
Object.defineProperty(
|
||||
WeakMap.prototype,
|
||||
'getOrInsertComputed',
|
||||
weakMapInsertionDescriptor
|
||||
)
|
||||
} else {
|
||||
Reflect.deleteProperty(
|
||||
WeakMap.prototype,
|
||||
'getOrInsertComputed'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
type PdfCanvasEntry = {
|
||||
canvas: OffscreenCanvas | null
|
||||
context: OffscreenCanvasRenderingContext2D | null
|
||||
}
|
||||
|
||||
function assertCanvasSize(width: number, height: number): void {
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error('PDF 画布尺寸无效')
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerPdfCanvasFactory {
|
||||
create(width: number, height: number): PdfCanvasEntry {
|
||||
assertCanvasSize(width, height)
|
||||
const canvas = new OffscreenCanvas(width, height)
|
||||
const context = canvas.getContext('2d', {
|
||||
willReadFrequently: true
|
||||
})
|
||||
if (!context) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
throw new Error('无法创建 PDF 页面渲染画布')
|
||||
}
|
||||
return {
|
||||
canvas,
|
||||
context
|
||||
}
|
||||
}
|
||||
|
||||
reset(
|
||||
entry: PdfCanvasEntry,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
assertCanvasSize(width, height)
|
||||
if (!entry.canvas) {
|
||||
throw new Error('PDF 画布已释放')
|
||||
}
|
||||
entry.canvas.width = width
|
||||
entry.canvas.height = height
|
||||
}
|
||||
|
||||
destroy(entry: PdfCanvasEntry): void {
|
||||
if (!entry.canvas) {
|
||||
return
|
||||
}
|
||||
entry.canvas.width = 0
|
||||
entry.canvas.height = 0
|
||||
entry.canvas = null
|
||||
entry.context = null
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerPdfFilterFactory {
|
||||
addFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addHCMFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addAlphaFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addLuminosityFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addKnockoutFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addHighlightHCMFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addSelectionHCMFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
addSelectionFilter(): string {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
createSelectionStyle(): null {
|
||||
return null
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
}
|
||||
|
||||
export function createWorkerPdfLoadingParameters(
|
||||
data: ArrayBuffer
|
||||
) {
|
||||
return {
|
||||
data: new Uint8Array(data),
|
||||
CanvasFactory: WorkerPdfCanvasFactory,
|
||||
FilterFactory: WorkerPdfFilterFactory,
|
||||
disableFontFace: true,
|
||||
useSystemFonts: false,
|
||||
useWorkerFetch: false
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DocumentOcrRequest,
|
||||
DocumentOcrResult
|
||||
} from '../../shared/document-parsing-contracts'
|
||||
import { createWorkerPdfLoadingParameters } from './document-ocr-pdf'
|
||||
|
||||
type InitializeMessage = {
|
||||
type: 'initialize'
|
||||
@@ -117,17 +118,24 @@ async function renderPdfPage(
|
||||
willReadFrequently: true
|
||||
})
|
||||
if (!context) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
throw new Error('无法创建 PDF 页面渲染画布')
|
||||
}
|
||||
await page.render({
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
canvasContext: context as unknown as CanvasRenderingContext2D,
|
||||
viewport
|
||||
}).promise
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: 'image/png'
|
||||
})
|
||||
return blob.arrayBuffer()
|
||||
try {
|
||||
await page.render({
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
canvasContext: context as unknown as CanvasRenderingContext2D,
|
||||
viewport
|
||||
}).promise
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: 'image/png'
|
||||
})
|
||||
return await blob.arrayBuffer()
|
||||
} finally {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function recognizePdf(
|
||||
@@ -135,9 +143,9 @@ async function recognizePdf(
|
||||
): Promise<DocumentOcrResult> {
|
||||
const pdfjs = await import('pdfjs-dist')
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
|
||||
const loadingTask = pdfjs.getDocument({
|
||||
data: new Uint8Array(request.data)
|
||||
})
|
||||
const loadingTask = pdfjs.getDocument(
|
||||
createWorkerPdfLoadingParameters(request.data)
|
||||
)
|
||||
const document = await loadingTask.promise
|
||||
const selectedPages = new Set(
|
||||
request.pageNumbers ??
|
||||
|
||||
@@ -236,6 +236,16 @@ export const app = {
|
||||
'Enter to send · Shift+Enter for a new line · Ctrl+V to paste an image or text',
|
||||
addContent: 'Add content',
|
||||
addAttachment: 'Add attachment',
|
||||
attachmentProgress: {
|
||||
selecting: 'Selecting attachments…',
|
||||
reading: 'Reading {{name}}',
|
||||
parsing: 'Parsing {{name}}',
|
||||
waiting: 'Files will be read and parsed after selection',
|
||||
fileCount: 'File {{current}} of {{total}}',
|
||||
progressLabel: 'Attachment reading and parsing progress',
|
||||
waitBeforeSending:
|
||||
'Attachments are still being parsed. Wait for them to finish before sending.'
|
||||
},
|
||||
removeAttachment: 'Remove {{name}}',
|
||||
settings: 'Conversation settings',
|
||||
expertLabel: 'Expert role',
|
||||
|
||||
@@ -212,6 +212,10 @@ export const integrations = {
|
||||
'Built-in MCP server · Access depends on mode · Authorized per conversation',
|
||||
serverSummaryReadOnly:
|
||||
'Built-in MCP server · Read-only · Authorized per conversation',
|
||||
serverSummaryDisabled:
|
||||
'Built-in MCP server · Disabled · Enable Magic Notes first',
|
||||
featureDisabled:
|
||||
'Magic Notes is disabled, so this built-in capability does not provide tools to any runtime.',
|
||||
collapseServer: 'Collapse server {{name}}',
|
||||
expandServer: 'Expand server {{name}}',
|
||||
toolCount: '{{count}} tools',
|
||||
@@ -227,6 +231,24 @@ export const integrations = {
|
||||
expandGroup: 'Expand tool group {{name}}',
|
||||
summary: 'Built-in GoodBuddy capability for direct models'
|
||||
},
|
||||
webSearch: {
|
||||
title: 'Web search',
|
||||
subtitle: 'Direct-model tool · Exa MCP · Ask / Execute',
|
||||
description:
|
||||
'Provides web_search and web_fetch for public web search and reading only. The tools are unavailable in Plan mode.',
|
||||
privacy:
|
||||
'Queries and public webpage addresses are sent to the third-party Exa service. Model API keys, local files, and knowledge content are not sent.',
|
||||
enableAriaLabel: 'Enable direct-model web search',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
test: 'Run real search test',
|
||||
testing: 'Searching…',
|
||||
unsupported: 'Web search settings are unavailable in this version',
|
||||
testFailed: 'Web search test failed',
|
||||
resultAriaLabel: 'Web search test result',
|
||||
result: 'Real search succeeded · {{duration}} ms',
|
||||
toolsAriaLabel: 'Direct-model web search tools'
|
||||
},
|
||||
editor: {
|
||||
editTitle: 'Edit MCP server',
|
||||
addTitle: 'Add MCP server',
|
||||
|
||||
@@ -316,7 +316,6 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
installed: 'Installed and verified',
|
||||
availableToDownload: 'Available from ModelScope',
|
||||
download: 'Download',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP',
|
||||
|
||||
@@ -12,7 +12,14 @@ export const settingsSections = {
|
||||
storagePrefix: 'Models are stored in',
|
||||
storageSuffix:
|
||||
'. Automatic downloads pin the source revision and verify SHA-256 hashes. Export a ZIP on an online device and import it directly on an offline device.',
|
||||
availableModels: 'Available speech models',
|
||||
modelSelector: 'Current speech model',
|
||||
modelSelectorDescription:
|
||||
'Choose an installed model, then select Save settings to switch speech recognition models.',
|
||||
modelSelectorDownloadDescription:
|
||||
'This model is not installed. Download it or import it from a ZIP archive first.',
|
||||
pendingSelection:
|
||||
'The model change is pending. Select Save settings to apply it.',
|
||||
catalogUnavailable: 'No speech model catalog is available.',
|
||||
loading: 'Loading speech models…',
|
||||
errors: {
|
||||
serviceUnavailable:
|
||||
@@ -60,13 +67,9 @@ export const settingsSections = {
|
||||
confirmDelete: 'Confirm delete',
|
||||
download: 'Download',
|
||||
importZip: 'Import ZIP',
|
||||
exportZip: 'Export ZIP',
|
||||
modelDetails: 'Model details',
|
||||
openRepository: 'Open model repository'
|
||||
exportZip: 'Export ZIP'
|
||||
},
|
||||
accessibility: {
|
||||
selectModel: 'Select {{name}}',
|
||||
notInstalled: '{{name}} is not installed',
|
||||
cancelOperation: 'Cancel the {{name}} operation',
|
||||
deleteModel: 'Delete {{name}}',
|
||||
downloadModel: 'Download {{name}}',
|
||||
@@ -81,10 +84,6 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} exported as ZIP',
|
||||
removed: 'Speech model deleted'
|
||||
},
|
||||
details: {
|
||||
license: 'License: ',
|
||||
licenseSeparator: '. '
|
||||
},
|
||||
languages: {
|
||||
中文: 'Chinese',
|
||||
粤语: 'Cantonese',
|
||||
|
||||
@@ -232,6 +232,15 @@ export const app = {
|
||||
'Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本',
|
||||
addContent: '添加内容',
|
||||
addAttachment: '添加附件',
|
||||
attachmentProgress: {
|
||||
selecting: '正在选择附件…',
|
||||
reading: '正在读取 {{name}}',
|
||||
parsing: '正在解析 {{name}}',
|
||||
waiting: '选择文件后将自动读取并解析',
|
||||
fileCount: '第 {{current}} / {{total}} 个文件',
|
||||
progressLabel: '附件读取与解析进度',
|
||||
waitBeforeSending: '附件仍在解析,请等待完成后再发送'
|
||||
},
|
||||
removeAttachment: '移除 {{name}}',
|
||||
settings: '对话设置',
|
||||
expertLabel: '专家角色',
|
||||
|
||||
@@ -197,6 +197,10 @@ export const integrations = {
|
||||
'内置 MCP 由 GoodBuddy 在主进程按当前对话签发短期权限,不公开服务地址或凭据。',
|
||||
serverSummaryMixed: '内置 MCP Server · 按模式读写 · 按对话授权',
|
||||
serverSummaryReadOnly: '内置 MCP Server · 只读 · 按对话授权',
|
||||
serverSummaryDisabled:
|
||||
'内置 MCP Server · 未启用 · 需要开启魔法笔记',
|
||||
featureDisabled:
|
||||
'魔法笔记功能已关闭,此内置能力当前不会向任何 Runtime 提供工具。',
|
||||
collapseServer: '收起服务器 {{name}}',
|
||||
expandServer: '展开服务器 {{name}}',
|
||||
toolCount: '{{count}} 个工具',
|
||||
@@ -212,6 +216,24 @@ export const integrations = {
|
||||
expandGroup: '展开工具组 {{name}}',
|
||||
summary: 'GoodBuddy 直连模型内置能力'
|
||||
},
|
||||
webSearch: {
|
||||
title: '联网搜索',
|
||||
subtitle: '直连模型工具 · Exa MCP · Ask / Execute',
|
||||
description:
|
||||
'提供 web_search 和 web_fetch,只允许搜索及读取公开网页;Plan 模式不会加载。',
|
||||
privacy:
|
||||
'查询词和公开网页地址会发送给第三方 Exa 服务,不会发送模型 API Key、本地文件或知识库内容。',
|
||||
enableAriaLabel: '启用直连模型联网搜索',
|
||||
enabled: '已启用',
|
||||
disabled: '已停用',
|
||||
test: '测试真实搜索',
|
||||
testing: '正在搜索…',
|
||||
unsupported: '当前版本不支持联网搜索设置',
|
||||
testFailed: '联网搜索测试失败',
|
||||
resultAriaLabel: '联网搜索测试结果',
|
||||
result: '真实搜索成功 · {{duration}} 毫秒',
|
||||
toolsAriaLabel: '直连模型联网搜索工具'
|
||||
},
|
||||
editor: {
|
||||
editTitle: '编辑 MCP Server',
|
||||
addTitle: '添加 MCP Server',
|
||||
|
||||
@@ -286,7 +286,6 @@ export const settings = {
|
||||
}
|
||||
},
|
||||
installed: '已安装并校验',
|
||||
availableToDownload: '可从 ModelScope 下载',
|
||||
download: '下载',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP',
|
||||
|
||||
@@ -6,7 +6,13 @@ export const settingsSections = {
|
||||
storagePrefix: '模型保存在',
|
||||
storageSuffix:
|
||||
'。自动下载会固定来源版本并校验 SHA-256;外网设备可导出 ZIP,内网设备可直接导入。',
|
||||
availableModels: '可用语音模型',
|
||||
modelSelector: '当前语音模型',
|
||||
modelSelectorDescription:
|
||||
'选择已安装模型后,点击“保存设置”切换语音识别模型。',
|
||||
modelSelectorDownloadDescription:
|
||||
'当前模型尚未安装,可先下载或从 ZIP 导入。',
|
||||
pendingSelection: '模型选择尚未生效,点击“保存设置”后切换。',
|
||||
catalogUnavailable: '当前没有可用的语音模型目录。',
|
||||
loading: '正在读取语音模型…',
|
||||
errors: {
|
||||
serviceUnavailable: '当前版本未提供语音模型服务',
|
||||
@@ -53,13 +59,9 @@ export const settingsSections = {
|
||||
confirmDelete: '确认删除',
|
||||
download: '下载',
|
||||
importZip: '导入 ZIP',
|
||||
exportZip: '导出 ZIP',
|
||||
modelDetails: '模型详情',
|
||||
openRepository: '打开模型仓库'
|
||||
exportZip: '导出 ZIP'
|
||||
},
|
||||
accessibility: {
|
||||
selectModel: '选择 {{name}}',
|
||||
notInstalled: '{{name}} 尚未安装',
|
||||
cancelOperation: '取消 {{name}} 操作',
|
||||
deleteModel: '删除 {{name}}',
|
||||
downloadModel: '下载 {{name}}',
|
||||
@@ -74,10 +76,6 @@ export const settingsSections = {
|
||||
exportedZip: '{{name}} 已导出为 ZIP',
|
||||
removed: '语音模型已删除'
|
||||
},
|
||||
details: {
|
||||
license: '许可证:',
|
||||
licenseSeparator: '。'
|
||||
},
|
||||
languages: {
|
||||
中文: '中文',
|
||||
粤语: '粤语',
|
||||
|
||||
+46
-231
@@ -3459,6 +3459,33 @@ button > svg * {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.context-chip--processing {
|
||||
display: grid;
|
||||
width: min(100%, 320px);
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.context-chip--processing progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
grid-column: 1 / -1;
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.context-chip__spinner {
|
||||
color: var(--accent);
|
||||
animation: context-chip-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes context-chip-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.context-chip > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -4827,102 +4854,17 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.speech-model-settings__list {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.speech-model-settings .settings-section__title--actions > button,
|
||||
.speech-model-row__actions,
|
||||
.speech-model-row__actions button,
|
||||
.speech-model-row__details button {
|
||||
.speech-model-settings .settings-section__title--actions > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.speech-model-settings .settings-section__title--actions > button,
|
||||
.speech-model-row__actions button,
|
||||
.speech-model-row__details button {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.speech-model-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
grid-template-columns: 20px minmax(0, 1fr) minmax(144px, auto);
|
||||
gap: var(--space-2) var(--space-3);
|
||||
transition:
|
||||
background var(--motion-fast) ease-out,
|
||||
border-color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.speech-model-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.speech-model-row--selected {
|
||||
box-shadow: inset 3px 0 0 var(--accent-solid);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.speech-model-row__selection {
|
||||
align-self: start;
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.speech-model-row__selection input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.speech-model-row__summary {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-column: 2;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.speech-model-row__name,
|
||||
.speech-model-row__tags,
|
||||
.speech-model-row__profile,
|
||||
.speech-model-status,
|
||||
.speech-model-row__actions,
|
||||
.speech-model-row__details summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.speech-model-row__name {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.speech-model-row__name strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.speech-model-row__summary p,
|
||||
.speech-model-row__details p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.speech-model-row__tags {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
.speech-model-card__repository {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.speech-model-tag {
|
||||
@@ -4942,146 +4884,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.speech-model-row__profile {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
grid-column: 2;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
|
||||
.speech-model-row__state {
|
||||
align-self: start;
|
||||
padding-top: var(--space-1);
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.speech-model-status {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 650;
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.speech-model-status--installed {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.speech-model-status--selected {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.speech-model-row__actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.speech-model-row__actions button,
|
||||
.speech-model-row__details button {
|
||||
min-height: 30px;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.speech-model-row__actions .danger-ghost {
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
font: inherit;
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.speech-model-row__actions .danger-ghost:hover {
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.speech-model-operation {
|
||||
display: grid;
|
||||
grid-column: 2 / -1;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.speech-model-operation progress {
|
||||
width: 100%;
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.speech-model-operation small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.speech-model-row__details {
|
||||
min-width: 0;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
.speech-model-row__details summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.speech-model-row__details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.speech-model-row__details summary svg {
|
||||
transition: transform var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.speech-model-row__details[open] summary svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.speech-model-row__details > div {
|
||||
display: grid;
|
||||
padding-top: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.speech-model-row__details button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@container speech-model-list (max-width: 500px) {
|
||||
.speech-model-row {
|
||||
align-items: start;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.speech-model-row__summary,
|
||||
.speech-model-row__profile {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.speech-model-row__state {
|
||||
grid-column: 2;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.speech-model-row__actions,
|
||||
.speech-model-operation,
|
||||
.speech-model-row__details {
|
||||
justify-content: flex-start;
|
||||
grid-column: 2;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.speech-model-settings .settings-section__title--actions {
|
||||
align-items: flex-start;
|
||||
@@ -5680,6 +5482,15 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.mcp-server-card--disabled {
|
||||
border-style: dashed;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.mcp-server-card--disabled .mcp-server-card__toggle strong {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mcp-server-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -5762,6 +5573,10 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mcp-server-card__body > .mcp-server-card__disabled-notice {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.mcp-server-card__body > code {
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
|
||||
Reference in New Issue
Block a user