mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(providers): make provider list sortable (#753)
Render provider settings through dnd-kit sortable rows, include official providers in the same order model, and persist providerOrder across server and desktop state. Tested: - bun test src/server/__tests__/providers.test.ts src/server/__tests__/persistence-upgrade.test.ts - cd desktop && bun run test -- --run src/stores/providerStore.test.ts src/__tests__/generalSettings.test.tsx -t "providerStore reorderProviders|Settings > Providers tab" - cd desktop && bun run lint - cd desktop && bun run build - bun run check:persistence-upgrade Not-tested: - bun run check:server (broad suite hit environment failures during this run: MCP stdio zshrc timeout, adapter dependency gap at the time, and e2e cascade) Confidence: high Scope-risk: moderate
This commit is contained in:
parent
0eee5a73b3
commit
01d8691673
@ -5,6 +5,9 @@
|
||||
"": {
|
||||
"name": "claude-code-desktop",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@ -105,6 +108,14 @@
|
||||
|
||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
|
||||
|
||||
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
|
||||
|
||||
"@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
|
||||
|
||||
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
"@electron/fuses": ["@electron/fuses@1.8.0", "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||
|
||||
@ -94,6 +94,9 @@
|
||||
"lint": "node ./node_modules/typescript/bin/tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
@ -35,6 +35,7 @@ const tauriProcessMock = vi.hoisted(() => ({
|
||||
}))
|
||||
const providerStoreState = {
|
||||
providers: [] as SavedProvider[],
|
||||
providerOrder: [] as string[],
|
||||
activeId: null as string | null,
|
||||
hasLoadedProviders: true,
|
||||
presets: [] as ProviderPreset[],
|
||||
@ -189,6 +190,7 @@ describe('Settings > General tab', () => {
|
||||
MOCK_GET_SETTINGS.mockResolvedValue({})
|
||||
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.providerOrder = []
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
providerStoreState.presets = []
|
||||
@ -1482,6 +1484,7 @@ describe('Settings > Providers tab', () => {
|
||||
notes: '',
|
||||
},
|
||||
]
|
||||
providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official']
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
})
|
||||
@ -1530,6 +1533,20 @@ describe('Settings > Providers tab', () => {
|
||||
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders saved and official providers in the stored sortable order', () => {
|
||||
providerStoreState.providerOrder = ['provider-1', 'openai-official', 'claude-official']
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
const rows = screen.getAllByRole('button', { name: 'Drag to reorder' })
|
||||
.map((handle) => handle.closest('[data-testid]')?.getAttribute('data-testid'))
|
||||
expect(rows).toEqual([
|
||||
'provider-provider-1',
|
||||
'openai-official-provider',
|
||||
'claude-official-provider',
|
||||
])
|
||||
})
|
||||
|
||||
it('requires confirmation before deleting a provider', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
|
||||
@ -11,6 +11,8 @@ import type {
|
||||
import type { ProviderPreset } from '../types/providerPreset'
|
||||
|
||||
type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null }
|
||||
type ProvidersListResponse = ProvidersResponse & { providerOrder?: string[] }
|
||||
type ProvidersReorderResponse = { providers: SavedProvider[]; providerOrder?: string[] }
|
||||
type ProviderResponse = { provider: SavedProvider }
|
||||
type PresetsResponse = { presets: ProviderPreset[] }
|
||||
type TestResultResponse = { result: ProviderTestResult }
|
||||
@ -22,7 +24,7 @@ type AuthStatusResponse = {
|
||||
|
||||
export const providersApi = {
|
||||
list() {
|
||||
return api.get<ProvidersResponse>('/api/providers')
|
||||
return api.get<ProvidersListResponse>('/api/providers')
|
||||
},
|
||||
|
||||
presets() {
|
||||
@ -62,7 +64,7 @@ export const providersApi = {
|
||||
},
|
||||
|
||||
reorder(orderedIds: string[]) {
|
||||
return api.put<{ providers: SavedProvider[] }>('/api/providers/reorder', { orderedIds })
|
||||
return api.put<ProvidersReorderResponse>('/api/providers/reorder', { orderedIds })
|
||||
},
|
||||
|
||||
test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string; authStrategy?: string }) {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import type { ModelInfo } from '../types/settings'
|
||||
|
||||
export const CLAUDE_OFFICIAL_PROVIDER_ID = 'claude-official'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
|
||||
export const BUILT_IN_PROVIDER_IDS = [
|
||||
CLAUDE_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
] as const
|
||||
export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'
|
||||
|
||||
|
||||
@ -1,6 +1,23 @@
|
||||
import { useState, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Copy, Eye, EyeOff, GripVertical, PowerOff, QrCode, RotateCw } from 'lucide-react'
|
||||
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
|
||||
import { useProviderStore } from '../stores/providerStore'
|
||||
import { useTranslation, type TranslationKey } from '../i18n'
|
||||
@ -34,7 +51,11 @@ import { MemorySettings } from './MemorySettings'
|
||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
|
||||
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider'
|
||||
import {
|
||||
BUILT_IN_PROVIDER_IDS,
|
||||
CLAUDE_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/openaiOfficialProvider'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
import { getBaseUrl } from '../api/client'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
@ -237,9 +258,74 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
|
||||
|
||||
// ─── Provider Settings ──────────────────────────────────────
|
||||
|
||||
type ProviderListItem =
|
||||
| { id: typeof CLAUDE_OFFICIAL_PROVIDER_ID; kind: 'claude-official' }
|
||||
| { id: typeof OPENAI_OFFICIAL_PROVIDER_ID; kind: 'openai-official' }
|
||||
| { id: string; kind: 'saved'; provider: SavedProvider }
|
||||
|
||||
function defaultProviderOrder(providers: SavedProvider[]): string[] {
|
||||
return [
|
||||
...providers.map((provider) => provider.id),
|
||||
...BUILT_IN_PROVIDER_IDS,
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeProviderOrder(providerOrder: string[], providers: SavedProvider[]): string[] {
|
||||
const knownIds = new Set<string>(defaultProviderOrder(providers))
|
||||
const seen = new Set<string>()
|
||||
const order: string[] = []
|
||||
|
||||
for (const id of providerOrder.length > 0 ? providerOrder : defaultProviderOrder(providers)) {
|
||||
if (!knownIds.has(id) || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
for (const id of defaultProviderOrder(providers)) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
function buildProviderListItems(
|
||||
providers: SavedProvider[],
|
||||
providerOrder: string[],
|
||||
): ProviderListItem[] {
|
||||
const savedItems = new Map(
|
||||
providers.map((provider) => [
|
||||
provider.id,
|
||||
{ id: provider.id, kind: 'saved', provider } satisfies ProviderListItem,
|
||||
]),
|
||||
)
|
||||
const items = new Map<string, ProviderListItem>([
|
||||
[CLAUDE_OFFICIAL_PROVIDER_ID, { id: CLAUDE_OFFICIAL_PROVIDER_ID, kind: 'claude-official' }],
|
||||
[OPENAI_OFFICIAL_PROVIDER_ID, { id: OPENAI_OFFICIAL_PROVIDER_ID, kind: 'openai-official' }],
|
||||
...savedItems,
|
||||
])
|
||||
|
||||
return normalizeProviderOrder(providerOrder, providers)
|
||||
.map((id) => items.get(id))
|
||||
.filter((item): item is ProviderListItem => item !== undefined)
|
||||
}
|
||||
|
||||
function providerItemTestId(item: ProviderListItem): string {
|
||||
switch (item.kind) {
|
||||
case 'claude-official':
|
||||
return 'claude-official-provider'
|
||||
case 'openai-official':
|
||||
return 'openai-official-provider'
|
||||
case 'saved':
|
||||
return `provider-${item.provider.id}`
|
||||
}
|
||||
}
|
||||
|
||||
function ProviderSettings() {
|
||||
const {
|
||||
providers,
|
||||
providerOrder,
|
||||
activeId,
|
||||
hasLoadedProviders,
|
||||
presets,
|
||||
@ -260,8 +346,14 @@ function ProviderSettings() {
|
||||
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
|
||||
const [isDeletingProvider, setIsDeletingProvider] = useState(false)
|
||||
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchProviders()
|
||||
@ -311,44 +403,21 @@ function ProviderSettings() {
|
||||
await fetchSettings()
|
||||
}
|
||||
|
||||
const handleProviderDragStart = (event: React.DragEvent, id: string) => {
|
||||
setDraggingId(id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', id)
|
||||
}
|
||||
const providerItems = useMemo(
|
||||
() => buildProviderListItems(providers, providerOrder),
|
||||
[providerOrder, providers],
|
||||
)
|
||||
|
||||
const handleProviderDragOver = (event: React.DragEvent, overId: string) => {
|
||||
if (!draggingId || draggingId === overId) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId !== overId) setDragOverId(overId)
|
||||
}
|
||||
const handleProviderDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const clearProviderDragState = () => {
|
||||
setDraggingId(null)
|
||||
setDragOverId(null)
|
||||
}
|
||||
const ids = providerItems.map((item) => item.id)
|
||||
const oldIndex = ids.indexOf(String(active.id))
|
||||
const newIndex = ids.indexOf(String(over.id))
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const handleProviderDrop = (event: React.DragEvent, targetId: string) => {
|
||||
event.preventDefault()
|
||||
const sourceId = draggingId || event.dataTransfer.getData('text/plain')
|
||||
if (!sourceId || sourceId === targetId) {
|
||||
clearProviderDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const ids = providers.map((p) => p.id)
|
||||
const from = ids.indexOf(sourceId)
|
||||
const to = ids.indexOf(targetId)
|
||||
if (from === -1 || to === -1) {
|
||||
clearProviderDragState()
|
||||
return
|
||||
}
|
||||
|
||||
ids.splice(from, 1)
|
||||
ids.splice(to, 0, sourceId)
|
||||
clearProviderDragState()
|
||||
void reorderProviders(ids)
|
||||
void reorderProviders(arrayMove(ids, oldIndex, newIndex))
|
||||
}
|
||||
|
||||
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
|
||||
@ -367,126 +436,92 @@ function ProviderSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Official provider — always visible at top */}
|
||||
<div
|
||||
data-testid="claude-official-provider"
|
||||
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
|
||||
isClaudeOfficialActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
|
||||
}`}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleProviderDragEnd}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-3.5"
|
||||
onClick={() => !isClaudeOfficialActive && handleActivateOfficial()}
|
||||
<SortableContext
|
||||
items={providerItems.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isClaudeOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
|
||||
{isClaudeOfficialActive && (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{providerItems.map((item) => {
|
||||
if (item.kind === 'claude-official') {
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={isClaudeOfficialActive}
|
||||
dragLabel={t('settings.providers.dragToReorder')}
|
||||
onActivate={!isClaudeOfficialActive ? handleActivateOfficial : undefined}
|
||||
title={t('settings.providers.officialName')}
|
||||
subtitle={t('settings.providers.officialDesc')}
|
||||
badges={isClaudeOfficialActive ? (
|
||||
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
||||
) : null}
|
||||
details={isClaudeOfficialActive ? (
|
||||
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
|
||||
<ClaudeOfficialLogin />
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{isClaudeOfficialActive && (
|
||||
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
|
||||
<ClaudeOfficialLogin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
if (item.kind === 'openai-official') {
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={isOpenAIOfficialActive}
|
||||
dragLabel={t('settings.providers.dragToReorder')}
|
||||
onActivate={!isOpenAIOfficialActive ? () => handleActivate(OPENAI_OFFICIAL_PROVIDER_ID) : undefined}
|
||||
title={t('settings.providers.openaiOfficialName')}
|
||||
subtitle={t('settings.providers.openaiOfficialDesc')}
|
||||
badges={isOpenAIOfficialActive ? (
|
||||
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
||||
) : null}
|
||||
details={isOpenAIOfficialActive ? (
|
||||
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
|
||||
<ChatGPTOfficialLogin />
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<div
|
||||
data-testid="openai-official-provider"
|
||||
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
|
||||
isOpenAIOfficialActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-3.5"
|
||||
onClick={() => !isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOpenAIOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.openaiOfficialName')}</span>
|
||||
{isOpenAIOfficialActive && (
|
||||
<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.openaiOfficialDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
const provider = item.provider
|
||||
const isActive = activeId === provider.id
|
||||
const test = testResults[provider.id]
|
||||
const preset = presetMap.get(provider.presetId)
|
||||
|
||||
{isOpenAIOfficialActive && (
|
||||
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
|
||||
<ChatGPTOfficialLogin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Saved providers */}
|
||||
{isLoading && providers.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => {
|
||||
const isActive = activeId === provider.id
|
||||
const test = testResults[provider.id]
|
||||
const preset = presetMap.get(provider.presetId)
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
onDragOver={(e) => handleProviderDragOver(e, provider.id)}
|
||||
onDrop={(e) => handleProviderDrop(e, provider.id)}
|
||||
onDragLeave={() => setDragOverId((cur) => (cur === provider.id ? null : cur))}
|
||||
className={`relative flex items-center gap-3 px-4 py-3.5 rounded-xl border transition-all group ${
|
||||
isActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
|
||||
} ${draggingId === provider.id ? 'opacity-50' : ''} ${
|
||||
dragOverId === provider.id ? 'border-[var(--color-brand)] border-dashed' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(e) => handleProviderDragStart(e, provider.id)}
|
||||
onDragEnd={clearProviderDragState}
|
||||
role="button"
|
||||
aria-label={t('settings.providers.dragToReorder')}
|
||||
title={t('settings.providers.dragToReorder')}
|
||||
className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] flex-shrink-0 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
drag_indicator
|
||||
</span>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
|
||||
{preset && preset.id !== 'custom' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
|
||||
)}
|
||||
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
|
||||
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
|
||||
</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('settings.providers.default')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
||||
{provider.baseUrl} · {provider.models.main}
|
||||
</div>
|
||||
{test && !test.loading && test.result && (
|
||||
<div className="text-xs mt-1 flex flex-col gap-0.5">
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
dragLabel={t('settings.providers.dragToReorder')}
|
||||
onActivate={!isActive ? () => handleActivate(provider.id) : undefined}
|
||||
title={provider.name}
|
||||
subtitle={`${provider.baseUrl} · ${provider.models.main}`}
|
||||
badges={(
|
||||
<>
|
||||
{preset && preset.id !== 'custom' && (
|
||||
<span className="rounded bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-text-tertiary)]">{preset.name}</span>
|
||||
)}
|
||||
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
|
||||
<span className="rounded bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--color-warning)]">
|
||||
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
result={test && !test.loading && test.result ? (
|
||||
<div className="mt-1 flex flex-col gap-0.5 text-xs">
|
||||
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
||||
{test.result.connectivity.success
|
||||
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
|
||||
@ -500,23 +535,31 @@ function ProviderSettings() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
actions={(
|
||||
<>
|
||||
{!isActive && (
|
||||
<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>
|
||||
{!isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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.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>
|
||||
{!isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{isLoading && providers.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Create Modal — conditionally rendered so state resets on close */}
|
||||
{showCreateModal && (
|
||||
@ -546,6 +589,97 @@ function ProviderSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
type SortableProviderCardProps = {
|
||||
item: ProviderListItem
|
||||
isActive: boolean
|
||||
dragLabel: string
|
||||
title: ReactNode
|
||||
subtitle: ReactNode
|
||||
badges?: ReactNode
|
||||
result?: ReactNode
|
||||
actions?: ReactNode
|
||||
details?: ReactNode
|
||||
onActivate?: () => void
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
item,
|
||||
isActive,
|
||||
dragLabel,
|
||||
title,
|
||||
subtitle,
|
||||
badges,
|
||||
result,
|
||||
actions,
|
||||
details,
|
||||
onActivate,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id })
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 20 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-testid={providerItemTestId(item)}
|
||||
className={`group relative flex flex-col rounded-[8px] border transition-colors ${
|
||||
isActive
|
||||
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-container-low)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
||||
} ${isDragging ? 'shadow-[var(--shadow-dropdown)] opacity-90' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={dragLabel}
|
||||
title={dragLabel}
|
||||
className="flex h-8 w-8 shrink-0 cursor-grab items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-secondary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:cursor-grabbing"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onActivate}
|
||||
aria-disabled={!onActivate}
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 rounded-[6px] text-left focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
||||
onActivate ? 'cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
|
||||
{badges}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--color-text-tertiary)]">{subtitle}</span>
|
||||
{result}
|
||||
</span>
|
||||
</button>
|
||||
{actions && (
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{details}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Provider Form Modal ──────────────────────────────────────
|
||||
|
||||
type ProviderFormProps = {
|
||||
|
||||
@ -196,7 +196,7 @@ describe('providerStore reorderProviders', () => {
|
||||
const b = makeProvider({ id: 'b', name: 'B' })
|
||||
const c = makeProvider({ id: 'c', name: 'C' })
|
||||
|
||||
let resolveReorder: (value: { providers: SavedProvider[] }) => void = () => {}
|
||||
let resolveReorder: (value: { providers: SavedProvider[]; providerOrder?: string[] }) => void = () => {}
|
||||
providersApiMock.reorder.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveReorder = resolve
|
||||
@ -218,6 +218,28 @@ describe('providerStore reorderProviders', () => {
|
||||
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['c', 'a', 'b'])
|
||||
})
|
||||
|
||||
it('optimistically applies full display order including built-in providers', async () => {
|
||||
const a = makeProvider({ id: 'a', name: 'A' })
|
||||
const b = makeProvider({ id: 'b', name: 'B' })
|
||||
providersApiMock.reorder.mockResolvedValue({
|
||||
providers: [b, a],
|
||||
providerOrder: ['openai-official', 'b', 'claude-official', 'a'],
|
||||
})
|
||||
|
||||
const { useProviderStore } = await import('./providerStore')
|
||||
useProviderStore.setState({
|
||||
providers: [a, b],
|
||||
providerOrder: ['a', 'b', 'claude-official', 'openai-official'],
|
||||
activeId: null,
|
||||
})
|
||||
|
||||
await useProviderStore.getState().reorderProviders(['openai-official', 'b', 'claude-official', 'a'])
|
||||
|
||||
expect(providersApiMock.reorder).toHaveBeenCalledWith(['openai-official', 'b', 'claude-official', 'a'])
|
||||
expect(useProviderStore.getState().providerOrder).toEqual(['openai-official', 'b', 'claude-official', 'a'])
|
||||
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('rolls back to the previous order when the request fails', async () => {
|
||||
const a = makeProvider({ id: 'a', name: 'A' })
|
||||
const b = makeProvider({ id: 'b', name: 'B' })
|
||||
|
||||
@ -7,6 +7,7 @@ import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import { useSettingsStore } from './settingsStore'
|
||||
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
||||
import {
|
||||
BUILT_IN_PROVIDER_IDS,
|
||||
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/openaiOfficialProvider'
|
||||
@ -22,6 +23,7 @@ import type { RuntimeSelection } from '../types/runtime'
|
||||
|
||||
type ProviderStore = {
|
||||
providers: SavedProvider[]
|
||||
providerOrder: string[]
|
||||
activeId: string | null
|
||||
hasLoadedProviders: boolean
|
||||
presets: ProviderPreset[]
|
||||
@ -41,6 +43,63 @@ type ProviderStore = {
|
||||
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
|
||||
}
|
||||
|
||||
function defaultProviderOrder(providers: SavedProvider[]): string[] {
|
||||
return [
|
||||
...providers.map((provider) => provider.id),
|
||||
...BUILT_IN_PROVIDER_IDS,
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeProviderOrder(providerOrder: string[] | undefined, providers: SavedProvider[]): string[] {
|
||||
const knownIds = new Set<string>(defaultProviderOrder(providers))
|
||||
const source = providerOrder && providerOrder.length > 0
|
||||
? providerOrder
|
||||
: defaultProviderOrder(providers)
|
||||
const seen = new Set<string>()
|
||||
const order: string[] = []
|
||||
|
||||
for (const id of source) {
|
||||
if (!knownIds.has(id) || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
for (const id of defaultProviderOrder(providers)) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
function isPermutation(candidateIds: string[], expectedIds: string[]): boolean {
|
||||
const expectedSet = new Set(expectedIds)
|
||||
const candidateSet = new Set(candidateIds)
|
||||
return (
|
||||
candidateIds.length === expectedIds.length &&
|
||||
candidateSet.size === candidateIds.length &&
|
||||
expectedIds.every((id) => candidateSet.has(id)) &&
|
||||
candidateIds.every((id) => expectedSet.has(id))
|
||||
)
|
||||
}
|
||||
|
||||
function sortSavedProvidersByOrder(providers: SavedProvider[], providerOrder: string[]): SavedProvider[] {
|
||||
const byId = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
return providerOrder
|
||||
.map((id) => byId.get(id))
|
||||
.filter((provider): provider is SavedProvider => provider !== undefined)
|
||||
}
|
||||
|
||||
function mergeSavedOrderIntoProviderOrder(providerOrder: string[], savedOrder: string[]): string[] {
|
||||
const savedSet = new Set(savedOrder)
|
||||
const queue = [...savedOrder]
|
||||
return providerOrder.map((id) => {
|
||||
if (!savedSet.has(id)) return id
|
||||
return queue.shift() ?? id
|
||||
})
|
||||
}
|
||||
|
||||
function providerModelIds(provider: SavedProvider): Set<string> {
|
||||
return new Set(
|
||||
Object.values(provider.models)
|
||||
@ -98,6 +157,7 @@ function refreshConnectedSessionsForProvider(provider: SavedProvider, activeId:
|
||||
|
||||
export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
providers: [],
|
||||
providerOrder: [...BUILT_IN_PROVIDER_IDS],
|
||||
activeId: null,
|
||||
hasLoadedProviders: false,
|
||||
presets: [],
|
||||
@ -108,8 +168,14 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
fetchProviders: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { providers, activeId } = await providersApi.list()
|
||||
set({ providers, activeId, hasLoadedProviders: true, isLoading: false })
|
||||
const { providers, activeId, providerOrder } = await providersApi.list()
|
||||
set({
|
||||
providers,
|
||||
providerOrder: normalizeProviderOrder(providerOrder, providers),
|
||||
activeId,
|
||||
hasLoadedProviders: true,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
@ -148,24 +214,37 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
|
||||
reorderProviders: async (orderedIds) => {
|
||||
const previous = get().providers
|
||||
// Optimistically reorder locally so the drag feels instant…
|
||||
const byId = new Map(previous.map((p) => [p.id, p]))
|
||||
const next = orderedIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((p): p is SavedProvider => p !== undefined)
|
||||
// Bail out if the id set is stale (e.g. a provider was deleted mid-drag).
|
||||
if (next.length !== previous.length) {
|
||||
const previousOrder = normalizeProviderOrder(get().providerOrder, previous)
|
||||
const savedIds = previous.map((provider) => provider.id)
|
||||
const nextOrder = isPermutation(orderedIds, previousOrder)
|
||||
? orderedIds
|
||||
: isPermutation(orderedIds, savedIds)
|
||||
? mergeSavedOrderIntoProviderOrder(previousOrder, orderedIds)
|
||||
: null
|
||||
|
||||
if (!nextOrder) {
|
||||
await get().fetchProviders()
|
||||
return
|
||||
}
|
||||
set({ providers: next })
|
||||
|
||||
// Optimistically reorder locally so the drag feels instant.
|
||||
set({
|
||||
providers: sortSavedProvidersByOrder(previous, nextOrder),
|
||||
providerOrder: nextOrder,
|
||||
})
|
||||
try {
|
||||
const { providers } = await providersApi.reorder(orderedIds)
|
||||
set({ providers })
|
||||
const { providers, providerOrder } = await providersApi.reorder(orderedIds)
|
||||
set({
|
||||
providers,
|
||||
providerOrder: normalizeProviderOrder(providerOrder, providers),
|
||||
})
|
||||
} catch (err) {
|
||||
// …and roll back to the pre-drag order if persistence fails. `previous`
|
||||
// is the server's last-known truth, so no refetch is needed.
|
||||
set({ providers: previous, error: err instanceof Error ? err.message : String(err) })
|
||||
// Roll back to the server's last-known truth if persistence fails.
|
||||
set({
|
||||
providers: previous,
|
||||
providerOrder: previousOrder,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -62,11 +62,13 @@ describe('persistent storage upgrade migrations', () => {
|
||||
schemaVersion?: number
|
||||
activeId?: string | null
|
||||
activeProviderId?: string
|
||||
providerOrder?: string[]
|
||||
rootFutureField?: unknown
|
||||
providers?: Array<Record<string, unknown>>
|
||||
}
|
||||
expect(migrated.schemaVersion).toBe(CURRENT_PROVIDER_INDEX_SCHEMA_VERSION)
|
||||
expect(migrated.activeId).toBe('provider-1')
|
||||
expect(migrated.providerOrder).toEqual(['provider-1', 'claude-official', 'openai-official'])
|
||||
expect(migrated.activeProviderId).toBeUndefined()
|
||||
expect(migrated.rootFutureField).toEqual({ keep: true })
|
||||
expect(migrated.providers?.[0]?.extraFutureField).toBe('keep-me')
|
||||
@ -124,6 +126,7 @@ describe('persistent storage upgrade migrations', () => {
|
||||
|
||||
const migrated = JSON.parse(await fs.readFile(path.join(tempDir, 'cc-haha', 'providers.json'), 'utf-8')) as {
|
||||
activeId?: string | null
|
||||
providerOrder?: string[]
|
||||
providers?: Array<{
|
||||
id?: string
|
||||
presetId?: string
|
||||
@ -133,6 +136,7 @@ describe('persistent storage upgrade migrations', () => {
|
||||
}>
|
||||
}
|
||||
expect(migrated.activeId).toBe('legacy-provider')
|
||||
expect(migrated.providerOrder).toEqual(['legacy-provider', 'claude-official', 'openai-official'])
|
||||
expect(migrated.providers?.[0]).toMatchObject({
|
||||
id: 'legacy-provider',
|
||||
presetId: 'custom',
|
||||
@ -196,9 +200,11 @@ describe('persistent storage upgrade migrations', () => {
|
||||
expect(report.migratedEntries).not.toContain('providers.json -> cc-haha/providers.json')
|
||||
const current = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'providers.json'), 'utf-8')) as {
|
||||
activeId?: string | null
|
||||
providerOrder?: string[]
|
||||
providers?: unknown[]
|
||||
}
|
||||
expect(current.activeId).toBeNull()
|
||||
expect(current.providerOrder).toEqual(['claude-official', 'openai-official'])
|
||||
expect(current.providers).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@ -95,7 +95,11 @@ describe('ProviderService', () => {
|
||||
test('should return empty array when no providers exist', async () => {
|
||||
const svc = new ProviderService()
|
||||
const result = await svc.listProviders()
|
||||
expect(result).toEqual({ providers: [], activeId: null })
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
providerOrder: ['claude-official', 'openai-official'],
|
||||
})
|
||||
})
|
||||
|
||||
test('should recover from a malformed providers index after an upgrade', async () => {
|
||||
@ -106,7 +110,11 @@ describe('ProviderService', () => {
|
||||
const result = await svc.listProviders()
|
||||
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
|
||||
|
||||
expect(result).toEqual({ providers: [], activeId: null })
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
providerOrder: ['claude-official', 'openai-official'],
|
||||
})
|
||||
expect(files.some((name) => name.startsWith('providers.json.invalid-'))).toBe(true)
|
||||
})
|
||||
|
||||
@ -635,7 +643,7 @@ describe('ProviderService', () => {
|
||||
const c = await svc.addProvider(sampleInput({ name: 'C' }))
|
||||
|
||||
const result = await svc.reorderProviders([c.id, a.id, b.id])
|
||||
expect(result.map((p) => p.name)).toEqual(['C', 'A', 'B'])
|
||||
expect(result.providers.map((p) => p.name)).toEqual(['C', 'A', 'B'])
|
||||
|
||||
// Persisted order survives a fresh read
|
||||
const { providers } = await svc.listProviders()
|
||||
@ -645,6 +653,23 @@ describe('ProviderService', () => {
|
||||
expect((config.providers as Array<{ name: string }>).map((p) => p.name)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
test('should persist display order including built-in official providers', async () => {
|
||||
const svc = new ProviderService()
|
||||
const a = await svc.addProvider(sampleInput({ name: 'A' }))
|
||||
const b = await svc.addProvider(sampleInput({ name: 'B' }))
|
||||
|
||||
const result = await svc.reorderProviders(['openai-official', b.id, 'claude-official', a.id])
|
||||
|
||||
expect(result.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
|
||||
expect(result.providers.map((p) => p.id)).toEqual([b.id, a.id])
|
||||
|
||||
const listed = await svc.listProviders()
|
||||
expect(listed.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
|
||||
|
||||
const config = await readProvidersConfig()
|
||||
expect(config.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
|
||||
})
|
||||
|
||||
test('should not change activeId when reordering', async () => {
|
||||
const svc = new ProviderService()
|
||||
const a = await svc.addProvider(sampleInput({ name: 'A' }))
|
||||
@ -1606,8 +1631,9 @@ describe('Providers API', () => {
|
||||
const res = await handleProvidersApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { providers: { name: string }[] }
|
||||
const body = (await res.json()) as { providers: { name: string }[]; providerOrder: string[] }
|
||||
expect(body.providers.map((p) => p.name)).toEqual(['B', 'A'])
|
||||
expect(body.providerOrder).toEqual([b.id, a.id, 'claude-official', 'openai-official'])
|
||||
})
|
||||
|
||||
test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => {
|
||||
|
||||
@ -172,8 +172,8 @@ async function handleReorder(req: Request): Promise<Response> {
|
||||
const body = await parseJsonBody(req)
|
||||
try {
|
||||
const input = ReorderProvidersSchema.parse(body)
|
||||
const providers = await providerService.reorderProviders(input.orderedIds)
|
||||
return Response.json({ providers })
|
||||
const { providers, providerOrder } = await providerService.reorderProviders(input.orderedIds)
|
||||
return Response.json({ providers, providerOrder })
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
throw err
|
||||
|
||||
@ -8,9 +8,12 @@ import {
|
||||
} from '../../services/openaiAuth/models.js'
|
||||
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
|
||||
import { getHahaOpenAIOAuthFilePath } from './hahaOpenAIOAuthService.js'
|
||||
import type { SavedProvider } from '../types/provider.js'
|
||||
import {
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
type SavedProvider,
|
||||
} from '../types/provider.js'
|
||||
|
||||
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
|
||||
export { OPENAI_OFFICIAL_PROVIDER_ID }
|
||||
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'
|
||||
export const OPENAI_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_OPENAI_OAUTH_PROVIDER'
|
||||
export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE'
|
||||
|
||||
@ -4,8 +4,9 @@ import * as path from 'path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js'
|
||||
import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js'
|
||||
import { BUILT_IN_PROVIDER_IDS } from '../types/provider.js'
|
||||
|
||||
export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1
|
||||
export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 2
|
||||
|
||||
type MigrationReport = {
|
||||
migratedEntries: string[]
|
||||
@ -76,6 +77,36 @@ function isLegacyRootProvider(value: unknown): value is LegacyRootProvider {
|
||||
)
|
||||
}
|
||||
|
||||
function defaultProviderOrder(providers: JsonObject[]): string[] {
|
||||
return [
|
||||
...providers
|
||||
.map((provider) => provider.id)
|
||||
.filter((id): id is string => typeof id === 'string'),
|
||||
...BUILT_IN_PROVIDER_IDS,
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeProviderOrder(value: unknown, providers: JsonObject[]): string[] {
|
||||
const knownIds = new Set<string>(defaultProviderOrder(providers))
|
||||
const source = Array.isArray(value) ? value : defaultProviderOrder(providers)
|
||||
const seen = new Set<string>()
|
||||
const order: string[] = []
|
||||
|
||||
for (const id of source) {
|
||||
if (typeof id !== 'string' || !knownIds.has(id) || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
for (const id of defaultProviderOrder(providers)) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
function errnoCode(error: unknown): string | undefined {
|
||||
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
@ -126,10 +157,15 @@ function migrateProvidersIndex(value: unknown): JsonObject {
|
||||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
activeId: null,
|
||||
providers: [],
|
||||
providerOrder: [...BUILT_IN_PROVIDER_IDS],
|
||||
}
|
||||
}
|
||||
|
||||
const { activeProviderId: _legacyActiveProviderId, ...rest } = value
|
||||
const {
|
||||
activeProviderId: _legacyActiveProviderId,
|
||||
providerOrder: rawProviderOrder,
|
||||
...rest
|
||||
} = value
|
||||
const providers = value.providers.filter(isSavedProvider)
|
||||
const rawActiveId =
|
||||
typeof value.activeId === 'string'
|
||||
@ -149,6 +185,7 @@ function migrateProvidersIndex(value: unknown): JsonObject {
|
||||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
activeId,
|
||||
providers,
|
||||
providerOrder: normalizeProviderOrder(rawProviderOrder, providers),
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,6 +294,7 @@ function migrateLegacyRootProvidersConfig(value: unknown): JsonObject | null {
|
||||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
activeId,
|
||||
providers,
|
||||
providerOrder: normalizeProviderOrder(undefined, providers),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,9 @@ import type {
|
||||
ProvidersIndex,
|
||||
SavedProvider,
|
||||
} from '../types/provider.js'
|
||||
import {
|
||||
BUILT_IN_PROVIDER_IDS,
|
||||
} from '../types/provider.js'
|
||||
import {
|
||||
ATTRIBUTION_HEADER_ENV_KEY,
|
||||
attributionHeaderEnvForModel,
|
||||
@ -92,12 +95,50 @@ export function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProviderOrder(providers: SavedProvider[]): string[] {
|
||||
return [
|
||||
...providers.map((provider) => provider.id),
|
||||
...BUILT_IN_PROVIDER_IDS,
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeProviderOrder(value: unknown, providers: SavedProvider[]): string[] {
|
||||
const providerIds = providers.map((provider) => provider.id)
|
||||
const knownIds = new Set<string>([
|
||||
...providerIds,
|
||||
...BUILT_IN_PROVIDER_IDS,
|
||||
])
|
||||
const source = Array.isArray(value)
|
||||
? value
|
||||
: defaultProviderOrder(providers)
|
||||
const seen = new Set<string>()
|
||||
const order: string[] = []
|
||||
|
||||
for (const id of source) {
|
||||
if (typeof id !== 'string' || !knownIds.has(id) || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
for (const id of defaultProviderOrder(providers)) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
order.push(id)
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
export function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.providers)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { activeProviderId: legacyActiveProviderId, ...rest } = value
|
||||
const {
|
||||
activeProviderId: legacyActiveProviderId,
|
||||
providerOrder: rawProviderOrder,
|
||||
...rest
|
||||
} = value
|
||||
const providers = value.providers
|
||||
.filter(isSavedProvider)
|
||||
.map((provider) => normalizeSavedProvider(provider))
|
||||
@ -119,6 +160,7 @@ export function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
|
||||
schemaVersion: typeof value.schemaVersion === 'number' ? value.schemaVersion : 1,
|
||||
activeId,
|
||||
providers,
|
||||
providerOrder: normalizeProviderOrder(rawProviderOrder, providers),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -53,11 +53,76 @@ import type {
|
||||
ApiFormat,
|
||||
ProviderAuthStrategy,
|
||||
} from '../types/provider.js'
|
||||
import {
|
||||
BUILT_IN_PROVIDER_IDS,
|
||||
} from '../types/provider.js'
|
||||
|
||||
const DEFAULT_INDEX: ProvidersIndex = {
|
||||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
activeId: null,
|
||||
providers: [],
|
||||
providerOrder: [...BUILT_IN_PROVIDER_IDS],
|
||||
}
|
||||
|
||||
function isPermutation(candidateIds: string[], expectedIds: string[]): boolean {
|
||||
const expectedSet = new Set(expectedIds)
|
||||
const candidateSet = new Set(candidateIds)
|
||||
return (
|
||||
candidateIds.length === expectedIds.length &&
|
||||
candidateSet.size === candidateIds.length &&
|
||||
expectedIds.every((id) => candidateSet.has(id)) &&
|
||||
candidateIds.every((id) => expectedSet.has(id))
|
||||
)
|
||||
}
|
||||
|
||||
function savedProviderIds(providers: SavedProvider[]): string[] {
|
||||
return providers.map((provider) => provider.id)
|
||||
}
|
||||
|
||||
function fullProviderOrderIds(providers: SavedProvider[]): string[] {
|
||||
return [
|
||||
...savedProviderIds(providers),
|
||||
...BUILT_IN_PROVIDER_IDS,
|
||||
]
|
||||
}
|
||||
|
||||
function sortSavedProvidersByOrder(providers: SavedProvider[], providerOrder: string[]): SavedProvider[] {
|
||||
const byId = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
return providerOrder
|
||||
.map((id) => byId.get(id))
|
||||
.filter((provider): provider is SavedProvider => provider !== undefined)
|
||||
}
|
||||
|
||||
function mergeSavedOrderIntoDisplayOrder(providerOrder: string[], savedOrder: string[]): string[] {
|
||||
const savedSet = new Set(savedOrder)
|
||||
const queue = [...savedOrder]
|
||||
return providerOrder.map((id) => {
|
||||
if (!savedSet.has(id)) return id
|
||||
return queue.shift() ?? id
|
||||
})
|
||||
}
|
||||
|
||||
function appendNewProviderToOrder(providerOrder: string[], providerId: string, existingProviders: SavedProvider[]): string[] {
|
||||
const existingProviderIds = new Set(existingProviders.map((provider) => provider.id))
|
||||
const lastSavedIndex = providerOrder.reduce(
|
||||
(latest, id, index) => existingProviderIds.has(id) ? index : latest,
|
||||
-1,
|
||||
)
|
||||
if (lastSavedIndex !== -1) {
|
||||
return [
|
||||
...providerOrder.slice(0, lastSavedIndex + 1),
|
||||
providerId,
|
||||
...providerOrder.slice(lastSavedIndex + 1),
|
||||
]
|
||||
}
|
||||
|
||||
const firstBuiltInIndex = providerOrder.findIndex((id) => BUILT_IN_PROVIDER_IDS.includes(id as typeof BUILT_IN_PROVIDER_IDS[number]))
|
||||
if (firstBuiltInIndex === -1) return [...providerOrder, providerId]
|
||||
return [
|
||||
...providerOrder.slice(0, firstBuiltInIndex),
|
||||
providerId,
|
||||
...providerOrder.slice(firstBuiltInIndex),
|
||||
]
|
||||
}
|
||||
|
||||
export class ProviderService {
|
||||
@ -125,9 +190,13 @@ export class ProviderService {
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
|
||||
async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null; providerOrder: string[] }> {
|
||||
const index = await this.readIndex()
|
||||
return { providers: index.providers, activeId: index.activeId }
|
||||
return {
|
||||
providers: index.providers,
|
||||
activeId: index.activeId,
|
||||
providerOrder: index.providerOrder,
|
||||
}
|
||||
}
|
||||
|
||||
async getProvider(id: string): Promise<SavedProvider> {
|
||||
@ -159,6 +228,7 @@ export class ProviderService {
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
}
|
||||
|
||||
index.providerOrder = appendNewProviderToOrder(index.providerOrder, provider.id, index.providers)
|
||||
index.providers.push(provider)
|
||||
await this.writeIndex(index)
|
||||
return provider
|
||||
@ -210,37 +280,39 @@ export class ProviderService {
|
||||
}
|
||||
|
||||
index.providers.splice(idx, 1)
|
||||
index.providerOrder = index.providerOrder.filter((providerId) => providerId !== id)
|
||||
await this.writeIndex(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder saved providers to match the given id order.
|
||||
* Reorder providers to match the given display id order.
|
||||
*
|
||||
* The display order is simply the array order in providers.json, so reordering
|
||||
* persists by rewriting the array — no separate `order` field is needed.
|
||||
* `orderedIds` must be a permutation of the existing provider ids (same members,
|
||||
* no duplicates, none missing); otherwise the request is rejected so a stale
|
||||
* client can't silently drop or duplicate a provider.
|
||||
* New clients send a full display-order permutation including the built-in
|
||||
* official providers. Legacy clients may still send only saved provider ids;
|
||||
* in that case the saved providers are reordered inside the current display
|
||||
* order without moving the built-in official rows.
|
||||
*/
|
||||
async reorderProviders(orderedIds: string[]): Promise<SavedProvider[]> {
|
||||
async reorderProviders(orderedIds: string[]): Promise<{ providers: SavedProvider[]; providerOrder: string[] }> {
|
||||
const index = await this.readIndex()
|
||||
|
||||
const currentIds = index.providers.map((p) => p.id)
|
||||
const orderedSet = new Set(orderedIds)
|
||||
const isPermutation =
|
||||
orderedIds.length === currentIds.length &&
|
||||
orderedSet.size === orderedIds.length &&
|
||||
currentIds.every((id) => orderedSet.has(id))
|
||||
const currentSavedIds = savedProviderIds(index.providers)
|
||||
const isFullDisplayPermutation = isPermutation(orderedIds, fullProviderOrderIds(index.providers))
|
||||
const isLegacySavedPermutation = isPermutation(orderedIds, currentSavedIds)
|
||||
|
||||
if (!isPermutation) {
|
||||
throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids')
|
||||
if (!isFullDisplayPermutation && !isLegacySavedPermutation) {
|
||||
throw ApiError.badRequest('orderedIds must be a permutation of existing provider ids and built-in provider ids')
|
||||
}
|
||||
|
||||
const byId = new Map(index.providers.map((p) => [p.id, p]))
|
||||
index.providers = orderedIds.map((id) => byId.get(id) as SavedProvider)
|
||||
index.providerOrder = isFullDisplayPermutation
|
||||
? orderedIds
|
||||
: mergeSavedOrderIntoDisplayOrder(index.providerOrder, orderedIds)
|
||||
index.providers = sortSavedProvidersByOrder(index.providers, index.providerOrder)
|
||||
await this.writeIndex(index)
|
||||
|
||||
return index.providers
|
||||
return {
|
||||
providers: index.providers,
|
||||
providerOrder: index.providerOrder,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Activation ---
|
||||
|
||||
@ -7,6 +7,13 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CLAUDE_OFFICIAL_PROVIDER_ID = 'claude-official'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
|
||||
export const BUILT_IN_PROVIDER_IDS = [
|
||||
CLAUDE_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
] as const
|
||||
|
||||
export const ApiFormatSchema = z.enum([
|
||||
'anthropic', // Native Anthropic Messages API (passthrough, no proxy)
|
||||
'openai_chat', // OpenAI Chat Completions /v1/chat/completions
|
||||
@ -61,6 +68,7 @@ export const ProvidersIndexSchema = z.object({
|
||||
schemaVersion: z.number().int().positive().optional(),
|
||||
activeId: z.string().nullable(),
|
||||
providers: z.array(SavedProviderSchema),
|
||||
providerOrder: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const CreateProviderSchema = z.object({
|
||||
@ -99,7 +107,8 @@ export const TestProviderSchema = z.object({
|
||||
})
|
||||
|
||||
export const ReorderProvidersSchema = z.object({
|
||||
// A permutation of the existing saved provider ids, in the desired display order.
|
||||
// A permutation of the display provider ids, including built-in official providers.
|
||||
// The legacy saved-provider-only permutation is still accepted by ProviderService.
|
||||
orderedIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user