优化页面交互并增强 Provider 配置能力
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

- 表单下拉框不再用 label 包裹,改为 div + aria-label,避免点击后
  下拉面板瞬间关闭
- Provider 卡片新增「从上游获取模型」和「修改」入口,支持编辑
  base URL、API Key 与 api_mode,凭据写入对应的环境变量
- 服务端抽出 provider-model-discovery 统一不同 api_mode 的模型发现,
  旧的 custom_providers 与新的 providers 字典结构都能正确更新
- Agent Bridge 改为后台启动,HTTP 接口在 Python 运行时就绪前即可用;
  worker 连接被拒时按类型重试,不重放可能已产生副作用的请求
- 断线重连后恢复 profiles、models 与会话列表,健康检查改为自调度
  的 setTimeout,离线时 2 秒后重试
- 批量删除确认文案改用 Popconfirm content,避免变成侧栏触发器文本
This commit is contained in:
2026-10-22 14:29:48 +08:00
committed by trae-solo-agent
parent 90c759c760
commit 3ca960f388
35 changed files with 876 additions and 78 deletions
+37
View File
@@ -63,3 +63,40 @@ test('session context menu renders flat items and its export submenu', async ({
await popup.locator('.arco-dropdown-option', { hasText: 'Export' }).click()
await expect(page.locator('.arco-trigger-popup').filter({ hasText: 'Compressed' })).toBeVisible()
})
test('new chat selectors stay open after clicking their field and allow selection', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page)
await page.route('**/api/hermes/available-models**', async route => {
const group = { provider: 'test-provider', label: 'Test Provider', models: ['test-model', 'other-model'] }
await route.fulfill({ json: {
default: 'test-model', default_provider: 'test-provider', groups: [group], allProviders: [group],
profiles: ['default', 'research'].map(profile => ({
profile, default: 'test-model', default_provider: 'test-provider', groups: [group],
})),
} })
})
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await page.getByRole('button', { name: 'New Chat', exact: true }).click()
const drawer = page.locator('.new-chat-drawer')
const selects = drawer.locator('.new-chat-field > .arco-select')
await expect(selects).toHaveCount(3)
await expect(selects.nth(1)).not.toHaveClass(/arco-select-disabled/)
for (const index of [0, 1, 2]) {
// Click the field chrome, not the internal input: a wrapping HTML label
// forwards this click to its input and can toggle the popup a second time.
await selects.nth(index).click({ position: { x: 8, y: 16 } })
const popup = page.locator('.arco-select-dropdown:visible')
await expect(popup).toBeVisible()
await page.waitForTimeout(300)
await expect(popup).toBeVisible()
const option = index === 0
? popup.locator('.arco-select-option', { hasText: 'research' })
: popup.locator('.arco-select-option').first()
await option.click()
await expect(popup).toHaveCount(0)
await expect(drawer).toBeVisible()
}
})
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test'
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
const session = { id: 'restored-chat', profile: 'research', source: 'cli', title: 'Restored conversation', started_at: 1000, last_active: 1100, message_count: 2 }
for (const path of ['/hermes/chat', '/hermes/session/restored-chat?profile=research']) {
test(`recovers sessions and preserves the route after backend restart: ${path}`, async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [session] })
await mockChatSocket(page)
let ready = false
let healthRequests = 0
await page.route(url => url.pathname === '/health', async route => {
healthRequests++
if (healthRequests === 1) await route.fulfill({ status: 503, json: { error: 'starting' } })
else {
ready = true
await route.fulfill({ json: { status: 'ok' } })
}
})
await page.route(url => url.pathname === '/api/hermes/sessions', async route => {
await route.fulfill(ready
? { json: { sessions: [session] } }
: { status: 503, json: { error: 'starting' } })
})
await page.goto(`/#${path}`)
await expect(page.locator('.session-item', { hasText: 'Restored conversation' })).toBeVisible({ timeout: 15000 })
expect(healthRequests).toBeGreaterThan(1)
if (path.includes('/session/')) await expect(page).toHaveURL(/session\/restored-chat/);
})
}
test('batch-delete text appears in its confirmation popup without leaking into the sidebar', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page, { sessions: [session] })
await mockChatSocket(page)
await page.goto('/#/hermes/chat')
await page.getByTitle('Batch selection', { exact: true }).click()
await page.locator('.session-item-checkbox').first().click()
const confirmation = 'Delete 1 selected sessions?'
await expect(page.locator('.page-sidebar-top')).not.toContainText(confirmation)
await page.getByRole('button', { name: 'Delete', exact: true }).click()
const popup = page.locator('.arco-popconfirm')
await expect(popup).toBeVisible()
await expect(popup).toContainText(confirmation)
await expect(popup.getByRole('button', { name: /cancel/i })).toBeVisible()
})
+45
View File
@@ -0,0 +1,45 @@
import { expect, test, type Locator, type Page } from '@playwright/test'
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
async function openSelector(page: Page, select: Locator) {
await expect(select).not.toHaveClass(/arco-select-disabled/)
await select.click({ position: { x: 8, y: 16 } })
const popup = page.locator('.arco-select-dropdown:visible')
await expect(popup).toBeVisible()
// Wait past Arco's popup animation: label forwarding used to close the
// dropdown just after the initial visible assertion succeeded.
await page.waitForTimeout(300)
await expect(popup).toBeVisible()
return popup
}
test('auxiliary provider and model menus stay open and apply choices', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY)
await mockHermesApi(page)
await page.goto('/#/hermes/models')
await page.getByRole('button', { name: 'Auxiliary Models', exact: true }).click()
await page.locator('.auxiliary-row').filter({ hasText: 'Vision' }).getByRole('button', { name: 'Edit', exact: true }).click()
const selects = page.locator('.auxiliary-form .arco-select')
await expect(selects).toHaveCount(2)
const providers = await openSelector(page, selects.nth(0))
await providers.locator('.arco-select-option', { hasText: 'Test Provider' }).click()
await expect(providers).toHaveCount(0)
await expect(selects.nth(0)).toContainText('Test Provider')
const models = await openSelector(page, selects.nth(1))
await models.locator('.arco-select-option', { hasText: 'test-model' }).click()
await expect(selects.nth(1)).toContainText('test-model')
await expect(page.locator('.auxiliary-form')).toBeVisible()
})
test('new workflow agent menu stays open and applies another profile', async ({ page }) => {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page)
await page.route(url => url.pathname.startsWith('/api/hermes/workflows'), route => route.fulfill({ json: { workflows: [] } }))
await page.goto('/#/hermes/workflow')
await page.getByRole('button', { name: 'New Workflow', exact: true }).click()
const select = page.locator('.workflow-create-form .arco-select')
const profiles = await openSelector(page, select)
await profiles.locator('.arco-select-option', { hasText: 'default' }).click()
await expect(select).toContainText('default')
await expect(page.locator('.workflow-create-form')).toBeVisible()
})
+93
View File
@@ -0,0 +1,93 @@
import { expect, test, type Page } from '@playwright/test'
import { authenticate, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
async function setup(page: Page, custom = false) {
await authenticate(page, TEST_ACCESS_KEY, 'research')
await mockHermesApi(page)
const group = {
provider: custom ? 'custom:proxy' : 'minimax-cn',
label: custom ? 'Proxy' : 'MiniMax (China)',
base_url: 'https://provider.example.test/v1', api_key: 'existing-test-key',
models: ['original-model'], available_models: ['original-model'],
builtin: !custom, credentials_editable: !custom,
...(!custom ? { base_url_env: 'MINIMAX_CN_BASE_URL' } : { provider_source: 'providers', provider_key: 'proxy_config' }),
}
await page.route(url => url.pathname === '/api/hermes/available-models', route => route.fulfill({ json: {
default: 'original-model', default_provider: group.provider, groups: [group], allProviders: [group],
profiles: [{ profile: 'research', default: 'original-model', default_provider: group.provider, groups: [group] }],
} }))
await page.goto('/#/hermes/models')
const card = page.locator('.provider-card').filter({ hasText: group.label })
await expect(card).toBeVisible()
return { group, card }
}
async function fillUrl(page: Page, url: string) {
await page.locator('.provider-edit-form .arco-form-item').filter({ hasText: 'Base URL' }).locator('input').fill(url)
}
test('card fetches the live catalog, updates the list, and retains its default model', async ({ page }) => {
const { group, card } = await setup(page)
let requestBody: any
let profileHeader: string | undefined
await page.route(url => url.pathname === '/api/hermes/provider-models', async route => {
requestBody = route.request().postDataJSON()
profileHeader = route.request().headers()['x-hermes-profile']
group.models = ['original-model', 'latest-model']
group.available_models = [...group.models]
await route.fulfill({ json: { models: [...group.models] } })
})
await card.getByRole('button', { name: 'Fetch models from upstream' }).click()
await expect(card.locator('.model-tag', { hasText: 'latest-model' })).toBeVisible()
await expect(card.locator('.default-model-select')).toContainText('original-model')
expect(requestBody).toMatchObject({ provider: 'minimax-cn', api_key: 'existing-test-key', update_cache: true })
expect(profileHeader).toBe('research')
})
test('editing built-in URL and key uses PUT and updates the card without recreation', async ({ page }) => {
const { group, card } = await setup(page)
const writes: Array<{ method: string; body: any; profile?: string }> = []
await page.route(url => url.pathname.startsWith('/api/hermes/config/providers/'), async route => {
const body = route.request().postDataJSON()
writes.push({ method: route.request().method(), body, profile: route.request().headers()['x-hermes-profile'] })
group.base_url = body.base_url
if (body.api_key) group.api_key = body.api_key
await route.fulfill({ json: { success: true } })
})
await card.getByRole('button', { name: 'Edit', exact: true }).click()
await fillUrl(page, 'https://new.example.test/v1')
await page.locator('.provider-edit-form input[type=password]').fill('new-test-key')
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.locator('.provider-edit-form')).toHaveCount(0)
await expect(card).toContainText('https://new.example.test/v1')
expect(writes).toEqual([{ method: 'PUT', body: { base_url: 'https://new.example.test/v1', api_key: 'new-test-key' }, profile: 'research' }])
await expect(card.locator('.default-model-select')).toContainText('original-model')
})
test('editing a dict-backed provider preserves a blank key and sends its storage identity', async ({ page }) => {
const { card } = await setup(page, true)
let requestBody: any
await page.route(url => url.pathname.startsWith('/api/hermes/config/providers/'), async route => {
requestBody = route.request().postDataJSON()
await route.fulfill({ json: { success: true } })
})
await card.getByRole('button', { name: 'Edit', exact: true }).click()
await expect(page.locator('.provider-edit-form input[type=password]')).toHaveValue('')
await fillUrl(page, 'https://changed.example.test/v1')
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.locator('.provider-edit-form')).toHaveCount(0)
expect(requestBody).toEqual({ base_url: 'https://changed.example.test/v1', source: 'providers', providerKey: 'proxy_config' })
})
test('failed upstream fetch keeps the existing catalog; failed edit stays open', async ({ page }) => {
const { card } = await setup(page)
await page.route(url => url.pathname === '/api/hermes/provider-models', route => route.fulfill({ status: 502, json: { error: 'Upstream unavailable' } }))
await card.getByRole('button', { name: 'Fetch models from upstream' }).click()
await expect(page.getByText('API Error 502: Upstream unavailable', { exact: true })).toBeVisible()
await expect(card.locator('.model-tag', { hasText: 'original-model' })).toBeVisible()
await page.route(url => url.pathname.startsWith('/api/hermes/config/providers/'), route => route.fulfill({ status: 500, json: { error: 'Save failed' } }))
await card.getByRole('button', { name: 'Edit', exact: true }).click()
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.getByText('API Error 500: Save failed', { exact: true })).toBeVisible()
await expect(page.locator('.provider-edit-form')).toBeVisible()
})
+1 -1
View File
@@ -19,7 +19,7 @@ test('fetches custom provider models through the backend proxy', async ({ page }
await page.getByRole('button', { name: 'Custom' }).click()
await page.getByPlaceholder('e.g. https://api.example.com/v1').fill('https://provider.example.test/v1')
await page.getByPlaceholder('sk-...').fill('test-provider-key')
await page.getByRole('button', { name: 'Fetch' }).click()
await page.getByRole('button', { name: 'Fetch', exact: true }).click()
await expect(page.getByText('Found 2 models')).toBeVisible()
// Scope to the dropdown options: Arco renders both the select's current value