refactor: replace jobs proxy with local controller and optimize model loading (#174)

* refactor: replace jobs proxy with local controller and optimize model loading

- Add local jobs controller that directly fetches upstream gateway with
  profile support and 30s timeout, replacing unreliable proxy catch-all
- Upstream errors (non-200) return 502 instead of leaking to frontend
- Switch loadModels() from fetchAvailableModels (slow, fetches all
  provider APIs) to fetchConfigModels (reads config.yaml only)
- Hide logo dance video in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve TypeScript errors from previous refactor

- Remove unused imports (danceVideo, useTheme) in AppSidebar
- Map ConfigModelsResponse.groups to AvailableModelGroup[] format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-24 09:57:30 +08:00
committed by GitHub
parent 88c7e25f78
commit f8283729ba
5 changed files with 116 additions and 10 deletions
@@ -9,14 +9,9 @@ import ProfileSelector from "./ProfileSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import ThemeSwitch from "./ThemeSwitch.vue";
import { useSessionSearch } from '@/composables/useSessionSearch'
import danceVideoLight from "@/assets/dance-light.mp4";
import danceVideoDark from "@/assets/dance-dark.mp4";
import { useTheme } from "@/composables/useTheme";
import { changelog } from "@/data/changelog";
const { t } = useI18n();
const { isDark } = useTheme();
const message = useMessage();
const route = useRoute();
const router = useRouter();
@@ -66,7 +61,7 @@ function openChangelog() {
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
<img :src="logoPath" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<video class="logo-dance" :src="isDark ? danceVideoDark : danceVideoLight" autoplay loop muted playsinline />
<!-- <video class="logo-dance" :src="isDark ? danceVideoDark : danceVideoLight" autoplay loop muted playsinline /> -->
</div>
<nav class="sidebar-nav">
+10 -4
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { checkHealth, fetchAvailableModels, updateDefaultModel, triggerUpdate, type AvailableModelGroup } from '@/api/hermes/system'
import { checkHealth, fetchConfigModels, updateDefaultModel, triggerUpdate, type AvailableModelGroup } from '@/api/hermes/system'
const WEB_UI_VERSION = __APP_VERSION__
@@ -57,10 +57,16 @@ export const useAppStore = defineStore('app', () => {
async function loadModels() {
try {
const res = await fetchAvailableModels()
modelGroups.value = res.groups
const res = await fetchConfigModels()
modelGroups.value = res.groups.map(g => ({
provider: g.provider,
label: g.provider,
base_url: '',
models: g.models.map(m => typeof m === 'string' ? m : m.id),
api_key: '',
}))
selectedModel.value = res.default
selectedProvider.value = res.default_provider || ''
selectedProvider.value = ''
} catch {
// ignore
}
@@ -0,0 +1,90 @@
import type { Context } from 'koa'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { config } from '../../config'
function getUpstream(profile: string): string {
const mgr = getGatewayManagerInstance()
return mgr ? mgr.getUpstream(profile) : config.upstream.replace(/\/$/, '')
}
function getApiKey(profile: string): string | null {
const mgr = getGatewayManagerInstance()
return mgr?.getApiKey(profile) ?? null
}
function resolveProfile(ctx: Context): string {
return ctx.get('x-hermes-profile') || (ctx.query.profile as string) || 'default'
}
function buildHeaders(profile: string): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const apiKey = getApiKey(profile)
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
return headers
}
const TIMEOUT_MS = 30_000
async function proxyRequest(ctx: Context, upstreamPath: string, method?: string): Promise<void> {
const profile = resolveProfile(ctx)
const upstream = getUpstream(profile)
const params = new URLSearchParams(ctx.search || '')
params.delete('token')
const search = params.toString()
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
const headers = buildHeaders(profile)
const body = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'
? JSON.stringify(ctx.request.body || {})
: undefined
const res = await fetch(url, {
method: method || ctx.req.method,
headers,
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
})
if (!res.ok) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Upstream error: ${res.status} ${res.statusText}` } }
return
}
ctx.status = res.status
ctx.set('Content-Type', res.headers.get('content-type') || 'application/json')
ctx.body = await res.json()
}
export async function list(ctx: Context) {
await proxyRequest(ctx, '/api/jobs')
}
export async function get(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
}
export async function create(ctx: Context) {
await proxyRequest(ctx, '/api/jobs')
}
export async function update(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
}
export async function remove(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
}
export async function pause(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/pause`)
}
export async function resume(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/resume`)
}
export async function run(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/run`)
}
+13
View File
@@ -0,0 +1,13 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/jobs'
export const jobRoutes = new Router()
jobRoutes.get('/api/hermes/jobs', ctrl.list)
jobRoutes.get('/api/hermes/jobs/:id', ctrl.get)
jobRoutes.post('/api/hermes/jobs', ctrl.create)
jobRoutes.patch('/api/hermes/jobs/:id', ctrl.update)
jobRoutes.delete('/api/hermes/jobs/:id', ctrl.remove)
jobRoutes.post('/api/hermes/jobs/:id/pause', ctrl.pause)
jobRoutes.post('/api/hermes/jobs/:id/resume', ctrl.resume)
jobRoutes.post('/api/hermes/jobs/:id/run', ctrl.run)
+2
View File
@@ -22,6 +22,7 @@ import { gatewayRoutes } from './hermes/gateways'
import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files'
import { downloadRoutes } from './hermes/download'
import { jobRoutes } from './hermes/jobs'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
/**
@@ -56,6 +57,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(weixinRoutes.routes())
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
app.use(downloadRoutes.routes()) // Must be before proxy
app.use(jobRoutes.routes()) // Must be before proxy
app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last)