fix: harden scoped tools and settings persistence

This commit is contained in:
lofyer
2026-08-13 14:56:53 +08:00
parent bf1ec5d2f1
commit aab961226f
62 changed files with 4343 additions and 1314 deletions
+13 -10
View File
@@ -740,6 +740,8 @@ describe('App', () => {
expect(
screen.getByRole('button', { name: 'Chat' })
).toBeInTheDocument()
expect(screen.getByText('Desktop workspace')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY WORKSPACE')).toBeInTheDocument()
expect(
screen.getByRole('heading', {
name: 'What would you like to accomplish today?'
@@ -760,6 +762,13 @@ describe('App', () => {
}
})
it('renders localized workspace branding in Chinese', async () => {
render(<App />)
expect(await screen.findByText('桌面工作区')).toBeInTheDocument()
expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument()
})
it('keeps Settings open when the interface language changes', async () => {
api.updates = {
getSettings: vi.fn(async () => ({
@@ -3278,7 +3287,7 @@ describe('App', () => {
)
})
it('normalizes a legacy Auto conversation to the explicit default Runtime', async () => {
it('preserves a legacy Auto conversation without silently persisting a replacement', async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000020',
@@ -3306,17 +3315,14 @@ describe('App', () => {
expect.arrayContaining([
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000020',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
}
runtimeSelection: { provider: 'auto' }
})
])
)
)
})
it('rebinds a loaded conversation when its model profile was removed', async () => {
it('keeps a removed model selection visible until the user replaces it', async () => {
const removedProfileId =
'00000000-0000-4000-8000-000000000099'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
@@ -3341,9 +3347,6 @@ describe('App', () => {
])
render(<App />)
expect(
await screen.findByRole('button', { name: /.*sonnet-5/u })
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversations.replace).toHaveBeenLastCalledWith(
expect.arrayContaining([
@@ -3351,7 +3354,7 @@ describe('App', () => {
id: '00000000-0000-4000-8000-000000000022',
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
profileId: removedProfileId
}
})
])
+31 -106
View File
@@ -65,9 +65,12 @@ import { maximumPastedImageBytes } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
repairAgentRuntimeSelection,
type AgentRuntimeSelection
} from '../../shared/runtime-selection-contracts'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
import type {
AssistantProject,
AssistantArtifact,
@@ -802,45 +805,6 @@ function mergeArtifacts(
)
}
function getDefaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (settings.provider === 'model') {
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
if (settings.provider === 'opencode') {
return {
provider: 'opencode',
...(settings.opencodeModelSource.kind === 'profile'
? { profileId: settings.opencodeModelSource.profileId }
: {})
}
}
if (settings.provider === 'continue') {
return {
provider: 'continue',
...(settings.continueModelSource.kind === 'profile'
? { profileId: settings.continueModelSource.profileId }
: {})
}
}
if (settings.opencodeBaseUrl || settings.opencodeEmbedded) {
return {
provider: 'opencode',
...(settings.opencodeModelSource.kind === 'profile'
? { profileId: settings.opencodeModelSource.profileId }
: {})
}
}
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
function getProjectDefaultRuntimeSelection(
project: AssistantProject | undefined,
settings: RuntimeSettings
@@ -848,7 +812,7 @@ function getProjectDefaultRuntimeSelection(
const selection = project?.runtimeSelection
return !selection || selection.provider === 'auto'
? getDefaultRuntimeSelection(settings)
: repairAgentRuntimeSelection(selection, settings)
: selection
}
function getRuntimeSelectionLabel(
@@ -859,6 +823,7 @@ function getRuntimeSelectionLabel(
directModel: string
automatic: string
automaticSelection: string
modelUnavailable: string
}
): string {
if (!selection || !settings) {
@@ -870,36 +835,36 @@ function getRuntimeSelectionLabel(
(candidate) => candidate.id === selection.profileId
)
: undefined
const requestedProfileMissing =
'profileId' in selection &&
Boolean(selection.profileId) &&
profile === undefined
if (selection.provider === 'model') {
return profile
? `${profile.name} · ${profile.modelName}`
: status?.label ?? labels.directModel
: requestedProfileMissing
? labels.modelUnavailable
: status?.label ?? labels.directModel
}
if (selection.provider === 'opencode') {
return profile ? `OpenCode · ${profile.name}` : 'OpenCode'
return profile
? `OpenCode · ${profile.name}`
: requestedProfileMissing
? `OpenCode · ${labels.modelUnavailable}`
: 'OpenCode'
}
if (selection.provider === 'continue') {
return profile ? `Continue · ${profile.name}` : 'Continue'
return profile
? `Continue · ${profile.name}`
: requestedProfileMissing
? `Continue · ${labels.modelUnavailable}`
: 'Continue'
}
return status
? `${labels.automatic} · ${status.label}`
: labels.automaticSelection
}
function getConfiguredAgentRuntimeSelection(
settings: RuntimeSettings,
provider: 'opencode' | 'continue'
): AgentRuntimeSelection {
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
function getConfiguredAgentRuntimeSource(
settings: RuntimeSettings,
provider: 'opencode' | 'continue',
@@ -910,7 +875,7 @@ function getConfiguredAgentRuntimeSource(
useOwnConfiguration: (runtime: string) => string
}
): { label: string; detail: string } {
const selection = getConfiguredAgentRuntimeSelection(settings, provider)
const selection = getRuntimeSelectionForProvider(provider, settings)
const profile =
'profileId' in selection
? settings.modelProfiles.find(
@@ -1766,7 +1731,8 @@ function App(): React.JSX.Element {
() => ({
directModel: t('runtime.directModel'),
automatic: t('runtime.automatic'),
automaticSelection: t('runtime.automaticSelection')
automaticSelection: t('runtime.automaticSelection'),
modelUnavailable: t('runtime.modelUnavailable')
}),
[t]
)
@@ -1787,10 +1753,10 @@ function App(): React.JSX.Element {
runtimeLabels
)
const openCodeMenuSelection = runtimeSettings
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'opencode')
? getRuntimeSelectionForProvider('opencode', runtimeSettings)
: undefined
const continueMenuSelection = runtimeSettings
? getConfiguredAgentRuntimeSelection(runtimeSettings, 'continue')
? getRuntimeSelectionForProvider('continue', runtimeSettings)
: undefined
const openCodeMenuSource = runtimeSettings
? getConfiguredAgentRuntimeSource(
@@ -1866,48 +1832,6 @@ function App(): React.JSX.Element {
}
}, [activeId, conversations])
useEffect(() => {
if (!runtimeSettings || !conversationStoreReady) {
return
}
const timeout = setTimeout(() => {
setConversations((current) => {
let changed = false
const next = current.map((conversation) => {
const project = projects.find(
(candidate) => candidate.id === conversation.projectId
)
const defaultSelection = getProjectDefaultRuntimeSelection(
project,
runtimeSettings
)
const selection =
!conversation.runtimeSelection ||
conversation.runtimeSelection.provider === 'auto'
? defaultSelection
: repairAgentRuntimeSelection(
conversation.runtimeSelection,
runtimeSettings
)
if (
conversation.runtimeSelection &&
agentRuntimeSelectionKey(conversation.runtimeSelection) ===
agentRuntimeSelectionKey(selection)
) {
return conversation
}
changed = true
return {
...conversation,
runtimeSelection: selection
}
})
return changed ? next : current
})
}, 0)
return () => clearTimeout(timeout)
}, [conversationStoreReady, projects, runtimeSettings])
useEffect(() => {
const selection = activeRuntimeSelectionRef.current
if (!selection || !runtimeSettings) {
@@ -4762,7 +4686,7 @@ function App(): React.JSX.Element {
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
<span>Desktop workspace</span>
<span>{t('brand.desktopWorkspace')}</span>
</div>
</div>
@@ -5299,7 +5223,7 @@ function App(): React.JSX.Element {
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<p className="eyebrow">GOODBUDDY WORKSPACE</p>
<p className="eyebrow">{t('chat.welcome.eyebrow')}</p>
<h1>{t('chat.welcome.title')}</h1>
<p className="welcome__description">
{t('chat.welcome.description')}
@@ -6849,6 +6773,7 @@ function App(): React.JSX.Element {
<SettingsPanel
appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats}
magicNotesEnabled={magicNotesEnabled}
onAppearanceThemeChange={setAppearanceTheme}
onClearLocalData={clearLocalData}
onClose={() => setView('chat')}
+7 -2
View File
@@ -35,7 +35,10 @@ import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contract
import type { AppNotificationInput } from './notifications'
import { trapTabFocus } from './dialog-focus'
import { PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type ChannelDraft = {
enabled: boolean
@@ -455,6 +458,8 @@ function ChannelEditor({
<small>
{settings.source === 'environment'
? t('channels.credential.environmentSource')
: settings.source === 'unreadable'
? t('channels.credential.secretUnreadable')
: settings.secretConfigured
? t('channels.credential.secretSaved')
: t('channels.credential.secretMissing')}
@@ -1327,7 +1332,7 @@ export function ChannelSettingsSection({
aria-label={t('channels.sectionAriaLabel')}
className="settings-section channel-settings"
>
{snapshot.warning && <p className="settings-warning">{snapshot.warning}</p>}
<SettingsWarningList warnings={snapshot.warnings} />
<div className="channel-settings__tabs">
<PageTabs
@@ -199,6 +199,36 @@ describe('DocumentParsingSettingsSection', () => {
afterEach(() => cleanup())
it.each([
['zh-CN', '正在加载…'],
['en-US', 'Loading…']
] as const)('localizes the loading state in %s', async (locale, label) => {
await changeUiLocale(locale)
getSnapshot.mockImplementationOnce(
() => new Promise(() => undefined)
)
render(<DocumentParsingSettingsSection />)
expect(screen.getByText(label)).toBeInTheDocument()
})
it('localizes recovered document parsing settings warnings', async () => {
await changeUiLocale('en-US')
getSnapshot.mockResolvedValueOnce({
...snapshot,
warnings: [{ code: 'document-parsing-settings-recovered' }]
})
render(<DocumentParsingSettingsSection />)
expect(
await screen.findByText(
/The document parsing settings file was corrupt/u
)
).toBeInTheDocument()
})
it('shows actual capability status and saves workflow settings', async () => {
const onNotify = vi.fn()
render(
@@ -29,7 +29,10 @@ import type {
DocumentParsingTestPurpose
} from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type DocumentParsingSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void
@@ -402,7 +405,9 @@ export function DocumentParsingSettingsSection({
error={error ?? unavailableError}
/>
{!error && !unavailableError && (
<p className="settings-empty">Loading</p>
<p className="settings-empty">
{t('documentParsing.loading')}
</p>
)}
</>
)
@@ -453,6 +458,7 @@ export function DocumentParsingSettingsSection({
category="document-parsing"
error={error}
/>
<SettingsWarningList warnings={snapshot.warnings} />
{settingsDirty && (
<p
className="settings-notice"
+10 -17
View File
@@ -31,7 +31,10 @@ import type {
WebSearchTestResult
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { PageTabs } from './WorkspacePrimitives'
const configurableMcpTargets: RuntimeTarget[] = ['model']
@@ -85,7 +88,11 @@ function editorFromServer(server: McpServerSummary): McpEditor {
}
}
export function McpSettingsSection(): React.JSX.Element {
export function McpSettingsSection({
magicNotesEnabled = false
}: {
magicNotesEnabled?: boolean
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const tRef = useRef(t)
useEffect(() => {
@@ -106,7 +113,6 @@ 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>()
@@ -158,20 +164,6 @@ 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
@@ -417,6 +409,7 @@ export function McpSettingsSection(): React.JSX.Element {
error={!editor ? error : undefined}
headingId="mcp-settings-heading"
/>
<SettingsWarningList warnings={snapshot?.warnings} />
<PageTabs
ariaLabel={t('mcp.tabs.ariaLabel')}
idPrefix="mcp-settings"
@@ -6,7 +6,10 @@ import type {
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import { SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
@@ -116,6 +119,7 @@ export function PlatformFeaturesSettingsSection({
error={error}
headingId="platform-features-heading"
/>
<SettingsWarningList warnings={settings?.warnings} />
<section
aria-label={t('platformFeatures.label')}
className="settings-section"
+9 -43
View File
@@ -18,8 +18,11 @@ import {
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import { trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection,
getRuntimeSelectionForProvider
} from './runtime-selection'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -36,43 +39,6 @@ type ProjectSwitcherProps = {
) => Promise<AssistantProject>
}
function runtimeSelectionForProvider(
provider: 'model' | 'opencode' | 'continue',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
function defaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (settings.provider === 'model') {
return runtimeSelectionForProvider('model', settings)
}
if (settings.provider === 'opencode') {
return runtimeSelectionForProvider('opencode', settings)
}
if (settings.provider === 'continue') {
return runtimeSelectionForProvider('continue', settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? runtimeSelectionForProvider('opencode', settings)
: runtimeSelectionForProvider('model', settings)
}
export function ProjectSwitcher({
projects,
activeProjectId,
@@ -157,7 +123,7 @@ export function ProjectSwitcher({
? draft
: {
...draft,
runtimeSelection: defaultRuntimeSelection(runtimeSettings)
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
}
if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, input)
@@ -276,7 +242,7 @@ export function ProjectSwitcher({
rootPath: '',
defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
? getDefaultRuntimeSelection(runtimeSettings)
: undefined
})
restoreFocusTarget.current = 'create'
@@ -308,7 +274,7 @@ export function ProjectSwitcher({
runtimeSelection:
activeProject.runtimeSelection ??
(runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
})
restoreFocusTarget.current = 'settings'
@@ -441,7 +407,7 @@ export function ProjectSwitcher({
onChange={(event) =>
setDraft((current) => ({
...current,
runtimeSelection: runtimeSelectionForProvider(
runtimeSelection: getRuntimeSelectionForProvider(
event.target.value as
| 'model'
| 'opencode'
@@ -454,7 +420,7 @@ export function ProjectSwitcher({
draft.runtimeSelection?.provider === 'auto'
? 'model'
: (draft.runtimeSelection?.provider ??
defaultRuntimeSelection(runtimeSettings)
getDefaultRuntimeSelection(runtimeSettings)
.provider)
}
>
+213 -1
View File
@@ -7,6 +7,7 @@ import {
waitFor,
within
} from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantExpert } from '../../shared/assistant-contracts'
import type {
@@ -569,6 +570,31 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('radio', { name: /Use system language/u })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Model connections' })
)
expect(
await screen.findByRole('button', {
name: 'Edit model connection Default model'
})
).toBeInTheDocument()
expect(screen.getByLabelText('Name')).toHaveValue('Default model')
fireEvent.click(
screen.getByRole('button', { name: 'Save settings' })
)
await waitFor(() =>
expect(updateRuntime).toHaveBeenLastCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
name: '默认模型'
})
])
})
)
)
fireEvent.click(
screen.getByRole('tab', { name: 'Agent Runtime' })
)
@@ -590,6 +616,69 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('does not translate user-defined model connection names', async () => {
const userProfileId = '00000000-0000-4000-8000-000000000099'
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
name: 'My renamed model'
},
{
...runtimeSettings.modelProfiles[0]!,
id: userProfileId,
name: '默认模型'
}
]
})
await changeUiLocale('en-US')
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: 'Model connections' })
)
expect(
await screen.findByRole('button', {
name: 'Edit model connection My renamed model'
})
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'Edit model connection 默认模型'
})
).toBeInTheDocument()
})
it('localizes structured Runtime recovery warnings', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
warnings: [{ code: 'runtime-settings-recovered' }]
})
await changeUiLocale('en-US')
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
await screen.findByText(/The Runtime settings file was corrupt/u)
).toBeInTheDocument()
})
it('toggles the Magic Notes platform entry setting', async () => {
const onMagicNotesEnabledChange = vi.fn()
render(
@@ -617,7 +706,6 @@ describe('SettingsPanel runtime files', () => {
})
)
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
fireEvent.click(
screen.getByRole('button', { name: '保存后自动' })
)
@@ -638,6 +726,59 @@ describe('SettingsPanel runtime files', () => {
)
})
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
function Harness(): React.JSX.Element {
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
return (
<SettingsPanel
{...heartbeatSettingsProps}
magicNotesEnabled={magicNotesEnabled}
onMagicNotesEnabledChange={setMagicNotesEnabled}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(
<Harness />
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
const noteServerToggle = await screen.findByRole('button', {
name: '展开服务器 笔记'
})
expect(noteServerToggle.closest('article')).toHaveClass(
'mcp-server-card--disabled'
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('switch', {
name: '显示魔法笔记入口'
})
)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
magicNotesEnabled: true
})
)
fireEvent.click(screen.getByRole('tab', { name: 'MCP' }))
await waitFor(() =>
expect(
screen
.getByRole('button', { name: '展开服务器 笔记' })
.closest('article')
).not.toHaveClass('mcp-server-card--disabled')
)
expect(
screen.getByText('内置 MCP Server · 按模式读写 · 按对话授权')
).toBeInTheDocument()
})
it('keeps page navigation beside an independently scrollable panel', () => {
render(
<SettingsPanel
@@ -759,6 +900,77 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
})
it('submits configured model values while environment values are effective', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelBaseUrl: 'https://environment.example/v1',
modelName: 'environment-model',
apiKeyConfigured: true,
credentialSource: 'environment',
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://environment.example/v1',
modelName: 'environment-model',
apiKeyConfigured: true,
credentialSource: 'environment'
}
],
configured: {
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
apiKeyConfigured: true,
credentialSource: 'environment'
}
],
opencodeBaseUrl: '',
opencodeBinaryPath: '',
opencodeConfigPath: '',
continueBinaryPath: '',
continueConfigPath: '',
workspacePath: 'C:\\Workspace',
opencodeModelSource: runtimeSettings.opencodeModelSource,
continueModelSource: runtimeSettings.continueModelSource
}
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByDisplayValue('C:\\Workspace')
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
expect(
await screen.findByDisplayValue('https://environment.example/v1')
).toBeDisabled()
expect(screen.getByDisplayValue('environment-model')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelBaseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
modelProfiles: [
expect.objectContaining({
baseUrl: 'https://stored.example/v1',
modelName: 'stored-model',
apiKey: { action: 'keep' }
})
]
})
)
)
})
it('applies a speech model draft only when Settings is saved', async () => {
render(
<SettingsPanel
+259 -138
View File
@@ -9,7 +9,7 @@ import {
Trash2,
X
} from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantExpert,
@@ -26,6 +26,7 @@ import type {
} from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import {
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
isAgentRuntimeModelProtocol
} from '../../shared/contracts'
@@ -40,7 +41,10 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import {
settingsCategoryList,
type SettingsCategoryId
@@ -81,6 +85,7 @@ type SettingsPanelProps = {
onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
magicNotesEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
}
@@ -120,6 +125,118 @@ function toModelProfileDrafts(
}))
}
function configuredRuntimeSettings(
settings: RuntimeSettings
): NonNullable<RuntimeSettings['configured']> {
return settings.configured ?? {
modelProfiles: settings.modelProfiles,
opencodeBaseUrl: settings.opencodeBaseUrl,
opencodeBinaryPath: settings.opencodeBinaryPath,
opencodeConfigPath: settings.opencodeConfigPath,
continueBinaryPath: settings.continueBinaryPath,
continueConfigPath: settings.continueConfigPath,
workspacePath: settings.workspacePath,
opencodeModelSource: settings.opencodeModelSource,
continueModelSource: settings.continueModelSource
}
}
type RuntimeDraftSelection =
| string
| ((selectedId: string) => string)
function hydrateRuntimeSettings(
value: RuntimeSettings,
setters: {
settings: (value: RuntimeSettings) => void
provider: (value: RuntimeSettings['provider']) => void
modelProfiles: (value: ModelProfileDraft[]) => void
selectedModelProfileId: (value: RuntimeDraftSelection) => void
defaultModelProfileId: (value: string) => void
opencodeModelSource: (value: RuntimeModelSource) => void
continueModelSource: (value: RuntimeModelSource) => void
opencodeBaseUrl: (value: string) => void
opencodeBinaryPath: (value: string) => void
opencodeConfigPath: (value: string) => void
continueBinaryPath: (value: string) => void
continueConfigPath: (value: string) => void
continueMode: (value: RuntimeSettings['continueMode']) => void
runtimeSandboxMode: (
value: RuntimeSettings['runtimeSandboxMode']
) => void
knowledgeEmbeddingEnabled: (value: boolean) => void
knowledgeEmbeddingBaseUrl: (value: string) => void
knowledgeEmbeddingModel: (value: string) => void
knowledgeEmbeddingApiKey: (value: string) => void
clearKnowledgeEmbeddingApiKey: (value: boolean) => void
knowledgeRerankEnabled: (value: boolean) => void
knowledgeRerankEndpoint: (value: string) => void
knowledgeRerankModel: (value: string) => void
knowledgeRerankApiKey: (value: string) => void
clearKnowledgeRerankApiKey: (value: boolean) => void
workspacePath: (value: string) => void
toolApproval: (value: RuntimeSettingsInput['toolApproval']) => void
subagentSmartRoutingEnabled: (value: boolean) => void
},
preserveSelectedProfile = false
): void {
const configured = configuredRuntimeSettings(value)
setters.settings(value)
setters.provider(value.provider)
setters.modelProfiles(toModelProfileDrafts(value))
const fallbackProfileId = value.modelProfiles.some(
(profile) => profile.id === value.defaultModelProfileId
)
? value.defaultModelProfileId
: value.modelProfiles[0]?.id ?? ''
setters.selectedModelProfileId(
preserveSelectedProfile
? (selectedId) =>
value.modelProfiles.some(
(profile) => profile.id === selectedId
)
? selectedId
: fallbackProfileId
: fallbackProfileId
)
setters.defaultModelProfileId(value.defaultModelProfileId)
setters.opencodeModelSource(configured.opencodeModelSource)
setters.continueModelSource(configured.continueModelSource)
setters.opencodeBaseUrl(configured.opencodeBaseUrl)
setters.opencodeBinaryPath(configured.opencodeBinaryPath)
setters.opencodeConfigPath(configured.opencodeConfigPath)
setters.continueBinaryPath(configured.continueBinaryPath)
setters.continueConfigPath(configured.continueConfigPath)
setters.continueMode(value.continueMode)
setters.runtimeSandboxMode(value.runtimeSandboxMode)
setters.knowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setters.knowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setters.knowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setters.knowledgeEmbeddingApiKey('')
setters.clearKnowledgeEmbeddingApiKey(false)
setters.knowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
)
setters.knowledgeRerankEndpoint(
value.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint
)
setters.knowledgeRerankModel(
value.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel
)
setters.knowledgeRerankApiKey('')
setters.clearKnowledgeRerankApiKey(false)
setters.workspacePath(configured.workspacePath)
setters.toolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setters.subagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
}
type RuntimeConfigCardProps = {
runtime: AgentRuntimeType
runtimeLabel: string
@@ -246,6 +363,7 @@ export function SettingsPanel({
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
magicNotesEnabled = false,
onMagicNotesEnabledChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const { i18n, t } = useTranslation('settings')
@@ -322,6 +440,13 @@ export function SettingsPanel({
subagentSmartRoutingEnabled,
setSubagentSmartRoutingEnabled
] = useState(false)
const modelProfileDisplayName = (
profile: Pick<ModelProfileDraft, 'id' | 'name'>
): string =>
profile.id === builtInDefaultModelProfileId &&
profile.name === '默认模型'
? t('model.profile.seededDefaultName')
: profile.name
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [embeddingConfiguration, setEmbeddingConfiguration] =
@@ -350,6 +475,47 @@ export function SettingsPanel({
const [agentRuntimeType, setAgentRuntimeType] =
useState<AgentRuntimeType>('opencode')
const settingsBodyRef = useRef<HTMLDivElement>(null)
const hydrateSettings = useCallback(
(
value: RuntimeSettings,
preserveSelectedProfile = false
): void => {
hydrateRuntimeSettings(
value,
{
settings: setSettings,
provider: setProvider,
modelProfiles: setModelProfiles,
selectedModelProfileId: setSelectedModelProfileId,
defaultModelProfileId: setDefaultModelProfileId,
opencodeModelSource: setOpencodeModelSource,
continueModelSource: setContinueModelSource,
opencodeBaseUrl: setOpencodeBaseUrl,
opencodeBinaryPath: setOpencodeBinaryPath,
opencodeConfigPath: setOpencodeConfigPath,
continueBinaryPath: setContinueBinaryPath,
continueConfigPath: setContinueConfigPath,
continueMode: setContinueMode,
runtimeSandboxMode: setRuntimeSandboxMode,
knowledgeEmbeddingEnabled: setKnowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: setKnowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: setKnowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: setKnowledgeEmbeddingApiKey,
clearKnowledgeEmbeddingApiKey: setClearKnowledgeEmbeddingApiKey,
knowledgeRerankEnabled: setKnowledgeRerankEnabled,
knowledgeRerankEndpoint: setKnowledgeRerankEndpoint,
knowledgeRerankModel: setKnowledgeRerankModel,
knowledgeRerankApiKey: setKnowledgeRerankApiKey,
clearKnowledgeRerankApiKey: setClearKnowledgeRerankApiKey,
workspacePath: setWorkspacePath,
toolApproval: setToolApproval,
subagentSmartRoutingEnabled: setSubagentSmartRoutingEnabled
},
preserveSelectedProfile
)
},
[]
)
const configurationTab =
activeTab === 'model' ||
activeTab === 'runtime' ||
@@ -409,52 +575,7 @@ export function SettingsPanel({
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setAgentRuntimeType('opencode')
setSettings(value)
setProvider(value.provider)
setModelProfiles(toModelProfileDrafts(value))
setSelectedModelProfileId(
value.modelProfiles.some(
(profile) => profile.id === value.defaultModelProfileId
)
? value.defaultModelProfileId
: value.modelProfiles[0]?.id ?? ''
)
setDefaultModelProfileId(value.defaultModelProfileId)
setOpencodeModelSource(value.opencodeModelSource)
setContinueModelSource(value.continueModelSource)
setOpencodeBaseUrl(value.opencodeBaseUrl)
setOpencodeBinaryPath(value.opencodeBinaryPath)
setOpencodeConfigPath(value.opencodeConfigPath)
setContinueBinaryPath(value.continueBinaryPath)
setContinueConfigPath(value.continueConfigPath)
setContinueMode(value.continueMode)
setRuntimeSandboxMode(value.runtimeSandboxMode)
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setKnowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
)
setKnowledgeRerankEndpoint(
value.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint
)
setKnowledgeRerankModel(
value.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel
)
setKnowledgeRerankApiKey('')
setClearKnowledgeRerankApiKey(false)
setWorkspacePath(value.workspacePath)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setSubagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
hydrateSettings(value)
})
.catch((reason: unknown) => {
setError(
@@ -475,7 +596,7 @@ export function SettingsPanel({
)
)
})
}, [i18n, open])
}, [hydrateSettings, i18n, open])
useEffect(() => {
if (open && settingsBodyRef.current) {
@@ -550,32 +671,52 @@ export function SettingsPanel({
if (!defaultProfile) {
throw new Error(t('errors.requireModelConnection'))
}
const profileInputs = modelProfiles.map((profile) => ({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.apiKey.trim()
? ({
action: 'replace',
value: profile.apiKey.trim()
} as const)
: ({ action: 'keep' } as const)
}))
const configuredProfiles = new Map(
settings?.configured?.modelProfiles.map((profile) => [
profile.id,
profile
])
)
const profileInputs = modelProfiles.map((profile) => {
const configured = configuredProfiles.get(profile.id)
const environmentManaged =
profile.credentialSource === 'environment' &&
configured !== undefined
return {
id: profile.id,
name: profile.name,
baseUrl: environmentManaged
? configured.baseUrl
: profile.baseUrl,
modelName: environmentManaged
? configured.modelName
: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.apiKey.trim()
? ({
action: 'replace',
value: profile.apiKey.trim()
} as const)
: ({ action: 'keep' } as const)
}
})
const defaultProfileInput =
profileInputs.find(
(profile) => profile.id === defaultProfile.id
) ?? profileInputs[0]!
const value = await window.goodbuddy.settings.updateRuntime({
provider,
modelBaseUrl: defaultProfile.baseUrl,
modelName: defaultProfile.modelName,
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
modelBaseUrl: defaultProfileInput.baseUrl,
modelName: defaultProfileInput.modelName,
modelProtocol: defaultProfileInput.protocol,
modelAuthentication: defaultProfileInput.authentication,
imageGenerationQuality:
defaultProfile.imageGenerationQuality,
defaultProfileInput.imageGenerationQuality,
opencodeBaseUrl,
opencodeEmbedded: !opencodeBaseUrl,
opencodeBinaryPath,
@@ -607,9 +748,7 @@ export function SettingsPanel({
}
: { action: 'keep' },
workspacePath,
apiKey: profileInputs.find(
(profile) => profile.id === defaultProfile.id
)!.apiKey,
apiKey: defaultProfileInput.apiKey,
modelProfiles: profileInputs,
defaultModelProfileId: defaultProfile.id,
opencodeModelSource,
@@ -628,48 +767,7 @@ export function SettingsPanel({
)
selectedSpeechModelId = speechSnapshot.selectedModelId
}
setSettings(value)
setModelProfiles(toModelProfileDrafts(value))
setSelectedModelProfileId((selectedId) =>
value.modelProfiles.some((profile) => profile.id === selectedId)
? selectedId
: value.defaultModelProfileId
)
setDefaultModelProfileId(value.defaultModelProfileId)
setOpencodeModelSource(value.opencodeModelSource)
setContinueModelSource(value.continueModelSource)
setOpencodeBaseUrl(value.opencodeBaseUrl)
setOpencodeBinaryPath(value.opencodeBinaryPath)
setOpencodeConfigPath(value.opencodeConfigPath)
setContinueBinaryPath(value.continueBinaryPath)
setContinueConfigPath(value.continueConfigPath)
setContinueMode(value.continueMode)
setRuntimeSandboxMode(value.runtimeSandboxMode)
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setKnowledgeRerankEnabled(
value.knowledgeRerankEnabled ??
defaultRuntimeSettings.knowledgeRerankEnabled
)
setKnowledgeRerankEndpoint(
value.knowledgeRerankEndpoint ??
defaultRuntimeSettings.knowledgeRerankEndpoint
)
setKnowledgeRerankModel(
value.knowledgeRerankModel ??
defaultRuntimeSettings.knowledgeRerankModel
)
setKnowledgeRerankApiKey('')
setClearKnowledgeRerankApiKey(false)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setSubagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
hydrateSettings(value, true)
if (speechModelSelectionDirty) {
setSpeechModelDraftId(selectedSpeechModelId)
setPersistedSpeechModelId(selectedSpeechModelId)
@@ -987,7 +1085,10 @@ export function SettingsPanel({
.filter((profile) =>
isAgentRuntimeModelProtocol(profile.protocol)
)
.map(({ id, name }) => ({ id, name }))
.map(({ id, name }) => ({
id,
name: modelProfileDisplayName({ id, name })
}))
const savedRoleDefaultModelProfileId =
savedRoleModelProfiles.some(
(profile) => profile.id === settings?.defaultModelProfileId
@@ -1246,9 +1347,7 @@ export function SettingsPanel({
)}
{activeTab === 'runtime' && (
<>
{settings?.warning && (
<p className="settings-warning">{settings.warning}</p>
)}
<SettingsWarningList warnings={settings?.warnings} />
<div className="settings-section">
<div className="settings-section__title">
<FolderOpen size={17} />
@@ -1327,12 +1426,16 @@ export function SettingsPanel({
})
: activeRuntimeModelProfile
? t('runtime.followGoodBuddy', {
name: activeRuntimeModelProfile.name,
name: modelProfileDisplayName(
activeRuntimeModelProfile
),
model: activeRuntimeModelProfile.modelName
})
: defaultTextModelProfile
? t('runtime.followGoodBuddy', {
name: defaultTextModelProfile.name,
name: modelProfileDisplayName(
defaultTextModelProfile
),
model: defaultTextModelProfile.modelName
})
: t('runtime.noCompatibleModel')}
@@ -1415,7 +1518,7 @@ export function SettingsPanel({
key={profile.id}
value={profile.id}
>
{profile.name}
{modelProfileDisplayName(profile)}
{isOpenCodeCompatible(profile)
? ''
: t('runtime.incompatibleSuffix')}
@@ -1440,7 +1543,12 @@ export function SettingsPanel({
path={opencodeConfigPath}
runtime="opencode"
runtimeLabel="OpenCode"
savedPath={settings?.opencodeConfigPath}
savedPath={
settings
? configuredRuntimeSettings(settings)
.opencodeConfigPath
: undefined
}
/>
)}
{opencodeModelSource.kind === 'platform' &&
@@ -1548,12 +1656,16 @@ export function SettingsPanel({
})
: activeRuntimeModelProfile
? t('runtime.followGoodBuddy', {
name: activeRuntimeModelProfile.name,
name: modelProfileDisplayName(
activeRuntimeModelProfile
),
model: activeRuntimeModelProfile.modelName
})
: defaultTextModelProfile
? t('runtime.followGoodBuddy', {
name: defaultTextModelProfile.name,
name: modelProfileDisplayName(
defaultTextModelProfile
),
model: defaultTextModelProfile.modelName
})
: t('runtime.noCompatibleModel')}
@@ -1634,7 +1746,7 @@ export function SettingsPanel({
key={profile.id}
value={profile.id}
>
{profile.name}
{modelProfileDisplayName(profile)}
{isContinueCompatible(profile)
? ''
: t('runtime.incompatibleSuffix')}
@@ -1658,7 +1770,12 @@ export function SettingsPanel({
path={continueConfigPath}
runtime="continue"
runtimeLabel="Continue"
savedPath={settings?.continueConfigPath}
savedPath={
settings
? configuredRuntimeSettings(settings)
.continueConfigPath
: undefined
}
/>
)}
<label className="field">
@@ -1798,7 +1915,7 @@ export function SettingsPanel({
: undefined
}
aria-label={t('model.profile.editAriaLabel', {
name: profile.name
name: modelProfileDisplayName(profile)
})}
onClick={() =>
setSelectedModelProfileId(profile.id)
@@ -1806,7 +1923,7 @@ export function SettingsPanel({
type="button"
>
<span className="model-connection-list__name">
<strong>{profile.name}</strong>
<strong>{modelProfileDisplayName(profile)}</strong>
<small>{profile.modelName}</small>
</span>
<span className="model-connection-list__badges">
@@ -1836,7 +1953,7 @@ export function SettingsPanel({
<div className="settings-section__title">
<div>
<strong id={`model-connection-${profile.id}`}>
{profile.name}
{modelProfileDisplayName(profile)}
</strong>
<small>{t('model.profile.detail')}</small>
</div>
@@ -1858,7 +1975,7 @@ export function SettingsPanel({
)}
<button
aria-label={t('model.profile.deleteAriaLabel', {
name: profile.name
name: modelProfileDisplayName(profile)
})}
className="danger-button danger-button--quiet"
disabled={modelProfiles.length <= 1}
@@ -1877,7 +1994,7 @@ export function SettingsPanel({
name: event.target.value
})
}
value={profile.name}
value={modelProfileDisplayName(profile)}
/>
</label>
<label className="field">
@@ -1912,7 +2029,7 @@ export function SettingsPanel({
<select
aria-label={t(
'model.profile.protocolAriaLabel',
{ name: profile.name }
{ name: modelProfileDisplayName(profile) }
)}
onChange={(event) =>
{
@@ -1979,7 +2096,7 @@ export function SettingsPanel({
<select
aria-label={t(
'model.profile.authenticationAriaLabel',
{ name: profile.name }
{ name: modelProfileDisplayName(profile) }
)}
onChange={(event) => {
const authentication = event.target
@@ -2027,7 +2144,7 @@ export function SettingsPanel({
<select
aria-label={t(
'model.profile.imageQualityAriaLabel',
{ name: profile.name }
{ name: modelProfileDisplayName(profile) }
)}
onChange={(event) =>
updateModelProfile(profile.id, {
@@ -2530,7 +2647,11 @@ export function SettingsPanel({
</>
)}
{activeTab === 'skills' && <SkillsSettingsSection />}
{activeTab === 'mcp' && <McpSettingsSection />}
{activeTab === 'mcp' && (
<McpSettingsSection
magicNotesEnabled={magicNotesEnabled}
/>
)}
{activeTab === 'about' && <UpdateSettingsSection />}
</div>
</div>
+30
View File
@@ -1,8 +1,38 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
settingsWarningKey,
type SettingsWarning
} from '../../shared/settings-warning-contracts'
import {
settingsCategories,
type SettingsCategoryId
} from './settings-categories'
import { translateSettingsWarning } from './settings-warnings'
export function SettingsWarningList({
warnings
}: {
warnings?: readonly SettingsWarning[]
}): React.JSX.Element | null {
const { t } = useTranslation('warnings')
if (!warnings?.length) {
return null
}
return (
<>
{warnings.map((warning) => (
<p
className="settings-warning"
key={settingsWarningKey(warning)}
role="alert"
>
{translateSettingsWarning(warning, t)}
</p>
))}
</>
)
}
export function SettingsCategoryHeader({
actions,
+5 -1
View File
@@ -6,7 +6,10 @@ import type {
VersionCheckResult
} from '../../shared/application-settings-contracts'
import type { AppInfo } from '../../shared/contracts'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
@@ -137,6 +140,7 @@ export function UpdateSettingsSection(): React.JSX.Element {
error={error}
headingId="update-settings-heading"
/>
<SettingsWarningList warnings={settings?.warnings} />
<section
aria-label={t('updates.label')}
className="settings-section update-settings"
+6 -2
View File
@@ -9,6 +9,7 @@ import { magicNotes as englishMagicNotes } from './locales/en-US/magicNotes'
import { settings as englishSettings } from './locales/en-US/settings'
import { settingsSections as englishSettingsSections } from './locales/en-US/settingsSections'
import { workspace as englishWorkspace } from './locales/en-US/workspace'
import { warnings as englishWarnings } from './locales/en-US/warnings'
import { activity as chineseActivity } from './locales/zh-CN/activity'
import { app as chineseApp } from './locales/zh-CN/app'
import { heartbeat as chineseHeartbeat } from './locales/zh-CN/heartbeat'
@@ -18,6 +19,7 @@ import { magicNotes as chineseMagicNotes } from './locales/zh-CN/magicNotes'
import { settings as chineseSettings } from './locales/zh-CN/settings'
import { settingsSections as chineseSettingsSections } from './locales/zh-CN/settingsSections'
import { workspace as chineseWorkspace } from './locales/zh-CN/workspace'
import { warnings as chineseWarnings } from './locales/zh-CN/warnings'
export const supportedUiLocales = ['zh-CN', 'en-US'] as const
export type UiLocale = (typeof supportedUiLocales)[number]
@@ -32,7 +34,8 @@ export const i18nResources = {
magicNotes: chineseMagicNotes,
settings: chineseSettings,
settingsSections: chineseSettingsSections,
workspace: chineseWorkspace
workspace: chineseWorkspace,
warnings: chineseWarnings
},
'en-US': {
activity: englishActivity,
@@ -43,7 +46,8 @@ export const i18nResources = {
magicNotes: englishMagicNotes,
settings: englishSettings,
settingsSections: englishSettingsSections,
workspace: englishWorkspace
workspace: englishWorkspace,
warnings: englishWarnings
}
} as const
@@ -2,6 +2,9 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
brand: {
desktopWorkspace: 'Desktop workspace'
},
notifications: {
success: 'Success',
error: 'Error',
@@ -125,6 +128,7 @@ export const app = {
user: 'You',
assistantResult: 'Assistant result {{index}}',
welcome: {
eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?',
description:
'Ask a question, organize information, or connect OpenCode for file search and development tools.'
@@ -70,6 +70,7 @@ export const integrations = {
environmentSource: 'Provided by environment variables',
secretSaved: 'Secret saved with encryption',
secretMissing: 'Secret not configured',
secretUnreadable: 'Secret saved, but currently unreadable',
readOnly:
'This channel is managed by environment variables. Change the launch environment and restart the app.',
enable: 'Enable the {{channel}} channel',
@@ -136,6 +136,7 @@ export const settings = {
none: 'Not configured',
encrypted: 'Encrypted in secure system storage',
environment: 'Provided by an environment variable',
unreadable: 'Saved, but currently unreadable',
configuredPlaceholder: 'Configured; leave blank to keep it',
enterApiKey: 'Enter API Key',
noAuthentication: 'No authentication',
@@ -224,6 +225,7 @@ export const settings = {
}
},
documentParsing: {
loading: 'Loading…',
status: {
title: 'Runtime status',
description: 'Capabilities currently available on this device',
@@ -409,6 +411,7 @@ export const settings = {
}
},
profile: {
seededDefaultName: 'Default model',
generatedName: 'Model connection {{count}}',
title: 'LLM model connections',
description:
@@ -0,0 +1,45 @@
import type { TranslationShape } from '../../resource-types'
import type { warnings as chineseWarnings } from '../zh-CN/warnings'
export const warnings = {
'application-settings-recovered':
'The application settings file was corrupt. The original file was isolated, and safe defaults are now in use.',
'document-parsing-settings-recovered':
'The document parsing settings file was corrupt. The original file was isolated, and safe defaults are now in use.',
'capability-settings-recovered':
'The capability settings file was corrupt. The original file was isolated. Web search and computer control remain off until you review and enable them.',
'runtime-settings-recovered':
'The Runtime settings file was corrupt. The original file was isolated, and defaults are now in use.',
'runtime-model-credential-unreadable':
'The API Key for model connection “{{subject}}” cannot be read. Re-enter or clear this credential.',
'runtime-model-credential-binding-mismatch':
'The service address for model connection “{{subject}}” does not match its saved API Key. Re-enter or clear this credential.',
'runtime-embedding-credential-unreadable':
'The embedding model API Key cannot be read. Re-enter or clear this credential.',
'runtime-embedding-credential-binding-mismatch':
'The embedding endpoint does not match its saved API Key. Re-enter or clear this credential.',
'runtime-rerank-credential-unreadable':
'The rerank model API Key cannot be read. Re-enter or clear this credential.',
'runtime-rerank-credential-binding-mismatch':
'The rerank endpoint does not match its saved API Key. Re-enter or clear this credential.',
'channel-settings-recovered':
'The channel settings file was corrupt. The original file was isolated, and all channels were restored as disabled.',
'channel-weixin-credential-unreadable':
'The WeChat connection credential cannot be read, so the channel is temporarily disabled. Connect it again with a QR code.',
'channel-weixin-secure-storage-unavailable':
'Secure system storage is temporarily unavailable, so the WeChat channel is disabled. Retry after secure storage recovers.',
'channel-weixin-legacy-binding-invalid':
'The legacy WeChat connection could not be migrated safely. Connect it again with a QR code.',
'channel-wecom-environment-invalid':
'The WeCom environment configuration is invalid or incomplete, so the channel remains off.',
'channel-dingtalk-environment-invalid':
'The DingTalk environment configuration is invalid or incomplete, so the channel remains off.',
'channel-wecom-credential-unreadable':
'The WeCom Secret cannot be read. Re-enter or clear this credential.',
'channel-dingtalk-credential-unreadable':
'The DingTalk Client Secret cannot be read. Re-enter or clear this credential.',
'channel-runtime-selections-repaired':
'Repaired {{count}} unavailable backend selections for unattended channels. Review each channel project setting.'
} as const satisfies TranslationShape<typeof chineseWarnings>
export default warnings
@@ -1,4 +1,7 @@
export const app = {
brand: {
desktopWorkspace: '桌面工作区'
},
notifications: {
success: '成功',
error: '错误',
@@ -121,6 +124,7 @@ export const app = {
user: '用户',
assistantResult: '助手成果 {{index}}',
welcome: {
eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?',
description:
'快速提问、梳理信息,或连接 OpenCode 使用文件搜索和开发工具。'
@@ -62,6 +62,7 @@ export const integrations = {
environmentSource: '由环境变量提供',
secretSaved: 'Secret 已加密保存',
secretMissing: 'Secret 尚未配置',
secretUnreadable: 'Secret 已保存,但当前无法读取',
readOnly:
'当前通道由环境变量管理。请在启动环境中修改配置后重启应用。',
enable: '启用{{channel}}通道',
@@ -124,6 +124,7 @@ export const settings = {
none: '尚未配置',
encrypted: '已由系统安全存储加密',
environment: '由环境变量提供',
unreadable: '已保存,但当前无法读取',
configuredPlaceholder: '已配置,留空保持不变',
enterApiKey: '输入 API Key',
noAuthentication: '无需认证',
@@ -205,6 +206,7 @@ export const settings = {
}
},
documentParsing: {
loading: '正在加载…',
status: {
title: '运行状态',
description: '显示当前设备实际可用的解析能力',
@@ -372,6 +374,7 @@ export const settings = {
}
},
profile: {
seededDefaultName: '默认模型',
generatedName: '模型连接 {{count}}',
title: 'LLM 模型连接',
description:
@@ -0,0 +1,42 @@
export const warnings = {
'application-settings-recovered':
'应用设置文件已损坏。原文件已隔离,当前使用安全默认设置。',
'document-parsing-settings-recovered':
'文档解析设置文件已损坏。原文件已隔离,当前使用安全默认设置。',
'capability-settings-recovered':
'能力设置文件已损坏。原文件已隔离,网页搜索和电脑控制已保持关闭,请检查后手动启用。',
'runtime-settings-recovered':
'Runtime 设置文件已损坏。原文件已隔离,当前使用默认设置。',
'runtime-model-credential-unreadable':
'模型连接“{{subject}}”的 API Key 无法读取。请重新输入或清除该凭据。',
'runtime-model-credential-binding-mismatch':
'模型连接“{{subject}}”的服务地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
'runtime-embedding-credential-unreadable':
'向量模型 API Key 无法读取。请重新输入或清除该凭据。',
'runtime-embedding-credential-binding-mismatch':
'向量接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
'runtime-rerank-credential-unreadable':
'重排模型 API Key 无法读取。请重新输入或清除该凭据。',
'runtime-rerank-credential-binding-mismatch':
'重排接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
'channel-settings-recovered':
'通道设置文件已损坏。原文件已隔离,所有通道已恢复为关闭状态。',
'channel-weixin-credential-unreadable':
'微信绑定凭据无法读取,通道已临时停用。请重新扫码绑定。',
'channel-weixin-secure-storage-unavailable':
'系统安全存储暂不可用,微信绑定已临时停用。恢复安全存储后可重试。',
'channel-weixin-legacy-binding-invalid':
'旧版微信绑定无法安全迁移,请重新扫码绑定。',
'channel-wecom-environment-invalid':
'企业微信环境变量配置无效或不完整,通道保持关闭。',
'channel-dingtalk-environment-invalid':
'钉钉环境变量配置无效或不完整,通道保持关闭。',
'channel-wecom-credential-unreadable':
'企业微信 Secret 无法读取。请重新输入或清除该凭据。',
'channel-dingtalk-credential-unreadable':
'钉钉 Client Secret 无法读取。请重新输入或清除该凭据。',
'channel-runtime-selections-repaired':
'已修复 {{count}} 个无人值守通道的不可用后端选择。请检查各通道项目设置。'
} as const
export default warnings
+2
View File
@@ -8,6 +8,7 @@ import activity from './locales/zh-CN/activity'
import magicNotes from './locales/zh-CN/magicNotes'
import integrations from './locales/zh-CN/integrations'
import workspace from './locales/zh-CN/workspace'
import warnings from './locales/zh-CN/warnings'
declare module 'i18next' {
interface CustomTypeOptions {
@@ -22,6 +23,7 @@ declare module 'i18next' {
magicNotes: typeof magicNotes
integrations: typeof integrations
workspace: typeof workspace
warnings: typeof warnings
}
returnNull: false
}
+37
View File
@@ -0,0 +1,37 @@
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
export function getRuntimeSelectionForProvider(
provider: 'model' | 'opencode' | 'continue',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
export function getDefaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (
settings.provider === 'model' ||
settings.provider === 'opencode' ||
settings.provider === 'continue'
) {
return getRuntimeSelectionForProvider(settings.provider, settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? getRuntimeSelectionForProvider('opencode', settings)
: getRuntimeSelectionForProvider('model', settings)
}
+21
View File
@@ -0,0 +1,21 @@
import type { TFunction } from 'i18next'
import type { SettingsWarning } from '../../shared/settings-warning-contracts'
export function translateSettingsWarning(
warning: SettingsWarning,
t: TFunction<'warnings'>
): string {
switch (warning.code) {
case 'runtime-model-credential-unreadable':
case 'runtime-model-credential-binding-mismatch':
return t(warning.code, {
subject: warning.subject ?? ''
})
case 'channel-runtime-selections-repaired':
return t(warning.code, {
count: warning.count ?? 0
})
default:
return t(warning.code)
}
}