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