feat: multi-gateway profile support, provider management overhaul, and model settings tab
- Profile-aware proxy: inject API key from profile-specific .env, route requests via X-Hermes-Profile header - Remove auth.json dependency: built-in providers use .env, custom providers use config.yaml - Add allProviders field to available-models response with all hardcoded provider catalogs - Add Models tab in Settings for editing provider API keys (built-in → .env, custom → config.yaml) - Add PUT /api/config/providers/:poolKey for updating provider credentials - ProviderFormModal uses backend allProviders for preset dropdown - Gateway log format support: parse both agent and gateway log formats - Add webui server.log to log viewer with log rotation at 3MB - Fix provider delete loading state and OAuth provider cleanup - Setup script: require Node.js 23+, auto-upgrade if version too low Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,12 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
// Inject active profile header for proxied gateway requests
|
||||
const profileName = localStorage.getItem('hermes_active_profile_name')
|
||||
if (profileName && profileName !== 'default') {
|
||||
headers['X-Hermes-Profile'] = profileName
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...options, headers })
|
||||
|
||||
// Global 401 handler — only redirect to login for local BFF endpoints
|
||||
|
||||
@@ -45,7 +45,12 @@ export function streamRunEvents(
|
||||
) {
|
||||
const baseUrl = getBaseUrlValue()
|
||||
const token = getApiKey()
|
||||
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||
const profile = localStorage.getItem('hermes_active_profile_name')
|
||||
const params = new URLSearchParams()
|
||||
if (token) params.set('token', token)
|
||||
if (profile && profile !== 'default') params.set('profile', profile)
|
||||
const qs = params.toString()
|
||||
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${qs ? `?${qs}` : ''}`
|
||||
|
||||
let closed = false
|
||||
const source = new EventSource(url)
|
||||
|
||||
@@ -29,12 +29,14 @@ export interface AvailableModelGroup {
|
||||
label: string // display name (e.g. "zai", "subrouter.ai")
|
||||
base_url: string
|
||||
models: string[]
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface AvailableModelsResponse {
|
||||
default: string
|
||||
default_provider: string
|
||||
groups: AvailableModelGroup[]
|
||||
allProviders: AvailableModelGroup[]
|
||||
}
|
||||
|
||||
export interface CustomProvider {
|
||||
@@ -85,3 +87,15 @@ export async function removeCustomProvider(name: string): Promise<void> {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProvider(poolKey: string, data: {
|
||||
name?: string
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
model?: string
|
||||
}): Promise<void> {
|
||||
await request(`/api/hermes/config/providers/${encodeURIComponent(poolKey)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
@@ -14,6 +14,7 @@ const dialog = useDialog()
|
||||
|
||||
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
||||
const displayName = computed(() => props.provider.label)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function handleDelete() {
|
||||
dialog.warning({
|
||||
@@ -22,11 +23,14 @@ async function handleDelete() {
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
deleting.value = true
|
||||
try {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
message.success(t('models.providerDeleted'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -54,7 +58,7 @@ async function handleDelete() {
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { PROVIDER_PRESETS } from '@/shared/providers'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CodexLoginModal from './CodexLoginModal.vue'
|
||||
|
||||
@@ -32,12 +31,14 @@ const formData = ref({
|
||||
|
||||
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const PRESET_PROVIDERS = PROVIDER_PRESETS as any[]
|
||||
|
||||
const CODEX_KEY = 'openai-codex'
|
||||
|
||||
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
||||
|
||||
const presetOptions = computed(() =>
|
||||
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
|
||||
)
|
||||
|
||||
function autoGenerateName(url: string): string {
|
||||
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
||||
const host = clean.split('/')[0]
|
||||
@@ -50,13 +51,13 @@ function autoGenerateName(url: string): string {
|
||||
watch(selectedPreset, (val) => {
|
||||
formData.value.model = ''
|
||||
if (val) {
|
||||
const preset = PRESET_PROVIDERS.find(p => p.value === val)
|
||||
if (preset) {
|
||||
formData.value.name = preset.label
|
||||
formData.value.base_url = preset.base_url
|
||||
modelOptions.value = preset.models.map((m: string) => ({ label: m, value: m }))
|
||||
if (preset.models.length > 0) {
|
||||
formData.value.model = preset.models[0]
|
||||
const group = modelsStore.allProviders.find(g => g.provider === val)
|
||||
if (group) {
|
||||
formData.value.name = group.label
|
||||
formData.value.base_url = group.base_url
|
||||
modelOptions.value = group.models.map((m: string) => ({ label: m, value: m }))
|
||||
if (group.models.length > 0) {
|
||||
formData.value.model = group.models[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +75,12 @@ watch(providerType, () => {
|
||||
selectedPreset.value = null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (modelsStore.providers.length === 0) {
|
||||
modelsStore.fetchProviders()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchModels() {
|
||||
const { base_url } = formData.value
|
||||
if (!base_url.trim()) {
|
||||
@@ -133,7 +140,7 @@ async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
const providerKey = providerType.value === 'preset'
|
||||
? (PRESET_PROVIDERS.find(p => p.value === selectedPreset.value)?.value || null)
|
||||
? selectedPreset.value
|
||||
: null
|
||||
|
||||
await modelsStore.addProvider({
|
||||
@@ -196,7 +203,7 @@ function handleClose() {
|
||||
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
|
||||
<NSelect
|
||||
v-model:value="selectedPreset"
|
||||
:options="PRESET_PROVIDERS"
|
||||
:options="presetOptions"
|
||||
:placeholder="t('models.chooseProvider')"
|
||||
filterable
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NInput, NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { updateProvider } from '@/api/hermes/system'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const savingKey = ref<string | null>(null)
|
||||
const editKeys = ref<Record<string, string>>({})
|
||||
|
||||
onMounted(() => {
|
||||
if (modelsStore.providers.length === 0) {
|
||||
modelsStore.fetchProviders()
|
||||
}
|
||||
})
|
||||
|
||||
const isCustom = (provider: string) => provider.startsWith('custom:')
|
||||
|
||||
function getEditKey(provider: string): string {
|
||||
if (!(provider in editKeys.value)) {
|
||||
const g = modelsStore.providers.find(p => p.provider === provider)
|
||||
editKeys.value[provider] = g?.api_key || ''
|
||||
}
|
||||
return editKeys.value[provider]
|
||||
}
|
||||
|
||||
async function handleSaveApiKey(providerKey: string) {
|
||||
const key = getEditKey(providerKey)
|
||||
if (!key.trim()) {
|
||||
message.warning(t('settings.models.apiKeyPlaceholder'))
|
||||
return
|
||||
}
|
||||
savingKey.value = providerKey
|
||||
try {
|
||||
await updateProvider(providerKey, { api_key: key.trim() })
|
||||
message.success(t('settings.models.saved'))
|
||||
await modelsStore.fetchProviders()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('settings.models.saveFailed'))
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCustom(providerKey: string) {
|
||||
const key = getEditKey(providerKey)
|
||||
savingKey.value = providerKey
|
||||
try {
|
||||
await updateProvider(providerKey, { api_key: key.trim() })
|
||||
message.success(t('settings.models.saved'))
|
||||
await modelsStore.fetchProviders()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('settings.models.saveFailed'))
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<NSpin :show="modelsStore.loading">
|
||||
<div v-if="modelsStore.providers.length === 0" class="empty-hint">
|
||||
<NEmpty :description="t('settings.models.noProviders')" />
|
||||
</div>
|
||||
|
||||
<div v-for="g in modelsStore.providers" :key="g.provider" class="provider-section">
|
||||
<div class="provider-header">
|
||||
<h4 class="provider-name">{{ g.label }}</h4>
|
||||
<span class="type-badge" :class="isCustom(g.provider) ? 'custom' : 'builtin'">
|
||||
{{ isCustom(g.provider) ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Built-in provider: only API key -->
|
||||
<div v-if="!isCustom(g.provider)" class="provider-fields">
|
||||
<div class="field-row">
|
||||
<NInput
|
||||
:value="getEditKey(g.provider)"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('settings.models.apiKeyPlaceholder')"
|
||||
autocomplete="off"
|
||||
@update:value="v => editKeys[g.provider] = v"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingKey === g.provider"
|
||||
@click="handleSaveApiKey(g.provider)"
|
||||
>
|
||||
{{ t('settings.models.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom provider: API key -->
|
||||
<div v-else class="provider-fields">
|
||||
<div class="field-row">
|
||||
<NInput
|
||||
:value="getEditKey(g.provider)"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('settings.models.apiKeyPlaceholder')"
|
||||
autocomplete="off"
|
||||
@update:value="v => editKeys[g.provider] = v"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingKey === g.provider"
|
||||
@click="handleSaveCustom(g.provider)"
|
||||
>
|
||||
{{ t('settings.models.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
background: $bg-card;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.builtin {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.n-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -306,6 +306,15 @@ export default {
|
||||
session: 'Session',
|
||||
privacy: 'Privacy',
|
||||
apiServer: 'API Server',
|
||||
models: 'Models',
|
||||
},
|
||||
models: {
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'Enter API key',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
saveFailed: 'Save failed',
|
||||
noProviders: 'No providers configured',
|
||||
},
|
||||
display: {
|
||||
streaming: 'Stream Responses',
|
||||
|
||||
@@ -298,6 +298,15 @@ export default {
|
||||
session: '会话',
|
||||
privacy: '隐私',
|
||||
apiServer: 'API 服务器',
|
||||
models: '模型',
|
||||
},
|
||||
models: {
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: '输入 API Key',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
saveFailed: '保存失败',
|
||||
noProviders: '暂无已配置的模型',
|
||||
},
|
||||
display: {
|
||||
streaming: '流式响应',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAppStore } from './app'
|
||||
|
||||
export const useModelsStore = defineStore('models', () => {
|
||||
const providers = ref<AvailableModelGroup[]>([])
|
||||
const allProviders = ref<AvailableModelGroup[]>([])
|
||||
const defaultModel = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -34,6 +35,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
try {
|
||||
const res = await systemApi.fetchAvailableModels()
|
||||
providers.value = res.groups
|
||||
allProviders.value = res.allProviders
|
||||
defaultModel.value = res.default
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch providers:', err)
|
||||
@@ -65,6 +67,7 @@ export const useModelsStore = defineStore('models', () => {
|
||||
|
||||
return {
|
||||
providers,
|
||||
allProviders,
|
||||
defaultModel,
|
||||
loading,
|
||||
customProviders,
|
||||
|
||||
@@ -12,6 +12,7 @@ import AgentSettings from "@/components/hermes/settings/AgentSettings.vue";
|
||||
import MemorySettings from "@/components/hermes/settings/MemorySettings.vue";
|
||||
import SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
|
||||
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
|
||||
import ModelSettings from "@/components/hermes/settings/ModelSettings.vue";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
@@ -49,6 +50,9 @@ onMounted(() => {
|
||||
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
|
||||
<PrivacySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="models" :tab="t('settings.tabs.models')">
|
||||
<ModelSettings />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user