feat: improve model connections and settings workflow

Model settings can now duplicate saved connections with encrypted API-key copying, while settings navigation stays usable across long forms and preserves the separate Platform Features tabs. The assistant storage startup worker now gates historical migration and compaction on actual pending legacy data, so ordinary chat free pages do not reopen the migration screen. Desktop and Agent release metadata is prepared for 0.13.8 and 0.13.2.

Release note: 改进模型连接复制、长表单设置导航和历史数据迁移判断;普通聊天退出并重开后不再重复显示迁移页面。
This commit is contained in:
mesalogo
2026-09-21 09:19:30 +08:00
parent 6739e8e902
commit 8dce1d7007
33 changed files with 908 additions and 356 deletions
+8 -2
View File
@@ -1561,12 +1561,15 @@ describe("App", () => {
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
expect(screen.getAllByRole("alert").some((alert) =>
alert.textContent?.includes("当前设置有未保存更改")
)).toBe(true);
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project");
expect(screen.getByLabelText("默认工作区目录")).toHaveValue("C:\\Unsaved activity draft");
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
@@ -5444,6 +5447,7 @@ describe("App", () => {
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
fireEvent.change(await screen.findByLabelText("默认工作区目录"), {
target: { value: "C:\\Unsaved from App" },
});
@@ -5453,7 +5457,9 @@ describe("App", () => {
fireEvent.click(knowledgeNavigation);
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
expect(screen.getAllByRole("alert").some((alert) =>
alert.textContent?.includes("当前设置有未保存更改")
)).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
expect(
await screen.findByRole("heading", { name: "知识库" }),
@@ -29,38 +29,40 @@ export function CapabilitiesAndToolsSettingsSection({
<SettingsCategoryHeader
category="capabilities"
headingId="capabilities-settings-heading"
navigation={
<div className="capabilities-settings__tabs">
<PageTabs
ariaLabel={t('capabilities.tabs.ariaLabel')}
idPrefix="capabilities-settings"
onChange={(tab) => {
setActiveTab(tab)
if (tab === 'mcp') {
setMcpVisited(true)
}
if (tab === 'tools') {
setToolsVisited(true)
}
}}
tabs={[
{
id: 'skills',
label: t('capabilities.tabs.skills')
},
{
id: 'mcp',
label: t('capabilities.tabs.mcp')
},
{
id: 'tools',
label: t('capabilities.tabs.tools')
}
]}
value={activeTab}
variant="segmented"
/>
</div>
}
/>
<div className="capabilities-settings__tabs">
<PageTabs
ariaLabel={t('capabilities.tabs.ariaLabel')}
idPrefix="capabilities-settings"
onChange={(tab) => {
setActiveTab(tab)
if (tab === 'mcp') {
setMcpVisited(true)
}
if (tab === 'tools') {
setToolsVisited(true)
}
}}
tabs={[
{
id: 'skills',
label: t('capabilities.tabs.skills')
},
{
id: 'mcp',
label: t('capabilities.tabs.mcp')
},
{
id: 'tools',
label: t('capabilities.tabs.tools')
}
]}
value={activeTab}
variant="segmented"
/>
</div>
<section
aria-labelledby="capabilities-settings-tab-skills"
className="capabilities-settings__panel"
+12 -11
View File
@@ -1173,6 +1173,18 @@ export function ChannelSettingsSection({
category="channels"
error={error}
headingId="channel-settings-heading"
navigation={
<div className="channel-settings__tabs">
<PageTabs
ariaLabel={t('channels.sectionAriaLabel')}
idPrefix="channel-settings"
onChange={setActiveChannel}
tabs={channelTabs}
value={activeChannel}
variant="segmented"
/>
</div>
}
/>
<section
aria-label={t('channels.sectionAriaLabel')}
@@ -1180,17 +1192,6 @@ export function ChannelSettingsSection({
>
<SettingsWarningList warnings={snapshot.warnings} />
<div className="channel-settings__tabs">
<PageTabs
ariaLabel={t('channels.sectionAriaLabel')}
idPrefix="channel-settings"
onChange={setActiveChannel}
tabs={channelTabs}
value={activeChannel}
variant="segmented"
/>
</div>
<div
aria-labelledby={`channel-settings-tab-${activeChannel}`}
className="channel-settings__panel"
+11 -5
View File
@@ -5,16 +5,22 @@ import i18n from './i18n'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('moves the MCP introduction to title help and retains the custom server notice', async () => {
it('removes the redundant MCP title and places the custom action in content', async () => {
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
await waitFor(() => expect(getSnapshot).toHaveBeenCalledOnce())
expect(screen.getByRole('heading', { name: i18n.t('integrations:mcp.title') }).closest('header')?.querySelector('p')).toBeNull()
expect(
screen.queryByRole('heading', {
name: i18n.t('integrations:mcp.title')
})
).not.toBeInTheDocument()
expect(screen.queryByText(i18n.t('integrations:mcp.description'))).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.title') }))
expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('integrations:mcp.description'))
expect(screen.getByRole('tooltip').querySelector('button')).toBeNull()
fireEvent.click(screen.getByRole('tab', { name: i18n.t('integrations:mcp.tabs.custom') }))
expect(screen.getByText(i18n.t('integrations:mcp.customNotice'))).toBeVisible()
expect(
screen.getByRole('button', {
name: i18n.t('integrations:mcp.addServer')
})
).toBeVisible()
})
+19 -30
View File
@@ -31,10 +31,6 @@ import type {
WebSearchTestResult
} from '../../shared/capability-contracts'
import { trapTabFocus } from './dialog-focus'
import {
SettingsSectionHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { PageTabs } from './WorkspacePrimitives'
const configurableMcpTargets: RuntimeTarget[] = [
@@ -393,29 +389,6 @@ export function McpSettingsSection({
return (
<>
<SettingsSectionHeader
actions={
activeTab === 'custom' ? (
<button
className="secondary-button"
disabled={Boolean(busy) || Boolean(editor)}
onClick={(event) =>
openEditor({ ...emptyEditor }, event.currentTarget)
}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('mcp.addServer')}
</button>
) : undefined
}
help={t('mcp.description')}
error={!editor ? error : undefined}
headingLevel={3}
headingId="mcp-settings-heading"
title={t('mcp.title')}
/>
<SettingsWarningList warnings={snapshot?.warnings} />
<div className="mcp-settings__tabs">
<PageTabs
ariaLabel={t('mcp.tabs.ariaLabel')}
@@ -458,9 +431,25 @@ export function McpSettingsSection({
>
{activeTab === 'custom' && (
<p className="settings-notice">
{t('mcp.customNotice')}
</p>
<>
<div className="settings-content-actions">
<button
className="secondary-button"
disabled={Boolean(busy) || Boolean(editor)}
onClick={(event) =>
openEditor({ ...emptyEditor }, event.currentTarget)
}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('mcp.addServer')}
</button>
</div>
{error && !editor && (
<p className="settings-warning" role="alert">{error}</p>
)}
<p className="settings-notice">{t('mcp.customNotice')}</p>
</>
)}
{activeTab === 'computer' && (
<section
@@ -1,6 +1,7 @@
import {
Cpu,
Download,
FolderOpen,
RefreshCw,
RotateCcw,
Save,
@@ -32,9 +33,7 @@ import {
type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications'
import {
PageTabs
} from './WorkspacePrimitives'
import { PageTabs } from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
@@ -46,6 +45,8 @@ type PlatformFeaturesSettingsSectionProps = {
enabled: boolean
) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onWorkspacePathChange: (path: string) => void
workspacePath: string
onNotify?: (notification: AppNotificationInput) => void
onDirtyChange?: (dirty: boolean) => void
onShortcutSettingsChanged?: (
@@ -53,9 +54,7 @@ type PlatformFeaturesSettingsSectionProps = {
) => void
}
type PlatformFeaturesTab =
| 'general'
| 'remote-projects'
type PlatformFeaturesTab = 'general' | 'remote-projects'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
@@ -72,11 +71,14 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onRemoteProjectsEnabledChange,
onWorkspacePathChange,
workspacePath,
onNotify,
onDirtyChange,
onShortcutSettingsChanged
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const { t: settingsT } = useTranslation('settings')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>()
@@ -523,35 +525,84 @@ export function PlatformFeaturesSettingsSection({
category="platform-features"
error={error}
headingId="platform-features-heading"
navigation={
<div className="platform-features-tabs">
<PageTabs
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
idPrefix="platform-features"
onChange={setActiveSection}
tabs={[
{
id: 'general',
label: t('platformFeatures.tabs.general')
},
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
}
]}
value={activeSection}
variant="segmented"
/>
</div>
}
/>
<SettingsWarningList warnings={settings?.warnings} />
<div className="platform-features-tabs">
<PageTabs
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
idPrefix="platform-features"
onChange={setActiveSection}
tabs={[
{
id: 'general',
label: t('platformFeatures.tabs.general')
},
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
}
]}
value={activeSection}
variant="segmented"
/>
</div>
<section
aria-labelledby="platform-features-tab-general"
className="settings-section"
hidden={activeSection !== 'general'}
id="platform-features-panel-general"
role="tabpanel"
>
{activeSection === 'general' && (
<section
aria-labelledby="platform-features-tab-general"
className="settings-section"
id="platform-features-panel-general"
role="tabpanel"
>
<article className="capability-card">
<div className="capability-card__header">
<span className="inline-help-label">
<FolderOpen aria-hidden="true" size={16} />
<strong>{settingsT('runtime.workspace.title')}</strong>
<InlineHelp label={settingsT('runtime.workspace.title')}>
{settingsT('runtime.workspace.description')}
</InlineHelp>
</span>
</div>
<label className="field">
<span>{settingsT('runtime.workspace.directoryLabel')}</span>
<div className="workspace-picker">
<input
aria-label={settingsT('runtime.workspace.directoryLabel')}
onChange={(event) =>
onWorkspacePathChange(event.target.value)
}
value={workspacePath}
/>
<button
className="secondary-button"
onClick={() => {
void window.goodbuddy.settings
.selectWorkspace()
.then((selected) => {
if (selected) {
onWorkspacePathChange(selected)
}
})
.catch((reason: unknown) => {
setError(
displayErrorMessage(
reason,
settingsT('errors.selectWorkspace')
)
)
})
}}
type="button"
>
{settingsT('actions.select')}
</button>
</div>
</label>
</article>
<article className="capability-card">
<div className="capability-card__header">
<span className="inline-help-label">
@@ -751,15 +802,16 @@ export function PlatformFeaturesSettingsSection({
</p>
)
)}
</section>
</section>
)}
<section
aria-labelledby="platform-features-tab-remote-projects"
className="settings-section"
hidden={activeSection !== 'remote-projects'}
id="platform-features-panel-remote-projects"
role="tabpanel"
>
{activeSection === 'remote-projects' && (
<section
aria-labelledby="platform-features-tab-remote-projects"
className="settings-section"
id="platform-features-panel-remote-projects"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
@@ -1045,7 +1097,8 @@ export function PlatformFeaturesSettingsSection({
</p>
)}
</article>
</section>
</section>
)}
</>
)
+201 -56
View File
@@ -1416,10 +1416,16 @@ describe('SettingsPanel runtime files', () => {
name: 'Agent Runtime'
})
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: /Platform Features/i })
)
expect(screen.getByText('Default workspace')).toBeInTheDocument()
expect(
screen.getByLabelText('Default workspace folder')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: 'Agent Runtime' })
)
expect(
screen.getByRole('button', { name: 'Save settings' })
).toBeInTheDocument()
@@ -1510,8 +1516,6 @@ describe('SettingsPanel runtime files', () => {
<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(await screen.findByRole('tab', { name: '通用设置' })).toHaveAttribute('aria-selected', 'true')
expect(screen.queryByRole('tab', { name: '魔法笔记', hidden: true })).not.toBeInTheDocument()
expect(document.getElementById('platform-features-panel-magic-notes')).toBeNull()
expect(screen.queryByRole('switch', { name: '显示魔法笔记入口' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
@@ -1533,10 +1537,7 @@ describe('SettingsPanel runtime files', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(
await screen.findByRole('tab', { name: '通用设置' })
).toHaveAttribute('aria-selected', 'true')
const modelScope = screen.getByRole('radio', {
const modelScope = await screen.findByRole('radio', {
name: /ModelScope/u
})
const huggingFace = screen.getByRole('radio', {
@@ -1561,9 +1562,8 @@ describe('SettingsPanel runtime files', () => {
dedupeKey: 'model-download-source'
})
fireEvent.click(screen.getByRole('tab', { name: '远程项目(技术预览)' }))
expect(
screen.getByRole('tabpanel', { name: '远程项目(技术预览)' })
screen.getByRole('tab', { name: '远程项目(技术预览)' })
).toBeInTheDocument()
})
@@ -1815,7 +1815,10 @@ describe('SettingsPanel runtime files', () => {
/>
)
await screen.findByDisplayValue('C:\\Workspace')
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await screen.findByLabelText('默认工作区目录')
fireEvent.click(screen.getByRole('tab', { name: 'Agent Runtime' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
@@ -1913,7 +1916,6 @@ describe('SettingsPanel runtime files', () => {
/>
)
await screen.findByDisplayValue('C:\\Workspace')
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
expect(
await screen.findByDisplayValue('https://environment.example/v1')
@@ -2057,21 +2059,9 @@ describe('SettingsPanel runtime files', () => {
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const remoteProjectsTab = await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
expect(remoteProjectsTab).toHaveAttribute(
'aria-selected',
'false'
fireEvent.click(
screen.getByRole('tab', { name: '远程项目(技术预览)' })
)
expect(
screen.queryByRole('switch', {
name: '远程项目(技术预览)'
})
).not.toBeInTheDocument()
expect(getAgentPackageInventory).not.toHaveBeenCalled()
fireEvent.click(remoteProjectsTab)
expect(remoteProjectsTab).toHaveAttribute('aria-selected', 'true')
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
@@ -2188,9 +2178,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
screen.getByRole('tab', { name: '远程项目(技术预览)' })
)
expect(
await screen.findByText(
@@ -2209,13 +2197,11 @@ describe('SettingsPanel runtime files', () => {
getAgentPackageInventory.mockRejectedValueOnce(
new Error('inventory unavailable')
)
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
expect(
await screen.findByText('inventory unavailable')
).toBeInTheDocument()
await screen.findByText('inventory unavailable')
fireEvent.click(
screen.getByRole('button', { name: '刷新包清单' })
)
@@ -2253,9 +2239,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
screen.getByRole('tab', { name: '远程项目(技术预览)' })
)
expect(
@@ -2347,8 +2331,11 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '远程项目(技术预览)' })
)
expect(
await screen.findByRole('tab', {
await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
).toBeInTheDocument()
@@ -2398,13 +2385,12 @@ describe('SettingsPanel runtime files', () => {
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const workspace = await screen.findByLabelText('默认工作区目录')
fireEvent.change(workspace, {
target: { value: 'C:\\Unsaved workspace' }
})
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('tab', { name: '外观' })
@@ -2432,7 +2418,7 @@ describe('SettingsPanel runtime files', () => {
screen.getByRole('button', { name: '关闭设置' })
).toHaveFocus()
)
fireEvent.click(runtimeTab)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(await screen.findByLabelText('默认工作区目录')).toHaveValue(
'C:\\Unsaved workspace'
)
@@ -2464,6 +2450,8 @@ describe('SettingsPanel runtime files', () => {
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.change(await screen.findByLabelText('默认工作区目录'), {
target: { value: 'C:\\Pending external navigation' }
})
@@ -2809,6 +2797,12 @@ describe('SettingsPanel runtime files', () => {
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '尚未保存的角色模型' }
})
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://role-model.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'role-model' }
})
fireEvent.click(screen.getByRole('tab', { name: '角色与提示词' }))
const roleModel = await screen.findByLabelText('角色模型连接')
@@ -4005,6 +3999,12 @@ describe('SettingsPanel runtime files', () => {
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: 'OpenCode 独立模型' }
})
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://opencode-model.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'opencode-model' }
})
expect(
screen.getByRole('button', {
name: '编辑模型连接 OpenCode 独立模型'
@@ -4056,6 +4056,136 @@ describe('SettingsPanel runtime files', () => {
)
})
it('identifies the model connection when its endpoint URL is invalid', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
const endpoint = screen.getByLabelText('模型接口 URL')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
expect(
(await screen.findAllByText(
'模型连接“模型连接 2”的模型接口 URL 无效,请填写完整的 HTTP 或 HTTPS 地址(最多 2048 个字符)。'
)).length
).toBe(2)
expect(endpoint).toHaveAttribute('aria-invalid', 'true')
expect(endpoint).toHaveAccessibleDescription(
/模型连接“模型连接 2”的模型接口 URL 无效/u
)
expect(updateRuntime).not.toHaveBeenCalled()
})
it('keeps the model category title and type tabs sticky together', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="model"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
const content = await screen.findByRole('tabpanel')
const header = content.querySelector('.settings-category-header')
expect(header).toHaveClass('settings-category-header--sticky')
expect(
header?.querySelector('.model-type-navigation')
).toBeInTheDocument()
expect(
header?.querySelector('.segmented-control')
).toBeInTheDocument()
})
it('keeps other category titles and sub-tabs in the sticky header', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const platformHeader = screen
.getByRole('heading', { level: 2, name: '平台功能' })
.closest('.settings-category-header')
expect(platformHeader).toHaveClass('settings-category-header--sticky')
expect(
platformHeader?.querySelector('.platform-features-tabs')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
const capabilitiesHeader = screen
.getByRole('heading', { level: 2, name: '能力与工具' })
.closest('.settings-category-header')
expect(capabilitiesHeader).toHaveClass(
'settings-category-header--sticky'
)
expect(
capabilitiesHeader?.querySelector('.capabilities-settings__tabs')
).toBeInTheDocument()
})
it('copies a model connection and schedules its saved API key for copying', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
modelProfiles: [
{
...runtimeSettings.modelProfiles[0]!,
apiKeyConfigured: true,
credentialSource: 'encrypted'
}
]
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="model"
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
await screen.findByDisplayValue('默认模型')
fireEvent.click(
screen.getByRole('button', { name: '复制模型连接 默认模型' })
)
expect(screen.getByDisplayValue('默认模型(副本 1)')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
name: '默认模型(副本 1)',
apiKey: {
action: 'copy',
sourceProfileId: modelProfileId
}
})
])
})
)
)
})
it('orders model protocols by the preferred connection flow', async () => {
render(
<SettingsPanel
@@ -4188,19 +4318,25 @@ describe('SettingsPanel runtime files', () => {
await screen.findByDisplayValue('默认模型')
const headers = await screen.findByRole('textbox', { name: '自定义请求头' })
const body = screen.getByRole('textbox', { name: '自定义请求体' })
expect(headers.closest('details')).not.toHaveAttribute('open')
expect(body.closest('details')).not.toHaveAttribute('open')
expect(headers).toHaveValue('{}')
expect(body).toHaveValue('{}')
expect(headers).toHaveAttribute('maxlength')
expect(body).toHaveAttribute('maxlength')
const bodyHelp = screen.getByRole('button', { name: '自定义请求体' })
expect(bodyHelp.closest('label, summary')).toBeNull()
expect(screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)).not.toBeVisible()
expect(
document.getElementById(
`model-request-body-help-${modelProfileId}`
)
).toHaveAttribute('hidden')
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
fireEvent.click(bodyHelp)
expect(body).toHaveAccessibleDescription(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
expect(
screen.getByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
).toBeInTheDocument()
screen.getAllByText(/本地 OpenCode 与 DeepSeek Harness 不使用此项/)
).toHaveLength(1)
fireEvent.change(headers, {
target: {
@@ -4281,6 +4417,12 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://runtime-model.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'runtime-model' }
})
fireEvent.change(screen.getByLabelText('名称'), {
target: { value: '新的默认文本模型' }
})
@@ -4340,6 +4482,12 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
target: { value: 'https://runtime-default.example/v1' }
})
fireEvent.change(screen.getByLabelText('模型'), {
target: { value: 'runtime-default' }
})
fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
@@ -4609,7 +4757,7 @@ describe('SettingsPanel runtime files', () => {
/>
)
await screen.findByDisplayValue('C:\\Workspace')
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
expect(
@@ -5184,18 +5332,21 @@ describe('SettingsPanel runtime files', () => {
onSaved={vi.fn()}
/>
)
await screen.findByText('GoodBuddy 内置 OpenCode')
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const workspace = await screen.findByLabelText('默认工作区目录')
const dialog = screen.getByRole('dialog', { name: '设置中心' })
const closeButton = screen.getByRole('button', { name: '关闭设置' })
const focusable = dialog.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
const last = focusable[focusable.length - 1]!
closeButton.focus()
fireEvent.keyDown(closeButton, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(closeButton).toHaveFocus()
expect(dialog).toContainElement(
document.activeElement as HTMLElement
)
expect(document.activeElement).not.toBe(closeButton)
fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
expect(dialog).toContainElement(
document.activeElement as HTMLElement
)
fireEvent.change(workspace, { target: { value: 'C:\\Escape draft' } })
workspace.focus()
fireEvent.keyDown(workspace, { key: 'Escape' })
@@ -5274,12 +5425,6 @@ describe('SettingsPanel runtime files', () => {
within(capabilityTabs).getByRole('tab', { name: 'Skills' })
).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByText('文档写作')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Skills' }))
expect(
screen.getByText(
'支持直连模型、OpenCode、Continue 和 DeepSeek Harness'
)
).toBeInTheDocument()
expect(
screen.getByText(/新导入的 Skill 默认启用/)
).toHaveTextContent(
+129 -86
View File
@@ -1,5 +1,5 @@
import {
FolderOpen,
Copy,
KeyRound,
LockKeyhole,
Plus,
@@ -44,6 +44,7 @@ import {
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile,
MODEL_REQUEST_CUSTOMIZATION_LIMITS,
modelConnectionUrlSchema,
modelRequestBodySchema,
modelRequestHeadersSchema
} from '../../shared/contracts'
@@ -100,6 +101,7 @@ type ModelProfileDraft = Omit<
requestBodyText: string
apiKey: string
clearApiKey: boolean
copyApiKeyFromProfileId?: string
}
type EmbeddingConnectionDraft =
Omit<
@@ -668,6 +670,7 @@ export function SettingsPanel({
defaultRuntimeSettings.provider
)
const [modelProfiles, setModelProfiles] = useState<ModelProfileDraft[]>([])
const [modelValidationAttempted, setModelValidationAttempted] = useState(false)
const [selectedModelProfileId, setSelectedModelProfileId] =
useState('')
const [defaultModelProfileId, setDefaultModelProfileId] = useState('')
@@ -840,6 +843,7 @@ export function SettingsPanel({
value: RuntimeSettings,
preserveSelectedProfile = false
): void => {
setModelValidationAttempted(false)
hydrateRuntimeSettings(
value,
{
@@ -1398,6 +1402,23 @@ export function SettingsPanel({
profile
])
)
setModelValidationAttempted(true)
const invalidProfiles = modelProfiles.filter((profile) =>
!modelConnectionUrlSchema.safeParse(
profile.credentialSource === 'environment'
? configuredProfiles.get(profile.id)?.baseUrl ?? profile.baseUrl
: profile.baseUrl
).success
)
if (invalidProfiles.length) {
setActiveTab('model')
setModelType('llm')
setSelectedModelProfileId(invalidProfiles[0]!.id)
if (settingsBodyRef.current) settingsBodyRef.current.scrollTop = 0
throw new Error(invalidProfiles.map((profile) =>
t('errors.invalidModelUrl', { name: modelProfileDisplayName(profile) })
).join('\n'))
}
const profileInputs = modelProfiles.map((profile) => {
const configured = configuredProfiles.get(profile.id)
const environmentManaged =
@@ -1407,6 +1428,9 @@ export function SettingsPanel({
profile.requestHeadersText
)
if (!requestHeaders.success) {
setActiveTab('model')
setModelType('llm')
setSelectedModelProfileId(profile.id)
throw new Error(
t('errors.invalidModelRequestHeaders', {
name: modelProfileDisplayName(profile)
@@ -1417,6 +1441,9 @@ export function SettingsPanel({
profile.requestBodyText
)
if (!requestBody.success) {
setActiveTab('model')
setModelType('llm')
setSelectedModelProfileId(profile.id)
throw new Error(
t('errors.invalidModelRequestBody', {
name: modelProfileDisplayName(profile)
@@ -1449,6 +1476,11 @@ export function SettingsPanel({
action: 'replace',
value: profile.apiKey.trim()
} as const)
: profile.copyApiKeyFromProfileId
? ({
action: 'copy',
sourceProfileId: profile.copyApiKeyFromProfileId
} as const)
: ({ action: 'keep' } as const)
}
})
@@ -1519,7 +1551,9 @@ export function SettingsPanel({
}
: { action: 'keep' },
workspacePath,
apiKey: defaultProfileInput.apiKey,
apiKey: defaultProfileInput.apiKey.action === 'copy'
? { action: 'keep' }
: defaultProfileInput.apiKey,
modelProfiles: profileInputs,
defaultModelProfileId: defaultProfile.id,
defaultImageModelProfileId,
@@ -1806,6 +1840,7 @@ export function SettingsPanel({
}
const addModelProfile = (): void => {
if (modelProfiles.length >= 20) return
const id = crypto.randomUUID()
setModelProfiles((profiles) => [
...profiles,
@@ -1837,6 +1872,34 @@ export function SettingsPanel({
setSelectedModelProfileId(id)
}
const copyModelProfile = (source: ModelProfileDraft): void => {
if (modelProfiles.length >= 20) return
const id = crypto.randomUUID()
let name = ''
let index = 1
do {
const suffix = t('model.profile.copySuffix', { index })
name = `${modelProfileDisplayName(source).trim().slice(0, 64 - suffix.length)}${suffix}`
index += 1
} while (modelProfiles.some((profile) =>
profile.name.trim().toLowerCase() === name.toLowerCase()
))
const copyApiKeyFromProfileId = !source.clearApiKey && source.apiKeyConfigured
? source.copyApiKeyFromProfileId ?? source.id
: undefined
setModelProfiles((profiles) => [...profiles, {
...source,
id,
name,
apiKey: source.clearApiKey ? '' : source.apiKey,
clearApiKey: false,
copyApiKeyFromProfileId,
apiKeyConfigured: Boolean(copyApiKeyFromProfileId),
credentialSource: copyApiKeyFromProfileId ? 'encrypted' : 'none'
}])
setSelectedModelProfileId(id)
}
const removeModelProfile = (id: string): void => {
if (modelProfiles.length <= 1) {
setError(t('errors.retainModelConnection'))
@@ -2189,6 +2252,39 @@ export function SettingsPanel({
}
category={activeTab}
error={error}
navigation={
activeTab === 'runtime' ? (
<div className="model-type-navigation agent-runtime-navigation">
<SegmentedControl
ariaLabel="Agent Runtime"
onChange={setAgentRuntimeType}
options={[
{ label: 'OpenCode', value: 'opencode' },
{ label: 'Continue', value: 'continue' },
{
label: t('runtime.deepseekHarness.selectorLabel'),
value: 'deepseek-harness'
}
]}
value={agentRuntimeType}
/>
</div>
) : activeTab === 'model' ? (
<div className="model-type-navigation">
<SegmentedControl
ariaLabel={t('model.typeAriaLabel')}
onChange={setModelType}
options={[
{ label: t('model.types.llm.label'), value: 'llm' },
{ label: t('model.types.embedding.label'), value: 'embedding' },
{ label: t('model.types.rerank.label'), value: 'rerank' },
{ label: t('model.types.speech.label'), value: 'speech' }
]}
value={modelType}
/>
</div>
) : undefined
}
/>
)}
{activeTab === 'appearance' && (
@@ -2299,6 +2395,8 @@ export function SettingsPanel({
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
onWorkspacePathChange={setWorkspacePath}
workspacePath={workspacePath}
onNotify={onNotify}
onShortcutSettingsChanged={onShortcutSettingsChanged}
/>
@@ -2306,65 +2404,6 @@ export function SettingsPanel({
{activeTab === 'runtime' && (
<>
<SettingsWarningList warnings={settings?.warnings} />
<div className="settings-section">
<div className="settings-section__title">
<FolderOpen size={17} />
<span className="inline-help-label">
<strong>{t('runtime.workspace.title')}</strong>
<InlineHelp label={t('runtime.workspace.title')}>{t('runtime.workspace.description')}</InlineHelp>
</span>
</div>
<label className="field">
<span>{t('runtime.workspace.directoryLabel')}</span>
<div className="workspace-picker">
<input
aria-label={t('runtime.workspace.directoryLabel')}
onChange={(event) => setWorkspacePath(event.target.value)}
value={workspacePath}
/>
<button
className="secondary-button"
onClick={() => {
void window.goodbuddy.settings
.selectWorkspace()
.then((selected) => {
if (selected) {
setWorkspacePath(selected)
}
})
.catch((reason: unknown) => {
setError(
settingsErrorMessage(
reason,
t('errors.selectWorkspace')
)
)
})
}}
type="button"
>
{t('actions.select')}
</button>
</div>
</label>
</div>
<div className="model-type-navigation agent-runtime-navigation">
<SegmentedControl
ariaLabel="Agent Runtime"
onChange={setAgentRuntimeType}
options={[
{ label: 'OpenCode', value: 'opencode' },
{ label: 'Continue', value: 'continue' },
{
label: t('runtime.deepseekHarness.selectorLabel'),
value: 'deepseek-harness'
}
]}
value={agentRuntimeType}
/>
</div>
{agentRuntimeType === 'opencode' && (
<div className="settings-section">
<div className="settings-section__title">
@@ -2920,28 +2959,6 @@ export function SettingsPanel({
{activeTab === 'model' && (
<>
<div className="model-type-navigation">
<SegmentedControl
ariaLabel={t('model.typeAriaLabel')}
onChange={setModelType}
options={[
{ label: t('model.types.llm.label'), value: 'llm' },
{
label: t('model.types.embedding.label'),
value: 'embedding'
},
{
label: t('model.types.rerank.label'),
value: 'rerank'
},
{
label: t('model.types.speech.label'),
value: 'speech'
}
]}
value={modelType}
/>
</div>
{modelType === 'llm' && (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
@@ -2954,6 +2971,7 @@ export function SettingsPanel({
</div>
<button
className="secondary-button model-connection-add"
disabled={modelProfiles.length >= 20}
onClick={addModelProfile}
type="button"
>
@@ -3025,6 +3043,8 @@ export function SettingsPanel({
const profile = selectedModelProfile
const environmentManaged =
profile.credentialSource === 'environment'
const endpointInvalid = modelValidationAttempted &&
!modelConnectionUrlSchema.safeParse(profile.baseUrl).success
const requestHeadersValid =
parseRequestHeadersDraft(
profile.requestHeadersText
@@ -3062,6 +3082,19 @@ export function SettingsPanel({
{t('model.profile.imageGeneration')}
</span>
)}
<button
aria-label={t('model.profile.copyAriaLabel', {
name: modelProfileDisplayName(profile)
})}
className="secondary-button"
disabled={modelProfiles.length >= 20}
onClick={() => copyModelProfile(profile)}
title={t('model.profile.copyDescription')}
type="button"
>
<Copy aria-hidden="true" size={14} />
{t('model.profile.copy')}
</button>
<button
aria-label={t('model.profile.deleteAriaLabel', {
name: modelProfileDisplayName(profile)
@@ -3091,6 +3124,8 @@ export function SettingsPanel({
<input
disabled={environmentManaged}
inputMode="url"
aria-invalid={endpointInvalid || undefined}
aria-describedby={endpointInvalid ? `model-url-error-${profile.id}` : undefined}
onChange={(event) =>
updateModelProfile(profile.id, {
baseUrl: event.target.value
@@ -3099,6 +3134,11 @@ export function SettingsPanel({
placeholder="https://api.example.com/v1"
value={profile.baseUrl}
/>
{endpointInvalid && (
<small className="field-error" id={`model-url-error-${profile.id}`} role="alert">
{t('errors.invalidModelUrl', { name: modelProfileDisplayName(profile) })}
</small>
)}
</label>
<label className="field">
<span>{t('model.profile.model')}</span>
@@ -3261,9 +3301,9 @@ export function SettingsPanel({
<div className="credential-state">
<LockKeyhole size={15} />
<span>
{t(
`credentials.${profile.credentialSource}`
)}
{profile.copyApiKeyFromProfileId
? t('model.profile.credentialCopyPending')
: t(`credentials.${profile.credentialSource}`)}
</span>
{profile.credentialSource === 'encrypted' && (
<button
@@ -3448,6 +3488,8 @@ export function SettingsPanel({
</select>
</div>
)}
<details className="settings-section" open={!requestHeadersValid || !requestBodyValid}>
<summary>{t('model.profile.advanced')}</summary>
<div className="field">
<span className="inline-help-label">
<label htmlFor={`model-request-headers-${profile.id}`}>{t('model.profile.requestHeaders')}</label>
@@ -3513,6 +3555,7 @@ export function SettingsPanel({
<p className="settings-warning">
{t('model.profile.requestCustomizationWarning')}
</p>
</details>
<small className="model-connection-detail__compatibility">
{t('model.profile.compatibilitySummary', {
directCapability:
+14 -1
View File
@@ -39,11 +39,15 @@ export function SettingsCategoryHeader({
actions,
category,
error,
navigation,
sticky = true,
headingId = `settings-category-${category}`
}: {
actions?: ReactNode
category: SettingsCategoryId
error?: string
navigation?: ReactNode
sticky?: boolean
headingId?: string
}): React.JSX.Element {
const definition = settingsCategories[category]
@@ -52,6 +56,8 @@ export function SettingsCategoryHeader({
actions={actions}
description={definition.description}
error={error}
navigation={navigation}
sticky={sticky}
headingId={headingId}
title={definition.label}
/>
@@ -62,6 +68,8 @@ export function SettingsSectionHeader({
actions,
description,
error,
navigation,
sticky = false,
headingLevel = 2,
help,
headingId,
@@ -70,6 +78,8 @@ export function SettingsSectionHeader({
actions?: ReactNode
description?: string
error?: string
navigation?: ReactNode
sticky?: boolean
headingLevel?: 2 | 3
help?: ReactNode
headingId: string
@@ -77,7 +87,7 @@ export function SettingsSectionHeader({
}): React.JSX.Element {
const Heading = headingLevel === 3 ? 'h3' : 'h2'
return (
<header className="settings-category-header">
<header className={`settings-category-header${sticky ? ' settings-category-header--sticky' : ''}`}>
<div className="settings-category-header__content">
<Heading id={headingId}>{help != null ? <span className="inline-help-label">{title}<InlineHelp label={title}>{help}</InlineHelp></span> : title}</Heading>
{description && <p>{description}</p>}
@@ -92,6 +102,9 @@ export function SettingsSectionHeader({
{error}
</p>
)}
{navigation && (
<div className="settings-category-header__navigation">{navigation}</div>
)}
</header>
)
}
@@ -1,20 +1,27 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import i18n from './i18n'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('moves runtime support to title help while keeping assignment notice and skill descriptions visible', async () => {
it('keeps skill descriptions visible and places import actions in the content area', async () => {
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot: vi.fn().mockResolvedValue({ skills: [
{ id: 'skill', name: 'Skill', source: 'builtin', enabled: true, description: 'Actual skill description', tags: [], assignments: [] }
] }) } })
render(<SkillsSettingsSection />)
expect(await screen.findByText('Actual skill description')).toBeVisible()
expect(screen.getByRole('heading', { name: 'Skills' }).closest('header')?.querySelector('p')).toBeNull()
expect(screen.queryByRole('heading', { name: 'Skills' })).not.toBeInTheDocument()
expect(screen.getByText(i18n.t('settingsSections:skills.notice'))).toBeVisible()
expect(screen.queryByText(i18n.t('settingsSections:skills.description'))).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Skills' }))
expect(screen.getByRole('tooltip')).toHaveTextContent(i18n.t('settingsSections:skills.description'))
expect(screen.getByRole('tooltip').querySelector('button')).toBeNull()
expect(
screen.getByRole('button', {
name: i18n.t('settingsSections:skills.actions.importDirectory')
})
).toBeVisible()
expect(
screen.getByRole('button', {
name: i18n.t('settingsSections:skills.actions.importZip')
})
).toBeVisible()
})
+32 -46
View File
@@ -6,7 +6,6 @@ import type {
CapabilitySnapshot,
RuntimeTarget
} from '../../shared/capability-contracts'
import { SettingsSectionHeader } from './SettingsPrimitives'
export function SkillsSettingsSection(): React.JSX.Element {
const { t } = useTranslation('settingsSections')
@@ -61,52 +60,40 @@ export function SkillsSettingsSection(): React.JSX.Element {
}
return (
<>
<SettingsSectionHeader
actions={
<>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('directory')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
{t('skills.actions.importDirectory')}
</button>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('zip')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
{t('skills.actions.importZip')}
</button>
</>
}
help={t('skills.description')}
error={error}
headingLevel={3}
headingId="skills-settings-heading"
title={t('skills.title')}
/>
<section
<section
aria-label={t('skills.listLabel')}
className="settings-section"
>
<p className="settings-notice">
{t('skills.notice')}
</p>
<div className="settings-content-actions">
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('directory')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
{t('skills.actions.importDirectory')}
</button>
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void run('import', () =>
window.goodbuddy.capabilities.importSkill('zip')
)
}
type="button"
>
<Download aria-hidden="true" size={14} />
{t('skills.actions.importZip')}
</button>
</div>
{error && <p className="settings-warning" role="alert">{error}</p>}
<p className="settings-notice">{t('skills.notice')}</p>
{!snapshot && !error && (
<p className="settings-empty">{t('skills.loading')}</p>
)}
@@ -205,7 +192,6 @@ export function SkillsSettingsSection(): React.JSX.Element {
</article>
))}
</div>
</section>
</>
</section>
)
}
@@ -135,6 +135,7 @@ export const settings = {
discardAndNavigate: 'Discard changes and continue'
},
errors: {
invalidModelUrl: 'Model connection “{{name}}” has an invalid endpoint URL. Enter a complete HTTP or HTTPS URL (up to 2048 characters).',
readSettings: 'Could not load settings',
detectRuntimes: 'Could not detect Agent Runtimes',
readEmbeddingStatus: 'Could not load embedding model status',
@@ -718,6 +719,12 @@ export const settings = {
},
profile: {
seededDefaultName: 'Default model',
copy: 'Copy connection',
copyAriaLabel: 'Copy model connection {{name}}',
copySuffix: ' (copy {{index}})',
copyDescription: 'Copy the current configuration and API key; changes take effect when saved',
credentialCopyPending: 'The source API key will be copied on save',
advanced: 'Advanced settings',
generatedName: 'Model connection {{count}}',
title: 'LLM model connections',
description:
@@ -121,6 +121,7 @@ export const settings = {
discardAndNavigate: '放弃更改并切换'
},
errors: {
invalidModelUrl: '模型连接“{{name}}”的模型接口 URL 无效,请填写完整的 HTTP 或 HTTPS 地址(最多 2048 个字符)。',
readSettings: '读取设置失败',
detectRuntimes: 'Runtime 自动检测失败',
readEmbeddingStatus: '读取向量模型状态失败',
@@ -663,6 +664,12 @@ export const settings = {
},
profile: {
seededDefaultName: '默认模型',
copy: '复制连接',
copyAriaLabel: '复制模型连接 {{name}}',
copySuffix: '(副本 {{index}})',
copyDescription: '复制当前配置和 API Key,保存后生效',
credentialCopyPending: '保存时复制源连接的 API Key',
advanced: '高级设置',
generatedName: '模型连接 {{count}}',
title: 'LLM 模型连接',
description:
+29
View File
@@ -8403,6 +8403,21 @@ button > svg {
min-width: 0;
}
.settings-category-header--sticky {
position: sticky;
top: 0;
z-index: 1;
flex: 0 0 auto;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-raised);
}
.settings-category-header__navigation {
min-width: 0;
grid-column: 1 / -1;
}
.settings-category-header__content :is(h2, h3) {
margin: 0;
color: var(--text-primary);
@@ -9220,6 +9235,9 @@ button > svg {
.model-type-navigation {
display: flex;
min-width: 0;
flex: 0 0 auto;
overflow-x: auto;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
@@ -10274,6 +10292,17 @@ details.settings-section > :not(summary) + :not(summary) {
flex-wrap: wrap;
gap: var(--space-2);
}
.settings-content-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
}
.settings-content-actions button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.feedback-entry-card__actions {
display: flex;