mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
1137c99d5c
commit
773a8a703f
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
24
desktop/src/constants/modelCatalog.ts
Normal file
24
desktop/src/constants/modelCatalog.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
@ -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',
|
||||
|
||||
@ -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': '请求失败',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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()
|
||||
|
||||
69
desktop/src/stores/sessionRuntimeStore.ts
Normal file
69
desktop/src/stores/sessionRuntimeStore.ts
Normal 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 }
|
||||
}),
|
||||
}))
|
||||
@ -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,
|
||||
|
||||
@ -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' }
|
||||
|
||||
|
||||
4
desktop/src/types/runtime.ts
Normal file
4
desktop/src/types/runtime.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type RuntimeSelection = {
|
||||
providerId: string | null
|
||||
modelId: string
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 显式覆盖成 workDir,preload.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')
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' }
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user