From 68f627e21c913ae57d5baa7e5b64a688213582a2 Mon Sep 17 00:00:00 2001 From: "Jude.Chen" Date: Fri, 2 Oct 2026 02:10:08 +0800 Subject: [PATCH 1/5] fix(webui): keep model groups and provider bindings consistent with the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model groups only offer routable pairs (binding, provider and model all enabled), flag stale members with a remove action, cap each group at 8, and roll back to the last accepted snapshot when a save is rejected. - Provider discovery matches bindings by upstream ID and offers to re-enable a disabled binding instead of binding it again. - Jev input mode labels come from the shared vocabulary (content is "完整内容" everywhere). - Log retention uses number inputs covering the full 0-3650 day range. - Model groups page moves to /admin/groups; /admin/pairs redirects. --- docs/openapi.yaml | 24 ++++- internal/api/dashboard.go | 1 + webui/e2e/management.spec.ts | 95 ++++++++++++++++++-- webui/src/App.vue | 2 +- webui/src/lib/generated-api.ts | 55 +++++++++++- webui/src/pages/Catalogue/ModelGroups.vue | 104 +++++++++++++++++++--- webui/src/pages/Catalogue/Providers.vue | 38 +++++++- webui/src/pages/System/Settings.vue | 46 +++++----- webui/src/router.ts | 7 +- 9 files changed, 315 insertions(+), 57 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 06e1563..9d37141 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1955,8 +1955,8 @@ paths: operationId: getDashboardPage description: | Serves the bundled Vue SPA shell (`text/html`). The same bytes are served for `/admin/`, - `/admin/login`, `/admin/providers`, `/admin/pairs`, - `/admin/settings` and `/admin/keys`: + `/admin/login`, `/admin/providers`, `/admin/models`, `/admin/groups`, `/admin/pairs`, + `/admin/settings`, `/admin/keys` and `/admin/logs`: the shell is structurally independent of the database, and every number on a page is fetched by the browser from an authenticated `/admin/v1/*` endpoint. That is why these paths can be served without a credential and why they disclose nothing. @@ -2017,12 +2017,28 @@ paths: '404': description: The management surface is not mounted. + /admin/groups: + get: + tags: [Dashboard] + summary: Serve the model groups page shell + operationId: getDashboardGroupsPage + description: See `GET /admin/`. This exact, data-free shell route supports GET and HEAD; group data comes from `/admin/v1/groups`. + security: [] + responses: + '200': + description: The page shell. + content: + text/html: + schema: {type: string} + '404': + description: The management surface is not mounted. + /admin/pairs: get: tags: [Dashboard] - summary: Serve the route bindings page shell + summary: Serve the legacy model groups page shell operationId: getDashboardPairsPage - description: See `GET /admin/`. This exact, data-free shell route supports GET and HEAD; binding data comes from `/admin/v1/pairs`. + description: See `GET /admin/`. Former address of the model groups page; the client redirects it to `/admin/groups` so existing bookmarks keep working. security: [] responses: '200': diff --git a/internal/api/dashboard.go b/internal/api/dashboard.go index 680d20e..ff7b2ee 100644 --- a/internal/api/dashboard.go +++ b/internal/api/dashboard.go @@ -31,6 +31,7 @@ var adminPageShells = map[string]struct{}{ "/admin/login": {}, "/admin/providers": {}, "/admin/models": {}, + "/admin/groups": {}, "/admin/pairs": {}, "/admin/settings": {}, "/admin/keys": {}, diff --git a/webui/e2e/management.spec.ts b/webui/e2e/management.spec.ts index 0e39756..35a09b8 100644 --- a/webui/e2e/management.spec.ts +++ b/webui/e2e/management.spec.ts @@ -96,6 +96,8 @@ async function installApiMocks(page: Page) { let overallLatencyOnly = false let providerDetailPairs: Array<{ model: string; upstream_model_id: string; enabled: boolean }> = [] let pairRows: Array> = [] + let providerRows: Array> = [] + let failNextGroupPut = false const providerRequests: Array<{ cursor: string | null; enabled: string | null }> = [] const providerModelSelections: Array<{ path: string; body: unknown }> = [] const pairDeleteRequests: string[] = [] @@ -149,6 +151,10 @@ async function installApiMocks(page: Page) { if (path === '/admin/v1/groups' && request.method() === 'PUT') { const body = request.postDataJSON() groupPutBodies.push(body) + if (failNextGroupPut) { + failNextGroupPut = false + return json({ error: { code: 'invalid_group', message: 'groups must contain distinct enabled provider/model pairs, at most eight per group', field: 'groups' } }, 400) + } groupDocument = body as typeof groupDocument return json(groupDocument) } @@ -203,7 +209,7 @@ async function installApiMocks(page: Page) { ? json({ items: [second], next_cursor: null }) : json({ items: [first], next_cursor: 'page-2' }) } - return json({ items: [], next_cursor: null }) + return json({ items: providerRows, next_cursor: null }) } if (path === '/admin/v1/sync-state' && request.method() === 'GET') return didSync ? json({ synchronized: true, url: 'https://models.dev/api.json', fetched_at: '2026-09-24T00:00:00Z', allowlist_digest: 'test-digest', imported_pairs: 3, skipped_pairs: 1, warnings: [] }) @@ -262,6 +268,9 @@ async function installApiMocks(page: Page) { setProviderDetailPairs(pairs: Array<{ model: string; upstream_model_id: string; enabled: boolean }>) { providerDetailPairs = pairs }, setPairRows(rows: Array>) { pairRows = rows }, setModelRows(rows: Array>) { modelRows = rows }, + setProviderRows(rows: Array>) { providerRows = rows }, + setGroupDocument(document: { simple: unknown[]; medium: unknown[]; complex: unknown[] }) { groupDocument = document as typeof groupDocument }, + failGroupPutOnce() { failNextGroupPut = true }, enableProviderPagination() { providerPagination = true }, failProviderDiscoveryOnce() { failNextProviderDiscovery = true }, useOverallLatencyWithoutSamples() { overallLatencyOnly = true }, @@ -515,6 +524,22 @@ test('provider drawer permanently unbinds a configured model', async ({ page }) await expect.poll(() => mock.pairDeleteRequests).toEqual(['/admin/v1/pairs/provider-a/gpt-4o']) }) +test('provider discovery recognises a disabled binding instead of offering to bind it again', async ({ page }) => { + const mock = await installApiMocks(page) + mock.enableProviderPagination() + mock.setProviderDetailPairs([{ model: 'openai/gpt-4o', upstream_model_id: 'gpt-4o', enabled: false }]) + await page.goto('/admin/providers') + await page.getByRole('button', { name: /模型绑定 · 0/ }).click() + + await expect(page.getByText('已绑定 · 已停用')).toBeVisible() + await expect(page.getByRole('button', { name: '绑定', exact: true })).toHaveCount(1) + await page.getByRole('button', { name: '重新启用' }).click() + await expect.poll(() => mock.registryPatchRequests).toEqual([{ + path: '/admin/v1/pairs/provider-a/openai%2Fgpt-4o', body: { enabled: true }, + }]) + await expect(page.getByText('已绑定', { exact: true })).toBeVisible() +}) + test('provider bindings remain removable when model discovery fails', async ({ page }) => { const mock = await installApiMocks(page) mock.enableProviderPagination() @@ -587,15 +612,29 @@ test('model binding capacity fields autosave together', async ({ page }) => { await expect(page.getByRole('button', { name: '保存更改' })).toHaveCount(0) }) -test('model groups autosave checkbox changes without a save button', async ({ page }) => { - const mock = await installApiMocks(page) - mock.setPairRows([{ - provider: 'provider-a', model: 'model-a', upstream_model_id: 'upstream-a', +function groupPair(provider: string, model: string, enabled = true) { + return { + provider, model, upstream_model_id: model, context_window: 32768, max_output: 4096, supports_tools: false, supports_vision: false, - supports_audio_input: false, supports_reasoning: false, enabled: true, + supports_audio_input: false, supports_reasoning: false, enabled, source: 'local', owner: 'admin', - }]) - await page.goto('/admin/pairs') + } +} + +function groupProvider(key: string, enabled = true) { + return { key, kind: 'openai_compatible', display_name: key, enabled, priority: 0, source: 'local', owner: 'admin', gateway_provider: null, base_url: 'https://a.example/v1', api_key_set: true, pair_count: 1 } +} + +function groupModel(id: string, enabled = true) { + return { id, display_name: id, enabled, pair_count: 1, source: 'local', owner: 'admin' } +} + +test('model groups autosave checkbox changes without a save button', async ({ page }) => { + const mock = await installApiMocks(page) + mock.setPairRows([groupPair('provider-a', 'model-a')]) + mock.setProviderRows([groupProvider('provider-a')]) + mock.setModelRows([groupModel('model-a')]) + await page.goto('/admin/groups') await page.getByRole('checkbox', { name: 'provider-a · model-a' }).first().check() await expect.poll(() => mock.groupPutBodies).toEqual([{ simple: [{ provider: 'provider-a', model: 'model-a' }], medium: [], complex: [], @@ -604,6 +643,46 @@ test('model groups autosave checkbox changes without a save button', async ({ pa await expect(page.getByText('更改已保存', { exact: true })).toBeVisible() }) +test('model groups only offer routable pairs and flag stale members', async ({ page }) => { + const mock = await installApiMocks(page) + mock.setPairRows([ + groupPair('provider-a', 'model-a'), + groupPair('provider-a', 'model-off', false), + groupPair('provider-b', 'model-a'), + ]) + mock.setProviderRows([groupProvider('provider-a'), groupProvider('provider-b', false)]) + mock.setModelRows([groupModel('model-a'), groupModel('model-off')]) + mock.setGroupDocument({ simple: [{ provider: 'provider-b', model: 'model-a' }], medium: [], complex: [] }) + await page.goto('/admin/groups') + + await expect(page.getByRole('checkbox', { name: 'provider-a · model-off(绑定已停用)' }).first()).toBeDisabled() + await expect(page.getByText('组内有不可用的成员').first()).toBeVisible() + await expect(page.getByText('提供商已停用', { exact: true }).first()).toBeVisible() + await page.getByRole('button', { name: '移除 provider-b model-a' }).click() + await expect.poll(() => mock.groupPutBodies).toEqual([{ simple: [], medium: [], complex: [] }]) +}) + +test('model groups roll back a rejected save', async ({ page }) => { + const mock = await installApiMocks(page) + mock.setPairRows([groupPair('provider-a', 'model-a')]) + mock.setProviderRows([groupProvider('provider-a')]) + mock.setModelRows([groupModel('model-a')]) + mock.failGroupPutOnce() + await page.goto('/admin/groups') + + const checkbox = page.getByRole('checkbox', { name: 'provider-a · model-a' }).first() + await checkbox.check() + await expect(page.getByText('模型分组配置无效')).toBeVisible() + await expect(checkbox).not.toBeChecked() +}) + +test('the legacy pairs path redirects to model groups', async ({ page }) => { + await installApiMocks(page) + await page.goto('/admin/pairs') + await expect(page).toHaveURL(/\/admin\/groups$/) + await expect(page.getByRole('heading', { name: '模型分组', level: 1 })).toBeVisible() +}) + test('model management can permanently unbind a pair and refresh the table', async ({ page }) => { const mock = await installApiMocks(page) mock.setPairRows([{ diff --git a/webui/src/App.vue b/webui/src/App.vue index 5f78bc7..15bc6eb 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -52,7 +52,7 @@ const navigation = computed(() => [ { label: t('app.overview'), icon: 'squares-2x2', to: '/' }, { label: t('app.providers'), icon: 'server-stack', to: '/providers' }, { label: t('app.models'), icon: 'cpu-chip', to: '/models' }, - { label: t('app.groups'), icon: 'layers-2', to: '/pairs' }, + { label: t('app.groups'), icon: 'layers-2', to: '/groups' }, ]) const systemNavigation = computed(() => [ { label: t('app.settings'), icon: 'cog-6-tooth', to: '/settings' }, diff --git a/webui/src/lib/generated-api.ts b/webui/src/lib/generated-api.ts index ed59cd6..30ed28e 100644 --- a/webui/src/lib/generated-api.ts +++ b/webui/src/lib/generated-api.ts @@ -975,8 +975,8 @@ export interface paths { /** * Serve the dashboard shell * @description Serves the bundled Vue SPA shell (`text/html`). The same bytes are served for `/admin/`, - * `/admin/login`, `/admin/providers`, `/admin/pairs`, - * `/admin/settings` and `/admin/keys`: + * `/admin/login`, `/admin/providers`, `/admin/models`, `/admin/groups`, `/admin/pairs`, + * `/admin/settings`, `/admin/keys` and `/admin/logs`: * the shell is structurally independent of the database, and every number on a page is fetched * by the browser from an authenticated `/admin/v1/*` endpoint. That is why these paths can be * served without a credential and why they disclose nothing. @@ -1037,6 +1037,26 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Serve the model groups page shell + * @description See `GET /admin/`. This exact, data-free shell route supports GET and HEAD; group data comes from `/admin/v1/groups`. + */ + get: operations["getDashboardGroupsPage"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/pairs": { parameters: { query?: never; @@ -1045,8 +1065,8 @@ export interface paths { cookie?: never; }; /** - * Serve the route bindings page shell - * @description See `GET /admin/`. This exact, data-free shell route supports GET and HEAD; binding data comes from `/admin/v1/pairs`. + * Serve the legacy model groups page shell + * @description See `GET /admin/`. Former address of the model groups page; the client redirects it to `/admin/groups` so existing bookmarks keep working. */ get: operations["getDashboardPairsPage"]; put?: never; @@ -4336,6 +4356,33 @@ export interface operations { }; }; }; + getDashboardGroupsPage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The page shell. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description The management surface is not mounted. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getDashboardPairsPage: { parameters: { query?: never; diff --git a/webui/src/pages/Catalogue/ModelGroups.vue b/webui/src/pages/Catalogue/ModelGroups.vue index ea6dde4..95d87f9 100644 --- a/webui/src/pages/Catalogue/ModelGroups.vue +++ b/webui/src/pages/Catalogue/ModelGroups.vue @@ -1,6 +1,6 @@ + + diff --git a/webui/src/components/JfDialog.vue b/webui/src/components/JfDialog.vue index 65af228..69df328 100644 --- a/webui/src/components/JfDialog.vue +++ b/webui/src/components/JfDialog.vue @@ -12,12 +12,15 @@ const props = withDefaults(defineProps<{ /** A destructive confirmation is announced as an alertdialog. */ role?: 'dialog' | 'alertdialog' closeLabel?: string + /** `confirm` stacks above drawers and other dialogs it may be raised from. */ + layer?: 'modal' | 'confirm' }>(), { description: undefined, size: 'md', dismissible: true, role: 'dialog', closeLabel: '关闭', + layer: 'modal', }) const emit = defineEmits<{ 'update:open': [boolean] }>() @@ -40,7 +43,7 @@ function onScrimClick() {