优化页面交互并增强 Provider 配置能力
- 表单下拉框不再用 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:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '无结果',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
|
||||
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, updateConfigYamlForProfile, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||
import { discoverProviderModels } from '../../services/hermes/provider-model-discovery'
|
||||
import { getCompatibleCustomProviders } from '../../services/hermes/custom-providers-compat'
|
||||
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
||||
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
||||
@@ -22,7 +23,7 @@ const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string }
|
||||
type ProviderApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server'
|
||||
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; api_mode?: ProviderApiMode; builtin?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string; provider_source?: 'custom_providers' | 'providers'; provider_key?: string }
|
||||
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; api_mode?: ProviderApiMode; builtin?: boolean; credentials_editable?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[]; base_url_env?: string; provider_source?: 'custom_providers' | 'providers'; provider_key?: string }
|
||||
type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||
type CustomModels = Record<string, string[]>
|
||||
|
||||
@@ -366,7 +367,9 @@ async function buildAvailableForProfile(
|
||||
seenProviders.add(provider)
|
||||
const availableModels = [...new Set(models)]
|
||||
const apiMode = providerApiMode(provider, extra?.api_mode)
|
||||
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(apiMode ? { api_mode: apiMode } : {}), ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}), ...(extra?.provider_source ? { provider_source: extra.provider_source } : {}), ...(extra?.provider_key ? { provider_key: extra.provider_key } : {}) })
|
||||
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key,
|
||||
...(PROVIDER_ENV_MAP[provider]?.base_url_env ? { base_url_env: PROVIDER_ENV_MAP[provider].base_url_env } : {}),
|
||||
...(PROVIDER_ENV_MAP[provider]?.api_key_env ? { credentials_editable: true } : {}), ...(apiMode ? { api_mode: apiMode } : {}), ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}), ...(extra?.provider_source ? { provider_source: extra.provider_source } : {}), ...(extra?.provider_key ? { provider_key: extra.provider_key } : {}) })
|
||||
}
|
||||
|
||||
const copilotEnabled = appConfig.copilotEnabled === true
|
||||
@@ -421,7 +424,7 @@ async function buildAvailableForProfile(
|
||||
let models = [...new Set([cp.model, ...configuredModels, ...builtinCatalogModels].filter(Boolean) as string[])]
|
||||
const cachedModels = getCachedProviderModels(modelCatalogCache, providerKey, baseUrl)
|
||||
if (cachedModels) models = [...new Set([...models, ...cachedModels])]
|
||||
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '', api_mode: cp.api_mode, builtin: isBuiltinProviderKey(providerKey), provider_source: cp.source, provider_key: cp.provider_key }
|
||||
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || (cp.key_env ? envGetValue(cp.key_env) : ''), api_mode: cp.api_mode, builtin: isBuiltinProviderKey(providerKey), provider_source: cp.source, provider_key: cp.provider_key }
|
||||
}),
|
||||
)
|
||||
for (const result of customFetches) {
|
||||
@@ -805,7 +808,7 @@ export async function removeCustomModel(ctx: any) {
|
||||
|
||||
export async function fetchProviderModelList(ctx: any) {
|
||||
try {
|
||||
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean; provider?: string; label?: string; update_cache?: boolean }
|
||||
const body = ctx.request.body as { base_url?: string; api_key?: string; freeOnly?: boolean; provider?: string; label?: string; update_cache?: boolean; api_mode?: ProviderApiMode }
|
||||
const baseUrl = String(body?.base_url || '').trim()
|
||||
const apiKey = String(body?.api_key || '').trim()
|
||||
const freeOnly = body?.freeOnly === true
|
||||
@@ -832,33 +835,15 @@ export async function fetchProviderModelList(ctx: any) {
|
||||
return
|
||||
}
|
||||
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`
|
||||
const headers: Record<string, string> = {}
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`
|
||||
|
||||
const res = await fetch(modelsUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const apiMode = providerApiMode(provider, body?.api_mode)
|
||||
let models = await discoverProviderModels(baseUrl, apiKey, apiMode === 'anthropic_messages')
|
||||
if (freeOnly) models = models.filter(model => model.endsWith(':free'))
|
||||
const uniqueModels = models
|
||||
if (body?.update_cache === true && provider && uniqueModels.length === 0) {
|
||||
ctx.status = 502
|
||||
ctx.body = { error: `Provider returned HTTP ${res.status}` }
|
||||
ctx.body = { error: 'Provider returned no models; existing catalog was kept' }
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json() as { data?: Array<{ id?: unknown }> }
|
||||
if (!Array.isArray(data.data)) {
|
||||
ctx.status = 502
|
||||
ctx.body = { error: 'Provider returned unexpected format' }
|
||||
return
|
||||
}
|
||||
|
||||
let models = data.data
|
||||
.map(m => String(m?.id || '').trim())
|
||||
.filter(Boolean)
|
||||
if (freeOnly) models = models.filter(m => m.endsWith(':free'))
|
||||
const uniqueModels = Array.from(new Set(models)).sort()
|
||||
if (body?.update_cache === true && provider) {
|
||||
await writeProviderModelCatalogEntry({
|
||||
provider,
|
||||
|
||||
@@ -194,23 +194,35 @@ export async function create(ctx: any) {
|
||||
|
||||
export async function update(ctx: any) {
|
||||
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||
const { name, base_url, api_key, model, api_mode } = ctx.request.body as {
|
||||
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode
|
||||
const { name, base_url, api_key, model, api_mode, source, providerKey } = ctx.request.body as {
|
||||
name?: string; base_url?: string; api_key?: string; model?: string; api_mode?: ProviderApiMode; source?: string; providerKey?: string
|
||||
}
|
||||
const customApiMode = normalizeApiMode(api_mode)
|
||||
if (base_url !== undefined) {
|
||||
try {
|
||||
const parsed = new URL(base_url)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol')
|
||||
} catch {
|
||||
ctx.status = 400; ctx.body = { error: 'base_url must be a valid HTTP or HTTPS URL' }; return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const profile = requestedProfile(ctx)
|
||||
const isCustom = poolKey.startsWith('custom:')
|
||||
if (isCustom) {
|
||||
const found = await updateConfigYamlForProfile(profile, (config) => {
|
||||
if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false }
|
||||
const entry = (config.custom_providers as any[]).find((e: any) => {
|
||||
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
|
||||
})
|
||||
if (!entry) return { data: config, result: false, write: false }
|
||||
const found = await updateConfigYamlForProfile(profile, async (config) => {
|
||||
const legacyIndex = source === 'providers' ? -1 : findLegacyCustomProviderIndex(config, poolKey)
|
||||
const dictKey = source === 'custom_providers' || legacyIndex !== -1
|
||||
? '' : findProviderDictKey(config, poolKey, providerKey)
|
||||
const entry = legacyIndex !== -1 ? config.custom_providers[legacyIndex] : dictKey ? config.providers[dictKey] : null
|
||||
const normalized = normalizeCustomProviderEntry(entry, dictKey, dictKey ? 'providers' : 'custom_providers')
|
||||
if (!entry || !normalized || providerKeyForCustomName(normalized.name) !== poolKey) return { data: config, result: false, write: false }
|
||||
if (name !== undefined) entry.name = name
|
||||
if (base_url !== undefined) entry.base_url = base_url
|
||||
if (api_key !== undefined) entry.api_key = api_key
|
||||
if (base_url !== undefined) entry.base_url = base_url.trim()
|
||||
if (api_key !== undefined) {
|
||||
if (normalized.key_env) await saveEnvValueForProfile(profile, normalized.key_env, api_key)
|
||||
entry.api_key = api_key
|
||||
}
|
||||
if (model !== undefined) entry.model = model
|
||||
if (customApiMode !== undefined) entry.api_mode = customApiMode
|
||||
return { data: config, result: true }
|
||||
@@ -220,10 +232,17 @@ export async function update(ctx: any) {
|
||||
}
|
||||
} else {
|
||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||
if (!envMapping?.api_key_env) {
|
||||
if (!envMapping || (!envMapping.api_key_env && !envMapping.base_url_env)) {
|
||||
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
|
||||
}
|
||||
if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) }
|
||||
if (base_url !== undefined && !envMapping.base_url_env) {
|
||||
ctx.status = 400; ctx.body = { error: `Cannot update base URL for "${poolKey}"` }; return
|
||||
}
|
||||
if (api_key !== undefined && !envMapping.api_key_env) {
|
||||
ctx.status = 400; ctx.body = { error: `Cannot update API key for "${poolKey}"` }; return
|
||||
}
|
||||
if (api_key !== undefined) await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key)
|
||||
if (base_url !== undefined) await saveEnvValueForProfile(profile, envMapping.base_url_env, base_url.trim())
|
||||
}
|
||||
// TODO: Test if provider works without gateway restart
|
||||
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
|
||||
import { setChatRunServer } from './routes/hermes/chat-run'
|
||||
import { GroupChatServer } from './services/hermes/group-chat'
|
||||
import { ChatRunSocket } from './services/hermes/run-chat'
|
||||
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { getAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { HermesSkillInjector } from './services/hermes/skill-injector'
|
||||
import { injectBundledMcpServer } from './services/hermes/studio-mcp-autoinject'
|
||||
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
|
||||
@@ -109,7 +109,7 @@ function skillInjectionDisabled(): boolean {
|
||||
return envFlagEnabled('HERMES_WEB_UI_DISABLE_SKILL_INJECTION')
|
||||
}
|
||||
|
||||
async function startRuntimeServicesBeforeListen(): Promise<void> {
|
||||
async function startRuntimeServices(): Promise<void> {
|
||||
if (gatewayAutostartDisabled()) {
|
||||
console.log('[bootstrap] profile gateway check disabled by HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART')
|
||||
} else {
|
||||
@@ -122,7 +122,7 @@ async function startRuntimeServicesBeforeListen(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
agentBridgeManager = await startAgentBridgeManager()
|
||||
await agentBridgeManager.start()
|
||||
console.log('[bootstrap] agent bridge started')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] agent bridge failed to start')
|
||||
@@ -187,8 +187,6 @@ export async function bootstrap() {
|
||||
console.warn('[bootstrap] failed to inject bundled MCP server:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
|
||||
await startRuntimeServicesBeforeListen()
|
||||
|
||||
const app = new Koa()
|
||||
// Initialize all web-ui SQLite tables
|
||||
const { initAllStores } = await import('./db/hermes/init')
|
||||
@@ -291,7 +289,10 @@ export async function bootstrap() {
|
||||
})
|
||||
})
|
||||
|
||||
agentBridgeManager = getAgentBridgeManager()
|
||||
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
|
||||
// Session/history APIs must remain available while the Python runtime starts.
|
||||
void startRuntimeServices()
|
||||
startVersionCheck()
|
||||
}
|
||||
|
||||
|
||||
@@ -373,10 +373,23 @@ export class AgentBridgeClient {
|
||||
}, '[agent-bridge-client] request')
|
||||
}
|
||||
try {
|
||||
const socket = await this.connectSocket()
|
||||
socket.write(`${JSON.stringify(payload)}\n`)
|
||||
const raw = await this.readResponse(socket, timeoutMs)
|
||||
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
|
||||
const workerConnectDeadline = Date.now() + Math.max(0, this.connectRetryMs)
|
||||
let response: { ok?: boolean; error?: string; error_type?: string }
|
||||
for (;;) {
|
||||
const socket = await this.connectSocket()
|
||||
socket.write(`${JSON.stringify(payload)}\n`)
|
||||
const raw = await this.readResponse(socket, timeoutMs)
|
||||
response = JSON.parse(raw) as typeof response
|
||||
// The broker reports worker connect failures before any request bytes
|
||||
// reach the worker. Only these typed failures are safe to replay;
|
||||
// timeouts, lost responses and agent errors may have side effects.
|
||||
const workerNotListening = response.ok === false && (
|
||||
(response.error_type === 'ConnectionRefusedError' && /^\[Errno (111|61|10061)\]/.test(response.error || '')) ||
|
||||
(response.error_type === 'FileNotFoundError' && /^\[Errno 2\]/.test(response.error || ''))
|
||||
)
|
||||
if (!workerNotListening || Date.now() >= workerConnectDeadline) break
|
||||
await delay(100)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
|
||||
error.response = response
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* Studio historically only read the legacy `custom_providers:` list, which makes
|
||||
* v12+ dict-shaped providers invisible in the model picker, catalog, and context
|
||||
* lookups (Issue #1570). This helper mirrors Agent's behavior so Studio can read
|
||||
* both shapes; write paths intentionally still target the legacy list (that's
|
||||
* the canonical write surface Studio's UI manipulates today).
|
||||
* both shapes. Creation targets the legacy list; updates and deletion preserve
|
||||
* the original source and dictionary key returned by this helper.
|
||||
*/
|
||||
|
||||
const KNOWN_KEYS = new Set([
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Strict live discovery: never replace a cached catalog with a partial/error response. */
|
||||
export async function discoverProviderModels(baseUrl: string, apiKey: string, anthropic = false): Promise<string[]> {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const modelsUrl = new URL(/\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`)
|
||||
const headers: Record<string, string> = {}
|
||||
if (anthropic) {
|
||||
if (apiKey) headers['x-api-key'] = apiKey
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
} else if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
}
|
||||
const models: string[] = []
|
||||
const cursors = new Set<string>()
|
||||
const signal = AbortSignal.timeout(8000)
|
||||
// Anthropic-compatible /models uses has_more + last_id cursor pagination.
|
||||
// https://platform.claude.com/docs/en/api/models/list
|
||||
for (let page = 0; page < 100; page++) {
|
||||
const res = await fetch(modelsUrl.toString(), { headers, signal })
|
||||
if (!res.ok) throw new Error(`Provider returned HTTP ${res.status}`)
|
||||
const data = await res.json() as { data?: Array<{ id?: unknown }>; has_more?: boolean; last_id?: string }
|
||||
if (!Array.isArray(data.data)) throw new Error('Provider returned unexpected format')
|
||||
models.push(...data.data.map(model => String(model?.id || '').trim()).filter(Boolean))
|
||||
if (!data.has_more) return [...new Set(models)].sort()
|
||||
const cursor = data.last_id
|
||||
if (typeof cursor !== 'string' || !cursor || cursors.has(cursor)) throw new Error('Provider returned invalid model pagination')
|
||||
cursors.add(cursor)
|
||||
modelsUrl.searchParams.set('after_id', cursor)
|
||||
}
|
||||
throw new Error('Provider model pagination limit exceeded')
|
||||
}
|
||||
Reference in New Issue
Block a user