From 9a0b50f370d2da8dc11fa49d1e17fcce5281115d Mon Sep 17 00:00:00 2001 From: ekko Date: Fri, 17 Apr 2026 12:04:20 +0800 Subject: [PATCH 1/4] fix: add MiniMax highspeed variants and deduplicate model list - Add MiniMax-M2.7/M2.5/M2.1/M2 -highspeed variants for both international and China providers (#17) - Deduplicate models in /available-models response to fix repeated entries when using Copilot or other providers (#18) Co-Authored-By: Claude Opus 4.6 --- packages/client/src/shared/providers.ts | 4 +-- .../server/src/routes/hermes/filesystem.ts | 25 ++++++++++++++++--- packages/server/src/shared/providers.ts | 4 +-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/client/src/shared/providers.ts b/packages/client/src/shared/providers.ts index cce6eb1..f8a757c 100644 --- a/packages/client/src/shared/providers.ts +++ b/packages/client/src/shared/providers.ts @@ -86,13 +86,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ label: 'MiniMax', value: 'minimax', base_url: 'https://api.minimax.io/anthropic/v1', - models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'], + models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'], }, { label: 'MiniMax (China)', value: 'minimax-cn', base_url: 'https://api.minimaxi.com/v1', - models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'], + models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'], }, { label: 'Alibaba Cloud', diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts index 4905106..eb623d6 100644 --- a/packages/server/src/routes/hermes/filesystem.ts +++ b/packages/server/src/routes/hermes/filesystem.ts @@ -513,21 +513,40 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { for (const result of results) { if (result.status === 'fulfilled' && result.value.models.length > 0) { const { key, label, base_url, models } = result.value - groups.push({ provider: key, label, base_url, models }) + groups.push({ provider: key, label, base_url, models: [...new Set(models)] }) } else if (result.status === 'rejected') { console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) } } } + // Deduplicate models within each group and merge groups with the same provider key + const dedupedGroups: typeof groups = [] + const seenProviders = new Map() + for (const g of groups) { + g.models = [...new Set(g.models)] + const existingIdx = seenProviders.get(g.provider) + if (existingIdx !== undefined) { + // Merge models into existing group + const existing = dedupedGroups[existingIdx] + const existingSet = new Set(existing.models) + for (const m of g.models) { + if (!existingSet.has(m)) existing.models.push(m) + } + } else { + seenProviders.set(g.provider, dedupedGroups.length) + dedupedGroups.push(g) + } + } + // Fallback: if no providers returned models, fall back to config.yaml parsing - if (groups.length === 0) { + if (dedupedGroups.length === 0) { const fallback = buildModelGroups(config) ctx.body = fallback return } - ctx.body = { default: currentDefault, groups } + ctx.body = { default: currentDefault, groups: dedupedGroups } } catch (err: any) { ctx.status = 500 ctx.body = { error: err.message } diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index cce6eb1..f8a757c 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -86,13 +86,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ label: 'MiniMax', value: 'minimax', base_url: 'https://api.minimax.io/anthropic/v1', - models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'], + models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'], }, { label: 'MiniMax (China)', value: 'minimax-cn', base_url: 'https://api.minimaxi.com/v1', - models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'], + models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'], }, { label: 'Alibaba Cloud', From 3d2b1c5e4742179e23a7385717d20a533ba8b4c6 Mon Sep 17 00:00:00 2001 From: ekko Date: Fri, 17 Apr 2026 16:48:24 +0800 Subject: [PATCH 2/4] fix: job edit schedule format error and refactor services directory - Fix #25: job update sends schedule as plain string but upstream expects { kind, expr, display } object, causing "'str' object has no attribute 'get'" - Move hermes-cli.ts, hermes.ts, hermes-profile.ts into services/hermes/ for multi-agent namespacing consistency - Fix ts-node Set spread compatibility in filesystem.ts Co-Authored-By: Claude Opus 4.6 --- packages/client/src/api/hermes/jobs.ts | 2 +- .../src/components/hermes/jobs/JobFormModal.vue | 13 +++++++++++++ packages/server/src/index.ts | 4 ++-- packages/server/src/routes/hermes/config.ts | 4 ++-- packages/server/src/routes/hermes/filesystem.ts | 8 ++++---- packages/server/src/routes/hermes/logs.ts | 2 +- packages/server/src/routes/hermes/profiles.ts | 2 +- packages/server/src/routes/hermes/sessions.ts | 2 +- packages/server/src/routes/hermes/weixin.ts | 4 ++-- packages/server/src/routes/webhook.ts | 2 +- .../server/src/services/{ => hermes}/hermes-cli.ts | 0 .../src/services/{ => hermes}/hermes-profile.ts | 0 packages/server/src/services/{ => hermes}/hermes.ts | 2 +- tests/server/profiles-routes.test.ts | 4 ++-- tests/server/proxy-handler.test.ts | 10 +++++++++- 15 files changed, 40 insertions(+), 19 deletions(-) rename packages/server/src/services/{ => hermes}/hermes-cli.ts (100%) rename packages/server/src/services/{ => hermes}/hermes-profile.ts (100%) rename packages/server/src/services/{ => hermes}/hermes.ts (98%) diff --git a/packages/client/src/api/hermes/jobs.ts b/packages/client/src/api/hermes/jobs.ts index 6aafc07..5873bb4 100644 --- a/packages/client/src/api/hermes/jobs.ts +++ b/packages/client/src/api/hermes/jobs.ts @@ -45,7 +45,7 @@ export interface CreateJobRequest { export interface UpdateJobRequest { name?: string - schedule?: string + schedule?: string | { kind: string; expr: string; display: string } prompt?: string deliver?: string skills?: string[] diff --git a/packages/client/src/components/hermes/jobs/JobFormModal.vue b/packages/client/src/components/hermes/jobs/JobFormModal.vue index 52040ed..0f57b5e 100644 --- a/packages/client/src/components/hermes/jobs/JobFormModal.vue +++ b/packages/client/src/components/hermes/jobs/JobFormModal.vue @@ -48,6 +48,8 @@ const targetOptions = computed(() => [ { label: t('jobs.local'), value: 'local' }, ]) +const originalSchedule = ref<{ kind: string; expr: string; display: string } | null>(null) + onMounted(async () => { if (props.jobId) { try { @@ -60,6 +62,9 @@ onMounted(async () => { deliver: job.deliver || 'origin', repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null), } + if (typeof job.schedule === 'object' && job.schedule) { + originalSchedule.value = job.schedule + } } catch (e: any) { message.error(t('jobs.loadFailed') + ': ' + e.message) } @@ -86,6 +91,14 @@ async function handleSave() { repeat: formData.value.repeat_times ?? undefined, } + if (isEdit.value && originalSchedule.value) { + (payload as any).schedule = { + kind: originalSchedule.value.kind, + expr: formData.value.schedule, + display: formData.value.schedule, + } + } + if (isEdit.value) { await jobsStore.updateJob(props.jobId!, payload) message.success(t('jobs.jobUpdated')) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2925cb6..1978569 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,7 +10,7 @@ import { config } from './config' import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes' import { uploadRoutes } from './routes/upload' import { webhookRoutes } from './routes/webhook' -import * as hermesCli from './services/hermes-cli' +import * as hermesCli from './services/hermes/hermes-cli' import { getToken, authMiddleware } from './services/auth' function getLocalVersion(): string { @@ -232,7 +232,7 @@ function bindShutdown() { async function ensureApiServerConfig() { const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs') const yaml = (await import('js-yaml')).default - const { getActiveConfigPath } = await import('./services/hermes-profile') + const { getActiveConfigPath } = await import('./services/hermes/hermes-profile') const configPath = getActiveConfigPath() const defaults: Record = { diff --git a/packages/server/src/routes/hermes/config.ts b/packages/server/src/routes/hermes/config.ts index f0d8ca0..314c652 100644 --- a/packages/server/src/routes/hermes/config.ts +++ b/packages/server/src/routes/hermes/config.ts @@ -3,8 +3,8 @@ import { readFile, writeFile, copyFile } from 'fs/promises' import { chmod } from 'fs/promises' import { join } from 'path' import YAML from 'js-yaml' -import { restartGateway } from '../../services/hermes-cli' -import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes-profile' +import { restartGateway } from '../../services/hermes/hermes-cli' +import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes/hermes-profile' // Platform sections that require gateway restart after config change const PLATFORM_SECTIONS = new Set([ diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts index eb623d6..7d427ab 100644 --- a/packages/server/src/routes/hermes/filesystem.ts +++ b/packages/server/src/routes/hermes/filesystem.ts @@ -2,8 +2,8 @@ import Router from '@koa/router' import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises' import { join, resolve } from 'path' import YAML from 'js-yaml' -import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes-profile' -import * as hermesCli from '../../services/hermes-cli' +import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes/hermes-profile' +import * as hermesCli from '../../services/hermes/hermes-cli' // --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- // Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL } @@ -513,7 +513,7 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { for (const result of results) { if (result.status === 'fulfilled' && result.value.models.length > 0) { const { key, label, base_url, models } = result.value - groups.push({ provider: key, label, base_url, models: [...new Set(models)] }) + groups.push({ provider: key, label, base_url, models: Array.from(new Set(models)) }) } else if (result.status === 'rejected') { console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) } @@ -524,7 +524,7 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { const dedupedGroups: typeof groups = [] const seenProviders = new Map() for (const g of groups) { - g.models = [...new Set(g.models)] + g.models = Array.from(new Set(g.models)) const existingIdx = seenProviders.get(g.provider) if (existingIdx !== undefined) { // Merge models into existing group diff --git a/packages/server/src/routes/hermes/logs.ts b/packages/server/src/routes/hermes/logs.ts index ca1d96c..fcffc4a 100644 --- a/packages/server/src/routes/hermes/logs.ts +++ b/packages/server/src/routes/hermes/logs.ts @@ -1,5 +1,5 @@ import Router from '@koa/router' -import * as hermesCli from '../../services/hermes-cli' +import * as hermesCli from '../../services/hermes/hermes-cli' export const logRoutes = new Router() diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts index 3ccb048..b774635 100644 --- a/packages/server/src/routes/hermes/profiles.ts +++ b/packages/server/src/routes/hermes/profiles.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile } from 'fs/promises' import { basename, join } from 'path' import { tmpdir, homedir } from 'os' import YAML from 'js-yaml' -import * as hermesCli from '../../services/hermes-cli' +import * as hermesCli from '../../services/hermes/hermes-cli' const apiServerDefaults = { enabled: true, diff --git a/packages/server/src/routes/hermes/sessions.ts b/packages/server/src/routes/hermes/sessions.ts index cc5eff5..58d630d 100644 --- a/packages/server/src/routes/hermes/sessions.ts +++ b/packages/server/src/routes/hermes/sessions.ts @@ -1,5 +1,5 @@ import Router from '@koa/router' -import * as hermesCli from '../../services/hermes-cli' +import * as hermesCli from '../../services/hermes/hermes-cli' export const sessionRoutes = new Router() diff --git a/packages/server/src/routes/hermes/weixin.ts b/packages/server/src/routes/hermes/weixin.ts index c8a9dc5..712e661 100644 --- a/packages/server/src/routes/hermes/weixin.ts +++ b/packages/server/src/routes/hermes/weixin.ts @@ -3,8 +3,8 @@ import axios from 'axios' import { readFile, writeFile } from 'fs/promises' import { chmod } from 'fs/promises' import { resolve } from 'path' -import { restartGateway } from '../../services/hermes-cli' -import { getActiveEnvPath } from '../../services/hermes-profile' +import { restartGateway } from '../../services/hermes/hermes-cli' +import { getActiveEnvPath } from '../../services/hermes/hermes-profile' const envPath = () => getActiveEnvPath() const ILINK_BASE = 'https://ilinkai.weixin.qq.com' diff --git a/packages/server/src/routes/webhook.ts b/packages/server/src/routes/webhook.ts index 19a5fe3..7e738be 100644 --- a/packages/server/src/routes/webhook.ts +++ b/packages/server/src/routes/webhook.ts @@ -1,5 +1,5 @@ import Router from '@koa/router' -import { emitWebhook } from '../services/hermes' +import { emitWebhook } from '../services/hermes/hermes' export const webhookRoutes = new Router() diff --git a/packages/server/src/services/hermes-cli.ts b/packages/server/src/services/hermes/hermes-cli.ts similarity index 100% rename from packages/server/src/services/hermes-cli.ts rename to packages/server/src/services/hermes/hermes-cli.ts diff --git a/packages/server/src/services/hermes-profile.ts b/packages/server/src/services/hermes/hermes-profile.ts similarity index 100% rename from packages/server/src/services/hermes-profile.ts rename to packages/server/src/services/hermes/hermes-profile.ts diff --git a/packages/server/src/services/hermes.ts b/packages/server/src/services/hermes/hermes.ts similarity index 98% rename from packages/server/src/services/hermes.ts rename to packages/server/src/services/hermes/hermes.ts index 5ccdfa9..c4bb896 100644 --- a/packages/server/src/services/hermes.ts +++ b/packages/server/src/services/hermes/hermes.ts @@ -1,4 +1,4 @@ -import { config } from '../config' +import { config } from '../../config' const UPSTREAM = config.upstream.replace(/\/$/, '') diff --git a/tests/server/profiles-routes.test.ts b/tests/server/profiles-routes.test.ts index dc64660..6f53fa4 100644 --- a/tests/server/profiles-routes.test.ts +++ b/tests/server/profiles-routes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock hermes-cli -vi.mock('../../packages/server/src/services/hermes-cli', () => ({ +vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ listProfiles: vi.fn(), getProfile: vi.fn(), createProfile: vi.fn(), @@ -16,7 +16,7 @@ vi.mock('../../packages/server/src/services/hermes-cli', () => ({ importProfile: vi.fn(), })) -import * as hermesCli from '../../packages/server/src/services/hermes-cli' +import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli' describe('Profile Routes', () => { beforeEach(() => { diff --git a/tests/server/proxy-handler.test.ts b/tests/server/proxy-handler.test.ts index f055cad..e720589 100644 --- a/tests/server/proxy-handler.test.ts +++ b/tests/server/proxy-handler.test.ts @@ -120,7 +120,15 @@ describe('Proxy Handler', () => { }) it('returns 502 on connection failure', async () => { - mockFetch.mockRejectedValue(new Error('ECONNREFUSED')) + // waitForGatewayReady loops calling fetch(healthUrl) until res.ok or timeout. + // Return ok:true for health checks so the loop exits immediately (gateway + // "ready"), then the retry fetch also fails with ECONNREFUSED → 502. + mockFetch.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/health')) { + return Promise.resolve({ ok: true }) + } + return Promise.reject(new Error('ECONNREFUSED')) + }) const ctx = createMockCtx() await proxy(ctx) From b290b755c98d4920e4a724efee39c3202cf280bb Mon Sep 17 00:00:00 2001 From: ekko Date: Fri, 17 Apr 2026 17:43:54 +0800 Subject: [PATCH 3/4] fix: use deep merge for config updates and save inputs on blur - Backend: replace shallow merge with recursive deepMerge in PUT /api/hermes/config to prevent nested config fields from being lost when updating partial values - Frontend: switch all NInput fields to default-value + @change (save on blur) instead of :value + @update:value (save on every keystroke) in both PlatformSettings.vue and SettingsView.vue api_server tab - Remove unused debounce logic and dead changeKey function Co-Authored-By: Claude Opus 4.6 --- .../hermes/settings/PlatformSettings.vue | 88 ++++--------- .../client/src/views/hermes/SettingsView.vue | 121 ++++++++++++------ packages/server/src/routes/hermes/config.ts | 16 ++- 3 files changed, 119 insertions(+), 106 deletions(-) diff --git a/packages/client/src/components/hermes/settings/PlatformSettings.vue b/packages/client/src/components/hermes/settings/PlatformSettings.vue index 7bc4996..b1761cc 100644 --- a/packages/client/src/components/hermes/settings/PlatformSettings.vue +++ b/packages/client/src/components/hermes/settings/PlatformSettings.vue @@ -22,25 +22,6 @@ function isSaving(platform: string, field: string) { return !!saving[savingKey(platform, field)] } -// Debounce timers -const debounceTimers: Record> = {} - -function debounceSave(platform: string, field: string, saveFn: () => Promise, delay = 600) { - const key = savingKey(platform, field) - if (debounceTimers[key]) clearTimeout(debounceTimers[key]) - debounceTimers[key] = setTimeout(async () => { - saving[key] = true - try { - await saveFn() - message.success(t('settings.saved')) - } catch (err: any) { - message.error(t('settings.saveFailed')) - } finally { - saving[key] = false - } - }, delay) -} - // Immediate save for switches async function immediateSave(platform: string, field: string, saveFn: () => Promise) { const key = savingKey(platform, field) @@ -59,10 +40,6 @@ async function saveChannel(platform: string, field: string, values: Record settingsStore.saveSection(platform, values)) } -function debouncedSaveChannel(platform: string, field: string, values: Record) { - debounceSave(platform, field, () => settingsStore.saveSection(platform, values)) -} - // Save credentials to .env (matching hermes gateway setup behavior) async function saveCredentials(platform: string, field: string, values: Record) { immediateSave(platform, field, async () => { @@ -71,13 +48,6 @@ async function saveCredentials(platform: string, field: string, values: Record) { - debounceSave(platform, field, async () => { - await saveCredsApi(platform, values) - await settingsStore.fetchSettings() - }) -} - function getCreds(key: string) { return (settingsStore.platforms[key] || {}) as Record } @@ -121,7 +91,6 @@ function pollWeixinStatus() { wxQrStatus.value = 'expired' } else if (data.status === 'confirmed') { wxQrStatus.value = 'confirmed' - // Save credentials to .env await saveWeixinCredentials({ account_id: data.account_id!, token: data.token!, @@ -131,7 +100,6 @@ function pollWeixinStatus() { message.success(t('settings.saved')) } } catch { - // Retry poll on network error pollWeixinStatus() } }, 3000) @@ -146,7 +114,6 @@ function stopWeixinPoll() { onUnmounted(() => { stopWeixinPoll() - Object.values(debounceTimers).forEach(t => clearTimeout(t)) }) const platforms = [ @@ -180,11 +147,6 @@ const platforms = [ name: 'Feishu', icon: '', }, - // { - // key: 'dingtalk', - // name: 'DingTalk', - // icon: '', - // }, { key: 'weixin', name: 'Weixin', @@ -211,7 +173,7 @@ const platforms = [ @@ -280,20 +242,20 @@ const platforms = [ - + - + @@ -361,20 +323,20 @@ const platforms = [ - + - + diff --git a/packages/client/src/views/hermes/SettingsView.vue b/packages/client/src/views/hermes/SettingsView.vue index fad91ef..4ee4236 100644 --- a/packages/client/src/views/hermes/SettingsView.vue +++ b/packages/client/src/views/hermes/SettingsView.vue @@ -1,29 +1,36 @@ @@ -31,11 +38,15 @@ async function saveApiServer(values: Record) {