From 71a8662690add0653bb8fe005deb9ad3ec2364b4 Mon Sep 17 00:00:00 2001 From: lofyer Date: Tue, 11 Aug 2026 17:52:55 +0800 Subject: [PATCH] feat: add project default runtime --- src/main/assistant/assistant-database.test.ts | 10 +- src/renderer/src/App.test.tsx | 56 ++++++++- src/renderer/src/App.tsx | 39 +++++-- src/renderer/src/ProjectSwitcher.tsx | 106 +++++++++++++++++- .../src/i18n/locales/en-US/workspace.ts | 8 +- .../src/i18n/locales/zh-CN/workspace.ts | 8 +- 6 files changed, 210 insertions(+), 17 deletions(-) diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 9b26959..c0ece7d 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -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) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 15feb27..56c2a35 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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() + + 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() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 885f832..1eccea3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { Promise onCreate: (input: ProjectCreateInput) => Promise onDelete: (projectId: string, confirmation: string) => Promise @@ -33,9 +36,47 @@ type ProjectSwitcherProps = { ) => Promise } +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({ ))} + {runtimeSettings && ( + + )} {error && (

{error} diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index 9b8521d..a4e088d 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -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: { diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index 93d5db1..85ad293 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -20,8 +20,14 @@ export const workspace = { name: '名称', description: '说明', rootPath: '根目录', - defaultMode: '默认模式' + defaultMode: '默认模式', + defaultRuntime: '新对话默认 Runtime' }, + runtimeOptions: { + direct: '直连模型' + }, + defaultRuntimeHelp: + '仅应用于此项目中新建的对话,不会更改已有对话。', channelManaged: '通道项目名称由 GoodBuddy 管理。', selectRoot: '选择项目根目录', danger: {