优化页面交互并增强 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
+29
View File
@@ -583,4 +583,33 @@ describe('App Store', () => {
expect(store.profileModelGroups[0].groups[0].models).toEqual(['deepseek-v4-flash'])
expect(store.profileModelGroups[0].groups[0].available_models).toEqual(['deepseek-v4-flash'])
})
it('retries health quickly while offline and stops polling after unmount', async () => {
vi.useFakeTimers()
mockSystemApi.checkHealth.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ status: 'ok' })
const store = useAppStore()
store.startHealthPolling()
await vi.advanceTimersByTimeAsync(0)
expect(store.connected).toBe(false)
await vi.advanceTimersByTimeAsync(2000)
expect(store.connected).toBe(true)
expect(mockSystemApi.checkHealth).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2000)
expect(mockSystemApi.checkHealth).toHaveBeenCalledTimes(2)
store.stopHealthPolling()
await vi.advanceTimersByTimeAsync(30000)
expect(mockSystemApi.checkHealth).toHaveBeenCalledTimes(2)
})
it('does not reschedule an in-flight health poll after polling is stopped', async () => {
vi.useFakeTimers()
let finish!: (value: any) => void
mockSystemApi.checkHealth.mockReturnValue(new Promise(resolve => { finish = resolve }))
const store = useAppStore()
store.startHealthPolling()
store.stopHealthPolling()
finish({ status: 'ok' })
await vi.advanceTimersByTimeAsync(60000)
expect(mockSystemApi.checkHealth).toHaveBeenCalledTimes(1)
})
})
@@ -139,4 +139,18 @@ describe('chat session ordering', () => {
expect(store.activeSessionId).toBeNull()
expect(store.activeSession).toBeNull()
})
it('keeps a failed initial session load retryable and restores sessions on retry', async () => {
vi.mocked(fetchSessions).mockRejectedValue(new Error('backend starting'))
const store = useChatStore()
expect(await store.loadSessions()).toBe(false)
expect(store.sessionsLoaded).toBe(false)
vi.mocked(fetchSessions).mockResolvedValue([makeSession('restored', { started_at: 1000 })] as any)
expect(await store.loadSessions()).toBe(true)
expect(store.sessionsLoaded).toBe(true)
expect(store.activeSessionId).toBe('restored')
vi.mocked(fetchSessions).mockRejectedValue(new Error('restart'))
expect(await store.loadSessions()).toBe(false)
expect(store.sessions.map(session => session.id)).toEqual(['restored'])
})
})
@@ -0,0 +1,43 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parse as parseSfc } from '@vue/compiler-sfc'
import { parse as parseTemplate, type ElementNode, type RootNode, type TemplateChildNode } from '@vue/compiler-dom'
import { describe, expect, it } from 'vitest'
function vueFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const path = join(directory, entry.name)
return entry.isDirectory() ? vueFiles(path) : path.endsWith('.vue') ? [path] : []
})
}
// A native label forwards clicks on custom control chrome to the first input.
// For popup controls this can immediately toggle the menu closed again.
const popupControls = new Set([
'a-select', 'a-tree-select', 'a-cascader', 'a-auto-complete', 'a-dropdown',
'a-trigger', 'a-date-picker', 'a-range-picker', 'a-time-picker', 'a-color-picker',
'ModelSelector', 'ProfileSelector', 'LanguageSwitch', 'WorkflowModelSelector',
])
describe('dropdown label activation', () => {
it('keeps custom popup controls outside native labels throughout the client', () => {
const violations: string[] = []
for (const path of vueFiles('packages/client/src')) {
const { descriptor } = parseSfc(readFileSync(path, 'utf8'))
if (!descriptor.template) continue
const offset = descriptor.template.loc.start.line - 1
function visit(node: RootNode | TemplateChildNode, insideLabel = false) {
if (node.type !== 0 && node.type !== 1) return
if (node.type === 1) {
if (insideLabel && popupControls.has(node.tag)) {
violations.push(`${path}:${offset + node.loc.start.line} <${node.tag}> inside <label>`)
}
insideLabel ||= node.tag === 'label'
}
for (const child of (node as RootNode | ElementNode).children) visit(child, insideLabel)
}
visit(parseTemplate(descriptor.template.content))
}
expect(violations).toEqual([])
})
})