feat: expand model tools and document handling

This commit is contained in:
lofyer
2026-08-11 19:52:58 +08:00
parent 71a8662690
commit 184180e618
50 changed files with 2537 additions and 747 deletions
+68
View File
@@ -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}`,
+94 -7
View File
@@ -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()
})
+4 -1
View File
@@ -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')}
+5 -2
View File
@@ -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
})
+4 -1
View File
@@ -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>
+179 -32
View File
@@ -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>
+104 -25
View File
@@ -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')
+6 -3
View File
@@ -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>
+24 -23
View File
@@ -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))
})
})
+358 -299
View File
@@ -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>
+261
View File
@@ -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'
)
}
}
})
})
+105
View File
@@ -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
}
}
+20 -12
View File
@@ -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
View File
@@ -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);