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()