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:
@@ -10,6 +10,7 @@ import {
|
||||
} from '../shared/assistant-storage-contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||
import { hasPendingAssistantStorageUpgrade } from './assistant/assistant-storage-upgrade'
|
||||
import { loadMainWindow } from './window'
|
||||
|
||||
export async function prepareAssistantStorage(
|
||||
@@ -22,6 +23,7 @@ export async function prepareAssistantStorage(
|
||||
throw error
|
||||
})
|
||||
if (!file) return
|
||||
if (!hasPendingAssistantStorageUpgrade(databasePath)) return
|
||||
let progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
|
||||
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
import {
|
||||
hasPendingAssistantStorageUpgrade,
|
||||
upgradeAssistantStorage
|
||||
} from './assistant-storage-upgrade'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
const directories: string[] = []
|
||||
const databases: AssistantDatabase[] = []
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
for (const database of databases.splice(0)) database.close()
|
||||
await Promise.all(directories.splice(0).map(
|
||||
(path) => rm(path, { recursive: true, force: true })
|
||||
@@ -50,6 +54,40 @@ function subagent(requestId: string): SubagentEvent {
|
||||
}
|
||||
|
||||
describe('subagent progress storage', () => {
|
||||
it('does not migrate or vacuum ordinary chat free pages on repeated startup', async () => {
|
||||
const { path, database } = await fixture()
|
||||
const header = { id: randomUUID(), title: 'Chat restart', updatedAt: 1000 }
|
||||
const message = {
|
||||
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
||||
content: 'Long draft'.repeat(100_000), createdAt: 1000
|
||||
}
|
||||
database.saveLocalConversations([{ header, messages: [message] }])
|
||||
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }])
|
||||
const expected = database.getConversation(header.id)
|
||||
database.close()
|
||||
const sql = new DatabaseSync(path)
|
||||
try {
|
||||
const free = sql.prepare('PRAGMA freelist_count').get()!.freelist_count
|
||||
expect(free).toBeGreaterThan(0)
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
for (let restart = 0; restart < 2; restart++) {
|
||||
const progress = vi.fn()
|
||||
upgradeAssistantStorage(path, progress)
|
||||
expect(progress).not.toHaveBeenCalled()
|
||||
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
||||
const reopened = new AssistantDatabase(path)
|
||||
try {
|
||||
reopened.initialize(tmpdir())
|
||||
expect(reopened.getConversation(header.id)).toEqual(expected)
|
||||
} finally { reopened.close() }
|
||||
}
|
||||
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBe(free)
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
} finally { sql.close() }
|
||||
})
|
||||
|
||||
it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => {
|
||||
const event = subagent(randomUUID())
|
||||
const textId = randomUUID()
|
||||
|
||||
@@ -2,18 +2,64 @@ import { statSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import {
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION,
|
||||
AssistantDatabase
|
||||
} from './assistant-database'
|
||||
import {
|
||||
compactSubagentPayload,
|
||||
restoreSubagentPayload,
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export function hasPendingAssistantStorageUpgrade(
|
||||
databasePath: string
|
||||
): boolean {
|
||||
const database = new DatabaseSync(databasePath, {
|
||||
readOnly: true,
|
||||
timeout: 5_000
|
||||
})
|
||||
try {
|
||||
const row = database.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
if (row.user_version < ASSISTANT_DATABASE_SCHEMA_VERSION) {
|
||||
return true
|
||||
}
|
||||
const noteTable = database.prepare(
|
||||
`SELECT 1
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'magic_note_entries'
|
||||
LIMIT 1`
|
||||
).get()
|
||||
if (!noteTable) return false
|
||||
const noteEntry = database.prepare(
|
||||
'SELECT 1 FROM magic_note_entries LIMIT 1'
|
||||
).get()
|
||||
if (!noteEntry) return false
|
||||
const hasLegacyPayload = Boolean(database.prepare(
|
||||
`SELECT 1
|
||||
FROM magic_note_entries
|
||||
WHERE json_extract(content_json, '$.storage') IS NULL
|
||||
OR json_extract(content_json, '$.storage') <> 'file'
|
||||
LIMIT 1`
|
||||
).get())
|
||||
if (hasLegacyPayload) return true
|
||||
const free = database.prepare('PRAGMA freelist_count').get() as {
|
||||
freelist_count: number
|
||||
}
|
||||
return free.freelist_count > 0
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function upgradeAssistantStorage(
|
||||
databasePath: string,
|
||||
onProgress: (progress: AssistantStorageProgress) => void,
|
||||
isCancelled: () => boolean = () => false
|
||||
): void {
|
||||
if (!hasPendingAssistantStorageUpgrade(databasePath)) return
|
||||
upgradeSubagentStorage(databasePath, onProgress, isCancelled)
|
||||
new AssistantDatabase(databasePath).upgradeMagicNoteStorage(onProgress, isCancelled)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,75 @@ describe('RuntimeSettingsStore', () => {
|
||||
expect((await reloaded.update({ ...input, modelProfiles: [text], defaultImageModelProfileId: null })).defaultImageModelProfileId).toBeNull()
|
||||
})
|
||||
|
||||
it('copies an existing model credential to a new profile without exposing it', async () => {
|
||||
const { store } = await createStore()
|
||||
const initial = await store.getPublicSettings()
|
||||
const source = initial.modelProfiles[0]!
|
||||
const copiedId = '00000000-0000-4000-8000-000000000123'
|
||||
await store.update(
|
||||
runtimeSettingsInputSchema.parse({
|
||||
...settings(),
|
||||
modelAuthentication: 'api-key',
|
||||
modelProfiles: [
|
||||
{
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
baseUrl: source.baseUrl,
|
||||
modelName: source.modelName,
|
||||
protocol: source.protocol,
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: source.imageGenerationQuality,
|
||||
apiKey: { action: 'replace', value: 'source-secret' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: source.id
|
||||
})
|
||||
)
|
||||
const copied = await store.update(
|
||||
runtimeSettingsInputSchema.parse({
|
||||
...settings(),
|
||||
modelAuthentication: 'api-key',
|
||||
modelProfiles: [
|
||||
{
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
baseUrl: source.baseUrl,
|
||||
modelName: source.modelName,
|
||||
protocol: source.protocol,
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: source.imageGenerationQuality,
|
||||
apiKey: { action: 'keep' }
|
||||
},
|
||||
{
|
||||
id: copiedId,
|
||||
name: 'Copied model',
|
||||
baseUrl: source.baseUrl,
|
||||
modelName: source.modelName,
|
||||
protocol: source.protocol,
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: source.imageGenerationQuality,
|
||||
apiKey: {
|
||||
action: 'copy',
|
||||
sourceProfileId: source.id
|
||||
}
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: source.id
|
||||
})
|
||||
)
|
||||
expect(copied.modelProfiles.find((profile) => profile.id === copiedId))
|
||||
.toMatchObject({
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted'
|
||||
})
|
||||
expect(JSON.stringify(copied)).not.toContain('source-secret')
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelProfiles: expect.arrayContaining([
|
||||
expect.objectContaining({ id: copiedId, apiKey: 'source-secret' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed conversation image settings', () => {
|
||||
expect(runtimeSettingsInputSchema.safeParse(settings({ defaultImageModelProfileId: 'not-a-uuid' })).success).toBe(false)
|
||||
expect(runtimeSettingsInputSchema.safeParse({ ...settings(), modelProfiles: [{ id: '00000000-0000-4000-8000-000000000001', name: 'Image', baseUrl: 'https://example.com', modelName: 'image', protocol: 'openai-images-generations', authentication: 'none', imageGenerationQuality: 'auto', apiKey: { action: 'keep' }, allowConversationInvocation: 'true' }] }).success).toBe(false)
|
||||
|
||||
@@ -2230,7 +2230,23 @@ export class RuntimeSettingsStore {
|
||||
requestHeaders: profile.requestHeaders ?? {},
|
||||
requestBody: profile.requestBody ?? {}
|
||||
}
|
||||
if (
|
||||
if (profile.apiKey.action === 'copy') {
|
||||
const sourceProfileId = profile.apiKey.sourceProfileId
|
||||
const source = current.modelProfiles.find(
|
||||
(candidate) => candidate.id === sourceProfileId
|
||||
)
|
||||
if (!source) {
|
||||
throw new Error('复制凭据的源模型连接不存在,请重新选择连接')
|
||||
}
|
||||
const environmentKey = source.id === current.defaultModelProfileId
|
||||
? this.getEnvironmentApiKey()
|
||||
: undefined
|
||||
if (environmentKey) {
|
||||
nextProfile.credential = encryptSavedApiKey(this.cipher, environmentKey)
|
||||
} else if (source.credential) {
|
||||
nextProfile.credential = source.credential
|
||||
}
|
||||
} else if (
|
||||
profile.apiKey.action === 'keep' &&
|
||||
existing?.credential
|
||||
) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
+25
-16
@@ -617,11 +617,27 @@ export {
|
||||
export const defaultAnthropicMaximumOutputTokens = 32_000
|
||||
export const maximumModelOutputTokens = 10_000_000
|
||||
|
||||
export const modelConnectionUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '模型服务地址不能为空')
|
||||
.max(2_048, '模型服务地址不能超过 2048 个字符')
|
||||
.refine(
|
||||
(value) => {
|
||||
try {
|
||||
return ['http:', 'https:'].includes(new URL(value).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
'模型服务地址必须是完整的 HTTP 或 HTTPS 地址'
|
||||
)
|
||||
|
||||
const modelProfileInputSchema = z
|
||||
.object({
|
||||
id: modelProfileIdSchema,
|
||||
name: z.string().trim().min(1).max(64),
|
||||
baseUrl: z.string().url().max(2_048),
|
||||
baseUrl: modelConnectionUrlSchema,
|
||||
modelName: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -647,7 +663,13 @@ const modelProfileInputSchema = z
|
||||
imageGenerationQuality: imageGenerationQualitySchema,
|
||||
requestHeaders: modelRequestHeadersSchema.optional(),
|
||||
requestBody: modelRequestBodySchema.optional(),
|
||||
apiKey: modelApiKeyUpdateSchema
|
||||
apiKey: z.union([
|
||||
modelApiKeyUpdateSchema,
|
||||
z.object({
|
||||
action: z.literal('copy'),
|
||||
sourceProfileId: modelProfileIdSchema
|
||||
}).strict()
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -694,7 +716,7 @@ export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
|
||||
export const runtimeSettingsInputSchema = z
|
||||
.object({
|
||||
provider: runtimeProviderSchema,
|
||||
modelBaseUrl: z.string().url().max(2_048),
|
||||
modelBaseUrl: modelConnectionUrlSchema,
|
||||
modelName: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -766,19 +788,6 @@ export const runtimeSettingsInputSchema = z
|
||||
message: '无认证模型连接不得配置 API Key'
|
||||
})
|
||||
}
|
||||
const endpoints = settings.modelProfiles?.map((profile, index) => ({
|
||||
path: ['modelProfiles', index, 'baseUrl'] as (string | number)[],
|
||||
value: profile.baseUrl
|
||||
})) ?? [{ path: ['modelBaseUrl'], value: settings.modelBaseUrl }]
|
||||
for (const endpoint of endpoints) {
|
||||
if (!['http:', 'https:'].includes(new URL(endpoint.value).protocol)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: endpoint.path,
|
||||
message: '模型服务地址必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (settings.modelProfiles) {
|
||||
for (const [index, profile] of settings.modelProfiles.entries()) {
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user