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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-23 13:41:27 +08:00
parent 1137c99d5c
commit 773a8a703f
23 changed files with 983 additions and 150 deletions

View File

@ -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) {
</div>
<div className="flex items-center gap-2">
{!isMemberSession && <ModelSelector />}
{!isMemberSession && activeTabId && (
<ModelSelector runtimeKey={activeTabId} disabled={isActive} />
)}
<button
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
@ -709,6 +712,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
const { replaceTabSession } = useTabStore.getState()
const { disconnectSession, connectToSession } = useChatStore.getState()
const newId = await createSession(newWorkDir)
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
disconnectSession(oldId)
replaceTabSession(oldId, newId)
connectToSession(newId)

View File

@ -1,26 +1,132 @@
import { useState, useRef, useEffect } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { useEffect, useMemo, useRef, useState } from 'react'
import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog'
import { useTranslation } from '../../i18n'
import type { EffortLevel } from '../../types/settings'
import { useChatStore } from '../../stores/chatStore'
import { useProviderStore } from '../../stores/providerStore'
import { DRAFT_RUNTIME_SELECTION_KEY, useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useSettingsStore } from '../../stores/settingsStore'
import type { SavedProvider } from '../../types/provider'
import type { RuntimeSelection } from '../../types/runtime'
import type { EffortLevel, ModelInfo } from '../../types/settings'
const MODEL_ICONS = {
opus: 'diamond',
sonnet: 'auto_awesome',
haiku: 'bolt',
} as const
type Props = {
/** Controlled mode: model ID override */
value?: string
/** Controlled mode: called on change instead of updating global store */
onChange?: (modelId: string) => void
type ProviderChoice = {
providerId: string | null
providerName: string
isDefault: boolean
models: ModelInfo[]
}
export function ModelSelector({ value, onChange }: Props = {}) {
type Props = {
value?: string
onChange?: (modelId: string) => void
runtimeKey?: string
disabled?: boolean
}
function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice {
return {
providerId: null,
providerName: officialName,
isDefault,
models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS,
}
}
function buildProviderModels(
provider: SavedProvider,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
): ModelInfo[] {
const entries: Array<{ id: string; label: string }> = [
{ id: provider.models.main.trim(), label: labels.main },
{ id: provider.models.haiku.trim(), label: labels.haiku },
{ id: provider.models.sonnet.trim(), label: labels.sonnet },
{ id: provider.models.opus.trim(), label: labels.opus },
]
const byId = new Map<string, { id: string; labels: string[] }>()
for (const entry of entries) {
if (!entry.id) continue
const existing = byId.get(entry.id)
if (existing) {
if (!existing.labels.includes(entry.label)) {
existing.labels.push(entry.label)
}
continue
}
byId.set(entry.id, { id: entry.id, labels: [entry.label] })
}
return [...byId.values()].map((entry) => ({
id: entry.id,
name: entry.id,
description: entry.labels.join(' · '),
context: '',
}))
}
function buildProviderChoices(
providers: SavedProvider[],
activeId: string | null,
availableModels: ModelInfo[],
officialName: string,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
): ProviderChoice[] {
return [
officialChoices(availableModels, activeId === null, officialName),
...providers.map((provider) => ({
providerId: provider.id,
providerName: provider.name,
isDefault: activeId === provider.id,
models: buildProviderModels(provider, labels),
})),
]
}
function resolveDefaultRuntimeSelection(
activeId: string | null,
activeProviderName: string | null,
providers: SavedProvider[],
currentModelId: string | undefined,
): RuntimeSelection {
const inferredProviderId = activeId ?? (
activeProviderName
? providers.find((provider) => provider.name === activeProviderName)?.id ?? null
: null
)
return {
providerId: inferredProviderId,
modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID,
}
}
export function ModelSelector({
value,
onChange,
runtimeKey,
disabled = false,
}: Props = {}) {
const t = useTranslation()
const { currentModel: storeModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
const {
currentModel: storeModel,
availableModels,
effortLevel,
activeProviderName,
setModel,
setEffort,
} = useSettingsStore()
const {
providers,
activeId,
isLoading: providersLoading,
fetchProviders,
} = useProviderStore()
const runtimeSelection = useSessionRuntimeStore((state) =>
runtimeKey ? state.selections[runtimeKey] : undefined,
)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const requestedProvidersRef = useRef(false)
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: t('settings.general.effort.low') },
@ -30,7 +136,13 @@ export function ModelSelector({ value, onChange }: Props = {}) {
]
const isControlled = value !== undefined
const selectedModel = isControlled ? availableModels.find((m) => m.id === value) || null : storeModel
const isRuntimeScoped = !isControlled && runtimeKey !== undefined
useEffect(() => {
if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return
requestedProvidersRef.current = true
void fetchProviders()
}, [fetchProviders, isRuntimeScoped, providersLoading])
useEffect(() => {
if (!open) return
@ -48,107 +160,234 @@ export function ModelSelector({ value, onChange }: Props = {}) {
}
}, [open])
const getModelIcon = (id: string): string => {
const lower = id.toLowerCase()
if (lower.includes('opus')) return MODEL_ICONS.opus
if (lower.includes('sonnet')) return MODEL_ICONS.sonnet
if (lower.includes('haiku')) return MODEL_ICONS.haiku
return 'smart_toy'
const roleLabels = useMemo(
() => ({
main: t('settings.providers.mainModel'),
haiku: t('settings.providers.haikuModel'),
sonnet: t('settings.providers.sonnetModel'),
opus: t('settings.providers.opusModel'),
}),
[t],
)
const providerChoices = useMemo(
() => buildProviderChoices(
providers,
activeId,
activeId === null ? availableModels : OFFICIAL_MODELS,
t('settings.providers.officialName'),
roleLabels,
),
[activeId, availableModels, providers, roleLabels, t],
)
const selectedModel = isControlled
? availableModels.find((model) => model.id === value) || null
: storeModel
const activeRuntimeSelection = isRuntimeScoped
? runtimeSelection ?? resolveDefaultRuntimeSelection(
activeId,
activeProviderName,
providers,
storeModel?.id,
)
: null
const selectedProviderChoice = activeRuntimeSelection
? providerChoices.find((choice) => choice.providerId === activeRuntimeSelection.providerId) ?? null
: null
const selectedRuntimeModel = activeRuntimeSelection
? selectedProviderChoice?.models.find((model) => model.id === activeRuntimeSelection.modelId)
?? {
id: activeRuntimeSelection.modelId,
name: activeRuntimeSelection.modelId,
description: '',
context: '',
}
: null
const buttonModelLabel = isRuntimeScoped
? selectedRuntimeModel?.name ?? storeModel?.name ?? t('model.selectModel')
: selectedModel?.name ?? t('model.selectModel')
const buttonProviderLabel = isRuntimeScoped
? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName')
: null
const handleRuntimeSelect = (selection: RuntimeSelection) => {
if (!runtimeKey) return
useSessionRuntimeStore.getState().setSelection(runtimeKey, selection)
if (runtimeKey !== DRAFT_RUNTIME_SELECTION_KEY) {
useChatStore.getState().setSessionRuntime(runtimeKey, selection)
}
setOpen(false)
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className="flex max-w-[280px] items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">auto_awesome</span>
<span>{selectedModel?.name ?? t('model.selectModel')}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{buttonModelLabel}
</span>
{buttonProviderLabel && (
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
{buttonProviderLabel}
</span>
)}
</div>
<span className="material-symbols-outlined flex-shrink-0 text-[12px]">expand_more</span>
</button>
{open && (
<div className="absolute right-0 bottom-full mb-2 w-[340px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50">
{/* Models */}
<div className="p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
<div className="absolute right-0 bottom-full z-50 mb-2 w-[360px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
<div className="max-h-[420px] overflow-y-auto p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.configuration')}
</div>
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
setModel(model.id)
}
setOpen(false)
}}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors
${isSelected
? 'bg-[var(--color-primary-fixed)] border border-[var(--color-brand)]/20'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
{/* Radio button */}
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
isSelected
? 'border-[var(--color-brand)]'
: 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[var(--color-brand)]" />
{isRuntimeScoped ? (
<div className="space-y-3">
{providerChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<div className="flex items-center justify-between px-2 pt-1">
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
{choice.providerName}
</span>
{choice.isDefault && (
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
{t('settings.providers.default')}
</span>
)}
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">
{getModelIcon(model.id)}
</span>
<div className="space-y-1">
{choice.models.map((model) => {
const isSelected =
activeRuntimeSelection?.providerId === choice.providerId &&
activeRuntimeSelection.modelId === model.id
return (
<button
key={`${choice.providerId ?? 'official'}:${model.id}`}
onClick={() => handleRuntimeSelect({ providerId: choice.providerId, modelId: model.id })}
className={`
w-full rounded-lg border px-3 py-2.5 text-left transition-colors
${isSelected
? 'border-[var(--color-brand)]/20 bg-[var(--color-primary-fixed)]'
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 truncate">{model.description}</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{model.name}
</div>
{model.description && (
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
</button>
)
})}
</div>
</div>
))}
</div>
) : (
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
<button
key={model.id}
onClick={() => {
if (isControlled) {
onChange?.(model.id)
} else {
void setModel(model.id)
}
setOpen(false)
}}
className={`
w-full rounded-lg px-3 py-2.5 text-left transition-colors
${isSelected
? 'bg-[var(--color-primary-fixed)] border border-[var(--color-brand)]/20'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-center gap-3">
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
{/* Effort — hidden in controlled mode (not relevant for task creation) */}
{!isControlled && <div className="border-t border-[var(--color-border)] p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
{t('model.effort')}
{!isControlled && !isRuntimeScoped && (
<div className="border-t border-[var(--color-border)] p-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
{t('model.effort')}
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => {
void setEffort(opt.value)
setOpen(false)
}}
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => { setEffort(opt.value); setOpen(false) }}
className={`
py-2 rounded-lg text-xs font-semibold transition-colors text-center
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>}
)}
</div>
)}
</div>

View File

@ -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 (
<div className="h-[var(--statusbar-height)] flex items-center justify-between px-4 border-t border-[var(--color-border)] bg-[var(--color-surface-sidebar)] select-none text-[11px]">
@ -20,9 +25,9 @@ export function StatusBar() {
</div>
<div className="flex items-center gap-4">
{currentModel && (
{modelLabel && (
<span className="text-[var(--color-text-tertiary)] font-[var(--font-mono)]">
{currentModel.name}
{modelLabel}
</span>
)}
</div>

View File

@ -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',
},
]

View File

@ -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',

View File

@ -83,6 +83,8 @@ export const zh: Record<TranslationKey, string> = {
'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': '请求失败',

View File

@ -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() {
</div>
<div className="flex items-center gap-3">
<ModelSelector />
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
<button
onClick={handleSubmit}
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}

View File

@ -201,7 +201,7 @@ function ProviderSettings() {
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('common.active')}</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
@ -248,7 +248,7 @@ function ProviderSettings() {
</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('common.active')}</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
@ -273,7 +273,7 @@ function ProviderSettings() {
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.activate')}</Button>
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>

View File

@ -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: {

View File

@ -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<void>
@ -221,6 +224,11 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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 })

View File

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

View File

@ -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<string, RuntimeSelection>
setSelection: (key: string, selection: RuntimeSelection) => void
clearSelection: (key: string) => void
moveSelection: (fromKey: string, toKey: string) => void
}
function loadSelections(): Record<string, RuntimeSelection> {
if (typeof localStorage === 'undefined') return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, RuntimeSelection>
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function persistSelections(selections: Record<string, RuntimeSelection>) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(selections))
} catch {
// noop
}
}
export const useSessionRuntimeStore = create<SessionRuntimeStore>((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 }
}),
}))

View File

@ -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<SessionStore>((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,

View File

@ -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' }

View File

@ -0,0 +1,4 @@
export type RuntimeSelection = {
providerId: string | null
modelId: string
}

View File

@ -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<string, string>
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<string, string>
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')
})
})

View File

@ -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<void>((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)
})

View File

@ -21,26 +21,49 @@ import type { AnthropicRequest } from './transform/types.js'
const providerService = new ProviderService()
export async function handleProxyRequest(req: Request, url: URL): Promise<Response> {
// 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 },
)
}

View File

@ -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<string, SessionProcess>()
private providerService = new ProviderService()
private buildSessionCliArgs(
sessionId: string,
@ -140,7 +143,7 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
const childEnv = await this.buildChildEnv(workDir, sdkUrl)
const childEnv = await this.buildChildEnv(workDir, sdkUrl, options)
let proc: ReturnType<typeof Bun.spawn>
try {
@ -508,6 +511,7 @@ export class ConversationService {
private async buildChildEnv(
workDir: string,
sdkUrl?: string,
options?: SessionStartOptions,
): Promise<Record<string, string>> {
// 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')

View File

@ -222,17 +222,17 @@ export class ProviderService {
// --- Settings sync ---
private async syncToSettings(provider: SavedProvider): Promise<void> {
const settings = await this.readSettings()
const existingEnv = (settings.env as Record<string, string>) || {}
private buildManagedEnv(
provider: SavedProvider,
options?: { proxyPath?: string },
): Record<string, string> {
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<Record<string, string>> {
const provider = await this.getProvider(id)
return this.buildManagedEnv(provider, {
proxyPath: `/proxy/providers/${provider.id}`,
})
}
private async syncToSettings(provider: SavedProvider): Promise<void> {
const settings = await this.readSettings()
const existingEnv = (settings.env as Record<string, string>) || {}
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(

View File

@ -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<string | null> {
export async function generateTitle(
conversationText: string,
providerId?: string | null,
): Promise<string | null> {
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({

View File

@ -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' }

View File

@ -49,6 +49,11 @@ const sessionTitleState = new Map<string, {
allUserMessages: string[]
}>()
const runtimeOverrides = new Map<string, {
providerId: string | null
modelId: string
}>()
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<WebSocketData>,
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
) {
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<WebSocketData>,
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<WebSocketData>,
sessionId: string,
): Promise<void> {
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<WebSocketData>) {
const { sessionId } = ws.data
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
@ -404,6 +484,7 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, 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<WebSocketData>, 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()