cc-haha/desktop/src/stores/providerStore.ts
程序员阿江(Relakkes) 57a77453ed fix: isolate desktop provider runtimes per session
Desktop sessions can run against different saved providers at the same time, so the runtime selection must remain scoped to the session from WebSocket startup through the CLI request path. This adds parallel-session regression coverage, a browser-driven fake-provider E2E, and refreshes idle connected sessions after provider saves so stale provider edits are reapplied without disturbing active turns.

Constraint: Desktop provider credentials must stay session-scoped and must not leak across concurrent CLI child processes
Rejected: Rely on provider test connectivity alone | it does not exercise the desktop WebSocket, session runtime, or CLI request path
Confidence: high
Scope-risk: moderate
Directive: Do not change runtime selection persistence or provider env injection without rerunning the parallel provider browser E2E
Tested: cd desktop && bun run test src/stores/providerStore.test.ts src/stores/chatStore.test.ts
Tested: CLAUDE_CONFIG_DIR=/Users/nanmi/.claude bun test src/server/__tests__/conversations.test.ts --timeout 30000
Tested: desktop/scripts/e2e-parallel-providers-agent-browser.sh
Tested: bun run quality:smoke --provider-model custom:main:custom-main --provider-model minimax:main:minimax-main
Tested: cd desktop && bun run lint
Tested: git diff --check
Not-tested: bun run verify passes fully; coverage lane still fails on the existing agent-utils ratchet below baseline
2026-05-08 08:53:05 +08:00

175 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// desktop/src/stores/providerStore.ts
import { create } from 'zustand'
import { providersApi } from '../api/providers'
import { useChatStore } from './chatStore'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useSettingsStore } from './settingsStore'
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
import type {
SavedProvider,
CreateProviderInput,
UpdateProviderInput,
TestProviderConfigInput,
ProviderTestResult,
} from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
import type { RuntimeSelection } from '../types/runtime'
type ProviderStore = {
providers: SavedProvider[]
activeId: string | null
hasLoadedProviders: boolean
presets: ProviderPreset[]
isLoading: boolean
isPresetsLoading: boolean
error: string | null
fetchProviders: () => Promise<void>
fetchPresets: () => Promise<void>
createProvider: (input: CreateProviderInput) => Promise<SavedProvider>
updateProvider: (id: string, input: UpdateProviderInput) => Promise<SavedProvider>
deleteProvider: (id: string) => Promise<void>
activateProvider: (id: string) => Promise<void>
activateOfficial: () => Promise<void>
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) => Promise<ProviderTestResult>
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
}
function providerModelIds(provider: SavedProvider): Set<string> {
return new Set(
Object.values(provider.models)
.map((modelId) => modelId.trim())
.filter(Boolean),
)
}
function resolveRuntimeRefreshSelection(
provider: SavedProvider,
activeId: string | null,
currentSelection: RuntimeSelection | undefined,
): RuntimeSelection | null {
if (currentSelection?.providerId === provider.id) {
const modelIds = providerModelIds(provider)
return {
providerId: provider.id,
modelId: modelIds.has(currentSelection.modelId)
? currentSelection.modelId
: provider.models.main,
}
}
if (!currentSelection && activeId === provider.id) {
return {
providerId: provider.id,
modelId: provider.models.main,
}
}
return null
}
function refreshConnectedSessionsForProvider(provider: SavedProvider, activeId: string | null) {
const chatStore = useChatStore.getState()
const runtimeStore = useSessionRuntimeStore.getState()
for (const [sessionId, session] of Object.entries(chatStore.sessions)) {
if (session.connectionState !== 'connected' || session.chatState !== 'idle') {
continue
}
const selection = resolveRuntimeRefreshSelection(
provider,
activeId,
runtimeStore.selections[sessionId],
)
if (!selection) continue
runtimeStore.setSelection(sessionId, selection)
chatStore.setSessionRuntime(sessionId, selection)
}
}
export const useProviderStore = create<ProviderStore>((set, get) => ({
providers: [],
activeId: null,
hasLoadedProviders: false,
presets: [],
isLoading: false,
isPresetsLoading: false,
error: null,
fetchProviders: async () => {
set({ isLoading: true, error: null })
try {
const { providers, activeId } = await providersApi.list()
set({ providers, activeId, hasLoadedProviders: true, isLoading: false })
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
}
},
fetchPresets: async () => {
set({ isPresetsLoading: true, error: null })
try {
const { presets } = await providersApi.presets()
set({ presets, isPresetsLoading: false })
} catch (err) {
set({ isPresetsLoading: false, error: err instanceof Error ? err.message : String(err) })
}
},
createProvider: async (input) => {
const { provider } = await providersApi.create(input)
await get().fetchProviders()
return provider
},
updateProvider: async (id, input) => {
const { provider } = await providersApi.update(id, input)
await get().fetchProviders()
refreshConnectedSessionsForProvider(provider, get().activeId)
return provider
},
deleteProvider: async (id) => {
await providersApi.delete(id)
await get().fetchProviders()
},
activateProvider: async (id) => {
await providersApi.activate(id)
await get().fetchProviders()
// 更新默认 provider 时,同步刷新默认 model避免 settings.json 里残留
// 旧 provider 的 model id 导致默认选择指向不存在的模型。
const provider = get().providers.find((p) => p.id === id)
if (provider) {
const settings = useSettingsStore.getState()
await settings.setModel(provider.models.main)
await settings.fetchAll()
}
},
activateOfficial: async () => {
await providersApi.activateOfficial()
await get().fetchProviders()
// 切回官方默认时同样重置 currentModel避免残留第三方 model id。
const settings = useSettingsStore.getState()
await settings.setModel(OFFICIAL_DEFAULT_MODEL_ID)
await settings.fetchAll()
},
testProvider: async (id, overrides?) => {
const { result } = await providersApi.test(id, overrides)
return result
},
testConfig: async (input) => {
const { result } = await providersApi.testConfig(input)
return result
},
}))