feat: add managed local tool environments

GoodBuddy can now provide managed Node.js and on-demand Python to local Skills and stdio MCP servers, or validate an explicitly selected custom interpreter. The Capabilities and tools settings page exposes source selection, diagnostics, installation progress, cancellation, and removal while new processes receive immutable PATH snapshots.

Application settings migrate to v10. Local terminal and remote Host environments remain unchanged. Windows x64 native-source Node and Python paths are validated; OSS mirror publication, redistributed license resources, and the remaining native platform probes are recorded as release blockers.

Release note: 新增工具执行环境,可为本机 Skills 和 stdio MCP 使用 GoodBuddy 托管的 Node.js、按需安装的 Python,或经过验证的自定义解释器。
This commit is contained in:
mesalogo
2026-09-02 10:36:56 +08:00
parent 1530fa116c
commit be39665ca9
69 changed files with 6958 additions and 264 deletions
+24 -2
View File
@@ -16,7 +16,10 @@ import type {
ConversationQueueDispatch,
DesktopApi
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import type {
TerminalSnapshot
@@ -989,6 +992,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2148,6 +2152,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2243,6 +2248,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2253,6 +2259,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2353,6 +2360,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2363,6 +2371,7 @@ describe('App', () => {
checkUpdatesOnStartup: true,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -4316,6 +4325,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10523,6 +10533,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10533,6 +10544,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10579,6 +10591,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10632,6 +10645,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -10664,6 +10678,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10674,6 +10689,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10709,6 +10725,7 @@ describe('App', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10730,7 +10747,12 @@ describe('App', () => {
}
try {
const { container } = render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
fireEvent.click(
await screen.findByRole('button', {
name: /本地工作区/u
})
)
await screen.findByRole('heading', { name: '设置中心' })
fireEvent.click(
screen.getByRole('tab', { name: '平台功能' })
)
@@ -3,19 +3,24 @@ import { useTranslation } from 'react-i18next'
import { McpSettingsSection } from './McpSettingsSection'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { ToolEnvironmentSettingsSection } from './ToolEnvironmentSettingsSection'
import { PageTabs } from './WorkspacePrimitives'
import type { AppNotificationInput } from './notifications'
type CapabilitySettingsTab = 'skills' | 'mcp'
type CapabilitySettingsTab = 'skills' | 'mcp' | 'tools'
export function CapabilitiesAndToolsSettingsSection({
magicNotesEnabled
magicNotesEnabled,
onNotify
}: {
magicNotesEnabled: boolean
onNotify?: (notification: AppNotificationInput) => void
}): React.JSX.Element {
const { t } = useTranslation('settings')
const [activeTab, setActiveTab] =
useState<CapabilitySettingsTab>('skills')
const [mcpVisited, setMcpVisited] = useState(false)
const [toolsVisited, setToolsVisited] = useState(false)
return (
<>
@@ -32,6 +37,9 @@ export function CapabilitiesAndToolsSettingsSection({
if (tab === 'mcp') {
setMcpVisited(true)
}
if (tab === 'tools') {
setToolsVisited(true)
}
}}
tabs={[
{
@@ -41,6 +49,10 @@ export function CapabilitiesAndToolsSettingsSection({
{
id: 'mcp',
label: t('capabilities.tabs.mcp')
},
{
id: 'tools',
label: t('capabilities.tabs.tools')
}
]}
value={activeTab}
@@ -67,6 +79,17 @@ export function CapabilitiesAndToolsSettingsSection({
<McpSettingsSection magicNotesEnabled={magicNotesEnabled} />
)}
</section>
<section
aria-labelledby="capabilities-settings-tab-tools"
className="capabilities-settings__panel"
hidden={activeTab !== 'tools'}
id="capabilities-settings-panel-tools"
role="tabpanel"
>
{toolsVisited && (
<ToolEnvironmentSettingsSection onNotify={onNotify} />
)}
</section>
</>
)
}
@@ -10,7 +10,10 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import i18n from './i18n'
import type { DesktopApi } from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
MagicNoteDetail,
MagicNotesSnapshot,
@@ -189,6 +192,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -204,6 +208,7 @@ beforeEach(() => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1227,6 +1232,7 @@ describe('MagicNotesWorkspace', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1261,6 +1267,7 @@ describe('MagicNotesWorkspace', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1378,6 +1385,7 @@ describe('MagicNotesWorkspace', () => {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+110 -9
View File
@@ -23,7 +23,10 @@ import type {
CapabilityAssignments,
CapabilitySnapshot
} from '../../shared/capability-contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
AgentPackageInventory
} from '../../shared/agent-package-contracts'
@@ -53,7 +56,9 @@ import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
function openCapabilitySettingsTab(tab: 'Skills' | 'MCP'): void {
function openCapabilitySettingsTab(
tab: 'Skills' | 'MCP' | '工具执行环境'
): void {
fireEvent.click(
screen.getByRole('tab', { name: '能力与工具' })
)
@@ -530,6 +535,7 @@ let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -585,6 +591,35 @@ const updateApplicationSettings = vi.fn<
}
return { ...applicationSettings }
})
const localToolEnvironmentSnapshot = {
settings: defaultLocalToolEnvironmentSettings,
candidates: [
{
kind: 'node' as const,
executablePath: 'C:\\Tools\\node.exe',
version: '22.14.0',
architecture: 'x64'
}
],
diagnostics: {},
managedPython: {
version: '3.12.8',
installed: false
}
}
const getLocalToolEnvironmentSnapshot = vi.fn(
async () => localToolEnvironmentSnapshot
)
const updateLocalToolEnvironmentSettings = vi.fn<
NonNullable<DesktopApi['localToolEnvironment']>['updateSettings']
>(async (settings) => ({
...localToolEnvironmentSnapshot,
settings
}))
const diagnoseLocalToolEnvironment = vi.fn(
async () => localToolEnvironmentSnapshot
)
const onLocalToolEnvironmentProgress = vi.fn(() => () => {})
let shortcutSettingsSnapshot: GlobalShortcutSettingsSnapshot = {
settings: {
enabled: true,
@@ -790,6 +825,7 @@ describe('SettingsPanel runtime files', () => {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -951,6 +987,25 @@ describe('SettingsPanel runtime files', () => {
openReleasePage: vi.fn(),
onResult: vi.fn(() => () => {})
},
localToolEnvironment: {
getSnapshot: getLocalToolEnvironmentSnapshot,
updateSettings: updateLocalToolEnvironmentSettings,
refreshCandidates: vi.fn(
async () => localToolEnvironmentSnapshot
),
selectExecutable: vi.fn(
async () => localToolEnvironmentSnapshot
),
diagnose: diagnoseLocalToolEnvironment,
installPython: vi.fn(
async () => localToolEnvironmentSnapshot
),
cancelPython: vi.fn(async () => true),
removePython: vi.fn(
async () => localToolEnvironmentSnapshot
),
onProgress: onLocalToolEnvironmentProgress
},
shortcuts: {
getSettings: getShortcutSettings,
updateSettings: updateShortcutSettings
@@ -4844,7 +4899,7 @@ describe('SettingsPanel runtime files', () => {
within(capabilityTabs)
.getAllByRole('tab')
.map((tab) => tab.textContent)
).toEqual(['Skills', 'MCP'])
).toEqual(['Skills', 'MCP', '工具执行环境'])
expect(
within(capabilityTabs).getByRole('tab', { name: 'Skills' })
).toHaveAttribute('aria-selected', 'true')
@@ -4882,12 +4937,6 @@ describe('SettingsPanel runtime files', () => {
)
)
expect(
within(capabilityTabs).queryByRole('tab', {
name: '工具执行环境'
})
).not.toBeInTheDocument()
fireEvent.click(
within(capabilityTabs).getByRole('tab', { name: 'MCP' })
)
@@ -5202,6 +5251,58 @@ describe('SettingsPanel runtime files', () => {
).not.toBeInTheDocument()
})
it('lazy mounts and retains the real tool environment tab independently from MCP', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: '能力与工具' })
)
expect(getLocalToolEnvironmentSnapshot).not.toHaveBeenCalled()
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: '工具执行环境' })
)
expect(
await screen.findByRole('heading', { name: 'Node.js' })
).toBeVisible()
expect(getLocalToolEnvironmentSnapshot).toHaveBeenCalledOnce()
fireEvent.click(
screen.getByRole('button', { name: '诊断全部' })
)
await waitFor(() =>
expect(diagnoseLocalToolEnvironment).toHaveBeenCalledWith('all')
)
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: 'MCP' })
)
expect(
await screen.findByRole('tablist', { name: 'MCP 设置分类' })
).toBeVisible()
fireEvent.click(
within(
screen.getByRole('tablist', { name: '能力与工具设置' })
).getByRole('tab', { name: '工具执行环境' })
)
expect(
screen.getByRole('heading', { name: 'Node.js' })
).toBeVisible()
expect(getLocalToolEnvironmentSnapshot).toHaveBeenCalledOnce()
})
it('traps MCP editor focus and restores it after saving or backdrop dismissal', async () => {
render(
<SettingsPanel
+1
View File
@@ -3931,6 +3931,7 @@ export function SettingsPanel({
{activeTab === 'capabilities' && (
<CapabilitiesAndToolsSettingsSection
magicNotesEnabled={magicNotesEnabled}
onNotify={onNotify}
/>
)}
{activeTab === 'about' && (
@@ -0,0 +1,338 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopApi } from '../../shared/contracts'
import type {
LocalToolEnvironmentProgress,
LocalToolEnvironmentSnapshot
} from '../../shared/local-tool-environment-contracts'
import { changeUiLocale } from './i18n'
import { ToolEnvironmentSettingsSection } from './ToolEnvironmentSettingsSection'
const baseSnapshot: LocalToolEnvironmentSnapshot = {
settings: {
artifactDownloadSource: 'native',
node: {
source: 'custom',
executablePath: 'C:\\Broken\\node.exe'
},
python: { source: 'managed' }
},
candidates: [
{
kind: 'node',
executablePath: 'C:\\Tools\\node.exe',
version: '22.14.0',
architecture: 'x64'
},
{
kind: 'python',
executablePath: 'C:\\Python\\python.exe',
version: '3.12.8',
architecture: 'amd64'
}
],
diagnostics: {
node: {
available: false,
source: 'custom',
executablePath: 'C:\\Broken\\node.exe',
detail: 'The selected executable could not be started.'
},
npm: {
available: false,
source: 'custom',
detail: 'npm is unavailable.'
},
npx: {
available: false,
source: 'custom',
detail: 'npx is unavailable.'
},
python: {
available: true,
source: 'managed',
version: 'Python 3.12.8',
executablePath: 'C:\\GoodBuddy\\python.exe',
detail: 'Ready'
},
pip: {
available: true,
source: 'managed',
version: 'pip 24.3',
executablePath: 'C:\\GoodBuddy\\pip.exe',
detail: 'Ready'
}
},
managedPython: {
version: '3.12.8',
installed: true,
executablePath: 'C:\\GoodBuddy\\python.exe'
}
}
describe('ToolEnvironmentSettingsSection', () => {
let progressListener:
| ((progress: LocalToolEnvironmentProgress) => void)
| undefined
const unsubscribe = vi.fn()
const getSnapshot = vi.fn(async () => baseSnapshot)
const updateSettings = vi.fn<
NonNullable<DesktopApi['localToolEnvironment']>['updateSettings']
>(async (settings) => ({ ...baseSnapshot, settings }))
const refreshCandidates = vi.fn(async () => baseSnapshot)
const selectExecutable = vi.fn(async () => baseSnapshot)
const diagnose = vi.fn(async () => baseSnapshot)
const installPython = vi.fn(async () => baseSnapshot)
const cancelPython = vi.fn(async () => true)
const removePython = vi.fn(async () => ({
...baseSnapshot,
managedPython: {
version: '3.12.8',
installed: false
}
}))
beforeEach(async () => {
vi.clearAllMocks()
await changeUiLocale('zh-CN')
progressListener = undefined
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
localToolEnvironment: {
getSnapshot,
updateSettings,
refreshCandidates,
selectExecutable,
diagnose,
installPython,
cancelPython,
removePython,
onProgress: (
listener: (
progress: LocalToolEnvironmentProgress
) => void
) => {
progressListener = listener
return unsubscribe
}
}
} as unknown as DesktopApi
})
})
afterEach(cleanup)
it('loads and unsubscribes while preserving an invalid selected path', async () => {
const view = render(<ToolEnvironmentSettingsSection />)
expect(
(await screen.findAllByText('C:\\Broken\\node.exe'))[0]
).toBeVisible()
expect(screen.getAllByText('工具状态')).toHaveLength(2)
expect(screen.getAllByText('工具链/配套工具状态')).toHaveLength(2)
expect(screen.getAllByText('能力依赖状态')).toHaveLength(2)
expect(
screen.getByRole('button', {
name: /C:\\Tools\\node\.exe/u
})
).toHaveTextContent('22.14.0 · x64')
expect(
screen.getByText(
/仅用于本机项目的新建本地 Runtime 进程和 stdio MCP Server/u
)
).toBeVisible()
view.unmount()
expect(unsubscribe).toHaveBeenCalledOnce()
})
it('saves the full nested settings and rolls back a failed source change', async () => {
const onNotify = vi.fn()
updateSettings.mockRejectedValueOnce(new Error('source offline'))
render(
<ToolEnvironmentSettingsSection onNotify={onNotify} />
)
const sourceGroup = await screen.findByRole('group', {
name: '选择工具制品下载源'
})
const native = within(sourceGroup).getByRole('radio', {
name: /原生地址/u
})
const oss = within(sourceGroup).getByRole('radio', {
name: /OSS 镜像/u
})
expect(native).toBeChecked()
fireEvent.click(oss)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
artifactDownloadSource: 'oss',
node: {
source: 'custom',
executablePath: 'C:\\Broken\\node.exe'
},
python: { source: 'managed' }
})
)
await waitFor(() => expect(native).toBeChecked())
expect(screen.getByRole('alert')).toHaveTextContent('source offline')
expect(onNotify).not.toHaveBeenCalled()
})
it('uses real diagnose, file selection, managed Python, and progress APIs', async () => {
const onNotify = vi.fn()
render(
<ToolEnvironmentSettingsSection onNotify={onNotify} />
)
await screen.findByText('工具下载源')
fireEvent.click(
screen.getByRole('button', { name: '诊断全部' })
)
await waitFor(() => expect(diagnose).toHaveBeenCalledWith('all'))
const nodeCard = screen
.getByRole('heading', { name: 'Node.js' })
.closest('article')!
fireEvent.click(
within(nodeCard).getByRole('button', {
name: '选择可执行文件'
})
)
await waitFor(() =>
expect(selectExecutable).toHaveBeenCalledWith('node')
)
const pythonCard = (
await screen.findByRole('heading', { name: 'Python' })
).closest('article')!
fireEvent.click(
within(pythonCard).getByRole('button', {
name: '更新 Python'
})
)
await waitFor(() => expect(installPython).toHaveBeenCalledOnce())
act(() => {
progressListener?.({
snapshot: {
...baseSnapshot,
managedPython: {
...baseSnapshot.managedPython,
operation: {
source: 'native',
phase: 'downloading',
receivedBytes: 50,
totalBytes: 100
}
}
}
})
})
const progress = await screen.findByRole('progressbar', {
name: 'Python 安装或更新进度'
})
expect(progress).toHaveAttribute('aria-valuenow', '50')
expect(screen.getByText('下载源:原生地址')).toBeVisible()
fireEvent.click(
within(pythonCard).getByRole('button', { name: '取消' })
)
await waitFor(() => expect(cancelPython).toHaveBeenCalledOnce())
act(() => {
progressListener?.({ snapshot: baseSnapshot })
})
await waitFor(() =>
expect(
screen.queryByRole('progressbar', {
name: 'Python 安装或更新进度'
})
).not.toBeInTheDocument()
)
const updatedPythonCard = screen
.getByRole('heading', { name: 'Python' })
.closest('article')!
fireEvent.click(
within(updatedPythonCard).getByRole('button', {
name: '移除 Python'
})
)
expect(
within(updatedPythonCard).getByText(
/本机 Runtime 和 stdio MCP 将无法使用/u
)
).toBeVisible()
fireEvent.click(
within(updatedPythonCard).getByRole('button', {
name: '确认移除 Python'
})
)
await waitFor(() => expect(removePython).toHaveBeenCalledOnce())
})
it('does not report a cancelled Python install as a failure', async () => {
const onNotify = vi.fn()
let rejectInstall!: (reason: unknown) => void
installPython.mockImplementationOnce(
() =>
new Promise<LocalToolEnvironmentSnapshot>((_resolve, reject) => {
rejectInstall = reject
})
)
render(<ToolEnvironmentSettingsSection onNotify={onNotify} />)
const pythonCard = (
await screen.findByRole('heading', { name: 'Python' })
).closest('article')!
fireEvent.click(
within(pythonCard).getByRole('button', { name: '更新 Python' })
)
await waitFor(() => expect(installPython).toHaveBeenCalledOnce())
act(() => {
progressListener?.({
snapshot: {
...baseSnapshot,
managedPython: {
...baseSnapshot.managedPython,
operation: {
source: 'oss',
phase: 'validating'
}
}
}
})
})
expect(await screen.findByText('下载源:OSS 镜像')).toBeVisible()
fireEvent.click(
within(pythonCard).getByRole('button', { name: '取消' })
)
await waitFor(() => expect(cancelPython).toHaveBeenCalledOnce())
act(() => {
progressListener?.({ snapshot: baseSnapshot })
rejectInstall(
new Error(
"Error invoking remote method 'local-tool-environment:install-python': AbortError: Managed Python operation cancelled"
)
)
})
await waitFor(() =>
expect(
screen.queryByText('安装或更新 Python 失败')
).not.toBeInTheDocument()
)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(onNotify).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,711 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
LocalToolEnvironmentSettings,
LocalToolEnvironmentSnapshot,
LocalToolKind,
LocalToolRuntimeSelection
} from '../../shared/local-tool-environment-contracts'
import type { AppNotificationInput } from './notifications'
import { displayErrorMessage } from './error-message'
type ToolEnvironmentSettingsSectionProps = {
onNotify?: (notification: AppNotificationInput) => void
}
function formatBytes(value: number): string {
if (value < 1_024) {
return `${value} B`
}
if (value < 1_024 * 1_024) {
return `${(value / 1_024).toFixed(1)} KiB`
}
return `${(value / (1_024 * 1_024)).toFixed(1)} MiB`
}
function isManagedPythonCancellation(reason: unknown): boolean {
if (
reason instanceof DOMException &&
reason.name === 'AbortError'
) {
return true
}
if (typeof reason !== 'object' || reason === null) {
return false
}
const error = reason as { name?: unknown; message?: unknown }
return (
error.name === 'AbortError' ||
(typeof error.message === 'string' &&
error.message.includes('Managed Python operation cancelled'))
)
}
export function ToolEnvironmentSettingsSection({
onNotify = () => {}
}: ToolEnvironmentSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [snapshot, setSnapshot] =
useState<LocalToolEnvironmentSnapshot>()
const [loadError, setLoadError] = useState<string>()
const [loadAttempt, setLoadAttempt] = useState(0)
const [actionError, setActionError] = useState<string>()
const [busy, setBusy] = useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState(false)
const pythonCancellationRequested = useRef(false)
const toolApi = window.goodbuddy.localToolEnvironment
const errorMessage = useCallback(
(reason: unknown, fallback: string): string =>
displayErrorMessage(reason, fallback),
[]
)
useEffect(() => {
const api = toolApi
if (!api) {
return
}
let active = true
const unsubscribe = api.onProgress(({ snapshot: next }) => {
if (active) {
setSnapshot(next)
}
})
void api.getSnapshot().then(
(next) => {
if (active) {
setSnapshot(next)
setLoadError(undefined)
}
},
(reason: unknown) => {
if (active) {
setLoadError(
errorMessage(
reason,
t('toolEnvironment.errors.readFailed')
)
)
}
}
)
return () => {
active = false
unsubscribe()
}
}, [errorMessage, loadAttempt, t, toolApi])
const updateSettings = async (
nextSettings: LocalToolEnvironmentSettings,
successMessage: string
): Promise<void> => {
const api = window.goodbuddy.localToolEnvironment
if (!api || !snapshot) {
return
}
const confirmedSnapshot = snapshot
setSnapshot({ ...snapshot, settings: nextSettings })
setActionError(undefined)
setBusy('settings')
try {
const next = await api.updateSettings(nextSettings)
setSnapshot(next)
onNotify({
tone: 'success',
message: successMessage,
dedupeKey: 'local-tool-environment-settings'
})
} catch (reason) {
setSnapshot(confirmedSnapshot)
setActionError(
errorMessage(
reason,
t('toolEnvironment.errors.saveFailed')
)
)
} finally {
setBusy(undefined)
}
}
const updateRuntime = async (
kind: LocalToolKind,
selection: LocalToolRuntimeSelection
): Promise<void> => {
if (!snapshot) {
return
}
await updateSettings(
{ ...snapshot.settings, [kind]: selection },
t('toolEnvironment.notifications.runtimeChanged', {
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
})
)
}
const runSnapshotAction = async (
key: string,
action: () => Promise<LocalToolEnvironmentSnapshot>,
fallback: string,
successMessage?: string
): Promise<boolean> => {
setBusy(key)
setActionError(undefined)
if (key === 'install-python') {
pythonCancellationRequested.current = false
}
try {
const next = await action()
setSnapshot(next)
if (
successMessage &&
!(key === 'install-python' && pythonCancellationRequested.current)
) {
onNotify({
tone: 'success',
message: successMessage,
dedupeKey: `local-tool-environment-${key}`
})
}
return true
} catch (reason) {
if (
key === 'install-python' &&
isManagedPythonCancellation(reason)
) {
return false
}
setActionError(errorMessage(reason, fallback))
return false
} finally {
setBusy(undefined)
}
}
if ((!toolApi || loadError) && !snapshot) {
return (
<div className="tool-environment__load-error" role="alert">
<p>
{loadError ?? t('toolEnvironment.errors.unavailable')}
</p>
{toolApi && (
<button
className="secondary-button"
onClick={() => {
setLoadError(undefined)
setLoadAttempt((current) => current + 1)
}}
type="button"
>
{t('toolEnvironment.actions.retry')}
</button>
)}
</div>
)
}
if (!snapshot) {
return (
<p aria-busy="true" aria-live="polite" role="status">
{t('toolEnvironment.loading')}
</p>
)
}
const api = toolApi!
const operation = snapshot.managedPython.operation
const progress =
operation?.receivedBytes !== undefined &&
operation.totalBytes !== undefined
? Math.min(
100,
Math.round(
(operation.receivedBytes / operation.totalBytes) * 100
)
)
: undefined
const renderRuntimeCard = (kind: LocalToolKind): React.JSX.Element => {
const selection = snapshot.settings[kind]
const primaryDiagnostic =
kind === 'node'
? snapshot.diagnostics.node
: snapshot.diagnostics.python ??
snapshot.diagnostics.python3
const dependencyDiagnostics =
kind === 'node'
? [
['npm', snapshot.diagnostics.npm],
['npx', snapshot.diagnostics.npx]
] as const
: [['pip', snapshot.diagnostics.pip]] as const
const candidates = snapshot.candidates.filter(
(candidate) => candidate.kind === kind
)
const selectedCustomPath =
selection.source === 'custom'
? selection.executablePath
: undefined
const customPath = selectedCustomPath ?? candidates[0]?.executablePath
return (
<article className="tool-environment-card">
<div className="tool-environment-card__header">
<div>
<h3>{t(`toolEnvironment.runtimes.${kind}.title`)}</h3>
<p>{t(`toolEnvironment.runtimes.${kind}.description`)}</p>
</div>
<button
className="secondary-button"
disabled={busy !== undefined}
onClick={() =>
void runSnapshotAction(
`diagnose-${kind}`,
() => api.diagnose(kind),
t('toolEnvironment.errors.diagnoseFailed', {
runtime: t(
`toolEnvironment.runtimes.${kind}.title`
)
})
)
}
type="button"
>
{busy === `diagnose-${kind}`
? t('toolEnvironment.actions.diagnosing')
: t('toolEnvironment.actions.diagnose')}
</button>
</div>
<fieldset className="tool-environment-options">
<legend>
{t('toolEnvironment.runtimeSourceLegend', {
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
})}
</legend>
<label className="tool-environment-option">
<input
checked={selection.source === 'managed'}
disabled={busy !== undefined}
name={`${kind}-runtime-source`}
onChange={() =>
void updateRuntime(kind, { source: 'managed' })
}
type="radio"
/>
<span>
<strong>{t('toolEnvironment.sources.managed')}</strong>
<small>
{t(
`toolEnvironment.runtimes.${kind}.managedDescription`
)}
</small>
</span>
</label>
<label className="tool-environment-option">
<input
checked={selection.source === 'custom'}
disabled={busy !== undefined || !customPath}
name={`${kind}-runtime-source`}
onChange={() => {
if (customPath) {
void updateRuntime(kind, {
source: 'custom',
executablePath: customPath
})
}
}}
type="radio"
/>
<span>
<strong>{t('toolEnvironment.sources.custom')}</strong>
<small>{t('toolEnvironment.sources.customDescription')}</small>
</span>
</label>
</fieldset>
<div className="tool-environment-card__custom">
<div className="tool-environment-card__actions">
<button
className="secondary-button"
disabled={busy !== undefined}
onClick={() =>
void runSnapshotAction(
`refresh-${kind}`,
() => api.refreshCandidates(),
t('toolEnvironment.errors.refreshFailed')
)
}
type="button"
>
{t('toolEnvironment.actions.refreshCandidates')}
</button>
<button
className="secondary-button"
disabled={busy !== undefined}
onClick={() =>
void runSnapshotAction(
`select-${kind}`,
() => api.selectExecutable(kind),
t('toolEnvironment.errors.selectFailed', {
runtime: t(
`toolEnvironment.runtimes.${kind}.title`
)
}),
t('toolEnvironment.notifications.runtimeChanged', {
runtime: t(
`toolEnvironment.runtimes.${kind}.title`
)
})
)
}
type="button"
>
{t('toolEnvironment.actions.chooseFile')}
</button>
</div>
{selectedCustomPath && (
<p className="tool-environment-path">
<span>{t('toolEnvironment.selectedPath')}</span>
<code>{selectedCustomPath}</code>
</p>
)}
{candidates.length > 0 ? (
<ul
aria-label={t('toolEnvironment.candidateList', {
runtime: t(`toolEnvironment.runtimes.${kind}.title`)
})}
className="tool-environment-candidates"
>
{candidates.map((candidate) => {
return (
<li key={candidate.executablePath}>
<button
aria-pressed={
selectedCustomPath === candidate.executablePath
}
disabled={busy !== undefined}
onClick={() =>
void updateRuntime(kind, {
source: 'custom',
executablePath: candidate.executablePath
})
}
type="button"
>
<code>{candidate.executablePath}</code>
<small>
{candidate.version} · {candidate.architecture}
</small>
</button>
</li>
)
})}
</ul>
) : (
<p className="tool-environment-muted">
{t('toolEnvironment.noCandidates')}
</p>
)}
</div>
<div className="tool-environment-status">
<h4>{t('toolEnvironment.toolStatus')}</h4>
{primaryDiagnostic ? (
<dl>
<dt>{t('toolEnvironment.status')}</dt>
<dd>
{primaryDiagnostic.available
? t('toolEnvironment.available')
: t('toolEnvironment.unavailable')}
</dd>
{primaryDiagnostic.version && (
<>
<dt>{t('toolEnvironment.version')}</dt>
<dd>{primaryDiagnostic.version}</dd>
</>
)}
{primaryDiagnostic.executablePath && (
<>
<dt>{t('toolEnvironment.path')}</dt>
<dd>
<code>{primaryDiagnostic.executablePath}</code>
</dd>
</>
)}
<dt>{t('toolEnvironment.detail')}</dt>
<dd>{primaryDiagnostic.detail}</dd>
</dl>
) : (
<p>{t('toolEnvironment.notDiagnosed')}</p>
)}
</div>
<div className="tool-environment-status">
<h4>{t('toolEnvironment.companionStatus')}</h4>
{dependencyDiagnostics.some(([, value]) => value) ? (
<ul>
{dependencyDiagnostics.map(
([name, diagnostic]) =>
diagnostic && (
<li key={name}>
<strong>{name}</strong>
<span>
{diagnostic.available
? t('toolEnvironment.available')
: t('toolEnvironment.unavailable')}
{diagnostic.version
? ` · ${diagnostic.version}`
: ''}
</span>
<small>{diagnostic.detail}</small>
</li>
)
)}
</ul>
) : (
<p>{t('toolEnvironment.notDiagnosed')}</p>
)}
</div>
<div className="tool-environment-status">
<h4>{t('toolEnvironment.capabilityDependencyStatus')}</h4>
<p>{t('toolEnvironment.capabilityDependenciesUnverified')}</p>
</div>
{kind === 'python' && selection.source === 'managed' && (
<div className="tool-environment-python">
<p>
{snapshot.managedPython.installed
? t('toolEnvironment.python.installed', {
version: snapshot.managedPython.version
})
: t('toolEnvironment.python.notInstalled', {
version: snapshot.managedPython.version
})}
</p>
{snapshot.managedPython.executablePath && (
<code>{snapshot.managedPython.executablePath}</code>
)}
{operation && (
<div className="tool-environment-progress">
<div
aria-label={t(
'toolEnvironment.python.progressLabel'
)}
aria-valuemax={progress === undefined ? undefined : 100}
aria-valuemin={progress === undefined ? undefined : 0}
aria-valuenow={progress}
className="tool-environment-progress__track"
role="progressbar"
>
{progress !== undefined && (
<span style={{ width: `${progress}%` }} />
)}
</div>
<span>
{t(
`toolEnvironment.python.phases.${operation.phase}`
)}
{operation.receivedBytes !== undefined &&
` · ${formatBytes(operation.receivedBytes)}`}
{operation.totalBytes !== undefined &&
` / ${formatBytes(operation.totalBytes)}`}
</span>
<span>
{t('toolEnvironment.python.progressSource', {
source: t(
`toolEnvironment.downloadSource.options.${operation.source}.label`
)
})}
</span>
</div>
)}
<div className="tool-environment-card__actions">
{operation ? (
<button
className="secondary-button"
disabled={
busy !== undefined && busy !== 'install-python'
}
onClick={() => {
pythonCancellationRequested.current = true
setBusy('cancel-python')
setActionError(undefined)
void api.cancelPython().then(
() => setBusy(undefined),
(reason: unknown) => {
setBusy(undefined)
setActionError(
errorMessage(
reason,
t(
'toolEnvironment.errors.cancelPythonFailed'
)
)
)
}
)
}}
type="button"
>
{t('toolEnvironment.actions.cancel')}
</button>
) : (
<button
className="primary-button"
disabled={busy !== undefined}
onClick={() =>
void runSnapshotAction(
'install-python',
() => api.installPython(),
t(
'toolEnvironment.errors.installPythonFailed'
),
t(
'toolEnvironment.notifications.pythonInstalled'
)
)
}
type="button"
>
{snapshot.managedPython.installed
? t('toolEnvironment.actions.updatePython')
: t('toolEnvironment.actions.installPython')}
</button>
)}
{snapshot.managedPython.installed &&
!operation &&
(confirmingRemove ? (
<>
<span role="alert">
{t('toolEnvironment.python.removeConfirmation')}
</span>
<button
className="danger-solid"
disabled={busy !== undefined}
onClick={() =>
void runSnapshotAction(
'remove-python',
() => api.removePython(),
t(
'toolEnvironment.errors.removePythonFailed'
),
t(
'toolEnvironment.notifications.pythonRemoved'
)
).then((removed) => {
if (removed) {
setConfirmingRemove(false)
}
})
}
type="button"
>
{t('toolEnvironment.actions.confirmRemovePython')}
</button>
<button
className="secondary-button"
onClick={() => setConfirmingRemove(false)}
type="button"
>
{t('toolEnvironment.actions.cancel')}
</button>
</>
) : (
<button
className="danger-ghost"
onClick={() => setConfirmingRemove(true)}
type="button"
>
{t('toolEnvironment.actions.removePython')}
</button>
))}
</div>
</div>
)}
</article>
)
}
return (
<div className="tool-environment">
<p className="tool-environment__scope">
{t('toolEnvironment.scope')}
</p>
{actionError && (
<p className="tool-environment__error" role="alert">
{actionError}
</p>
)}
<section className="tool-environment-card">
<div className="tool-environment-card__header">
<div>
<h3>{t('toolEnvironment.downloadSource.title')}</h3>
<p>{t('toolEnvironment.downloadSource.description')}</p>
</div>
<button
className="secondary-button"
disabled={busy !== undefined}
onClick={() =>
void runSnapshotAction(
'diagnose-all',
() => api.diagnose('all'),
t('toolEnvironment.errors.diagnoseAllFailed')
)
}
type="button"
>
{busy === 'diagnose-all'
? t('toolEnvironment.actions.diagnosing')
: t('toolEnvironment.actions.diagnoseAll')}
</button>
</div>
<fieldset className="tool-environment-options">
<legend>{t('toolEnvironment.downloadSource.legend')}</legend>
{(['native', 'oss'] as const).map((source) => (
<label className="tool-environment-option" key={source}>
<input
checked={
snapshot.settings.artifactDownloadSource === source
}
disabled={busy !== undefined}
name="artifact-download-source"
onChange={() =>
void updateSettings(
{
...snapshot.settings,
artifactDownloadSource: source
},
t(
'toolEnvironment.notifications.downloadSourceChanged'
)
)
}
type="radio"
/>
<span>
<strong>
{t(
`toolEnvironment.downloadSource.options.${source}.label`
)}
</strong>
<small>
{t(
`toolEnvironment.downloadSource.options.${source}.description`
)}
</small>
</span>
</label>
))}
</fieldset>
<p className="tool-environment-muted">
{t('toolEnvironment.downloadSource.noFallback')}
</p>
</section>
{renderRuntimeCard('node')}
{renderRuntimeCard('python')}
</div>
)
}
@@ -6,7 +6,10 @@ import {
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import {
defaultLocalToolEnvironmentSettings,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { UpdateSettingsSection } from './UpdateSettingsSection'
@@ -21,6 +24,7 @@ describe('UpdateSettingsSection', () => {
checkUpdatesOnStartup: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -75,8 +75,8 @@ export const settings = {
},
capabilities: {
label: 'Capabilities and tools',
navigationDescription: 'Skills and MCP',
description: 'Manage Skills and MCP'
navigationDescription: 'Skills, MCP, and local tool environment',
description: 'Manage Skills, MCP, and the local tool environment'
},
about: {
label: 'About and updates',
@@ -89,7 +89,8 @@ export const settings = {
tabs: {
ariaLabel: 'Capabilities and tools settings',
skills: 'Skills',
mcp: 'MCP'
mcp: 'MCP',
tools: 'Tool execution environment'
}
},
actions: {
@@ -731,6 +731,116 @@ export const settingsSections = {
assignedTo: 'Assigned to',
deleteAria: 'Delete {{name}}'
},
toolEnvironment: {
loading: 'Loading the local tool environment…',
scope:
'Applies only to newly started local Runtime processes and stdio MCP servers for local projects. It does not change terminals or remote Hosts.',
runtimeSourceLegend: '{{runtime}} execution source',
sources: {
managed: 'GoodBuddy managed',
custom: 'Custom environment',
customDescription: 'Use a local executable that you select.'
},
runtimes: {
node: {
title: 'Node.js',
description:
'Used by local tools that require Node.js, npm, or npx.',
managedDescription:
'Use the Node.js managed with GoodBuddy. There are no separate install or remove actions.'
},
python: {
title: 'Python',
description: 'Used by local tools that require Python or pip.',
managedDescription:
'Use GoodBuddy-managed Python, which can be installed, updated, or removed as needed.'
}
},
downloadSource: {
title: 'Tool download source',
description:
'Controls only downloads for GoodBuddy-managed local tool artifacts. Models, app updates, terminals, and remote Hosts are unaffected.',
legend: 'Choose the tool artifact download source',
noFallback:
'GoodBuddy accesses only the selected source and does not automatically fall back to the other source.',
options: {
native: {
label: 'Native endpoint',
description:
'Use the default GoodBuddy tool artifact source.'
},
oss: {
label: 'OSS mirror',
description:
'Use the OSS tool artifact source for intranet or mirror networks.'
}
}
},
selectedPath: 'Current custom path',
candidateList: '{{runtime}} executable candidates',
noCandidates:
'No candidates were discovered. Refresh candidates or choose an executable with the system file picker.',
toolStatus: 'Tool status',
companionStatus: 'Toolchain/companion status',
capabilityDependencyStatus: 'Capability dependency status',
capabilityDependenciesUnverified:
'Capability dependencies are unverified because no capability evidence is available.',
status: 'Status',
available: 'Available',
unavailable: 'Unavailable',
version: 'Version',
path: 'Path',
detail: 'Details',
notDiagnosed: 'Not diagnosed',
python: {
installed: 'GoodBuddy-managed Python {{version}} is installed',
notInstalled:
'GoodBuddy-managed Python {{version}} is not installed',
progressLabel: 'Python installation or update progress',
progressSource: 'Download source: {{source}}',
removeConfirmation:
'After removal, local Runtimes and stdio MCP servers cannot use this managed Python until it is installed again. Custom Python is unaffected.',
phases: {
downloading: 'Downloading',
extracting: 'Extracting',
validating: 'Validating',
publishing: 'Installing'
}
},
actions: {
retry: 'Retry',
diagnose: 'Diagnose',
diagnosing: 'Diagnosing…',
diagnoseAll: 'Diagnose all',
refreshCandidates: 'Refresh candidates',
chooseFile: 'Choose executable',
installPython: 'Install Python',
updatePython: 'Update Python',
cancel: 'Cancel',
removePython: 'Remove Python',
confirmRemovePython: 'Confirm removal of Python'
},
errors: {
unavailable:
'The local tool environment service is unavailable in this version',
readFailed: 'Could not load the local tool environment',
saveFailed:
'Could not save the tool environment settings. The last confirmed settings were restored.',
refreshFailed: 'Could not refresh executable candidates',
selectFailed: 'Could not select the {{runtime}} executable',
diagnoseFailed: 'Could not diagnose {{runtime}}',
diagnoseAllFailed: 'Could not diagnose the local tool environment',
installPythonFailed: 'Could not install or update Python',
cancelPythonFailed: 'Could not cancel the Python operation',
removePythonFailed: 'Could not remove Python'
},
notifications: {
runtimeChanged: '{{runtime}} execution source updated',
downloadSourceChanged: 'Tool download source updated',
pythonInstalled: 'GoodBuddy-managed Python installed or updated',
pythonRemoved: 'GoodBuddy-managed Python removed'
}
},
feedback: {
entry: {
title: 'Help improve GoodBuddy',
@@ -64,8 +64,8 @@ export const settings = {
},
capabilities: {
label: '能力与工具',
navigationDescription: 'Skills 与 MCP',
description: '管理 Skills 和 MCP'
navigationDescription: 'Skills、MCP 与本机工具环境',
description: '管理 Skills、MCP 和本机工具执行环境'
},
about: {
label: '关于与更新',
@@ -77,7 +77,8 @@ export const settings = {
tabs: {
ariaLabel: '能力与工具设置',
skills: 'Skills',
mcp: 'MCP'
mcp: 'MCP',
tools: '工具执行环境'
}
},
actions: {
@@ -684,6 +684,110 @@ export const settingsSections = {
assignedTo: '分配给',
deleteAria: '删除 {{name}}'
},
toolEnvironment: {
loading: '正在读取工具执行环境…',
scope:
'工具执行环境仅用于本机项目的新建本地 Runtime 进程和 stdio MCP Server;不会改变终端环境,也不会应用到远程 Host。',
runtimeSourceLegend: '{{runtime}} 执行来源',
sources: {
managed: 'GoodBuddy 托管',
custom: '自定义环境',
customDescription: '使用你选择的本机可执行文件。'
},
runtimes: {
node: {
title: 'Node.js',
description: '用于需要 Node.js、npm 或 npx 的本机工具。',
managedDescription:
'使用 GoodBuddy 随应用托管的 Node.js;无需单独安装或移除。'
},
python: {
title: 'Python',
description: '用于需要 Python 或 pip 的本机工具。',
managedDescription:
'使用 GoodBuddy 托管的 Python;可按需安装、更新或移除。'
}
},
downloadSource: {
title: '工具下载源',
description:
'只控制 GoodBuddy 托管的工具执行环境制品下载,不影响模型、应用更新、终端或远程 Host。',
legend: '选择工具制品下载源',
noFallback:
'GoodBuddy 只访问所选来源;失败时不会自动回退到另一个来源。',
options: {
native: {
label: '原生地址',
description: '使用 GoodBuddy 默认工具制品来源。'
},
oss: {
label: 'OSS 镜像',
description: '使用面向内网或镜像网络的 OSS 工具制品来源。'
}
}
},
selectedPath: '当前自定义路径',
candidateList: '{{runtime}} 可执行文件候选',
noCandidates:
'未发现候选文件。可刷新候选,或从系统文件选择器选择可执行文件。',
toolStatus: '工具状态',
companionStatus: '工具链/配套工具状态',
capabilityDependencyStatus: '能力依赖状态',
capabilityDependenciesUnverified:
'尚未获得具体能力的依赖验证证据。',
status: '状态',
available: '可用',
unavailable: '不可用',
version: '版本',
path: '路径',
detail: '详情',
notDiagnosed: '尚未诊断',
python: {
installed: 'GoodBuddy 托管的 Python {{version}} 已安装',
notInstalled: 'GoodBuddy 托管的 Python {{version}} 尚未安装',
progressLabel: 'Python 安装或更新进度',
progressSource: '下载源:{{source}}',
removeConfirmation:
'移除后,本机 Runtime 和 stdio MCP 将无法使用此受管 Python,直到重新安装。自定义 Python 不受影响。',
phases: {
downloading: '正在下载',
extracting: '正在解压',
validating: '正在验证',
publishing: '正在安装'
}
},
actions: {
retry: '重试',
diagnose: '诊断',
diagnosing: '诊断中…',
diagnoseAll: '诊断全部',
refreshCandidates: '刷新候选',
chooseFile: '选择可执行文件',
installPython: '安装 Python',
updatePython: '更新 Python',
cancel: '取消',
removePython: '移除 Python',
confirmRemovePython: '确认移除 Python'
},
errors: {
unavailable: '当前版本未提供工具执行环境服务',
readFailed: '读取工具执行环境失败',
saveFailed: '保存工具执行环境设置失败;已恢复此前确认的设置',
refreshFailed: '刷新可执行文件候选失败',
selectFailed: '选择 {{runtime}} 可执行文件失败',
diagnoseFailed: '诊断 {{runtime}} 失败',
diagnoseAllFailed: '诊断工具执行环境失败',
installPythonFailed: '安装或更新 Python 失败',
cancelPythonFailed: '取消 Python 操作失败',
removePythonFailed: '移除 Python 失败'
},
notifications: {
runtimeChanged: '{{runtime}} 执行来源已更新',
downloadSourceChanged: '工具下载源已更新',
pythonInstalled: 'GoodBuddy 托管的 Python 已安装或更新',
pythonRemoved: 'GoodBuddy 托管的 Python 已移除'
}
},
feedback: {
entry: {
title: '帮助改进 GoodBuddy',
+252
View File
@@ -10453,6 +10453,258 @@ details.settings-section > :not(summary) + :not(summary) {
display: none;
}
.tool-environment {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.tool-environment__scope,
.tool-environment__error,
.tool-environment__load-error {
padding: var(--space-3);
margin: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.55;
}
.tool-environment__error,
.tool-environment__load-error {
border-color: var(--danger-border);
background: var(--danger-subtle);
color: var(--danger);
}
.tool-environment__load-error p {
margin: 0 0 var(--space-3);
}
.tool-environment-card {
display: grid;
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
gap: var(--space-4);
}
.tool-environment-card__header,
.tool-environment-card__actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.tool-environment-card__header {
justify-content: space-between;
}
.tool-environment-card__header h3,
.tool-environment-status h4 {
margin: 0;
color: var(--text-primary);
}
.tool-environment-card__header h3 {
font-size: var(--font-section-title);
}
.tool-environment-card__header p,
.tool-environment-muted,
.tool-environment-status p,
.tool-environment-python p {
margin: var(--space-1) 0 0;
color: var(--text-secondary);
font-size: var(--font-body);
line-height: 1.5;
}
.tool-environment-options {
display: grid;
padding: 0;
border: 0;
margin: 0;
gap: var(--space-2);
}
.tool-environment-options legend {
padding: 0;
margin-bottom: var(--space-2);
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 600;
}
.tool-environment-option {
display: flex;
min-width: 0;
align-items: flex-start;
padding: var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-subtle);
cursor: pointer;
gap: var(--space-2);
}
.tool-environment-option:has(input:checked) {
border-color: var(--accent);
background: var(--accent-subtle);
}
.tool-environment-option:has(input:disabled) {
cursor: not-allowed;
opacity: 0.65;
}
.tool-environment-option input {
margin-top: var(--space-1);
}
.tool-environment-option span,
.tool-environment-card__custom,
.tool-environment-status,
.tool-environment-python,
.tool-environment-progress {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.tool-environment-option strong,
.tool-environment-status strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.tool-environment-option small,
.tool-environment-status small,
.tool-environment-progress > span {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.tool-environment-path {
display: grid;
min-width: 0;
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
}
.tool-environment-path code,
.tool-environment-python > code,
.tool-environment-status code {
color: var(--text-primary);
font-family: var(--font-family-mono);
overflow-wrap: anywhere;
}
.tool-environment-candidates,
.tool-environment-status ul {
display: grid;
padding: 0;
margin: var(--space-2) 0 0;
list-style: none;
gap: var(--space-2);
}
.tool-environment-candidates button {
display: grid;
width: 100%;
min-width: 0;
padding: var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-primary);
text-align: left;
gap: var(--space-1);
}
.tool-environment-candidates button[aria-pressed='true'] {
border-color: var(--accent);
background: var(--accent-subtle);
}
.tool-environment-candidates code {
font-family: var(--font-family-mono);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.tool-environment-candidates small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.tool-environment-status {
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
.tool-environment-status h4 {
font-size: var(--font-body);
}
.tool-environment-status dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1) var(--space-3);
}
.tool-environment-status dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.tool-environment-status li {
display: grid;
grid-template-columns: minmax(60px, auto) minmax(0, 1fr);
align-items: baseline;
gap: var(--space-1) var(--space-2);
}
.tool-environment-status li small {
grid-column: 1 / -1;
}
.tool-environment-progress__track {
height: var(--space-2);
overflow: hidden;
border-radius: var(--radius-control);
background: var(--surface-muted);
}
.tool-environment-progress__track > span {
display: block;
height: 100%;
background: var(--accent-solid);
}
@media (max-width: 720px) {
.tool-environment-card__header {
align-items: stretch;
flex-direction: column;
}
.tool-environment-card__actions {
align-items: stretch;
flex-direction: column;
}
}
.mcp-tool-section {
display: flex;
flex-direction: column;