feat: add project default runtime

This commit is contained in:
lofyer
2026-08-11 17:52:55 +08:00
parent e0e7bc573c
commit 71a8662690
6 changed files with 210 additions and 17 deletions
@@ -393,11 +393,17 @@ describe('AssistantDatabase', () => {
name: '产品发布 2',
description: '更新后的项目',
rootPath: 'C:\\Release',
defaultWorkMode: 'execute'
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'continue'
}
})
expect(updated).toMatchObject({
name: '产品发布 2',
defaultWorkMode: 'execute'
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'continue'
}
})
database.setProjectArchived(project.id, true)
expect(database.listProjects()).toHaveLength(1)
+55 -1
View File
@@ -3357,9 +3357,16 @@ describe('App', () => {
expect(within(dialog).getByLabelText('根目录')).toHaveValue(
project.rootPath
)
expect(
within(dialog).getByLabelText('新对话默认 Runtime')
).toHaveValue('model')
fireEvent.change(within(dialog).getByLabelText('说明'), {
target: { value: '更新后的说明' }
})
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{ target: { value: 'continue' } }
)
fireEvent.click(
within(dialog).getByRole('button', { name: '保存项目' })
)
@@ -3368,7 +3375,11 @@ describe('App', () => {
project.id,
expect.objectContaining({
description: '更新后的说明',
rootPath: project.rootPath
rootPath: project.rootPath,
runtimeSelection: {
provider: 'continue',
profileId: modelProfileId
}
})
)
)
@@ -3403,6 +3414,49 @@ describe('App', () => {
)
})
it('uses the project default Runtime for new conversations', async () => {
vi.mocked(api.projects.list).mockResolvedValueOnce([
{
...project,
runtimeSelection: {
provider: 'opencode',
profileId: modelProfileId
}
}
])
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000220',
projectId,
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
},
title: '已有对话',
updatedAt: 1,
messages: []
}
])
render(<App />)
await screen.findAllByText('已有对话')
fireEvent.click(
screen.getByRole('button', { name: //u })
)
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode',
profileId: modelProfileId
})
)
expect(
screen.getByRole('button', {
name: /OpenCode · /u
})
).toBeInTheDocument()
})
it('uses a message icon for conversation navigation', async () => {
render(<App />)
+31 -8
View File
@@ -818,6 +818,16 @@ function getDefaultRuntimeSelection(
}
}
function getProjectDefaultRuntimeSelection(
project: AssistantProject | undefined,
settings: RuntimeSettings
): AgentRuntimeSelection {
const selection = project?.runtimeSelection
return !selection || selection.provider === 'auto'
? getDefaultRuntimeSelection(settings)
: repairAgentRuntimeSelection(selection, settings)
}
function getRuntimeSelectionLabel(
selection: AgentRuntimeSelection | undefined,
settings: RuntimeSettings | undefined,
@@ -1798,11 +1808,17 @@ function App(): React.JSX.Element {
if (!runtimeSettings || !conversationStoreReady) {
return
}
const defaultSelection = getDefaultRuntimeSelection(runtimeSettings)
const timeout = setTimeout(() => {
setConversations((current) => {
let changed = false
const next = current.map((conversation) => {
const project = projects.find(
(candidate) => candidate.id === conversation.projectId
)
const defaultSelection = getProjectDefaultRuntimeSelection(
project,
runtimeSettings
)
const selection =
!conversation.runtimeSelection ||
conversation.runtimeSelection.provider === 'auto'
@@ -1828,7 +1844,7 @@ function App(): React.JSX.Element {
})
}, 0)
return () => clearTimeout(timeout)
}, [conversationStoreReady, runtimeSettings])
}, [conversationStoreReady, projects, runtimeSettings])
useEffect(() => {
const selection = activeRuntimeSelectionRef.current
@@ -1914,7 +1930,7 @@ function App(): React.JSX.Element {
const conversation = createConversation(
projectId,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined,
tRef.current('conversation.greeting')
)
@@ -3520,7 +3536,7 @@ function App(): React.JSX.Element {
const created = createConversation(
projectId,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined,
t('conversation.greeting')
)
@@ -3540,7 +3556,7 @@ function App(): React.JSX.Element {
const conversation = createConversation(
project.id,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined,
t('conversation.greeting')
)
@@ -3635,7 +3651,7 @@ function App(): React.JSX.Element {
const created = createConversation(
next.id,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
? getProjectDefaultRuntimeSelection(next, runtimeSettings)
: undefined,
t('conversation.greeting')
)
@@ -3767,7 +3783,10 @@ function App(): React.JSX.Element {
const replacement = createConversation(
activeProjectId || undefined,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
? getProjectDefaultRuntimeSelection(
activeProject,
runtimeSettings
)
: undefined,
t('conversation.greeting')
)
@@ -4531,7 +4550,10 @@ function App(): React.JSX.Element {
const conversation = createConversation(
activeProjectId || undefined,
runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
? getProjectDefaultRuntimeSelection(
activeProject,
runtimeSettings
)
: undefined,
t('conversation.greeting')
)
@@ -4602,6 +4624,7 @@ function App(): React.JSX.Element {
<ProjectSwitcher
activeProjectId={activeProjectId}
runtimeSettings={runtimeSettings}
onArchive={archiveProject}
onCreate={createProject}
onDelete={deleteProject}
+102 -4
View File
@@ -17,11 +17,14 @@ import {
interactiveWorkModes,
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import { trapTabFocus } from './dialog-focus'
type ProjectSwitcherProps = {
projects: AssistantProject[]
activeProjectId: string
runtimeSettings?: RuntimeSettings
onArchive: (projectId: string) => Promise<void>
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
onDelete: (projectId: string, confirmation: string) => Promise<void>
@@ -33,9 +36,47 @@ type ProjectSwitcherProps = {
) => Promise<AssistantProject>
}
function runtimeSelectionForProvider(
provider: 'model' | 'opencode' | 'continue',
settings: RuntimeSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: settings.continueModelSource
return {
provider,
...(source.kind === 'profile' ? { profileId: source.profileId } : {})
}
}
function defaultRuntimeSelection(
settings: RuntimeSettings
): AgentRuntimeSelection {
if (settings.provider === 'model') {
return runtimeSelectionForProvider('model', settings)
}
if (settings.provider === 'opencode') {
return runtimeSelectionForProvider('opencode', settings)
}
if (settings.provider === 'continue') {
return runtimeSelectionForProvider('continue', settings)
}
return settings.opencodeBaseUrl || settings.opencodeEmbedded
? runtimeSelectionForProvider('opencode', settings)
: runtimeSelectionForProvider('model', settings)
}
export function ProjectSwitcher({
projects,
activeProjectId,
runtimeSettings,
onArchive,
onCreate,
onDelete,
@@ -111,10 +152,17 @@ export function ProjectSwitcher({
setSaving(true)
setError(undefined)
try {
const input =
draft.runtimeSelection || !runtimeSettings
? draft
: {
...draft,
runtimeSelection: defaultRuntimeSelection(runtimeSettings)
}
if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, draft)
await onUpdate(activeProject.id, input)
} else {
await onCreate(draft)
await onCreate(input)
}
closeDialog()
} catch (reason) {
@@ -226,7 +274,10 @@ export function ProjectSwitcher({
name: '',
description: '',
rootPath: '',
defaultWorkMode: 'ask'
defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
: undefined
})
restoreFocusTarget.current = 'create'
setDialogMode('create')
@@ -253,7 +304,12 @@ export function ProjectSwitcher({
rootPath: activeProject.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode(
activeProject.defaultWorkMode
)
),
runtimeSelection:
activeProject.runtimeSelection ??
(runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
: undefined)
})
restoreFocusTarget.current = 'settings'
setDialogMode('settings')
@@ -373,6 +429,48 @@ export function ProjectSwitcher({
))}
</select>
</label>
{runtimeSettings && (
<label>
<span>
{t('projectSwitcher.dialog.fields.defaultRuntime')}
</span>
<select
aria-label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
onChange={(event) =>
setDraft((current) => ({
...current,
runtimeSelection: runtimeSelectionForProvider(
event.target.value as
| 'model'
| 'opencode'
| 'continue',
runtimeSettings
)
}))
}
value={
draft.runtimeSelection?.provider === 'auto'
? 'model'
: (draft.runtimeSelection?.provider ??
defaultRuntimeSelection(runtimeSettings)
.provider)
}
>
<option value="model">
{t(
'projectSwitcher.dialog.runtimeOptions.direct'
)}
</option>
<option value="opencode">OpenCode</option>
<option value="continue">Continue</option>
</select>
<small>
{t('projectSwitcher.dialog.defaultRuntimeHelp')}
</small>
</label>
)}
{error && (
<p className="project-create-card__error" role="alert">
{error}
@@ -23,8 +23,14 @@ export const workspace = {
name: 'Name',
description: 'Description',
rootPath: 'Root folder',
defaultMode: 'Default mode'
defaultMode: 'Default mode',
defaultRuntime: 'Default Runtime for new conversations'
},
runtimeOptions: {
direct: 'Direct model'
},
defaultRuntimeHelp:
'Applies only to new conversations in this project. Existing conversations are unchanged.',
channelManaged: 'GoodBuddy manages channel project names.',
selectRoot: 'Select project root folder',
danger: {
@@ -20,8 +20,14 @@ export const workspace = {
name: '名称',
description: '说明',
rootPath: '根目录',
defaultMode: '默认模式'
defaultMode: '默认模式',
defaultRuntime: '新对话默认 Runtime'
},
runtimeOptions: {
direct: '直连模型'
},
defaultRuntimeHelp:
'仅应用于此项目中新建的对话,不会更改已有对话。',
channelManaged: '通道项目名称由 GoodBuddy 管理。',
selectRoot: '选择项目根目录',
danger: {