From 7b05731d44b26c43f8acd5c94439c85d98ecd504 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 23 May 2026 19:41:51 +0800 Subject: [PATCH] Refine user profile access and chat sync --- packages/client/src/api/hermes/chat.ts | 33 +++++++++++++ packages/client/src/i18n/locales/de.ts | 4 +- packages/client/src/i18n/locales/en.ts | 4 +- packages/client/src/i18n/locales/es.ts | 12 ++--- packages/client/src/i18n/locales/fr.ts | 12 ++--- packages/client/src/i18n/locales/ja.ts | 12 ++--- packages/client/src/i18n/locales/ko.ts | 12 ++--- packages/client/src/i18n/locales/pt.ts | 12 ++--- packages/client/src/i18n/locales/zh-TW.ts | 12 ++--- packages/client/src/i18n/locales/zh.ts | 12 ++--- packages/client/src/stores/hermes/chat.ts | 39 ++++++++++++++-- packages/server/src/middleware/user-auth.ts | 8 +++- .../server/src/routes/hermes/group-chat.ts | 6 ++- .../src/services/hermes/group-chat/index.ts | 30 ++++++++++-- .../hermes/run-chat/handle-api-run.ts | 14 +++++- .../hermes/run-chat/handle-bridge-run.ts | 12 ++++- tests/server/group-chat-member-sync.test.ts | 46 +++++++++++++++++++ 17 files changed, 223 insertions(+), 57 deletions(-) diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index e1365ff..84ee7fb 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -51,6 +51,13 @@ export interface RunEvent { session_id?: string /** Queue length from run.queued event */ queue_length?: number + /** User message broadcast to other windows already watching the same session. */ + message?: { + id?: string | number + role?: string + content?: string + timestamp?: number + } } // ============================ @@ -83,8 +90,11 @@ const sessionEventHandlers = new Map void onApprovalRequested?: (event: RunEvent) => void onApprovalResolved?: (event: RunEvent) => void + onPeerUserMessage?: (event: RunEvent) => void }>() +const peerUserMessageHandlers = new Set<(event: RunEvent) => void>() + /** * Global message.delta event handler * Distributes events to appropriate session based on session_id @@ -324,6 +334,20 @@ function globalApprovalResolvedHandler(event: RunEvent): void { } } +function globalPeerUserMessageHandler(event: RunEvent): void { + const sid = event.session_id + if (!sid) return + + const handlers = sessionEventHandlers.get(sid) + if (handlers?.onPeerUserMessage) { + handlers.onPeerUserMessage(event) + } + + for (const handler of peerUserMessageHandlers) { + handler(event) + } +} + /** * Register event handlers for a session * @param sessionId - Session ID @@ -351,6 +375,7 @@ export function registerSessionHandlers( onRunQueued?: (event: RunEvent) => void onApprovalRequested?: (event: RunEvent) => void onApprovalResolved?: (event: RunEvent) => void + onPeerUserMessage?: (event: RunEvent) => void } ): () => void { sessionEventHandlers.set(sessionId, handlers) @@ -369,6 +394,13 @@ export function unregisterSessionHandlers(sessionId: string): void { sessionEventHandlers.delete(sessionId) } +export function onPeerUserMessage(handler: (event: RunEvent) => void): () => void { + peerUserMessageHandlers.add(handler) + return () => { + peerUserMessageHandlers.delete(handler) + } +} + export function respondToolApproval( sessionId: string, approvalId: string, @@ -441,6 +473,7 @@ export function connectChatRun(): Socket { chatRunSocket.on('run.queued', globalRunQueuedHandler) chatRunSocket.on('approval.requested', globalApprovalRequestedHandler) chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler) + chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler) // Compression events chatRunSocket.on('compression.started', globalCompressionStartedHandler) diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 31ad927..1c0cb39 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -47,7 +47,7 @@ export default { username: 'Benutzername', role: 'Rolle', statusLabel: 'Status', - profiles: 'Profiles', + profiles: 'Zugreifbare Profile', profilesPlaceholder: 'Zugreifbare Profile auswaehlen', allProfiles: 'Alle Profile', noProfiles: 'Keine Profile zugewiesen', @@ -679,7 +679,7 @@ jobTriggered: 'Job ausgelost', stopped: 'Gestoppt', restartGateway: 'Gateway neu starten', restartProfile: 'Profil neu starten', - switchProfile: 'Profil wechseln', + switchProfile: 'Frontend-Profil wechseln', gatewayRestarted: 'Gateway neu gestartet: {name}', gatewayRestartFailed: 'Gateway-Neustart fehlgeschlagen', profileRestarted: 'Profil neu gestartet: {name}', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index 56df180..d5b4608 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -47,7 +47,7 @@ export default { username: 'Username', role: 'Role', statusLabel: 'Status', - profiles: 'Profiles', + profiles: 'Accessible Profiles', profilesPlaceholder: 'Select accessible profiles', allProfiles: 'All profiles', noProfiles: 'No profiles assigned', @@ -782,7 +782,7 @@ export default { stopped: 'Stopped', restartGateway: 'Restart Gateway', restartProfile: 'Restart Profile', - switchProfile: 'Switch Profile', + switchProfile: 'Switch Frontend Profile', gatewayRestarted: 'Gateway restarted: {name}', gatewayRestartFailed: 'Failed to restart gateway', profileRestarted: 'Profile restarted: {name}', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index 57d270f..ae2d327 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -41,16 +41,16 @@ export default { users: { title: 'Gestion de cuentas', - description: 'Crea usuarios, asigna roles y controla que Profile pueden usar los administradores normales.', + description: 'Crea usuarios, asigna roles y controla que perfiles pueden usar los administradores normales.', create: 'Crear usuario', edit: 'Editar usuario', username: 'Nombre de usuario', role: 'Rol', statusLabel: 'Estado', - profiles: 'Profiles', - profilesPlaceholder: 'Selecciona Profile accesibles', - allProfiles: 'Todos los Profile', - noProfiles: 'Sin Profile asignados', + profiles: 'Perfiles accesibles', + profilesPlaceholder: 'Selecciona perfiles accesibles', + allProfiles: 'Todos los perfiles', + noProfiles: 'Sin perfiles asignados', lastLogin: 'Ultimo inicio', newPasswordOptional: 'Nueva contrasena (dejar vacio para conservar)', loadFailed: 'No se pudieron cargar los usuarios', @@ -679,7 +679,7 @@ jobTriggered: 'Job ejecutado', stopped: 'Detenido', restartGateway: 'Reiniciar gateway', restartProfile: 'Reiniciar perfil', - switchProfile: 'Cambiar perfil', + switchProfile: 'Cambiar perfil frontend', gatewayRestarted: 'Gateway reiniciado: {name}', gatewayRestartFailed: 'No se pudo reiniciar el gateway', profileRestarted: 'Perfil reiniciado: {name}', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index a52a9c4..5b9f946 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -41,16 +41,16 @@ export default { users: { title: 'Gestion des comptes', - description: 'Creez des utilisateurs, attribuez des roles et controlez les Profile accessibles aux administrateurs standard.', + description: 'Creez des utilisateurs, attribuez des roles et controlez les profils accessibles aux administrateurs standard.', create: 'Creer un utilisateur', edit: 'Modifier l utilisateur', username: 'Nom d utilisateur', role: 'Role', statusLabel: 'Statut', - profiles: 'Profiles', - profilesPlaceholder: 'Selectionner les Profile accessibles', - allProfiles: 'Tous les Profile', - noProfiles: 'Aucun Profile assigne', + profiles: 'Profils accessibles', + profilesPlaceholder: 'Selectionner les profils accessibles', + allProfiles: 'Tous les profils', + noProfiles: 'Aucun profil assigne', lastLogin: 'Derniere connexion', newPasswordOptional: 'Nouveau mot de passe (laisser vide pour conserver)', loadFailed: 'Echec du chargement des utilisateurs', @@ -679,7 +679,7 @@ jobTriggered: 'Job declenche', stopped: 'Arrêté', restartGateway: 'Redémarrer le gateway', restartProfile: 'Redémarrer le profil', - switchProfile: 'Changer de profil', + switchProfile: 'Changer le profil frontend', gatewayRestarted: 'Gateway redémarré : {name}', gatewayRestartFailed: 'Échec du redémarrage du gateway', profileRestarted: 'Profil redémarré : {name}', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 8a62e08..13d7d37 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -41,16 +41,16 @@ export default { users: { title: 'アカウント管理', - description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできる Profile を制御します。', + description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできるプロファイルを制御します。', create: 'ユーザー作成', edit: 'ユーザー編集', username: 'ユーザー名', role: 'ロール', statusLabel: 'ステータス', - profiles: 'Profiles', - profilesPlaceholder: 'アクセス可能な Profile を選択', - allProfiles: 'すべての Profile', - noProfiles: 'Profile 未割り当て', + profiles: 'アクセス可能なプロファイル', + profilesPlaceholder: 'アクセス可能なプロファイルを選択', + allProfiles: 'すべてのプロファイル', + noProfiles: 'プロファイル未割り当て', lastLogin: '最終ログイン', newPasswordOptional: '新しいパスワード(空欄なら変更なし)', loadFailed: 'ユーザー一覧の読み込みに失敗しました', @@ -679,7 +679,7 @@ export default { stopped: '停止中', restartGateway: 'Gateway を再起動', restartProfile: 'プロファイルを再起動', - switchProfile: 'プロファイルを切り替え', + switchProfile: 'フロントエンドプロファイルを切り替え', gatewayRestarted: 'Gateway を再起動しました: {name}', gatewayRestartFailed: 'Gateway の再起動に失敗しました', profileRestarted: 'プロファイルを再起動しました: {name}', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index b760962..d6bdcc8 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -41,16 +41,16 @@ export default { users: { title: '계정 관리', - description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 Profile 을 제어합니다.', + description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 프로필을 제어합니다.', create: '사용자 만들기', edit: '사용자 편집', username: '사용자 이름', role: '역할', statusLabel: '상태', - profiles: 'Profiles', - profilesPlaceholder: '접근 가능한 Profile 선택', - allProfiles: '모든 Profile', - noProfiles: '할당된 Profile 없음', + profiles: '접근 가능한 프로필', + profilesPlaceholder: '접근 가능한 프로필 선택', + allProfiles: '모든 프로필', + noProfiles: '할당된 프로필 없음', lastLogin: '마지막 로그인', newPasswordOptional: '새 비밀번호 (비워두면 유지)', loadFailed: '사용자 목록을 불러오지 못했습니다', @@ -679,7 +679,7 @@ export default { stopped: '중지됨', restartGateway: 'Gateway 재시작', restartProfile: '프로필 재시작', - switchProfile: '프로필 전환', + switchProfile: '프론트엔드 프로필 전환', gatewayRestarted: 'Gateway가 재시작되었습니다: {name}', gatewayRestartFailed: 'Gateway 재시작 실패', profileRestarted: '프로필이 재시작되었습니다: {name}', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index c1f62bc..81ad7c5 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -41,16 +41,16 @@ export default { users: { title: 'Gerenciamento de contas', - description: 'Crie usuarios, atribua funcoes e controle quais Profile administradores comuns podem acessar.', + description: 'Crie usuarios, atribua funcoes e controle quais perfis administradores comuns podem acessar.', create: 'Criar usuario', edit: 'Editar usuario', username: 'Nome de usuario', role: 'Funcao', statusLabel: 'Status', - profiles: 'Profiles', - profilesPlaceholder: 'Selecione Profile acessiveis', - allProfiles: 'Todos os Profile', - noProfiles: 'Nenhum Profile atribuido', + profiles: 'Perfis acessiveis', + profilesPlaceholder: 'Selecione perfis acessiveis', + allProfiles: 'Todos os perfis', + noProfiles: 'Nenhum perfil atribuido', lastLogin: 'Ultimo login', newPasswordOptional: 'Nova senha (deixe em branco para manter)', loadFailed: 'Falha ao carregar usuarios', @@ -679,7 +679,7 @@ jobTriggered: 'Job acionado', stopped: 'Parado', restartGateway: 'Reiniciar gateway', restartProfile: 'Reiniciar perfil', - switchProfile: 'Trocar perfil', + switchProfile: 'Trocar perfil frontend', gatewayRestarted: 'Gateway reiniciado: {name}', gatewayRestartFailed: 'Falha ao reiniciar gateway', profileRestarted: 'Perfil reiniciado: {name}', diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 9f803f2..731d7f9 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -41,16 +41,16 @@ export default { users: { title: '帳號管理', - description: '建立使用者、分配角色,並控制一般管理員可存取的 Profile。', + description: '建立使用者、分配角色,並控制一般管理員可存取的設定檔。', create: '建立使用者', edit: '編輯使用者', username: '使用者名稱', role: '角色', statusLabel: '狀態', - profiles: 'Profiles', - profilesPlaceholder: '選擇可存取的 Profile', - allProfiles: '全部 Profile', - noProfiles: '未關聯 Profile', + profiles: '可存取設定檔', + profilesPlaceholder: '選擇可存取的設定檔', + allProfiles: '全部設定檔', + noProfiles: '未關聯設定檔', lastLogin: '最後登入', newPasswordOptional: '新密碼(留空不修改)', loadFailed: '使用者列表載入失敗', @@ -774,7 +774,7 @@ export default { stopped: '已停止', restartGateway: '重啟閘道', restartProfile: '重啟設定檔', - switchProfile: '切換前端 Profile', + switchProfile: '切換前端設定檔', gatewayRestarted: '閘道已重啟:{name}', gatewayRestartFailed: '重啟閘道失敗', profileRestarted: '設定檔已重啟:{name}', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 8a3940c..339edbc 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -41,16 +41,16 @@ export default { users: { title: '账户管理', - description: '创建用户、分配角色,并控制普通管理员可访问的 Profile。', + description: '创建用户、分配角色,并控制普通管理员可访问的配置。', create: '创建用户', edit: '编辑用户', username: '用户名', role: '角色', statusLabel: '状态', - profiles: 'Profiles', - profilesPlaceholder: '选择可访问的 Profile', - allProfiles: '全部 Profile', - noProfiles: '未关联 Profile', + profiles: '可访问配置', + profilesPlaceholder: '选择可访问的配置', + allProfiles: '全部配置', + noProfiles: '未关联配置', lastLogin: '最后登录', newPasswordOptional: '新密码(留空不修改)', loadFailed: '用户列表加载失败', @@ -774,7 +774,7 @@ export default { stopped: '已停止', restartGateway: '重启网关', restartProfile: '重启配置', - switchProfile: '切换配置', + switchProfile: '切换前端配置', gatewayRestarted: '网关已重启:{name}', gatewayRestartFailed: '重启网关失败', profileRestarted: '配置已重启:{name}', diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index 35fe2ea..ba5e54c 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -1,4 +1,4 @@ -import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' +import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat' import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions' import { getApiKey } from '@/api/client' import { defineStore } from 'pinia' @@ -1451,11 +1451,11 @@ export const useChatStore = defineStore('chat', () => { * Emits 'resume' to join the session room on the server, * then sets up event listeners to receive ongoing events. */ - function resumeServerWorkingRun(sid: string) { + function resumeServerWorkingRun(sid: string, force = false) { // Don't register duplicate listeners if already streaming if (streamStates.value.has(sid)) return // Only set up listeners if the server reported an active run during resume. - if (!serverWorking.value.has(sid)) return + if (!force && !serverWorking.value.has(sid)) return let closed = false let runProducedAssistantText = false @@ -1872,6 +1872,39 @@ export const useChatStore = defineStore('chat', () => { }) } + function handlePeerUserMessage(evt: RunEvent) { + const sid = evt.session_id + if (!sid || activeSessionId.value !== sid || !activeSession.value) return + + const peer = evt.message + const content = typeof peer?.content === 'string' ? peer.content : '' + if (!content.trim()) return + + const messageId = peer?.id != null ? String(peer.id) : '' + const msgs = getSessionMsgs(sid) + if (messageId && msgs.some(msg => msg.id === messageId)) { + serverWorking.value.add(sid) + resumeServerWorkingRun(sid, true) + return + } + + const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp) + ? Math.round(peer.timestamp * 1000) + : Date.now() + + addMessage(sid, { + id: messageId || uid(), + role: 'user', + content, + timestamp, + }) + updateSessionTitle(sid) + serverWorking.value.add(sid) + resumeServerWorkingRun(sid, true) + } + + onPeerUserMessage(handlePeerUserMessage) + function stopStreaming() { const sid = activeSessionId.value if (!sid) return diff --git a/packages/server/src/middleware/user-auth.ts b/packages/server/src/middleware/user-auth.ts index f64b70b..a3644c2 100644 --- a/packages/server/src/middleware/user-auth.ts +++ b/packages/server/src/middleware/user-auth.ts @@ -3,6 +3,7 @@ import { createHmac, timingSafeEqual } from 'crypto' import { getToken } from '../services/auth' import { findUserById, + listUserProfiles, touchUserLogin, userCanAccessProfile, type UserRecord, @@ -13,6 +14,7 @@ export interface AuthenticatedUser { id: number username: string role: UserRole + profiles?: string[] } export interface RequestProfile { @@ -110,11 +112,15 @@ export async function issueUserJwt(user: Pick): AuthenticatedUser { - return { + const authenticated: AuthenticatedUser = { id: user.id, username: user.username, role: user.role, } + if (user.role !== 'super_admin') { + authenticated.profiles = listUserProfiles(user.id).map(profile => profile.profile_name) + } + return authenticated } export async function authenticateUserToken(token: string): Promise { diff --git a/packages/server/src/routes/hermes/group-chat.ts b/packages/server/src/routes/hermes/group-chat.ts index 5159c67..83e4893 100644 --- a/packages/server/src/routes/hermes/group-chat.ts +++ b/packages/server/src/routes/hermes/group-chat.ts @@ -196,7 +196,11 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => { return } - const rooms = chatServer.getStorage().getAllRooms() + const user = ctx.state.user + const storage = chatServer.getStorage() + const rooms = !user || user.role === 'super_admin' + ? storage.getAllRooms() + : storage.getRoomsForProfiles(user.profiles || []) ctx.body = { rooms } }) diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts index e9dce81..bd646d4 100644 --- a/packages/server/src/services/hermes/group-chat/index.ts +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -72,6 +72,17 @@ interface RoomAgent { invited: number } +interface RoomInfo { + id: string + name: string + inviteCode: string | null + triggerTokens: number + maxHistoryTokens: number + tailMessageCount: number + totalTokens: number + sessionSeed: string +} + interface Member { id: string userId: string @@ -278,18 +289,31 @@ class ChatStorage { // ─── Rooms ──────────────────────────────────────────────── - getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined { + getRoom(roomId: string): RoomInfo | undefined { return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any } - getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined { + getRoomByInviteCode(code: string): RoomInfo | undefined { return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any } - getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string }[] { + getAllRooms(): RoomInfo[] { return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[] } + getRoomsForProfiles(profiles: string[]): RoomInfo[] { + const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))] + if (!uniqueProfiles.length) return [] + const placeholders = uniqueProfiles.map(() => '?').join(', ') + return (this.db()?.prepare( + `SELECT DISTINCT r.id, r.name, r.inviteCode, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed + FROM gc_rooms r + INNER JOIN gc_room_agents a ON a.roomId = r.id + WHERE a.profile IN (${placeholders}) + ORDER BY r.id` + ).all(...uniqueProfiles) || []) as any[] + } + saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void { this.db()?.prepare( 'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount) VALUES (?, ?, ?, ?, ?, ?)' diff --git a/packages/server/src/services/hermes/run-chat/handle-api-run.ts b/packages/server/src/services/hermes/run-chat/handle-api-run.ts index 3f8d5b5..94a44ab 100644 --- a/packages/server/src/services/hermes/run-chat/handle-api-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-api-run.ts @@ -109,6 +109,7 @@ export async function handleApiRun( state.source = 'api_server' state.activeRunMarker = runMarker + let peerUserMessage: { id?: number; role: 'user'; content: string; timestamp: number } | null = null if (!skipUserMessage) { const inputStr = contentBlocksToString(input) state.messages.push({ @@ -126,12 +127,13 @@ export async function handleApiRun( createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview }) } - addMessage({ + const messageId = addMessage({ session_id, role: 'user', content: inputStr, timestamp: now, }) + peerUserMessage = { id: messageId, role: 'user', content: inputStr, timestamp: now } } else { const inputStr = contentBlocksToString(input) state.messages.push({ @@ -147,15 +149,23 @@ export async function handleApiRun( const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview }) } - addMessage({ + const messageId = addMessage({ session_id, role: 'user', content: inputStr, timestamp: now, }) + peerUserMessage = { id: messageId, role: 'user', content: inputStr, timestamp: now } } socket.join(`session:${session_id}`) + if (peerUserMessage) { + socket.to(`session:${session_id}`).emit('run.peer_user_message', { + event: 'run.peer_user_message', + session_id, + message: peerUserMessage, + }) + } } const emit = (event: string, payload: any) => { diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts index 72db4ff..cb4a4f6 100644 --- a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -173,7 +173,7 @@ export async function handleBridgeRun( const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview }) } - addMessage({ + const messageId = addMessage({ session_id, role: 'user', content: inputStr, @@ -181,6 +181,16 @@ export async function handleBridgeRun( }) socket.join(`session:${session_id}`) + socket.to(`session:${session_id}`).emit('run.peer_user_message', { + event: 'run.peer_user_message', + session_id, + message: { + id: messageId, + role: 'user', + content: inputStr, + timestamp: now, + }, + }) const emit = (event: string, payload: any) => { const tagged = { ...payload, session_id } nsp.to(`session:${session_id}`).emit(event, tagged) diff --git a/tests/server/group-chat-member-sync.test.ts b/tests/server/group-chat-member-sync.test.ts index 6b144b5..27e9e13 100644 --- a/tests/server/group-chat-member-sync.test.ts +++ b/tests/server/group-chat-member-sync.test.ts @@ -224,6 +224,52 @@ describe('Group Chat member/agent identity sync', () => { }) }) + it('filters room list to rooms containing one of the regular admin profiles', async () => { + const allRooms = [ + { id: 'room-default', name: 'Default', inviteCode: null }, + { id: 'room-private', name: 'Private', inviteCode: null }, + ] + const visibleRooms = [allRooms[0]] + const storage = { + getAllRooms: vi.fn(() => allRooms), + getRoomsForProfiles: vi.fn(() => visibleRooms), + } + setGroupChatServer({ getStorage: () => storage } as any) + + const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET') + const ctx: any = { + state: { user: { id: 2, username: 'ops', role: 'admin', profiles: ['default', 'research'] } }, + status: 200, + body: undefined, + } + await handler(ctx, async () => {}) + + expect(storage.getRoomsForProfiles).toHaveBeenCalledWith(['default', 'research']) + expect(storage.getAllRooms).not.toHaveBeenCalled() + expect(ctx.body).toEqual({ rooms: visibleRooms }) + }) + + it('keeps room list unrestricted for super admins', async () => { + const rooms = [{ id: 'room-1', name: 'All', inviteCode: null }] + const storage = { + getAllRooms: vi.fn(() => rooms), + getRoomsForProfiles: vi.fn(() => []), + } + setGroupChatServer({ getStorage: () => storage } as any) + + const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET') + const ctx: any = { + state: { user: { id: 1, username: 'admin', role: 'super_admin' } }, + status: 200, + body: undefined, + } + await handler(ctx, async () => {}) + + expect(storage.getAllRooms).toHaveBeenCalledOnce() + expect(storage.getRoomsForProfiles).not.toHaveBeenCalled() + expect(ctx.body).toEqual({ rooms }) + }) + it('routes @mentions only from user messages, not agent replies', () => { const server = Object.create(GroupChatServer.prototype) as any const emit = vi.fn()