diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index 25f8b1d63373..27963ec58445 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -169,17 +169,27 @@ + + diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index f01be91a566b..fc9f6ed02299 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -31,6 +31,9 @@ vi.mock('vue-i18n', async () => { if (key === 'admin.accounts.modelMappingConflict') { return `Model mapping conflict: ${params?.from} → ${params?.to}` } + if (params && key.startsWith('admin.accounts.syncUpstreamPicker.')) { + return `${key} ${JSON.stringify(params)}` + } return params?.count ? `${key} ${params.count}` : key } }) @@ -85,13 +88,18 @@ function mountSelector(props: Record = {}) { }, global: { stubs: { - ModelIcon: true + ModelIcon: true, + // The upstream picker is a BaseDialog: render it in place and without a transition so it can be queried. + Teleport: true, + Transition: true } } }) } -function findModelRow(wrapper: ReturnType, modelId: string) { +type SelectorWrapper = ReturnType + +function findModelRow(wrapper: SelectorWrapper, modelId: string) { const row = wrapper .findAll('[data-testid="model-option"]') .find(candidate => candidate.attributes('data-model-id') === modelId) @@ -103,6 +111,75 @@ function findModelRow(wrapper: ReturnType, modelId: string return row } +function findPicker(wrapper: SelectorWrapper) { + return wrapper.find('[data-testid="upstream-model-picker"]') +} + +function pickerRows(wrapper: SelectorWrapper) { + return wrapper.findAll('[data-testid="upstream-picker-row"]') +} + +function pickerRowIds(wrapper: SelectorWrapper) { + return pickerRows(wrapper).map(row => row.attributes('data-model-id')) +} + +// A row's state is its checkbox's checked property; the row's data-checked has to agree with it. +function isPickerRowChecked(row: ReturnType[number]) { + const checked = (row.get('[data-testid="upstream-picker-checkbox"]').element as HTMLInputElement).checked + expect(row.attributes('data-checked')).toBe(String(checked)) + return checked +} + +function checkedPickerRowIds(wrapper: SelectorWrapper) { + return pickerRows(wrapper) + .filter(isPickerRowChecked) + .map(row => row.attributes('data-model-id')) +} + +function findPickerRow(wrapper: SelectorWrapper, modelId: string) { + const row = pickerRows(wrapper).find(candidate => candidate.attributes('data-model-id') === modelId) + if (!row) { + throw new Error(`Picker row not found: ${modelId}`) + } + return row +} + +function pickerText(wrapper: SelectorWrapper, testId: string) { + return wrapper.get(`[data-testid="${testId}"]`).text() +} + +async function openPicker(wrapper: SelectorWrapper) { + await wrapper.get('[data-testid="sync-upstream-models"]').trigger('click') + await flushPromises() + expect(findPicker(wrapper).exists()).toBe(true) +} + +async function confirmPicker(wrapper: SelectorWrapper) { + await wrapper.get('[data-testid="upstream-picker-confirm"]').trigger('click') +} + +async function setPickerFilter(wrapper: SelectorWrapper, filter: string) { + const option = wrapper + .findAll('[data-testid="upstream-picker-filter-option"]') + .find(candidate => candidate.attributes('data-filter') === filter) + if (!option) { + throw new Error(`Picker filter not found: ${filter}`) + } + await option.trigger('click') +} + +// Upstream returns one model twice, one padded and a blank; the whitelist holds one of them and one it lacks. +const pickerWhitelist = ['zz-legacy', 'kept-model'] +function pickerFixture(): SyncUpstreamModelsResult { + return { + models: [' model-10 ', 'model-2', 'kept-model', '', 'model-2'], + metadata: { + 'model-2': { id: 'model-2', display_name: 'Model Two', context_window: 200_000, max_output_tokens: 64_000 }, + 'kept-model': { id: 'kept-model', display_name: 'kept-model', context_window: 1_050_000 } + } + } +} + describe('ModelWhitelistSelector', () => { beforeEach(() => { copyToClipboard.mockClear() @@ -203,7 +280,7 @@ describe('ModelWhitelistSelector', () => { wrapper.unmount() }) - it('calls the saved-account sync once and adds each new exact ID while preserving manual IDs', async () => { + it('calls the saved-account sync once and applies the picked exact IDs, manual ones kept when checked, on confirm', async () => { let resolve!: (result: SyncUpstreamModelsResult) => void syncUpstreamModels.mockReturnValue(new Promise(result => { resolve = result })) const result: SyncUpstreamModelsResult = { @@ -218,13 +295,27 @@ describe('ModelWhitelistSelector', () => { expect(syncUpstreamModels).toHaveBeenCalledOnce() expect(syncUpstreamModels).toHaveBeenCalledWith(46) expect(syncUpstreamModelsPreview).not.toHaveBeenCalled() + expect(findPicker(wrapper).exists()).toBe(false) resolve(result) await flushPromises() + // The sync opens the picker; neither the whitelist nor the synced result reaches the form before confirm. + expect(pickerRowIds(wrapper)).toEqual(['manual-model', 'New.Exact-ID', 'user-only-model']) + expect(wrapper.emitted('upstream-synced')).toBeUndefined() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + // The search ignores case on the ID itself: this mixed-case ID has no display name. + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('new.exact-id') + expect(pickerRowIds(wrapper)).toEqual(['New.Exact-ID']) + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('') + + await findPickerRow(wrapper, 'user-only-model').trigger('click') + await confirmPicker(wrapper) + expect(wrapper.emitted('upstream-synced')).toEqual([[result]]) expect(wrapper.emitted('update:modelValue')).toEqual([[['manual-model', 'user-only-model', 'New.Exact-ID']]]) expect(wrapper.emitted('update:capacityDrafts')).toBeUndefined() + expect(findPicker(wrapper).exists()).toBe(false) wrapper.unmount() }) @@ -237,12 +328,14 @@ describe('ModelWhitelistSelector', () => { }) expect(wrapper.find('[data-testid="context-capacity-input"]').exists()).toBe(false) - await wrapper.get('[data-testid="sync-upstream-models"]').trigger('click') - await flushPromises() + await openPicker(wrapper) expect(syncUpstreamModels).toHaveBeenCalledOnce() expect(syncUpstreamModels).toHaveBeenCalledWith(91) - expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol', 'oauth-model']]]) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + // The whitelist entry the upstream did not return starts unchecked, so confirming as offered drops it. + await confirmPicker(wrapper) + expect(wrapper.emitted('update:modelValue')).toEqual([[['oauth-model']]]) wrapper.unmount() }) @@ -350,7 +443,7 @@ describe('ModelWhitelistSelector', () => { expect(wrapper.emitted('capacity-validity')?.at(-1)).toEqual([true]) }) - it('warns when model IDs sync but capability metadata is incomplete', async () => { + it('shows incomplete capability metadata inside the picker instead of a toast', async () => { syncUpstreamModels.mockResolvedValue({ models: ['x-preview-f-free'], warnings: [{ @@ -366,12 +459,18 @@ describe('ModelWhitelistSelector', () => { await syncButton!.trigger('click') await flushPromises() + expect(pickerText(wrapper, 'upstream-picker-metadata-notice')).toBe('admin.accounts.syncUpstreamModelsMetadataIncomplete') + expect(showWarning).not.toHaveBeenCalled() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + + await confirmPicker(wrapper) expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]]) - expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataIncomplete') - expect(showSuccess).not.toHaveBeenCalled() + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.updated {"added":1,"removed":0}') + expect(showWarning).not.toHaveBeenCalled() + wrapper.unmount() }) - it('shows success and a partial warning when some capabilities were saved', async () => { + it('shows partial capability metadata inside the picker and the whitelist change on confirm', async () => { syncUpstreamModels.mockResolvedValue({ models: ['gpt-6-astra', 'gpt-image-2'], warnings: [ @@ -381,18 +480,7 @@ describe('ModelWhitelistSelector', () => { } ] }) - const wrapper = mount(ModelWhitelistSelector, { - props: { - modelValue: [], - platform: 'openai', - accountId: 46 - }, - global: { - stubs: { - ModelIcon: true - } - } - }) + const wrapper = mountSelector({ accountId: 46 }) const syncButton = wrapper .findAll('button') @@ -401,9 +489,26 @@ describe('ModelWhitelistSelector', () => { await syncButton!.trigger('click') await flushPromises() + expect(pickerText(wrapper, 'upstream-picker-metadata-notice')).toBe('admin.accounts.syncUpstreamModelsMetadataPartial') + await confirmPicker(wrapper) expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-6-astra', 'gpt-image-2']]]) - expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsSuccess 2') - expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataPartial') + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.updated {"added":2,"removed":0}') + expect(showWarning).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('reports an upstream list of blank IDs without opening the picker or touching the whitelist', async () => { + const result: SyncUpstreamModelsResult = { models: ['', ' '] } + syncUpstreamModels.mockResolvedValue(result) + const wrapper = mountSelector({ modelValue: ['kept-model'], accountId: 7 }) + await wrapper.get('[data-testid="sync-upstream-models"]').trigger('click') + await flushPromises() + + expect(findPicker(wrapper).exists()).toBe(false) + expect(showInfo).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsEmpty') + expect(wrapper.emitted('upstream-synced')).toEqual([[result]]) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + wrapper.unmount() }) it('reports a successful preview so account creation can persist metadata', async () => { @@ -433,6 +538,8 @@ describe('ModelWhitelistSelector', () => { await flushPromises() expect(syncUpstreamModelsPreview).toHaveBeenCalledOnce() + expect(wrapper.emitted('upstream-synced')).toBeUndefined() + await confirmPicker(wrapper) expect(wrapper.emitted('upstream-synced')).toEqual([[{ models: ['x-preview-f-free'], metadata: { 'x-preview-f-free': { id: 'x-preview-f-free', reasoning: true, supported_reasoning_levels: ['low', 'high', 'max'] } } @@ -452,6 +559,7 @@ describe('ModelWhitelistSelector', () => { const wrapper = mountSelector({ accountId: 46 }) await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.syncUpstreamModels')!.trigger('click') await flushPromises() + await confirmPicker(wrapper) expect(wrapper.emitted('upstream-synced')).toEqual([[result]]) wrapper.unmount() @@ -467,6 +575,7 @@ describe('ModelWhitelistSelector', () => { const wrapper = mountSelector({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://a.example/v1', api_key: 'key-a' } }) await wrapper.findAll('button').find(button => button.text() === 'admin.accounts.syncUpstreamModels')!.trigger('click') await flushPromises() + await confirmPicker(wrapper) await wrapper.get('div.cursor-pointer').trigger('click') expect(wrapper.text()).toContain('provider-a-only') await wrapper.setProps({ syncedModels: undefined, syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://b.example/v1', api_key: 'key-b' } }) @@ -482,6 +591,7 @@ describe('ModelWhitelistSelector', () => { await wrapper.setProps({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://b.example/v1', api_key: 'key-b' } }) resolve({ models: ['stale-provider-a-model'] }) await flushPromises() + expect(findPicker(wrapper).exists()).toBe(false) expect(wrapper.emitted('upstream-synced')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(showSuccess).not.toHaveBeenCalled() @@ -509,6 +619,7 @@ describe('ModelWhitelistSelector', () => { await flushPromises() expect(syncUpstreamModels).toHaveBeenCalledOnce() expect(syncUpstreamModels).toHaveBeenCalledWith(42) + expect(findPicker(wrapper).exists()).toBe(false) expect(wrapper.emitted('upstream-synced')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(showSuccess).not.toHaveBeenCalled() @@ -544,12 +655,329 @@ describe('ModelWhitelistSelector', () => { const result = { models: ['new-model'], capacity_rows: [capacityRow()] } resolve(result) await flushPromises() + expect(pickerRowIds(wrapper)).toEqual(['new-model']) + await confirmPicker(wrapper) expect(wrapper.emitted('upstream-synced')).toEqual([[result]]) expect(wrapper.emitted('update:modelValue')).toEqual([[['new-model']]]) expect(wrapper.props('capacityDrafts')).toEqual({ 'gpt-5.6-sol': '1M' }) wrapper.unmount() }) + it('opens the picker sorted by ID with upstream models checked and whitelist-only entries unchecked', async () => { + syncUpstreamModels.mockResolvedValue(pickerFixture()) + const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 }) + await openPicker(wrapper) + + expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10', 'zz-legacy']) + expect(checkedPickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10']) + const tags = (modelId: string) => findPickerRow(wrapper, modelId).findAll('.badge').map(tag => tag.attributes('data-testid')) + expect(tags('kept-model')).toEqual([]) + expect(tags('model-2')).toEqual(['upstream-picker-tag-new']) + expect(tags('model-10')).toEqual(['upstream-picker-tag-new']) + expect(tags('zz-legacy')).toEqual(['upstream-picker-tag-missing']) + expect(findPickerRow(wrapper, 'model-2').get('[data-testid="upstream-picker-details"]').text()).toBe( + 'Model Two · admin.accounts.syncUpstreamPicker.contextWindow {"value":"200K"} · admin.accounts.syncUpstreamPicker.maxOutput {"value":"64K"}' + ) + // A display name equal to the ID is not repeated. + expect(findPickerRow(wrapper, 'kept-model').get('[data-testid="upstream-picker-details"]').text()).toBe( + 'admin.accounts.syncUpstreamPicker.contextWindow {"value":"1.05M"}' + ) + expect(findPickerRow(wrapper, 'zz-legacy').find('[data-testid="upstream-picker-details"]').exists()).toBe(false) + // Each checkbox is named by the ID and described by its row's tag and capability line. + const description = (modelId: string) => (findPickerRow(wrapper, modelId) + .get('[data-testid="upstream-picker-checkbox"]').attributes('aria-describedby') ?? '') + .split(' ').filter(Boolean).map(id => wrapper.get(`[id="${id}"]`).text()) + expect(description('model-2')).toEqual([ + 'admin.accounts.syncUpstreamPicker.tags.new', + 'Model Two · admin.accounts.syncUpstreamPicker.contextWindow {"value":"200K"} · admin.accounts.syncUpstreamPicker.maxOutput {"value":"64K"}' + ]) + expect(description('kept-model')).toEqual(['admin.accounts.syncUpstreamPicker.contextWindow {"value":"1.05M"}']) + expect(description('zz-legacy')).toEqual(['admin.accounts.syncUpstreamPicker.tags.missing']) + expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":3,"total":4}') + expect(pickerText(wrapper, 'upstream-picker-summary')).toBe('admin.accounts.syncUpstreamPicker.summary {"added":2,"removed":1}') + expect(wrapper.find('[data-testid="upstream-picker-configured-notice"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="upstream-picker-metadata-notice"]').exists()).toBe(false) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.emitted('upstream-synced')).toBeUndefined() + expect(showSuccess).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('checks every row with 全选 whatever the search and the status filter show', async () => { + syncUpstreamModels.mockResolvedValue(pickerFixture()) + const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 }) + await openPicker(wrapper) + await setPickerFilter(wrapper, 'new') + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('10') + expect(pickerRowIds(wrapper)).toEqual(['model-10']) + + await wrapper.get('[data-testid="upstream-picker-select-all"]').trigger('click') + + expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":4,"total":4}') + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('') + await setPickerFilter(wrapper, 'all') + expect(checkedPickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10', 'zz-legacy']) + wrapper.unmount() + }) + + it('inverts every row with 反选, including the rows the search hides', async () => { + syncUpstreamModels.mockResolvedValue(pickerFixture()) + const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 }) + await openPicker(wrapper) + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('model-2') + expect(pickerRowIds(wrapper)).toEqual(['model-2']) + + await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click') + + expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":1,"total":4}') + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('') + expect(checkedPickerRowIds(wrapper)).toEqual(['zz-legacy']) + await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click') + expect(checkedPickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10']) + wrapper.unmount() + }) + + it('renders a long list in a window while the counter, 全选, 反选 and confirm cover every row', async () => { + const models = Array.from({ length: 300 }, (_, index) => `bulk-model-${String(index).padStart(3, '0')}`) + syncUpstreamModels.mockResolvedValue({ models }) + const wrapper = mountSelector({ accountId: 7 }) + await openPicker(wrapper) + + // Only the first rows of the sorted list are in the DOM. + const rendered = pickerRowIds(wrapper) + expect(rendered.length).toBeGreaterThan(0) + expect(rendered.length).toBeLessThan(models.length) + expect(rendered).toEqual(models.slice(0, rendered.length)) + expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":300,"total":300}') + + await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click') + expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":0,"total":300}') + expect(checkedPickerRowIds(wrapper)).toEqual([]) + expect(wrapper.get('[data-testid="upstream-picker-confirm"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-testid="upstream-picker-select-all"]').trigger('click') + expect(pickerText(wrapper, 'upstream-picker-counter')).toBe('admin.accounts.syncUpstreamPicker.selectedCount {"selected":300,"total":300}') + await confirmPicker(wrapper) + expect(wrapper.emitted('update:modelValue')).toEqual([[models]]) + wrapper.unmount() + }) + + it('keeps a windowed list windowed when a toggle takes it to the threshold, until the next new list', async () => { + const models = Array.from({ length: 101 }, (_, index) => `edge-model-${String(index).padStart(3, '0')}`) + syncUpstreamModels.mockResolvedValue({ models }) + const wrapper = mountSelector({ accountId: 7 }) + await openPicker(wrapper) + await setPickerFilter(wrapper, 'checked') + expect(pickerRowIds(wrapper).length).toBeLessThan(100) + + // 101 -> 100 rows: the rows around the gap keep their windowed layout. + await findPickerRow(wrapper, 'edge-model-000').trigger('click') + await flushPromises() + expect(pickerRowIds(wrapper)).not.toContain('edge-model-000') + expect(pickerRowIds(wrapper).length).toBeLessThan(100) + + // A new list of 100 rows renders in full. + await setPickerFilter(wrapper, 'unchecked') + await setPickerFilter(wrapper, 'checked') + expect(pickerRowIds(wrapper)).toEqual(models.slice(1)) + wrapper.unmount() + }) + + it('combines the status filter with a case-insensitive search on the ID and the display name', async () => { + syncUpstreamModels.mockResolvedValue(pickerFixture()) + const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 }) + await openPicker(wrapper) + expect(wrapper.findAll('[data-testid="upstream-picker-filter-option"]').map(option => option.text())).toEqual([ + 'admin.accounts.syncUpstreamPicker.filters.all 4', + 'admin.accounts.syncUpstreamPicker.filters.checked 3', + 'admin.accounts.syncUpstreamPicker.filters.unchecked 1', + 'admin.accounts.syncUpstreamPicker.filters.new 2', + 'admin.accounts.syncUpstreamPicker.filters.missing 1' + ]) + + await setPickerFilter(wrapper, 'new') + expect(pickerRowIds(wrapper)).toEqual(['model-2', 'model-10']) + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('TWO') + expect(pickerRowIds(wrapper)).toEqual(['model-2']) + await setPickerFilter(wrapper, 'missing') + expect(pickerRowIds(wrapper)).toEqual([]) + expect(pickerText(wrapper, 'upstream-picker-empty')).toBe('admin.accounts.noMatchingModels') + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('') + expect(pickerRowIds(wrapper)).toEqual(['zz-legacy']) + + await setPickerFilter(wrapper, 'checked') + expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-2', 'model-10']) + await findPickerRow(wrapper, 'model-2').trigger('click') + expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-10']) + await setPickerFilter(wrapper, 'unchecked') + expect(pickerRowIds(wrapper)).toEqual(['model-2', 'zz-legacy']) + expect(wrapper.findAll('[data-testid="upstream-picker-filter-option"]').map(option => option.attributes('aria-checked'))) + .toEqual(['false', 'false', 'true', 'false', 'false']) + await wrapper.get('[data-testid="upstream-picker-filter"]').trigger('keydown', { key: 'ArrowLeft' }) + expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-10']) + + // Copying an ID does not toggle its row. + await findPickerRow(wrapper, 'kept-model').get('[data-testid="upstream-picker-copy"]').trigger('click') + expect(copyToClipboard).toHaveBeenCalledWith('kept-model') + expect(isPickerRowChecked(findPickerRow(wrapper, 'kept-model'))).toBe(true) + wrapper.unmount() + }) + + it('moves the focus to the row taking the place of a toggled row that leaves the filtered list', async () => { + syncUpstreamModels.mockResolvedValue(pickerFixture()) + const wrapper = mount(ModelWhitelistSelector, { + props: { modelValue: pickerWhitelist, platform: 'openai', accountId: 7 }, + attachTo: document.body, + global: { stubs: { ModelIcon: true, Teleport: true, Transition: true } } + }) + await openPicker(wrapper) + await setPickerFilter(wrapper, 'checked') + const checkbox = (modelId: string) => findPickerRow(wrapper, modelId).get('[data-testid="upstream-picker-checkbox"]') + const toggleFocused = async (modelId: string) => { + const box = checkbox(modelId) + ;(box.element as HTMLInputElement).focus() + await box.trigger('click') + await flushPromises() + } + + await toggleFocused('model-2') + expect(pickerRowIds(wrapper)).toEqual(['kept-model', 'model-10']) + expect(document.activeElement).toBe(checkbox('model-10').element) + // The last row hands the focus back to the row before it. + await toggleFocused('model-10') + expect(document.activeElement).toBe(checkbox('kept-model').element) + // With no row left, the search takes it. + await toggleFocused('kept-model') + expect(pickerRowIds(wrapper)).toEqual([]) + expect(document.activeElement).toBe(wrapper.get('[data-testid="upstream-picker-search"]').element) + wrapper.unmount() + }) + + it('confirms whitelist entries in their own order, then newly checked models in list order', async () => { + const result: SyncUpstreamModelsResult = { models: ['zeta-kept', 'beta-new', 'alpha-kept', 'alpha-new'] } + syncUpstreamModels.mockResolvedValue(result) + const wrapper = mountSelector({ modelValue: ['zeta-kept', 'alpha-kept', 'gone-model'], accountId: 7 }) + await openPicker(wrapper) + expect(pickerRowIds(wrapper)).toEqual(['alpha-kept', 'alpha-new', 'beta-new', 'gone-model', 'zeta-kept']) + + await findPickerRow(wrapper, 'alpha-kept').trigger('click') + const betaCheckbox = findPickerRow(wrapper, 'beta-new').get('[data-testid="upstream-picker-checkbox"]') + await betaCheckbox.trigger('click') + expect(isPickerRowChecked(findPickerRow(wrapper, 'beta-new'))).toBe(false) + await betaCheckbox.trigger('click') + expect(pickerText(wrapper, 'upstream-picker-summary')).toBe('admin.accounts.syncUpstreamPicker.summary {"added":2,"removed":2}') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + + await confirmPicker(wrapper) + + expect(wrapper.emitted('update:modelValue')).toEqual([[['zeta-kept', 'alpha-new', 'beta-new']]]) + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.updated {"added":2,"removed":2}') + expect(showInfo).not.toHaveBeenCalled() + expect(wrapper.emitted('upstream-synced')).toEqual([[result]]) + expect(findPicker(wrapper).exists()).toBe(false) + // A confirmed result keeps its models available as candidates. + await wrapper.get('[data-testid="model-selector-toggle"]').trigger('click') + expect(findModelRow(wrapper, 'beta-new').exists()).toBe(true) + wrapper.unmount() + }) + + it('keeps Confirm disabled with a hint while no model is checked', async () => { + syncUpstreamModels.mockResolvedValue(pickerFixture()) + const wrapper = mountSelector({ modelValue: pickerWhitelist, accountId: 7 }) + await openPicker(wrapper) + const confirmButton = wrapper.get('[data-testid="upstream-picker-confirm"]') + expect(confirmButton.attributes('disabled')).toBeUndefined() + expect(wrapper.find('[data-testid="upstream-picker-empty-hint"]').exists()).toBe(false) + + await wrapper.get('[data-testid="upstream-picker-select-all"]').trigger('click') + await wrapper.get('[data-testid="upstream-picker-invert"]').trigger('click') + + expect(checkedPickerRowIds(wrapper)).toEqual([]) + expect(confirmButton.attributes('disabled')).toBeDefined() + expect(pickerText(wrapper, 'upstream-picker-empty-hint')).toBe('admin.accounts.syncUpstreamPicker.emptySelectionHint') + await confirmButton.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(findPicker(wrapper).exists()).toBe(true) + + await findPickerRow(wrapper, 'zz-legacy').trigger('click') + expect(confirmButton.attributes('disabled')).toBeUndefined() + expect(wrapper.find('[data-testid="upstream-picker-empty-hint"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('cancels from the button, the header close or Escape without touching the whitelist or the candidates', async () => { + syncUpstreamModels.mockImplementation(async () => ({ models: ['upstream-only-model', 'gpt-5.6-sol'] })) + const wrapper = mountSelector({ modelValue: ['gpt-5.6-sol'], accountId: 7 }) + + await openPicker(wrapper) + await findPickerRow(wrapper, 'gpt-5.6-sol').trigger('click') + await wrapper.get('[data-testid="upstream-picker-search"]').setValue('upstream') + await wrapper.get('[data-testid="upstream-picker-cancel"]').trigger('click') + expect(findPicker(wrapper).exists()).toBe(false) + + // Every open starts again from the result and the current whitelist. + await openPicker(wrapper) + expect((wrapper.get('[data-testid="upstream-picker-search"]').element as HTMLInputElement).value).toBe('') + expect(checkedPickerRowIds(wrapper)).toEqual(['gpt-5.6-sol', 'upstream-only-model']) + await wrapper.get('button[aria-label="Close modal"]').trigger('click') + expect(findPicker(wrapper).exists()).toBe(false) + + await openPicker(wrapper) + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })) + await flushPromises() + expect(findPicker(wrapper).exists()).toBe(false) + + expect(syncUpstreamModels).toHaveBeenCalledTimes(3) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.emitted('upstream-synced')).toBeUndefined() + expect(showSuccess).not.toHaveBeenCalled() + expect(showInfo).not.toHaveBeenCalled() + await wrapper.get('[data-testid="model-selector-toggle"]').trigger('click') + expect(wrapper.find('[data-testid="model-option"][data-model-id="upstream-only-model"]').exists()).toBe(false) + wrapper.unmount() + }) + + it('explains a configured model list and reports an unchanged whitelist on confirm', async () => { + syncUpstreamModelsPreview.mockResolvedValue({ models: ['b-model', 'a-model'], model_list_source: 'configured' }) + const wrapper = mountSelector({ + modelValue: ['b-model', 'a-model'], + syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://a.example/v1', api_key: 'key-a' } + }) + await openPicker(wrapper) + + expect(pickerText(wrapper, 'upstream-picker-configured-notice')).toBe('admin.accounts.syncUpstreamPicker.configuredSource') + expect(wrapper.findAll('.badge')).toHaveLength(0) + await confirmPicker(wrapper) + expect(wrapper.emitted('update:modelValue')).toEqual([[['b-model', 'a-model']]]) + expect(showInfo).toHaveBeenCalledWith('admin.accounts.syncUpstreamPicker.unchanged') + expect(showSuccess).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('closes an open picker unapplied when the sync source changes, and never submits a form', async () => { + syncUpstreamModelsPreview.mockResolvedValue({ models: ['provider-a-only'] }) + const wrapper = mountSelector({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://a.example/v1', api_key: 'key-a' } }) + await openPicker(wrapper) + + const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + wrapper.get('[data-testid="upstream-picker-search"]').element.dispatchEvent(enter) + await flushPromises() + expect(enter.defaultPrevented).toBe(true) + expect(findPicker(wrapper).exists()).toBe(true) + const buttons = [ + ...findPicker(wrapper).findAll('button'), + ...wrapper.get('[data-testid="upstream-picker-footer"]').findAll('button') + ] + expect(buttons.length).toBeGreaterThan(0) + expect(buttons.filter(button => button.attributes('type') !== 'button')).toEqual([]) + + await wrapper.setProps({ syncCredentials: { platform: 'openai', type: 'apikey', base_url: 'https://b.example/v1', api_key: 'key-b' } }) + expect(findPicker(wrapper).exists()).toBe(false) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.emitted('upstream-synced')).toBeUndefined() + wrapper.unmount() + }) + it('shows the upstream sync button for OpenCode Go create-account credentials', () => { const wrapper = mountSelector({ platform: 'opencode_go', diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 9ccf16e494ff..8bfb858f564c 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -1072,6 +1072,33 @@ export default {accounts: { 'Model IDs were synced, but no capability metadata could be updated.', syncUpstreamModelsMetadataPartial: 'Some model capabilities were updated; remaining models are still incomplete.', + syncUpstreamPicker: { + title: 'Choose whitelist models', + searchPlaceholder: 'Search model ID or name', + selectAll: 'Select all', + invert: 'Invert', + filterLabel: 'Filter by status', + filters: { + all: 'All', + checked: 'Checked', + unchecked: 'Unchecked', + new: 'New', + missing: 'Not returned by upstream' + }, + tags: { + new: 'New', + missing: 'Not returned by upstream' + }, + selectedCount: '{selected} of {total} selected', + contextWindow: 'Context {value}', + maxOutput: 'Max output {value}', + summary: 'Will add {added} and remove {removed}', + emptySelectionHint: 'Check at least one model; an empty whitelist allows all models', + configuredSource: + 'The upstream cannot list models; these are the models currently configured for this account', + updated: 'Whitelist updated: {added} added, {removed} removed', + unchanged: 'Whitelist unchanged' + }, clearAllModels: 'Clear all models', customModelName: 'Custom model name', enterCustomModelName: 'Enter custom model name', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 59e49b62909e..5610c70a3764 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -1182,6 +1182,32 @@ export default {accounts: { syncUpstreamModelsError: '同步上游模型失败:{message}', syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但未能更新任何能力元数据。', syncUpstreamModelsMetadataPartial: '已更新部分模型的能力元数据;其余模型能力仍不完整。', + syncUpstreamPicker: { + title: '选择白名单模型', + searchPlaceholder: '搜索模型 ID 或名称', + selectAll: '全选', + invert: '反选', + filterLabel: '按状态筛选', + filters: { + all: '全部', + checked: '已勾选', + unchecked: '未勾选', + new: '新增', + missing: '上游未返回' + }, + tags: { + new: '新增', + missing: '上游未返回' + }, + selectedCount: '已选 {selected} / 共 {total}', + contextWindow: '上下文 {value}', + maxOutput: '最大输出 {value}', + summary: '将新增 {added} 个,移除 {removed} 个', + emptySelectionHint: '至少勾选一个模型;白名单为空等于不限制模型', + configuredSource: '上游不支持列出模型,以下为账号当前已配置的模型', + updated: '白名单已更新:新增 {added} 个,移除 {removed} 个', + unchanged: '白名单没有变化' + }, clearAllModels: '清除所有模型', customModelName: '自定义模型名称', enterCustomModelName: '输入自定义模型名称', diff --git a/frontend/src/styles/console/pages/accounts.css b/frontend/src/styles/console/pages/accounts.css index 8cac19876c2b..1a73be87285b 100644 --- a/frontend/src/styles/console/pages/accounts.css +++ b/frontend/src/styles/console/pages/accounts.css @@ -30,6 +30,11 @@ * Wave 2 (dialogs, section 9; one attribute each): * admin/account/AccountStatsModal account-stats admin/account/AccountTestModal account-test * account/OAuthAuthorizationFlow account-oauth-flow account/CreateAccountModal account-create-steps + * The upstream model picker (section 9.13; static hooks, a row's state is its data-checked, a filter's aria-checked): + * account/UpstreamModelPickerDialog (new) upstream-picker-list (the scroll area: the rows or the empty message), + * upstream-picker-row, upstream-picker-id, upstream-picker-details, + * upstream-picker-copy; its toolbar uses the shared search-box and seg + * hooks (controls.css 3.2, 7.1) * Card grid, compact list, the drawer's content, the account forms (#create-account-form, #edit-account-form, * #bulk-edit-account-form) and the taxonomy manager rows are reached through their existing ids and data-test * attributes. The system prompt binding inside the account dialogs is P3's (settings-ops-public.css). */ @@ -1381,6 +1386,74 @@ html.flat-theme #edit-account-form div[class~="bg-sky-50/60"] > div:last-child b color: rgb(var(--ui-on-primary)); } +/* 9.13 The upstream model picker (UpstreamModelPickerDialog, stacked on the create and edit dialogs by the model + whitelist's 「同步上游支持的模型」). Shared parts draw the rest: the search field and the status filter (controls.css + 3.2, 7.1: the grey track, the current segment the raised pill), the .btn recipes, the native checkboxes + (controls.css 6), the 新增 / 上游未返回 pills (data.css 6) and the two notices (data.css 11 callouts; the metadata + warning keeps its status colour). Here the list: a structural box (1px line, radius 8, the surface) that keeps the + height it opened with (set inline: the whole list's, at most 50vh), so fewer rows and the empty message sit inside + it; past 100 rows it holds only the rows near view between two aria-hidden spacer items. Its rows sit on the line + at 7 / 12 with the hover fill; the model ID at 13 / 500 ink, wrapping anywhere (nothing is cut); the capability + line 11px muted with tabular figures; the copy button the table's 28px icon button (muted, the subtle fill and ink + on hover). No state is drawn: the checkbox carries it. */ +html.flat-theme [data-ui~="upstream-picker-list"] { + border-color: rgb(var(--ui-line)); + border-radius: var(--ui-radius-lg); + background-color: rgb(var(--ui-surface)); +} + +html.flat-theme [data-ui~="upstream-picker-row"] { + gap: 10px; + padding: 7px 12px; + border-color: rgb(var(--ui-line)); + transition: background-color var(--ui-duration) var(--ui-ease); +} + +html.flat-theme [data-ui~="upstream-picker-row"]:hover { + background-color: rgb(var(--ui-hover)); +} + +html.flat-theme [data-ui~="upstream-picker-id"] { + font-size: 13px; + line-height: 19.5px; + font-weight: 500; + color: rgb(var(--ui-ink)); +} + +html.flat-theme [data-ui~="upstream-picker-details"] { + font-size: 11px; + line-height: 16.5px; + font-variant-numeric: tabular-nums; + color: rgb(var(--ui-muted)); +} + +html.flat-theme [data-ui~="upstream-picker-copy"] { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: var(--ui-radius-md); + color: rgb(var(--ui-muted)); + transition: color var(--ui-duration) var(--ui-ease), background-color var(--ui-duration) var(--ui-ease); +} + +html.flat-theme [data-ui~="upstream-picker-copy"]:hover { + background-color: rgb(var(--ui-subtle)); + color: rgb(var(--ui-ink)); +} + +html.flat-theme [data-ui~="upstream-picker-copy"]:focus-visible { + outline: 2px solid rgb(var(--ui-focus)); + outline-offset: 2px; + box-shadow: none; +} + +html.flat-theme [data-ui~="upstream-picker-copy"] svg { + width: 15px; + height: 15px; +} + /* ================================================================== 8 · phones */ @media (max-width: 639.98px) { /* The reference's 390px toolbar: the search takes the whole first row. */