diff --git a/package.json b/package.json index cd459a7..ec4c454 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.5.8", + "version": "0.5.9", "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model (Claude, GPT, Gemini, DeepSeek) web UI with Telegram, Discord, Slack, WhatsApp integration", "repository": { "type": "git", diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts index 289676a..ed79a33 100644 --- a/packages/client/src/api/client.ts +++ b/packages/client/src/api/client.ts @@ -26,6 +26,23 @@ export function hasApiKey(): boolean { return !!getApiKey() } +/** + * Get current active profile name. + * Reads from store first (authoritative source), falls back to localStorage. + */ +function getActiveProfileName(): string | null { + try { + // Dynamic import to avoid circular dependency + const { useProfilesStore } = require('@/stores/hermes/profiles') + const store = useProfilesStore() + // Store is the source of truth - it's updated from /api/hermes/profiles + return store.activeProfileName + } catch { + // Fallback to localStorage if store is not available (e.g., during initialization) + return localStorage.getItem('hermes_active_profile_name') + } +} + export async function request(path: string, options: RequestInit = {}): Promise { const base = getBaseUrl() const url = `${base}${path}` @@ -40,7 +57,7 @@ export async function request(path: string, options: RequestInit = {}): Promi } // Inject active profile header for proxied gateway requests - const profileName = localStorage.getItem('hermes_active_profile_name') + const profileName = getActiveProfileName() if (profileName && profileName !== 'default') { headers['X-Hermes-Profile'] = profileName } diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 3a6b746..6c1199c 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -291,7 +291,17 @@ export function connectChatRun(): Socket { const baseUrl = getBaseUrlValue() const token = getApiKey() - const profile = localStorage.getItem('hermes_active_profile_name') || 'default' + + // Get active profile from store (authoritative source) + let profile = 'default' + try { + const { useProfilesStore } = require('@/stores/hermes/profiles') + const profilesStore = useProfilesStore() + profile = profilesStore.activeProfileName || 'default' + } catch { + // Fallback to localStorage during early initialization + profile = localStorage.getItem('hermes_active_profile_name') || 'default' + } chatRunSocket = io(`${baseUrl}/chat-run`, { auth: { token }, diff --git a/packages/client/src/data/changelog.ts b/packages/client/src/data/changelog.ts index 869a571..d11f137 100644 --- a/packages/client/src/data/changelog.ts +++ b/packages/client/src/data/changelog.ts @@ -5,6 +5,14 @@ export interface ChangelogEntry { } export const changelog: ChangelogEntry[] = [ + { + version: '0.5.9', + date: '2026-05-04', + changes: [ + 'changelog.new_0_5_9_1', + 'changelog.new_0_5_9_2', + ], + }, { version: '0.5.8', date: '2026-05-03', diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 08ea1a1..e90ce81 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -609,6 +609,8 @@ jobTriggered: 'Job ausgelost', new_0_5_6_6: 'Attachment-Verarbeitung neu gestaltet mit Anthropic-Stil ContentBlock-Array-Format (Text, Bild, Datei)', new_0_5_6_7: 'Frontend-Dateidownload-Funktion für ContentBlock- und Markdown-Formate mit Authentifizierung hinzugefügt', new_0_5_6_8: 'Multi-Prozess-Konflikt behoben, der SQLite-Database-Resets verursacht hat, durch Entfernen redundanter nodemon-Instanzen', + new_0_5_9_1: 'Profilverwaltung在整个应用程序中统一,mit konsistentem API und State-Management', + new_0_5_9_2: 'GitHub-Issue- und Pull-Request-Vorlagen für besseren Contribution-Workflow hinzugefügt', new_0_5_8_1: 'Drawer-Panel mit Mobile-Sidebar-Support und anpassbarem Rainbow-Button hinzugefügt', new_0_5_8_2: 'Profile-Switching-State-Sync-Problem behoben mit sofortiger UI-Aktualisierung und Backend-Verifizierung', new_0_5_8_3: 'Sonderzeichen und Emoji in der Sprachwiedergabe gefiltert für bessere Text-to-Speech', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index e572213..6e9f222 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -780,6 +780,8 @@ export default { new_0_5_6_6: 'Redesigned attachment handling using Anthropic-style ContentBlock array format with type discriminated unions (text, image, file)', new_0_5_6_7: 'Added frontend file download functionality supporting both ContentBlock and Markdown formats with authentication', new_0_5_6_8: 'Fixed multi-process conflict causing SQLite database resets by eliminating redundant nodemon instances', + new_0_5_9_1: 'Unify profile management across the application with consistent API and state management', + new_0_5_9_2: 'Add GitHub issue and pull request templates for better contribution workflow', new_0_5_8_1: 'Add drawer panel with mobile sidebar support and customizable rainbow button', new_0_5_8_2: 'Fix profile switching state sync issue with immediate UI update and backend verification', new_0_5_8_3: 'Filter special characters and emoji in speech playback for better text-to-speech', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index aff3b84..1b2d3b0 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -605,6 +605,8 @@ jobTriggered: 'Job ejecutado', new_0_5_6_6: 'Rediseñado el manejo de adjuntos usando formato de matriz ContentBlock estilo Anthropic (texto, imagen, archivo)', new_0_5_6_7: 'Añadida funcionalidad de descarga de archivos en frontend soportando formatos ContentBlock y Markdown con autenticación', new_0_5_6_8: 'Corregido conflicto de múltiples procesos que causaba reinicios de base de datos SQLite eliminando instancias nodemon redundantes', + new_0_5_9_1: 'Unificar la gestión de perfiles en toda la aplicación con API y gestión de estado consistentes', + new_0_5_9_2: 'Añadir plantillas de issues y pull requests de GitHub para un mejor flujo de contribución', new_0_5_8_1: 'Añadir panel de cajón con soporte de barra lateral móvil y botón arcoíris personalizable', new_0_5_8_2: 'Corregir problema de sincronización de estado de cambio de perfil con actualización inmediata de UI y verificación de backend', new_0_5_8_3: 'Filtrar caracteres especiales y emoji en reproducción de voz para mejor síntesis de voz', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 856efa2..3f622f2 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -605,6 +605,8 @@ jobTriggered: 'Job declenche', new_0_5_6_6: 'Repensé la gestion des pièces jointes en utilisant le format de tableau ContentBlock style Anthropic (texte, image, fichier)', new_0_5_6_7: 'Ajouté la fonctionnalité de téléchargement de fichiers frontend supportant les formats ContentBlock et Markdown avec authentification', new_0_5_6_8: 'Corrigé le conflit multi-processus causant des réinitialisations de base de données SQLite en éliminant les instances nodemon redondantes', + new_0_5_9_1: 'Unifier la gestion des profils dans toute l\'application avec une API et une gestion d\'état cohérentes', + new_0_5_9_2: 'Ajouter des modèles issues et pull requests GitHub pour un meilleur flux de contribution', new_0_5_8_1: 'Ajouter le panneau de tiroir avec support de barre latérale mobile et bouton arc-en-ciel personnalisable', new_0_5_8_2: 'Corriger le problème de synchronisation d\'état de changement de profil avec mise à jour immédiate de l\'UI et vérification du backend', new_0_5_8_3: 'Filtrer les caractères spéciaux et emoji dans la lecture vocale pour une meilleure synthèse vocale', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 23ec5fe..de86a9f 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -605,6 +605,8 @@ export default { new_0_5_6_6: 'AnthropicスタイルのContentBlock配列形式(テキスト、画像、ファイル)を使用して添付ファイル処理を再設計', new_0_5_6_7: 'ContentBlockおよびMarkdown形式をサポートし、認証付きのフロントエンドファイルダウンロード機能を追加', new_0_5_6_8: '重複するnodemonインスタンスを削除し、SQLiteデータベースのリセットを引き起こすマルチプロセス競合を修正', + new_0_5_9_1: 'アプリケーション全体でプロファイル管理を統一し、一貫したAPIと状態管理を提供', + new_0_5_9_2: 'より良いコントリビューションワークフローのためにGitHubイシューとプルリクエストテンプレートを追加', new_0_5_8_1: 'モバイルサイドバーサポートとカスタマイズ可能なレインボーボタン付きドロワーパネルを追加', new_0_5_8_2: 'プロファイル切り替え状態同期問題を修正し、UIを即座に更新してバックエンドを検証', new_0_5_8_3: '音声合成を改善するため、音声再生の特殊文字と絵文字をフィルタリング', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 0e7e059..951f31d 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -605,6 +605,8 @@ export default { new_0_5_6_6: 'Anthropic 스타일의 ContentBlock 배열 형식(텍스트, 이미지, 파일)을 사용하여 첨부파일 처리를 재설계', new_0_5_6_7: '인증이 포함된 ContentBlock 및 Markdown 형식을 지원하는 프론트엔드 파일 다운로드 기능 추가', new_0_5_6_8: '중복된 nodemon 인스턴스를 제거하여 SQLite 데이터베이스 재설정을 일으키는 다중 프로세스 충돌 수정', + new_0_5_9_1: '일관된 API 및 상태 관리로 전체 응용 프로그램에서 프로필 관리 통합', + new_0_5_9_2: '더 나은 기여 워크플로우를 위해 GitHub 이슈 및 풀 리퀘스트 템플릿 추가', new_0_5_8_1: '모바일 사이드바 지원 및 사용자 정의 가능한 무지개 버튼이 포함된 서랍 패널 추가', new_0_5_8_2: '프로필 전환 상태 동기화 문제를 수정하고 즉시 UI 업데이트 및 백엔드 검증', new_0_5_8_3: '음성 합성 개선을 위해 음성 재생의 특수 문자 및 이모지 필터링', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 6c3a09b..c8abbb8 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -605,6 +605,8 @@ jobTriggered: 'Job acionado', new_0_5_6_6: 'Processamento de anexos reprojetado usando formato de matriz ContentBlock estilo Anthropic (texto, imagem, arquivo)', new_0_5_6_7: 'Adicionada funcionalidade de download de arquivos frontend suportando formatos ContentBlock e Markdown com autenticação', new_0_5_6_8: 'Corrigido conflito de múltiplos processos que causava redefinições do banco de dados SQLite eliminando instâncias nodemon redundantes', + new_0_5_9_1: 'Unificar gerenciamento de perfis em todo o aplicativo com API e gerenciamento de estado consistentes', + new_0_5_9_2: 'Adicionar modelos de issues e pull requests do GitHub para melhor fluxo de contribuição', new_0_5_8_1: 'Adicionar painel de gaveta com suporte de barra lateral móvel e botão arco-íris personalizável', new_0_5_8_2: 'Corrigir problema de sincronização de estado de troca de perfil com atualização imediata de IU e verificação de backend', new_0_5_8_3: 'Filtrar caracteres especiais e emoji na reprodução de voz para melhor síntese de voz', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 9c38c33..0913465 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -782,6 +782,8 @@ export default { new_0_5_6_6: '重新设计附件处理,采用 Anthropic 风格的 ContentBlock 数组格式,支持类型区分(文本、图片、文件)', new_0_5_6_7: '新增前端文件下载功能,支持 ContentBlock 和 Markdown 两种格式,带身份验证', new_0_5_6_8: '修复多进程冲突导致的 SQLite 数据库重置问题,清理冗余 nodemon 进程', + new_0_5_9_1: '统一应用程序中的 profile 管理,提供一致的 API 和状态管理', + new_0_5_9_2: '添加 GitHub issue 和 pull request 模板以改进贡献工作流程', new_0_5_8_1: '新增抽屉面板支持移动端侧边栏,可自定义彩虹边框按钮', new_0_5_8_2: '修复 profile 切换状态同步问题,立即更新 UI 并验证后端状态', new_0_5_8_3: '过滤语音播放中的特殊字符和表情符号,改善语音合成效果', diff --git a/packages/client/src/stores/hermes/session-browser-prefs.ts b/packages/client/src/stores/hermes/session-browser-prefs.ts index 291fc66..1af3276 100644 --- a/packages/client/src/stores/hermes/session-browser-prefs.ts +++ b/packages/client/src/stores/hermes/session-browser-prefs.ts @@ -7,8 +7,9 @@ const HUMAN_ONLY_KEY_PREFIX = 'hermes_human_only_v1_' function currentProfileName(): string { try { - return useProfilesStore().activeProfileName || localStorage.getItem('hermes_active_profile_name') || 'default' + return useProfilesStore().activeProfileName || 'default' } catch { + // Fallback during store initialization return localStorage.getItem('hermes_active_profile_name') || 'default' } } diff --git a/packages/server/src/controllers/hermes/cron-history.ts b/packages/server/src/controllers/hermes/cron-history.ts index 934bdd6..a976d57 100644 --- a/packages/server/src/controllers/hermes/cron-history.ts +++ b/packages/server/src/controllers/hermes/cron-history.ts @@ -3,9 +3,15 @@ import { readdir, stat, readFile } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' import { existsSync } from 'fs' +import { getActiveProfileDir } from '../../services/hermes/hermes-profile' const HERMES_BASE = join(homedir(), '.hermes') -const CRON_OUTPUT = join(HERMES_BASE, 'cron', 'output') + +function getCronOutputDir(): string { + // Use the active profile's directory, so cron history follows profile switches + const profileDir = getActiveProfileDir() + return join(profileDir, 'cron', 'output') +} export interface RunEntry { jobId: string @@ -24,20 +30,21 @@ export interface RunDetail { /** List all run output files, optionally filtered by job ID */ export async function listRuns(ctx: Context) { const jobId = ctx.query.jobId as string | undefined + const cronOutput = getCronOutputDir() - if (!existsSync(CRON_OUTPUT)) { + if (!existsSync(cronOutput)) { ctx.body = { runs: [] } return } try { - const dirs = await readdir(CRON_OUTPUT) + const dirs = await readdir(cronOutput) const runs: RunEntry[] = [] const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs for (const dir of targetDirs) { - const dirPath = join(CRON_OUTPUT, dir) + const dirPath = join(cronOutput, dir) try { const dirStat = await stat(dirPath) if (!dirStat.isDirectory()) continue @@ -93,7 +100,8 @@ export async function readRun(ctx: Context) { return } - const filePath = join(CRON_OUTPUT, jobId, fileName) + const cronOutput = getCronOutputDir() + const filePath = join(cronOutput, jobId, fileName) if (!existsSync(filePath)) { ctx.status = 404 diff --git a/packages/server/src/controllers/hermes/jobs.ts b/packages/server/src/controllers/hermes/jobs.ts index 0e0249b..0d47098 100644 --- a/packages/server/src/controllers/hermes/jobs.ts +++ b/packages/server/src/controllers/hermes/jobs.ts @@ -13,7 +13,20 @@ function getApiKey(profile: string): string | null { } function resolveProfile(ctx: Context): string { - return ctx.get('x-hermes-profile') || (ctx.query.profile as string) || 'default' + // Use header/query from request first, then fall back to authoritative source + const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string) + + if (requestedProfile) { + return requestedProfile + } + + // Fallback: read from authoritative source (active_profile file) + try { + const { getActiveProfileName } = require('../../services/hermes/hermes-profile') + return getActiveProfileName() + } catch { + return 'default' + } } function buildHeaders(profile: string): Record { diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts index a31e39a..e60c785 100644 --- a/packages/server/src/controllers/hermes/profiles.ts +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -11,6 +11,24 @@ import { smartCloneCleanup } from '../../services/hermes/profile-credentials' export async function list(ctx: any) { try { const profiles = await hermesCli.listProfiles() + + // Override active flag from the authoritative source (active_profile file) + // CLI output may be stale, but the file is written by hermes profile use + const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') + const activeProfileName = getActiveProfileName() + + // Check if CLI's active flag matches the file (warn if inconsistent) + const cliActive = profiles.find(p => p.active) + if (cliActive?.name !== activeProfileName) { + logger.warn('[listProfiles] CLI active flag (%s) differs from active_profile file (%s) - using file as authoritative source', + cliActive?.name || 'none', activeProfileName) + } + + // Fix the active flag based on the actual active_profile file + profiles.forEach(p => { + p.active = (p.name === activeProfileName) + }) + ctx.body = { profiles } } catch (err: any) { ctx.status = 500 @@ -142,9 +160,31 @@ export async function switchProfile(ctx: any) { } try { const output = await hermesCli.useProfile(name) - await new Promise(r => setTimeout(r, 1000)) + + // Verify the active_profile file immediately (Hermes CLI writes synchronously) + // Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency) + const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') + let actualActive = getActiveProfileName() + + // Quick retry (max 2 times, 100ms delay each) + for (let i = 0; i < 2; i++) { + if (actualActive === name) break + logger.debug('[switchProfile] Quick retry %d: current=%s, expected=%s', i + 1, actualActive, name) + await new Promise(r => setTimeout(r, 100)) + actualActive = getActiveProfileName() + } + + if (actualActive !== name) { + logger.error('[switchProfile] Verification failed: active_profile is %s (expected %s)', actualActive, name) + ctx.status = 500 + ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` } + return + } + + // Update GatewayManager to match the authoritative source const mgr = getGatewayManagerInstance() if (mgr) { mgr.setActiveProfile(name) } + try { const detail = await hermesCli.getProfile(name) logger.debug('Profile detail.path = %s', detail.path) @@ -159,12 +199,14 @@ export async function switchProfile(ctx: any) { } catch (err: any) { logger.error(err, 'Ensure config failed') } + const drainResult = await SessionDeleter.getInstance().drain(name) SessionDeleter.getInstance().switchProfile(name) logger.info('[switchProfile] drain result for profile "%s": %d deleted, %d failed', name, drainResult.deleted.length, drainResult.failed.length) if (drainResult.failed.length > 0) { logger.warn({ profile: name, failed: drainResult.failed }, 'Failed to drain some pending session deletes after profile switch') } + ctx.body = { success: true, message: output.trim(), diff --git a/packages/server/src/routes/hermes/proxy-handler.ts b/packages/server/src/routes/hermes/proxy-handler.ts index 3f80983..b0e4b44 100644 --- a/packages/server/src/routes/hermes/proxy-handler.ts +++ b/packages/server/src/routes/hermes/proxy-handler.ts @@ -49,7 +49,20 @@ async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): /** Resolve profile name from request */ function resolveProfile(ctx: Context): string { - return ctx.get('x-hermes-profile') || (ctx.query.profile as string) || 'default' + // Use header/query from request, but fall back to authoritative source if not provided + const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string) + + if (requestedProfile) { + return requestedProfile + } + + // Fallback: read from authoritative source (active_profile file) + try { + const { getActiveProfileName } = require('../../services/hermes/hermes-profile') + return getActiveProfileName() + } catch { + return 'default' + } } /** Resolve upstream URL for a request based on profile header/query */