refactor: restructure project for multi-agent extensibility
- Migrate source to packages/client and packages/server directories - Namespace all Hermes-specific code under hermes/ subdirectories (api/hermes/, components/hermes/, views/hermes/, stores/hermes/) - Add hermes.* route names and /hermes/* path prefixes - Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax - Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/* - Fix frontend API paths to match backend /api/hermes/* routes - Fix WebSocket terminal path to /api/hermes/terminal - Add proxyMiddleware for reliable unmatched route proxying - Add profiles route module and hermes-cli profile commands - Update CLAUDE.md development guide with new architecture - Add Chinese README (README_zh.md) - Add Web Terminal feature to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ provider: AvailableModelGroup }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
||||
const displayName = computed(() => props.provider.label)
|
||||
|
||||
async function handleDelete() {
|
||||
dialog.warning({
|
||||
title: t('models.deleteProvider'),
|
||||
content: t('models.deleteConfirm', { name: displayName.value }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
message.success(t('models.providerDeleted'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-card">
|
||||
<div class="card-header">
|
||||
<h3 class="provider-name">{{ displayName }}</h3>
|
||||
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
|
||||
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('models.provider') }}</span>
|
||||
<code class="info-value mono">{{ provider.provider }}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('models.baseUrl') }}</span>
|
||||
<code class="info-value mono">{{ provider.base_url }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.provider-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba($accent-primary, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.builtin {
|
||||
background: rgba($accent-primary, 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
background: rgba($success, 0.12);
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } 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'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const fetchingModels = ref(false)
|
||||
|
||||
const providerType = ref<'preset' | 'custom'>('preset')
|
||||
const selectedPreset = ref<string | null>(null)
|
||||
const formData = ref({
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
})
|
||||
|
||||
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const PRESET_PROVIDERS = PROVIDER_PRESETS as any[]
|
||||
|
||||
function autoGenerateName(url: string): string {
|
||||
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
||||
const host = clean.split('/')[0]
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
return t('models.local', { host })
|
||||
}
|
||||
return host.charAt(0).toUpperCase() + host.slice(1)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => formData.value.base_url, (url) => {
|
||||
if (providerType.value === 'custom' && url.trim()) {
|
||||
formData.value.name = autoGenerateName(url.trim())
|
||||
}
|
||||
})
|
||||
|
||||
watch(providerType, () => {
|
||||
modelOptions.value = []
|
||||
formData.value = { name: '', base_url: '', api_key: '', model: '' }
|
||||
selectedPreset.value = null
|
||||
})
|
||||
|
||||
async function fetchModels() {
|
||||
const { base_url } = formData.value
|
||||
if (!base_url.trim()) {
|
||||
message.warning(t('models.enterBaseUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
fetchingModels.value = true
|
||||
try {
|
||||
const url = base_url.replace(/\/+$/, '') + '/models'
|
||||
const headers: Record<string, string> = {}
|
||||
if (formData.value.api_key.trim()) {
|
||||
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
|
||||
}
|
||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||
if (!Array.isArray(data.data)) throw new Error(t('models.unexpectedFormat'))
|
||||
|
||||
modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id }))
|
||||
if (modelOptions.value.length > 0 && !formData.value.model) {
|
||||
formData.value.model = modelOptions.value[0].value
|
||||
}
|
||||
message.success(t('models.foundModels', { count: modelOptions.value.length }))
|
||||
} catch (e: any) {
|
||||
message.error(t('models.fetchFailed') + ': ' + e.message)
|
||||
} finally {
|
||||
fetchingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (providerType.value === 'preset' && !selectedPreset.value) {
|
||||
message.warning(t('models.selectProviderRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.base_url.trim()) {
|
||||
message.warning(t('models.baseUrlRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.api_key.trim()) {
|
||||
message.warning(t('models.apiKeyRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.model) {
|
||||
message.warning(t('models.modelRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const providerKey = providerType.value === 'preset'
|
||||
? (PRESET_PROVIDERS.find(p => p.value === selectedPreset.value)?.value || null)
|
||||
: null
|
||||
|
||||
await modelsStore.addProvider({
|
||||
name: formData.value.name.trim(),
|
||||
base_url: formData.value.base_url.trim(),
|
||||
api_key: formData.value.api_key.trim(),
|
||||
model: formData.value.model,
|
||||
providerKey,
|
||||
})
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.addProvider')"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('models.providerType')">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<NButton
|
||||
:type="providerType === 'preset' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="providerType = 'preset'"
|
||||
>
|
||||
{{ t('models.preset') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
:type="providerType === 'custom' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="providerType = 'custom'"
|
||||
>
|
||||
{{ t('models.custom') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
|
||||
<NSelect
|
||||
v-model:value="selectedPreset"
|
||||
:options="PRESET_PROVIDERS"
|
||||
:placeholder="t('models.chooseProvider')"
|
||||
filterable
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
|
||||
<NInput
|
||||
v-model:value="formData.name"
|
||||
:placeholder="t('models.autoGeneratedName')"
|
||||
disabled
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.baseUrl')" required>
|
||||
<NInput
|
||||
v-model:value="formData.base_url"
|
||||
:placeholder="t('models.baseUrlPlaceholder')"
|
||||
:disabled="providerType === 'preset'"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.apiKey')" required>
|
||||
<NInput
|
||||
v-model:value="formData.api_key"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('models.apiKeyPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.defaultModel')" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<NSelect
|
||||
v-model:value="formData.model"
|
||||
:options="modelOptions"
|
||||
filterable
|
||||
:placeholder="t('models.selectModel')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<NButton
|
||||
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
|
||||
:loading="fetchingModels"
|
||||
@click="fetchModels"
|
||||
>
|
||||
{{ t('common.fetch') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleSave">
|
||||
{{ t('common.add') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import ProviderCard from './ProviderCard.vue'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="modelsStore.providers.length === 0" class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<p>{{ t('models.noProviders') }}</p>
|
||||
</div>
|
||||
<div v-else class="providers-grid">
|
||||
<ProviderCard
|
||||
v-for="g in modelsStore.providers"
|
||||
:key="g.provider"
|
||||
:provider="g"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
gap: 12px;
|
||||
|
||||
.empty-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.providers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user