feat: add project default runtime
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user