From 773a8a703fe65888dc6d8ce228a70ce640af55bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 23 Apr 2026 13:41:27 +0800 Subject: [PATCH] Keep desktop chats on the selected provider/model The desktop model picker now stores a session-scoped provider/model selection instead of relying on the global active provider. That selection is replayed on connect, passed into the CLI startup path, and preserved across turns until the user changes it again. To make that true end-to-end, the server now restarts the session process when runtime selection changes, injects provider-scoped env for third-party providers, and routes proxy traffic by provider id. The selector UI was also tightened so provider grouping stays visible while the actual model choice remains readable. Constraint: Different providers can expose the same model id, so chat runtime selection cannot be derived from model id alone Constraint: A desktop session reuses one CLI subprocess across turns, so runtime changes must restart that process to take effect Rejected: Keep using Settings active provider as the chat selector | conflates defaults with live session state and breaks overlapping models Rejected: UI-only runtime switching without server restart | later turns would continue using the old CLI subprocess configuration Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep provider defaults and session runtime overrides separate, and preserve provider-scoped proxy routing when extending model selection surfaces Tested: cd desktop && bun run lint Tested: cd desktop && bun run test src/stores/chatStore.test.ts Tested: cd desktop && bun run test src/__tests__/generalSettings.test.tsx Tested: bun test src/server/__tests__/conversation-service.test.ts Tested: bun test src/server/__tests__/conversations.test.ts Tested: bun -e "await import('./src/server/services/titleService.ts'); await import('./src/server/ws/handler.ts')" Not-tested: Real third-party provider round-trip from the desktop UI against a live upstream account --- desktop/src/components/chat/ChatInput.tsx | 6 +- .../src/components/controls/ModelSelector.tsx | 437 ++++++++++++++---- desktop/src/components/layout/StatusBar.tsx | 9 +- desktop/src/constants/modelCatalog.ts | 24 + desktop/src/i18n/locales/en.ts | 2 + desktop/src/i18n/locales/zh.ts | 2 + desktop/src/pages/EmptySession.tsx | 31 +- desktop/src/pages/Settings.tsx | 6 +- desktop/src/stores/chatStore.test.ts | 31 ++ desktop/src/stores/chatStore.ts | 15 + desktop/src/stores/providerStore.ts | 14 +- desktop/src/stores/sessionRuntimeStore.ts | 69 +++ desktop/src/stores/sessionStore.ts | 2 + desktop/src/types/chat.ts | 2 + desktop/src/types/runtime.ts | 4 + .../__tests__/conversation-service.test.ts | 74 +++ src/server/__tests__/conversations.test.ts | 149 +++++- src/server/proxy/handler.ts | 37 +- src/server/services/conversationService.ts | 31 +- src/server/services/providerService.ts | 50 +- src/server/services/titleService.ts | 30 +- src/server/ws/events.ts | 1 + src/server/ws/handler.ts | 107 ++++- 23 files changed, 983 insertions(+), 150 deletions(-) create mode 100644 desktop/src/constants/modelCatalog.ts create mode 100644 desktop/src/stores/sessionRuntimeStore.ts create mode 100644 desktop/src/types/runtime.ts diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d6004b38..3b29772d 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -4,6 +4,7 @@ import { useChatStore } from '../../stores/chatStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' @@ -669,7 +670,9 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
- {!isMemberSession && } + {!isMemberSession && activeTabId && ( + + )} {open && ( -
- {/* Models */} -
-
+
+
+
{t('model.configuration')}
-
- {availableModels.map((model) => { - const isSelected = model.id === selectedModel?.id - return ( - + ) + })}
- - ) - })} -
+
+ ))} +
+ ) : ( +
+ {availableModels.map((model) => { + const isSelected = model.id === selectedModel?.id + return ( + + ) + })} +
+ )}
- {/* Effort — hidden in controlled mode (not relevant for task creation) */} - {!isControlled &&
-
- {t('model.effort')} + {!isControlled && !isRuntimeScoped && ( +
+
+ {t('model.effort')} +
+
+ {EFFORT_OPTIONS.map((opt) => { + const isSelected = opt.value === effortLevel + return ( + + ) + })} +
-
- {EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel - return ( - - ) - })} -
-
} + )}
)}
diff --git a/desktop/src/components/layout/StatusBar.tsx b/desktop/src/components/layout/StatusBar.tsx index e95ad7b0..82c742d1 100644 --- a/desktop/src/components/layout/StatusBar.tsx +++ b/desktop/src/components/layout/StatusBar.tsx @@ -1,15 +1,20 @@ import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTabStore } from '../../stores/tabStore' export function StatusBar() { const { currentModel } = useSettingsStore() const activeTabId = useTabStore((s) => s.activeTabId) + const runtimeSelection = useSessionRuntimeStore((s) => + activeTabId ? s.selections[activeTabId] : undefined, + ) const projectPath = useSessionStore((s) => s.sessions.find((session) => session.id === activeTabId)?.projectPath) const projectName = projectPath ? projectPath.split('-').filter(Boolean).pop() || '' : '' + const modelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? null return (
@@ -20,9 +25,9 @@ export function StatusBar() {
- {currentModel && ( + {modelLabel && ( - {currentModel.name} + {modelLabel} )}
diff --git a/desktop/src/constants/modelCatalog.ts b/desktop/src/constants/modelCatalog.ts new file mode 100644 index 00000000..fbd8dbb7 --- /dev/null +++ b/desktop/src/constants/modelCatalog.ts @@ -0,0 +1,24 @@ +import type { ModelInfo } from '../types/settings' + +export const OFFICIAL_DEFAULT_MODEL_ID = 'claude-opus-4-7' + +export const OFFICIAL_MODELS: ModelInfo[] = [ + { + id: 'claude-opus-4-7', + name: 'Opus 4.7', + description: 'Most capable for ambitious work', + context: '1m', + }, + { + id: 'claude-sonnet-4-6', + name: 'Sonnet 4.6', + description: 'Most efficient for everyday tasks', + context: '200k', + }, + { + id: 'claude-haiku-4-5', + name: 'Haiku 4.5', + description: 'Fastest for quick answers', + context: '200k', + }, +] diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 92dbacf2..2167f6bf 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -81,6 +81,8 @@ export const en = { 'settings.providers.proxyFailed': '② Proxy failed: {error}', 'settings.providers.confirmDelete': 'Delete provider "{name}"? This cannot be undone.', 'settings.providers.activate': 'Activate', + 'settings.providers.default': 'Default', + 'settings.providers.setDefault': 'Set default', 'settings.providers.test': 'Test', 'settings.providers.edit': 'Edit', 'settings.providers.requestFailed': 'Request failed', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ff644c69..309d08e9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -83,6 +83,8 @@ export const zh: Record = { 'settings.providers.proxyFailed': '② 代理转换失败: {error}', 'settings.providers.confirmDelete': '删除服务商 "{name}"?此操作不可撤销。', 'settings.providers.activate': '激活', + 'settings.providers.default': '默认', + 'settings.providers.setDefault': '设为默认', 'settings.providers.test': '测试', 'settings.providers.edit': '编辑', 'settings.providers.requestFailed': '请求失败', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 47658d8b..a8bf7eac 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -3,8 +3,12 @@ import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' +import { useProviderStore } from '../stores/providerStore' +import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/sessionRuntimeStore' +import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore' +import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog' import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { ModelSelector } from '../components/controls/ModelSelector' @@ -54,6 +58,7 @@ export function EmptySession() { const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) const createSession = useSessionStore((state) => state.createSession) const sendMessage = useChatStore((state) => state.sendMessage) + const setSessionRuntime = useChatStore((state) => state.setSessionRuntime) const connectToSession = useChatStore((state) => state.connectToSession) const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) @@ -201,10 +206,34 @@ export function EmptySession() { setIsSubmitting(true) try { + const settings = useSettingsStore.getState() + let providerState = useProviderStore.getState() + if ( + settings.activeProviderName && + providerState.providers.length === 0 && + !providerState.isLoading + ) { + await providerState.fetchProviders() + providerState = useProviderStore.getState() + } + const inferredProviderId = providerState.activeId ?? ( + settings.activeProviderName + ? providerState.providers.find((provider) => provider.name === settings.activeProviderName)?.id ?? null + : null + ) + const draftSelection = + useSessionRuntimeStore.getState().selections[DRAFT_RUNTIME_SELECTION_KEY] + ?? { + providerId: inferredProviderId, + modelId: settings.currentModel?.id ?? OFFICIAL_DEFAULT_MODEL_ID, + } const sessionId = await createSession(workDir || undefined) setActiveView('code') useTabStore.getState().openTab(sessionId, 'New Session') connectToSession(sessionId) + useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection) + useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY) + setSessionRuntime(sessionId, draftSelection) const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, @@ -557,7 +586,7 @@ export function EmptySession() {
- +
{t('settings.providers.officialDesc')}
@@ -248,7 +248,7 @@ function ProviderSettings() { )} {isActive && ( - {t('common.active')} + {t('settings.providers.default')} )}
@@ -273,7 +273,7 @@ function ProviderSettings() {
{!isActive && ( - + )} diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 511d54ef..c09d9bf3 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageEntry } from '../types/session' +import { useSessionRuntimeStore } from './sessionRuntimeStore' const { sendMock, @@ -114,6 +115,8 @@ describe('chatStore history mapping', () => { refreshTasksMock.mockReset() cliTaskStoreSnapshot.tasks = [] cliTaskStoreSnapshot.sessionId = null + useSessionRuntimeStore.setState({ selections: {} }) + localStorage.clear() useChatStore.setState({ ...initialState, sessions: {}, @@ -258,6 +261,34 @@ describe('chatStore history mapping', () => { ]) }) + it('replays saved runtime selection when reconnecting a session', () => { + useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, { + providerId: 'provider-1', + modelId: 'kimi-k2.6', + }) + + useChatStore.getState().connectToSession(TEST_SESSION_ID) + + expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { + type: 'set_runtime_config', + providerId: 'provider-1', + modelId: 'kimi-k2.6', + }) + }) + + it('sends explicit runtime overrides over websocket', () => { + useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, { + providerId: null, + modelId: 'claude-opus-4-7', + }) + + expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { + type: 'set_runtime_config', + providerId: null, + modelId: 'claude-opus-4-7', + }) + }) + it('keeps AskUserQuestion permission requests out of the message list while tracking the pending request', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index b4289781..8ad3a130 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -4,11 +4,13 @@ import { sessionsApi } from '../api/sessions' import { useTeamStore } from './teamStore' import { useSessionStore } from './sessionStore' import { useCLITaskStore } from './cliTaskStore' +import { useSessionRuntimeStore } from './sessionRuntimeStore' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' +import type { RuntimeSelection } from '../types/runtime' import type { AgentTaskNotification, AttachmentRef, @@ -106,6 +108,7 @@ type ChatStore = { requestId: string, response: ComputerUsePermissionResponse, ) => void + setSessionRuntime: (sessionId: string, selection: RuntimeSelection) => void setSessionPermissionMode: (sessionId: string, mode: PermissionMode) => void stopGeneration: (sessionId: string) => void loadHistory: (sessionId: string) => Promise @@ -221,6 +224,11 @@ export const useChatStore = create((set, get) => ({ get().handleServerMessage(sessionId, msg) }) + const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId] + if (runtimeSelection) { + wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection }) + } + get().loadHistory(sessionId) sessionsApi.getSlashCommands(sessionId) .then(({ commands }) => { @@ -378,6 +386,13 @@ export const useChatStore = create((set, get) => ({ })) }, + setSessionRuntime: (sessionId, selection) => { + wsManager.send(sessionId, { + type: 'set_runtime_config', + ...selection, + }) + }, + setSessionPermissionMode: (sessionId, mode) => { if (!get().sessions[sessionId]) return wsManager.send(sessionId, { type: 'set_permission_mode', mode }) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 32771ebf..e4214d33 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand' import { providersApi } from '../api/providers' import { useSettingsStore } from './settingsStore' +import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog' import type { SavedProvider, CreateProviderInput, @@ -12,11 +13,6 @@ import type { } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' -// 与后端 src/server/api/models.ts 的 DEFAULT_MODEL 保持一致: -// 切回"官方"时把聊天页的 currentModel 重置到这个,避免残留第三方 provider -// 的 model id 在官方模型列表里找不到、ModelSelector 显示但不选中的状态。 -const OFFICIAL_DEFAULT_MODEL_ID = 'claude-opus-4-7' - type ProviderStore = { providers: SavedProvider[] activeId: string | null @@ -84,10 +80,8 @@ export const useProviderStore = create((set, get) => ({ activateProvider: async (id) => { await providersApi.activate(id) await get().fetchProviders() - // 联动聊天页:把 currentModel 重置到新 provider 的 main model。 - // 不这么做的话,用户在切换前手动选过的 model id (写进了 settings.json 的 - // `model` 字段) 会继续被后端当作 explicit 返回,但新 provider 的模型列表 - // 里没这个 id, ModelSelector 会卡在"显示旧名字 + radio 不选中"。 + // 更新默认 provider 时,同步刷新默认 model,避免 settings.json 里残留 + // 旧 provider 的 model id 导致默认选择指向不存在的模型。 const provider = get().providers.find((p) => p.id === id) if (provider) { const settings = useSettingsStore.getState() @@ -99,7 +93,7 @@ export const useProviderStore = create((set, get) => ({ activateOfficial: async () => { await providersApi.activateOfficial() await get().fetchProviders() - // 切回官方时同样重置 currentModel,避免残留第三方 model id。 + // 切回官方默认时同样重置 currentModel,避免残留第三方 model id。 const settings = useSettingsStore.getState() await settings.setModel(OFFICIAL_DEFAULT_MODEL_ID) await settings.fetchAll() diff --git a/desktop/src/stores/sessionRuntimeStore.ts b/desktop/src/stores/sessionRuntimeStore.ts new file mode 100644 index 00000000..0a0baee7 --- /dev/null +++ b/desktop/src/stores/sessionRuntimeStore.ts @@ -0,0 +1,69 @@ +import { create } from 'zustand' +import type { RuntimeSelection } from '../types/runtime' + +const STORAGE_KEY = 'cc-haha-session-runtime' + +export const DRAFT_RUNTIME_SELECTION_KEY = '__draft__' + +type SessionRuntimeStore = { + selections: Record + setSelection: (key: string, selection: RuntimeSelection) => void + clearSelection: (key: string) => void + moveSelection: (fromKey: string, toKey: string) => void +} + +function loadSelections(): Record { + if (typeof localStorage === 'undefined') return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) as Record + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function persistSelections(selections: Record) { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(selections)) + } catch { + // noop + } +} + +export const useSessionRuntimeStore = create((set) => ({ + selections: loadSelections(), + + setSelection: (key, selection) => + set((state) => { + const selections = { + ...state.selections, + [key]: selection, + } + persistSelections(selections) + return { selections } + }), + + clearSelection: (key) => + set((state) => { + if (!(key in state.selections)) return state + const { [key]: _removed, ...rest } = state.selections + persistSelections(rest) + return { selections: rest } + }), + + moveSelection: (fromKey, toKey) => + set((state) => { + const selection = state.selections[fromKey] + if (!selection) return state + const { [fromKey]: _removed, ...rest } = state.selections + const selections = { + ...rest, + [toKey]: selection, + } + persistSelections(selections) + return { selections } + }), +})) diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index 82fc405b..8f395e37 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { sessionsApi } from '../api/sessions' +import { useSessionRuntimeStore } from './sessionRuntimeStore' import type { SessionListItem } from '../types/session' type SessionStore = { @@ -74,6 +75,7 @@ export const useSessionStore = create((set, get) => ({ deleteSession: async (id: string) => { await sessionsApi.delete(id) + useSessionRuntimeStore.getState().clearSelection(id) set((s) => ({ sessions: s.sessions.filter((session) => session.id !== id), activeSessionId: s.activeSessionId === id ? null : s.activeSessionId, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 2fbb9fec..27239a05 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -1,4 +1,5 @@ import type { PermissionMode } from './settings' +import type { RuntimeSelection } from './runtime' // Source: src/server/ws/events.ts @@ -19,6 +20,7 @@ export type ClientMessage = response: ComputerUsePermissionResponse } | { type: 'set_permission_mode'; mode: PermissionMode } + | ({ type: 'set_runtime_config' } & RuntimeSelection) | { type: 'stop_generation' } | { type: 'ping' } diff --git a/desktop/src/types/runtime.ts b/desktop/src/types/runtime.ts new file mode 100644 index 00000000..1c0fcd00 --- /dev/null +++ b/desktop/src/types/runtime.ts @@ -0,0 +1,4 @@ +export type RuntimeSelection = { + providerId: string | null + modelId: string +} diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 8cbb9871..c47943e8 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { ConversationService } from '../services/conversationService.js' +import { ProviderService } from '../services/providerService.js' describe('ConversationService', () => { let tmpDir: string @@ -130,6 +131,61 @@ describe('ConversationService', () => { expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() }) + test('buildChildEnv injects explicit provider runtime env for session-scoped providers', async () => { + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'custom', + name: 'Packy', + apiKey: 'provider-key', + baseUrl: 'https://api.packy.example', + apiFormat: 'openai_chat', + models: { + main: 'kimi-k2.6', + haiku: '', + sonnet: '', + opus: '', + }, + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp', undefined, { + providerId: provider.id, + })) as Record + + expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`) + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('proxy-managed') + expect(env.ANTHROPIC_MODEL).toBe('kimi-k2.6') + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined() + }) + + test('buildChildEnv can force official auth even when a custom default provider exists', async () => { + const ccHahaDir = path.join(tmpDir, 'cc-haha') + await fs.mkdir(ccHahaDir, { recursive: true }) + await fs.writeFile( + path.join(ccHahaDir, 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'custom-provider-token' } }), + 'utf-8', + ) + + const { hahaOAuthService } = await import('../services/hahaOAuthService.js') + await hahaOAuthService.saveTokens({ + accessToken: 'forced-official-token', + refreshToken: 'forced-official-refresh', + expiresAt: Date.now() + 30 * 60_000, + scopes: ['user:inference'], + subscriptionType: 'max', + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp', undefined, { + providerId: null, + })) as Record + + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop') + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token') + }) + test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) @@ -185,4 +241,22 @@ describe('ConversationService', () => { expect(args).toContain('--sdk-url') expect(args).toContain('--replay-user-messages') }) + + test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => { + const service = new ConversationService() as any + const args = service.buildSessionCliArgs( + '123e4567-e89b-12d3-a456-426614174000', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + false, + { + model: 'model-b-opus', + effort: 'max', + }, + ) as string[] + + expect(args).toContain('--model') + expect(args).toContain('model-b-opus') + expect(args).toContain('--effort') + expect(args).toContain('max') + }) }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 18f143ec..cc818870 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -10,7 +10,8 @@ import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { fileURLToPath } from 'node:url' -import { ConversationService } from '../services/conversationService.js' +import { ConversationService, conversationService } from '../services/conversationService.js' +import { ProviderService } from '../services/providerService.js' // ============================================================================ // ConversationService unit tests @@ -571,4 +572,150 @@ describe('WebSocket Chat Integration', () => { expect(secondMessages.some((msg) => msg.type === 'message_complete')).toBe(true) }) }) + + it('should keep using the selected runtime config across the whole session until changed', async () => { + const providerService = new ProviderService() + const providerA = await providerService.addProvider({ + presetId: 'custom', + name: 'Provider A', + apiKey: 'key-a', + baseUrl: 'http://127.0.0.1:1/anthropic', + apiFormat: 'anthropic', + models: { + main: 'model-a-main', + haiku: 'model-a-haiku', + sonnet: 'model-a-sonnet', + opus: 'model-a-opus', + }, + }) + const providerB = await providerService.addProvider({ + presetId: 'custom', + name: 'Provider B', + apiKey: 'key-b', + baseUrl: 'http://127.0.0.1:1/anthropic', + apiFormat: 'anthropic', + models: { + main: 'model-b-main', + haiku: 'model-b-haiku', + sonnet: 'model-b-sonnet', + opus: 'model-b-opus', + }, + }) + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined + }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + try { + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + let phase: 'boot' | 'turn1' | 'switching' | 'turn2' | 'turn3' | 'done' = 'boot' + let switchingTriggered = false + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error(`Timed out waiting for runtime persistence flow for session ${sessionId}`)) + }, 15_000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + + if (msg.type === 'connected' && phase === 'boot') { + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: providerA.id, + modelId: 'model-a-sonnet', + })) + ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' })) + phase = 'turn1' + return + } + + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + return + } + + if (msg.type === 'message_complete' && phase === 'turn1' && !switchingTriggered) { + switchingTriggered = true + phase = 'switching' + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: providerB.id, + modelId: 'model-b-opus', + })) + return + } + + if ( + msg.type === 'status' && + msg.state === 'idle' && + phase === 'switching' + ) { + ws.send(JSON.stringify({ type: 'user_message', content: 'second turn' })) + phase = 'turn2' + return + } + + if (msg.type === 'message_complete' && phase === 'turn2') { + ws.send(JSON.stringify({ type: 'user_message', content: 'third turn' })) + phase = 'turn3' + return + } + + if (msg.type === 'message_complete' && phase === 'turn3') { + clearTimeout(timeout) + phase = 'done' + ws.close() + resolve() + } + } + + ws.onerror = () => { + reject(new Error(`WebSocket error for runtime persistence session ${sessionId}`)) + } + }) + + expect(startCalls).toHaveLength(2) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + providerId: providerA.id, + model: 'model-a-sonnet', + }, + }) + expect(startCalls[1]).toMatchObject({ + sessionId, + options: { + providerId: providerB.id, + model: 'model-b-opus', + }, + }) + } finally { + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + } + }, 20_000) }) diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 6569eb19..88e00443 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -21,26 +21,49 @@ import type { AnthropicRequest } from './transform/types.js' const providerService = new ProviderService() export async function handleProxyRequest(req: Request, url: URL): Promise { - // Only handle POST /proxy/v1/messages - if (req.method !== 'POST' || url.pathname !== '/proxy/v1/messages') { + const providerMatch = url.pathname.match(/^\/proxy\/providers\/([^/]+)\/v1\/messages$/) + const providerId = providerMatch ? decodeURIComponent(providerMatch[1]!) : undefined + const isActiveProxyPath = url.pathname === '/proxy/v1/messages' + + // Only handle POST /proxy/v1/messages or POST /proxy/providers/:providerId/v1/messages + if (req.method !== 'POST' || (!isActiveProxyPath && !providerMatch)) { return Response.json( - { error: 'Not Found', message: 'Proxy only handles POST /proxy/v1/messages' }, + { + error: 'Not Found', + message: 'Proxy only handles POST /proxy/v1/messages and POST /proxy/providers/:providerId/v1/messages', + }, { status: 404 }, ) } - // Read active provider config - const config = await providerService.getActiveProviderForProxy() + // Read active/default provider config or an explicitly-scoped provider config. + const config = await providerService.getProviderForProxy(providerId) if (!config) { return Response.json( - { type: 'error', error: { type: 'invalid_request_error', message: 'No active provider configured for proxy' } }, + { + type: 'error', + error: { + type: 'invalid_request_error', + message: providerId + ? `Provider "${providerId}" is not configured for proxy` + : 'No active provider configured for proxy', + }, + }, { status: 400 }, ) } if (config.apiFormat === 'anthropic') { return Response.json( - { type: 'error', error: { type: 'invalid_request_error', message: 'Active provider uses anthropic format — proxy not needed' } }, + { + type: 'error', + error: { + type: 'invalid_request_error', + message: providerId + ? `Provider "${providerId}" uses anthropic format — proxy not needed` + : 'Active provider uses anthropic format — proxy not needed', + }, + }, { status: 400 }, ) } diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 6874be2e..cbce0b43 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' +import { ProviderService } from './providerService.js' import { sessionService } from './sessionService.js' import { buildClaudeCliArgs, @@ -47,6 +48,7 @@ type SessionStartOptions = { permissionMode?: string model?: string effort?: string + providerId?: string | null } export class ConversationStartupError extends Error { @@ -67,6 +69,7 @@ export class ConversationStartupError extends Error { export class ConversationService { private sessions = new Map() + private providerService = new ProviderService() private buildSessionCliArgs( sessionId: string, @@ -140,7 +143,7 @@ export class ConversationService { // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts // chdir 后落到正确目录。 // - const childEnv = await this.buildChildEnv(workDir, sdkUrl) + const childEnv = await this.buildChildEnv(workDir, sdkUrl, options) let proc: ReturnType try { @@ -508,6 +511,7 @@ export class ConversationService { private async buildChildEnv( workDir: string, sdkUrl?: string, + options?: SessionStartOptions, ): Promise> { // Provider isolation: when Desktop has its own provider config/index, // strip inherited provider env vars so the child CLI reads fresh values @@ -528,7 +532,7 @@ export class ConversationService { const cleanEnv = { ...process.env } delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN - if (this.shouldStripInheritedProviderEnv()) { + if (this.shouldStripInheritedProviderEnv(options?.providerId)) { for (const key of PROVIDER_ENV_KEYS) { delete cleanEnv[key] } @@ -544,6 +548,11 @@ export class ConversationService { } } + const explicitProviderEnv = + typeof options?.providerId === 'string' + ? await this.providerService.getProviderRuntimeEnv(options.providerId) + : null + return { ...cleanEnv, CLAUDE_CODE_ENABLE_TASKS: '1', @@ -565,7 +574,8 @@ export class ConversationService { // 残留、只走用户 /login 的 OAuth token。自定义 provider 模式绝不能设, // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方 // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。 - ...(this.shouldMarkManagedOAuth() + ...(explicitProviderEnv ?? {}), + ...(this.shouldMarkManagedOAuth(options?.providerId) ? await this.buildOfficialOAuthEnv() : {}), } @@ -599,7 +609,11 @@ export class ConversationService { return env } - private shouldStripInheritedProviderEnv(): boolean { + private shouldStripInheritedProviderEnv(providerId?: string | null): boolean { + if (providerId !== undefined) { + return true + } + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') const ccHahaDir = path.join(configDir, 'cc-haha') @@ -637,7 +651,14 @@ export class ConversationService { * 默认 (读不到 settings.json) 按"官方"处理 — 即使用户从未用过 cc-haha * provider 管理,也希望官方 OAuth 能正常工作。 */ - private shouldMarkManagedOAuth(): boolean { + private shouldMarkManagedOAuth(providerId?: string | null): boolean { + if (providerId === null) { + return true + } + if (typeof providerId === 'string') { + return false + } + const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') const settingsPath = path.join(configDir, 'cc-haha', 'settings.json') diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index dc28eda3..f5f92532 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -222,17 +222,17 @@ export class ProviderService { // --- Settings sync --- - private async syncToSettings(provider: SavedProvider): Promise { - const settings = await this.readSettings() - const existingEnv = (settings.env as Record) || {} - + private buildManagedEnv( + provider: SavedProvider, + options?: { proxyPath?: string }, + ): Record { const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic' + const proxyPath = options?.proxyPath ?? '/proxy' const baseUrl = needsProxy - ? `http://127.0.0.1:${ProviderService.serverPort}/proxy` + ? `http://127.0.0.1:${ProviderService.serverPort}${proxyPath}` : provider.baseUrl - settings.env = { - ...existingEnv, + return { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey, ANTHROPIC_MODEL: provider.models.main, @@ -240,6 +240,23 @@ export class ProviderService { ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, } + } + + async getProviderRuntimeEnv(id: string): Promise> { + const provider = await this.getProvider(id) + return this.buildManagedEnv(provider, { + proxyPath: `/proxy/providers/${provider.id}`, + }) + } + + private async syncToSettings(provider: SavedProvider): Promise { + const settings = await this.readSettings() + const existingEnv = (settings.env as Record) || {} + + settings.env = { + ...existingEnv, + ...this.buildManagedEnv(provider), + } await this.writeSettings(settings) } @@ -306,11 +323,20 @@ export class ProviderService { // --- Proxy support --- - async getActiveProviderForProxy(): Promise<{ + async getProviderForProxy(providerId?: string): Promise<{ baseUrl: string apiKey: string apiFormat: ApiFormat } | null> { + if (providerId) { + const provider = await this.getProvider(providerId) + return { + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + apiFormat: provider.apiFormat ?? 'anthropic', + } + } + const index = await this.readIndex() if (!index.activeId) return null const provider = index.providers.find((p) => p.id === index.activeId) @@ -322,6 +348,14 @@ export class ProviderService { } } + async getActiveProviderForProxy(): Promise<{ + baseUrl: string + apiKey: string + apiFormat: ApiFormat + } | null> { + return this.getProviderForProxy() + } + // --- Test --- async testProvider( diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index 0b4c134f..5a99f356 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -40,29 +40,41 @@ export function deriveTitle(raw: string): string | undefined { } /** - * Generate an AI title using the active provider's Haiku model. + * Generate an AI title using the session's provider Haiku model when possible. * Fire-and-forget — returns null on any failure. */ -export async function generateTitle(conversationText: string): Promise { +export async function generateTitle( + conversationText: string, + providerId?: string | null, +): Promise { const trimmed = conversationText.trim() if (!trimmed) return null try { const providerService = new ProviderService() - const { activeId, providers } = await providerService.listProviders() - if (!activeId) return null + if (providerId === null) return null - const provider = providers.find((p) => p.id === activeId) - if (!provider?.baseUrl || !provider?.apiKey) return null + let resolvedProvider = providerId + ? await providerService.getProvider(providerId) + : null - const model = provider.models.haiku || provider.models.main - const url = `${provider.baseUrl.replace(/\/+$/, '')}/v1/messages` + if (!resolvedProvider) { + const { activeId, providers } = await providerService.listProviders() + resolvedProvider = activeId + ? providers.find((provider) => provider.id === activeId) ?? null + : null + } + + if (!resolvedProvider?.baseUrl || !resolvedProvider?.apiKey) return null + + const model = resolvedProvider.models.haiku || resolvedProvider.models.main + const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-api-key': provider.apiKey, + 'x-api-key': resolvedProvider.apiKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 6ee11911..525062d3 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -23,6 +23,7 @@ export type ClientMessage = response: ComputerUsePermissionResponse } | { type: 'set_permission_mode'; mode: string } + | { type: 'set_runtime_config'; providerId: string | null; modelId: string } | { type: 'stop_generation' } | { type: 'ping' } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index c8695b78..4fe4c894 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -49,6 +49,11 @@ const sessionTitleState = new Map() +const runtimeOverrides = new Map() + export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> { return sessionSlashCommands.get(sessionId) || [] } @@ -128,6 +133,10 @@ export const handleWebSocket = { handleSetPermissionMode(ws, message) break + case 'set_runtime_config': + void handleSetRuntimeConfig(ws, message) + break + case 'stop_generation': handleStopGeneration(ws) break @@ -213,7 +222,7 @@ async function handleUserMessage( }`, ) } - const runtimeSettings = await getRuntimeSettings() + const runtimeSettings = await getRuntimeSettings(sessionId) const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + `?token=${encodeURIComponent(crypto.randomUUID())}` @@ -331,6 +340,43 @@ function handleSetPermissionMode( } } +async function handleSetRuntimeConfig( + ws: ServerWebSocket, + message: Extract +) { + const { sessionId } = ws.data + const modelId = typeof message.modelId === 'string' ? message.modelId.trim() : '' + if (!modelId) { + sendMessage(ws, { + type: 'error', + message: 'Runtime model selection is invalid.', + code: 'RUNTIME_CONFIG_INVALID', + }) + return + } + + const nextOverride = { + providerId: message.providerId ?? null, + modelId, + } + const prevOverride = runtimeOverrides.get(sessionId) + runtimeOverrides.set(sessionId, nextOverride) + + if ( + prevOverride && + prevOverride.providerId === nextOverride.providerId && + prevOverride.modelId === nextOverride.modelId + ) { + return + } + + if (!conversationService.hasSession(sessionId)) { + return + } + + await restartSessionWithRuntimeConfig(ws, sessionId) +} + async function restartSessionWithPermissionMode( ws: ServerWebSocket, sessionId: string, @@ -346,7 +392,7 @@ async function restartSessionWithPermissionMode( conversationService.stopSession(sessionId) // Rebuild runtime settings (will pick up the persisted mode) - const runtimeSettings = await getRuntimeSettings() + const runtimeSettings = await getRuntimeSettings(sessionId) const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + `?token=${encodeURIComponent(crypto.randomUUID())}` @@ -366,6 +412,40 @@ async function restartSessionWithPermissionMode( } } +async function restartSessionWithRuntimeConfig( + ws: ServerWebSocket, + sessionId: string, +): Promise { + try { + sendMessage(ws, { + type: 'status', + state: 'thinking', + verb: 'Switching provider and model...', + }) + + const workDir = conversationService.getSessionWorkDir(sessionId) + conversationService.stopSession(sessionId) + + const runtimeSettings = await getRuntimeSettings(sessionId) + const sdkUrl = + `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + + `?token=${encodeURIComponent(crypto.randomUUID())}` + await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) + + sendMessage(ws, { type: 'status', state: 'idle' }) + console.log(`[WS] Restarted CLI for ${sessionId} with runtime override`) + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err) + console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`) + sendMessage(ws, { + type: 'error', + message: `Failed to switch provider/model: ${errMsg}`, + code: 'CLI_RESTART_FAILED', + }) + sendMessage(ws, { type: 'status', state: 'idle' }) + } +} + function handleStopGeneration(ws: ServerWebSocket) { const { sessionId } = ws.data console.log(`[WS] Stop generation requested for session: ${sessionId}`) @@ -404,6 +484,7 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s const text = count === 1 ? state.firstUserMessage : state.allUserMessages.join('\n') + const runtimeProviderId = runtimeOverrides.get(sessionId)?.providerId // Fire-and-forget: derive quick title, then upgrade with AI void (async () => { @@ -418,7 +499,7 @@ function triggerTitleGeneration(ws: ServerWebSocket, sessionId: s } // Stage 2: AI-generated title - const aiTitle = await generateTitle(text) + const aiTitle = await generateTitle(text, runtimeProviderId) if (aiTitle) { await saveAiTitle(sessionId, aiTitle) sendMessage(ws, { type: 'session_title_updated', sessionId, title: aiTitle }) @@ -471,6 +552,7 @@ function cleanupSessionRuntimeState(sessionId: string) { cleanupStreamState(sessionId) sessionSlashCommands.delete(sessionId) sessionTitleState.delete(sessionId) + runtimeOverrides.delete(sessionId) } function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { @@ -845,11 +927,28 @@ function rebindSessionOutput( }) } -async function getRuntimeSettings(): Promise<{ +async function getRuntimeSettings(sessionId?: string): Promise<{ permissionMode?: string model?: string effort?: string + providerId?: string | null }> { + const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined + if (runtimeOverride) { + const userSettings = await settingsService.getUserSettings() + const effort = + typeof userSettings.effort === 'string' && userSettings.effort.trim() + ? userSettings.effort + : undefined + + return { + permissionMode: await settingsService.getPermissionMode().catch(() => undefined), + model: runtimeOverride.modelId, + effort, + providerId: runtimeOverride.providerId, + } + } + // Check if a custom provider is active const { activeId } = await providerService.listProviders() const userSettings = await settingsService.getUserSettings()