优化页面交互并增强 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
+4
View File
@@ -100,6 +100,7 @@ export interface AvailableModelGroup {
builtin?: boolean
/** Env var used by Hermes to override this provider's base URL. If present, the preset URL is editable. */
base_url_env?: string
credentials_editable?: boolean
/** Config source for custom providers. Dict-backed providers can be deleted from providers:<key>. */
provider_source?: 'custom_providers' | 'providers'
provider_key?: string
@@ -224,6 +225,7 @@ export async function fetchProviderModels(data: {
provider?: string
label?: string
update_cache?: boolean
api_mode?: ProviderApiMode
}): Promise<{ models: string[] }> {
return request<{ models: string[] }>('/api/hermes/provider-models', {
method: 'POST',
@@ -277,6 +279,8 @@ export async function removeCustomProvider(name: string, options: { source?: 'cu
}
export async function updateProvider(poolKey: string, data: {
source?: 'custom_providers' | 'providers'
providerKey?: string
name?: string
base_url?: string
api_key?: string
@@ -1125,13 +1125,16 @@ async function handleSessionModelCustomSubmit() {
<a-popconfirm
v-if="isBatchMode && selectedCount > 0"
:visible="showBatchDeleteConfirm"
:content="t('chat.confirmBatchDelete', { count: selectedCount })"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:ok-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
:cancel-button-props="{ disabled: isBatchDeleting }"
@ok="handleBatchDeleteConfirm"
@cancel="showBatchDeleteConfirm = false"
@update:visible="showBatchDeleteConfirm = $event"
>
<a-button type="secondary" size="mini" status="danger" :loading="isBatchDeleting" :disabled="isBatchDeleting">
<a-button :aria-label="t('common.delete')" type="secondary" size="mini" status="danger" :loading="isBatchDeleting" :disabled="isBatchDeleting">
<template #icon>
<svg
width="14"
@@ -1146,7 +1149,6 @@ async function handleSessionModelCustomSubmit() {
</svg>
</template>
</a-button>
{{ t('chat.confirmBatchDelete', { count: selectedCount }) }}
</a-popconfirm>
<a-button
v-if="isBatchMode"
@@ -1435,34 +1437,37 @@ async function handleSessionModelCustomSubmit() {
>
<template #title>{{ t('chat.newChat') }}</template>
<div class="new-chat-form">
<label class="new-chat-field">
<div class="new-chat-field">
<span class="new-chat-label">{{ t("sidebar.profiles") }}</span>
<a-select
:aria-label="t('sidebar.profiles')"
:model-value="newChatProfile"
:options="newChatProfileOptions"
:loading="newChatLoading || profilesStore.loading"
@change="handleNewChatProfileChange"
/>
</label>
<label class="new-chat-field">
</div>
<div class="new-chat-field">
<span class="new-chat-label">{{ t("models.provider") }}</span>
<a-select
:aria-label="t('models.provider')"
:model-value="newChatProvider"
:options="newChatProviderOptions"
:disabled="newChatLoading"
@change="handleNewChatProviderChange"
/>
</label>
<label class="new-chat-field">
</div>
<div class="new-chat-field">
<span class="new-chat-label">{{ t("models.models") }}</span>
<a-select
:aria-label="t('models.models')"
v-model="newChatModel"
:options="newChatModelOptions"
:disabled="newChatLoading || !newChatProvider"
allow-search
allow-clear
/>
</label>
</div>
<div class="new-chat-field">
<span class="new-chat-label">
{{ t("chat.workspace") }}
@@ -282,25 +282,27 @@ watch(() => form.value.provider, (provider) => {
:mask-closable="!saving"
>
<div class="auxiliary-form">
<label>
<div class="auxiliary-select-field">
<span>{{ t('models.provider') }}</span>
<a-select
:aria-label="t('models.provider')"
v-model="form.provider"
:options="providerOptions"
:placeholder="t('models.chooseProvider')"
allow-search
/>
</label>
<label>
</div>
<div class="auxiliary-select-field">
<span>{{ t('models.defaultModel') }}</span>
<a-select
:aria-label="t('models.defaultModel')"
v-model="form.model"
:options="modelOptions"
:placeholder="t('models.selectModel')"
:disabled="!form.provider || form.provider === 'auto' || form.provider === 'main'"
allow-search
/>
</label>
</div>
<label>
<span>{{ t('models.auxiliaryTimeout') }}</span>
<a-input-number
@@ -395,7 +397,8 @@ watch(() => form.value.provider, (provider) => {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
label {
label,
.auxiliary-select-field {
display: flex;
flex-direction: column;
gap: 6px;
@@ -6,6 +6,8 @@ import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import ProviderEditModal from './ProviderEditModal.vue'
import { fetchProviderModels } from '@/api/hermes/system'
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
import { useI18n } from 'vue-i18n'
@@ -24,6 +26,32 @@ const isConfigBackedProvider = computed(() => isCustomProviderKey.value || (!pro
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showEditModal = ref(false)
const fetchingUpstream = ref(false)
const canEditProvider = computed(() => isConfigBackedProvider.value || props.provider.credentials_editable || !!props.provider.base_url_env)
async function handleFetchUpstream() {
if (fetchingUpstream.value) return
fetchingUpstream.value = true
try {
const result = await fetchProviderModels({
provider: props.provider.provider,
label: props.provider.label,
base_url: props.provider.base_url,
api_key: props.provider.api_key,
update_cache: true,
api_mode: props.provider.api_mode,
freeOnly: props.provider.provider === 'openrouter',
})
await modelsStore.fetchProviders()
await appStore.reloadModels()
message.success(t('models.upstreamModelsFetched', { count: result.models.length }))
} catch (e: any) {
message.error(e?.message || t('models.upstreamModelsFailed'))
} finally {
fetchingUpstream.value = false
}
}
const destructiveActionLabel = computed(() => {
if (isConfigBackedProvider.value) return t('common.delete')
if (isCopilot.value) return t('models.disableProvider')
@@ -302,11 +330,15 @@ async function handleDelete() {
</div>
<div class="cy-panel-b card-actions">
<a-button size="mini" type="text" :loading="fetchingUpstream" :disabled="!provider.base_url || showEditModal" @click="handleFetchUpstream">{{ t('models.fetchUpstreamModels') }}</a-button>
<a-button v-if="canEditProvider" size="mini" type="text" :disabled="fetchingUpstream || deleting" @click="showEditModal = true">{{ t('common.edit') }}</a-button>
<a-button size="mini" type="text" @click="showAliasListModal = true">{{ t('models.aliasManage') }}</a-button>
<a-button size="mini" type="text" @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</a-button>
<a-button size="mini" type="text" status="danger" :loading="deleting" @click="handleDelete">{{ destructiveActionLabel }}</a-button>
</div>
<ProviderEditModal v-if="showEditModal" :provider="provider" @close="showEditModal = false" />
<a-modal
v-model:visible="showAliasListModal"
:footer="false"
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { updateProvider, type AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useMessage } from '@/composables/useMessage'
const props = defineProps<{ provider: AvailableModelGroup }>()
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const appStore = useAppStore()
const message = useMessage()
const visible = ref(true)
const saving = ref(false)
const baseUrl = ref(props.provider.base_url)
const apiKey = ref('')
const canEditBaseUrl = computed(() => props.provider.provider.startsWith('custom:') || !!props.provider.provider_source || !!props.provider.base_url_env)
async function save() {
if (saving.value) return
const url = baseUrl.value.trim()
if (canEditBaseUrl.value) {
try {
if (!['http:', 'https:'].includes(new URL(url).protocol)) throw new Error('Invalid protocol')
} catch {
message.error(t('models.invalidBaseUrl'))
return
}
}
saving.value = true
try {
await updateProvider(props.provider.provider, {
...(canEditBaseUrl.value ? { base_url: url } : {}),
...(apiKey.value.trim() ? { api_key: apiKey.value.trim() } : {}),
source: props.provider.provider_source,
providerKey: props.provider.provider_key,
})
await modelsStore.fetchProviders()
await appStore.reloadModels()
message.success(t('models.providerUpdated'))
emit('close')
} catch (e: any) {
message.error(e?.message || t('models.providerUpdateFailed'))
} finally {
saving.value = false
}
}
</script>
<template>
<a-modal
v-model:visible="visible"
:title="t('models.editProviderFor', { name: provider.label })"
width="min(560px, calc(100vw - 32px))"
:mask-closable="!saving"
:esc-to-close="!saving"
:closable="!saving"
@cancel="emit('close')"
>
<a-form :model="{ baseUrl, apiKey }" layout="vertical" class="provider-edit-form">
<a-form-item :label="t('models.provider')">
<a-input :model-value="provider.provider" disabled />
</a-form-item>
<a-form-item :label="t('models.baseUrl')">
<a-input v-model="baseUrl" :aria-label="t('models.baseUrl')" :disabled="saving || !canEditBaseUrl" />
</a-form-item>
<a-form-item :label="t('models.apiKey')" :extra="t('models.keepExistingKey')">
<a-input-password v-model="apiKey" :aria-label="t('models.apiKey')" :placeholder="t('models.keepExistingKey')" :disabled="saving" autocomplete="new-password" />
</a-form-item>
</a-form>
<template #footer>
<a-button :disabled="saving" @click="emit('close')">{{ t('common.cancel') }}</a-button>
<a-button type="primary" :loading="saving" @click="save">{{ t('common.save') }}</a-button>
</template>
</a-modal>
</template>
@@ -221,6 +221,7 @@ async function fetchModels() {
: formData.value.name.trim() || provider
const data = await fetchProviderModels({
base_url: base_url.trim(),
api_mode: formData.value.api_mode,
api_key: formData.value.api_key.trim(),
provider,
label,
+8
View File
@@ -1211,6 +1211,14 @@ export default {
// Models
models: {
fetchUpstreamModels: 'Fetch models from upstream',
upstreamModelsFetched: 'Fetched {count} models from upstream',
upstreamModelsFailed: 'Failed to fetch models from upstream',
editProviderFor: 'Edit {name}',
keepExistingKey: 'Leave blank to keep the existing key',
invalidBaseUrl: 'Enter a valid HTTP or HTTPS Base URL',
providerUpdated: 'Provider configuration updated',
providerUpdateFailed: 'Failed to update provider configuration',
title: 'Models',
searchPlaceholder: 'Search models...',
noResults: 'No results',
+8
View File
@@ -1211,6 +1211,14 @@ export default {
// 模型
models: {
fetchUpstreamModels: '从上游获取模型',
upstreamModelsFetched: '已从上游获取 {count} 个模型',
upstreamModelsFailed: '从上游获取模型失败',
editProviderFor: '修改 {name}',
keepExistingKey: '留空保留现有 Key',
invalidBaseUrl: '请输入有效的 HTTP 或 HTTPS Base URL',
providerUpdated: 'Provider 配置已更新',
providerUpdateFailed: '修改 Provider 配置失败',
title: '模型',
searchPlaceholder: '搜索模型...',
noResults: '无结果',
+11 -4
View File
@@ -41,7 +41,8 @@ export const useAppStore = defineStore('app', () => {
const customModels = ref<Record<string, string[]>>({})
const modelAliases = ref<Record<string, Record<string, string>>>({})
const modelVisibility = ref<ModelVisibility>({})
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
const healthPollTimer = ref<ReturnType<typeof setTimeout>>()
let healthPollGeneration = 0
const nodeVersion = ref('')
const isDocker = ref(false)
@@ -301,13 +302,19 @@ export const useAppStore = defineStore('app', () => {
function startHealthPolling(interval = 30000) {
stopHealthPolling()
checkConnection()
healthPollTimer.value = setInterval(checkConnection, interval)
const generation = healthPollGeneration
const poll = async () => {
await checkConnection()
if (generation !== healthPollGeneration) return
healthPollTimer.value = setTimeout(poll, connected.value ? interval : Math.min(interval, 2000))
}
void poll()
}
function stopHealthPolling() {
healthPollGeneration++
if (healthPollTimer.value) {
clearInterval(healthPollTimer.value)
clearTimeout(healthPollTimer.value)
healthPollTimer.value = undefined
}
}
+3 -1
View File
@@ -995,11 +995,13 @@ export const useChatStore = defineStore('chat', () => {
} else {
clearActiveSession()
}
sessionsLoaded.value = true
return true
} catch (err) {
console.error('Failed to load sessions:', err)
return false
} finally {
isLoadingSessions.value = false
sessionsLoaded.value = true
}
}
+15 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
@@ -24,6 +24,8 @@ const routeProfile = computed(() => {
return typeof value === 'string' && value.trim() ? value : null
})
const initialLoadFinished = ref(false)
const productTitle = 'hermes-ui'
const tabTitle = computed(() => {
if (route.name !== 'hermes.session') return productTitle
@@ -39,8 +41,8 @@ onUnmounted(() => {
})
async function loadRouteSession() {
await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
const loaded = await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (loaded && routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
await router.replace({ name: 'hermes.chat' })
}
}
@@ -55,6 +57,16 @@ onMounted(async () => {
settingsStore.fetchSettings(),
])
await loadRouteSession()
initialLoadFinished.value = true
})
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
if (!connected || !initialized) return
if (!wasInitialized && chatStore.sessionsLoaded) return
await Promise.all([profilesStore.fetchProfiles(), settingsStore.fetchSettings(), appStore.loadModels()])
if (chatStore.isLoadingSessions) return
if (!chatStore.sessionsLoaded) await loadRouteSession()
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
})
watch([routeSessionId, routeProfile], async ([sessionId]) => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
@@ -24,9 +24,11 @@ const routeProfile = computed(() => {
return typeof value === 'string' && value.trim() ? value : null
})
const initialLoadFinished = ref(false)
async function loadRouteSession() {
await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
const loaded = await chatStore.loadSessions(chatStore.sessionProfileFilter, routeSessionId.value)
if (loaded && routeSessionId.value && chatStore.activeSessionId !== routeSessionId.value) {
await router.replace({ name: 'hermes.globalAgent' })
}
}
@@ -39,6 +41,16 @@ onMounted(async () => {
settingsStore.fetchSettings(),
])
await loadRouteSession()
initialLoadFinished.value = true
})
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
if (!connected || !initialized || chatStore.runtimeMode !== 'global_agent') return
if (!wasInitialized && chatStore.sessionsLoaded) return
await Promise.all([profilesStore.fetchProfiles(), settingsStore.fetchSettings(), appStore.loadModels()])
if (chatStore.isLoadingSessions) return
if (!chatStore.sessionsLoaded) await loadRouteSession()
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
})
onUnmounted(() => {
@@ -332,6 +332,7 @@ async function openDefaultHistorySession(replace = false) {
}
async function syncRouteSession() {
if (!hermesSessionsLoaded.value) return
const sessionId = routeSessionId.value
if (!sessionId) return
@@ -379,6 +380,13 @@ onUnmounted(() => {
window.removeEventListener('hermes:open-page-sidebar', openPageSidebar)
})
watch(() => appStore.connected, async (connected) => {
if (!connected || hermesSessionsLoading.value) return
await profilesStore.fetchProfiles()
await loadHermesSessions()
await syncRouteSession()
})
watch([routeSessionId, routeProfile], async ([sessionId]) => {
if (!sessionId) {
historySessionId.value = null
@@ -2147,14 +2147,15 @@ function nodeColor(node: { data: WorkflowAgentNodeData }) {
@press-enter="submitCreateWorkflow"
/>
</label>
<label class="workflow-field">
<div class="workflow-field">
<span class="workflow-field-label">{{ t('workflow.profile') }}</span>
<a-select
:aria-label="t('workflow.profile')"
v-model="createWorkflowProfile"
:options="workflowProfileOptions"
:loading="profilesStore.loading"
/>
</label>
</div>
<div class="workflow-field">
<span class="workflow-field-label">{{ t('workflow.workspace.select') }}</span>
<FolderPicker v-model="createWorkflowWorkspace" />