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', name: '产品发布 2',
description: '更新后的项目', description: '更新后的项目',
rootPath: 'C:\\Release', rootPath: 'C:\\Release',
defaultWorkMode: 'execute' defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'continue'
}
}) })
expect(updated).toMatchObject({ expect(updated).toMatchObject({
name: '产品发布 2', name: '产品发布 2',
defaultWorkMode: 'execute' defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'continue'
}
}) })
database.setProjectArchived(project.id, true) database.setProjectArchived(project.id, true)
expect(database.listProjects()).toHaveLength(1) expect(database.listProjects()).toHaveLength(1)
+55 -1
View File
@@ -3357,9 +3357,16 @@ describe('App', () => {
expect(within(dialog).getByLabelText('根目录')).toHaveValue( expect(within(dialog).getByLabelText('根目录')).toHaveValue(
project.rootPath project.rootPath
) )
expect(
within(dialog).getByLabelText('新对话默认 Runtime')
).toHaveValue('model')
fireEvent.change(within(dialog).getByLabelText('说明'), { fireEvent.change(within(dialog).getByLabelText('说明'), {
target: { value: '更新后的说明' } target: { value: '更新后的说明' }
}) })
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{ target: { value: 'continue' } }
)
fireEvent.click( fireEvent.click(
within(dialog).getByRole('button', { name: '保存项目' }) within(dialog).getByRole('button', { name: '保存项目' })
) )
@@ -3368,7 +3375,11 @@ describe('App', () => {
project.id, project.id,
expect.objectContaining({ expect.objectContaining({
description: '更新后的说明', 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 () => { it('uses a message icon for conversation navigation', async () => {
render(<App />) 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( function getRuntimeSelectionLabel(
selection: AgentRuntimeSelection | undefined, selection: AgentRuntimeSelection | undefined,
settings: RuntimeSettings | undefined, settings: RuntimeSettings | undefined,
@@ -1798,11 +1808,17 @@ function App(): React.JSX.Element {
if (!runtimeSettings || !conversationStoreReady) { if (!runtimeSettings || !conversationStoreReady) {
return return
} }
const defaultSelection = getDefaultRuntimeSelection(runtimeSettings)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setConversations((current) => { setConversations((current) => {
let changed = false let changed = false
const next = current.map((conversation) => { const next = current.map((conversation) => {
const project = projects.find(
(candidate) => candidate.id === conversation.projectId
)
const defaultSelection = getProjectDefaultRuntimeSelection(
project,
runtimeSettings
)
const selection = const selection =
!conversation.runtimeSelection || !conversation.runtimeSelection ||
conversation.runtimeSelection.provider === 'auto' conversation.runtimeSelection.provider === 'auto'
@@ -1828,7 +1844,7 @@ function App(): React.JSX.Element {
}) })
}, 0) }, 0)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [conversationStoreReady, runtimeSettings]) }, [conversationStoreReady, projects, runtimeSettings])
useEffect(() => { useEffect(() => {
const selection = activeRuntimeSelectionRef.current const selection = activeRuntimeSelectionRef.current
@@ -1914,7 +1930,7 @@ function App(): React.JSX.Element {
const conversation = createConversation( const conversation = createConversation(
projectId, projectId,
runtimeSettings runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined, : undefined,
tRef.current('conversation.greeting') tRef.current('conversation.greeting')
) )
@@ -3520,7 +3536,7 @@ function App(): React.JSX.Element {
const created = createConversation( const created = createConversation(
projectId, projectId,
runtimeSettings runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined, : undefined,
t('conversation.greeting') t('conversation.greeting')
) )
@@ -3540,7 +3556,7 @@ function App(): React.JSX.Element {
const conversation = createConversation( const conversation = createConversation(
project.id, project.id,
runtimeSettings runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined, : undefined,
t('conversation.greeting') t('conversation.greeting')
) )
@@ -3635,7 +3651,7 @@ function App(): React.JSX.Element {
const created = createConversation( const created = createConversation(
next.id, next.id,
runtimeSettings runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(next, runtimeSettings)
: undefined, : undefined,
t('conversation.greeting') t('conversation.greeting')
) )
@@ -3767,7 +3783,10 @@ function App(): React.JSX.Element {
const replacement = createConversation( const replacement = createConversation(
activeProjectId || undefined, activeProjectId || undefined,
runtimeSettings runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(
activeProject,
runtimeSettings
)
: undefined, : undefined,
t('conversation.greeting') t('conversation.greeting')
) )
@@ -4531,7 +4550,10 @@ function App(): React.JSX.Element {
const conversation = createConversation( const conversation = createConversation(
activeProjectId || undefined, activeProjectId || undefined,
runtimeSettings runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(
activeProject,
runtimeSettings
)
: undefined, : undefined,
t('conversation.greeting') t('conversation.greeting')
) )
@@ -4602,6 +4624,7 @@ function App(): React.JSX.Element {
<ProjectSwitcher <ProjectSwitcher
activeProjectId={activeProjectId} activeProjectId={activeProjectId}
runtimeSettings={runtimeSettings}
onArchive={archiveProject} onArchive={archiveProject}
onCreate={createProject} onCreate={createProject}
onDelete={deleteProject} onDelete={deleteProject}
+102 -4
View File
@@ -17,11 +17,14 @@ import {
interactiveWorkModes, interactiveWorkModes,
normalizeInteractiveWorkMode normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import { trapTabFocus } from './dialog-focus' import { trapTabFocus } from './dialog-focus'
type ProjectSwitcherProps = { type ProjectSwitcherProps = {
projects: AssistantProject[] projects: AssistantProject[]
activeProjectId: string activeProjectId: string
runtimeSettings?: RuntimeSettings
onArchive: (projectId: string) => Promise<void> onArchive: (projectId: string) => Promise<void>
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject> onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
onDelete: (projectId: string, confirmation: string) => Promise<void> onDelete: (projectId: string, confirmation: string) => Promise<void>
@@ -33,9 +36,47 @@ type ProjectSwitcherProps = {
) => Promise<AssistantProject> ) => 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({ export function ProjectSwitcher({
projects, projects,
activeProjectId, activeProjectId,
runtimeSettings,
onArchive, onArchive,
onCreate, onCreate,
onDelete, onDelete,
@@ -111,10 +152,17 @@ export function ProjectSwitcher({
setSaving(true) setSaving(true)
setError(undefined) setError(undefined)
try { try {
const input =
draft.runtimeSelection || !runtimeSettings
? draft
: {
...draft,
runtimeSelection: defaultRuntimeSelection(runtimeSettings)
}
if (dialogMode === 'settings' && activeProject) { if (dialogMode === 'settings' && activeProject) {
await onUpdate(activeProject.id, draft) await onUpdate(activeProject.id, input)
} else { } else {
await onCreate(draft) await onCreate(input)
} }
closeDialog() closeDialog()
} catch (reason) { } catch (reason) {
@@ -226,7 +274,10 @@ export function ProjectSwitcher({
name: '', name: '',
description: '', description: '',
rootPath: '', rootPath: '',
defaultWorkMode: 'ask' defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
: undefined
}) })
restoreFocusTarget.current = 'create' restoreFocusTarget.current = 'create'
setDialogMode('create') setDialogMode('create')
@@ -253,7 +304,12 @@ export function ProjectSwitcher({
rootPath: activeProject.rootPath, rootPath: activeProject.rootPath,
defaultWorkMode: normalizeInteractiveWorkMode( defaultWorkMode: normalizeInteractiveWorkMode(
activeProject.defaultWorkMode activeProject.defaultWorkMode
) ),
runtimeSelection:
activeProject.runtimeSelection ??
(runtimeSettings
? defaultRuntimeSelection(runtimeSettings)
: undefined)
}) })
restoreFocusTarget.current = 'settings' restoreFocusTarget.current = 'settings'
setDialogMode('settings') setDialogMode('settings')
@@ -373,6 +429,48 @@ export function ProjectSwitcher({
))} ))}
</select> </select>
</label> </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 && ( {error && (
<p className="project-create-card__error" role="alert"> <p className="project-create-card__error" role="alert">
{error} {error}
@@ -23,8 +23,14 @@ export const workspace = {
name: 'Name', name: 'Name',
description: 'Description', description: 'Description',
rootPath: 'Root folder', 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.', channelManaged: 'GoodBuddy manages channel project names.',
selectRoot: 'Select project root folder', selectRoot: 'Select project root folder',
danger: { danger: {
@@ -20,8 +20,14 @@ export const workspace = {
name: '名称', name: '名称',
description: '说明', description: '说明',
rootPath: '根目录', rootPath: '根目录',
defaultMode: '默认模式' defaultMode: '默认模式',
defaultRuntime: '新对话默认 Runtime'
}, },
runtimeOptions: {
direct: '直连模型'
},
defaultRuntimeHelp:
'仅应用于此项目中新建的对话,不会更改已有对话。',
channelManaged: '通道项目名称由 GoodBuddy 管理。', channelManaged: '通道项目名称由 GoodBuddy 管理。',
selectRoot: '选择项目根目录', selectRoot: '选择项目根目录',
danger: { danger: {