Refine user profile access and chat sync

This commit is contained in:
Codex
2026-05-23 19:41:51 +08:00
committed by ekko
parent 3f6a25d8f1
commit 7b05731d44
17 changed files with 223 additions and 57 deletions
+33
View File
@@ -51,6 +51,13 @@ export interface RunEvent {
session_id?: string session_id?: string
/** Queue length from run.queued event */ /** Queue length from run.queued event */
queue_length?: number 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<string, {
onRunQueued?: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
}>() }>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
/** /**
* Global message.delta event handler * Global message.delta event handler
* Distributes events to appropriate session based on session_id * 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 * Register event handlers for a session
* @param sessionId - Session ID * @param sessionId - Session ID
@@ -351,6 +375,7 @@ export function registerSessionHandlers(
onRunQueued?: (event: RunEvent) => void onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
} }
): () => void { ): () => void {
sessionEventHandlers.set(sessionId, handlers) sessionEventHandlers.set(sessionId, handlers)
@@ -369,6 +394,13 @@ export function unregisterSessionHandlers(sessionId: string): void {
sessionEventHandlers.delete(sessionId) sessionEventHandlers.delete(sessionId)
} }
export function onPeerUserMessage(handler: (event: RunEvent) => void): () => void {
peerUserMessageHandlers.add(handler)
return () => {
peerUserMessageHandlers.delete(handler)
}
}
export function respondToolApproval( export function respondToolApproval(
sessionId: string, sessionId: string,
approvalId: string, approvalId: string,
@@ -441,6 +473,7 @@ export function connectChatRun(): Socket {
chatRunSocket.on('run.queued', globalRunQueuedHandler) chatRunSocket.on('run.queued', globalRunQueuedHandler)
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler) chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler) chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler)
// Compression events // Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler) chatRunSocket.on('compression.started', globalCompressionStartedHandler)
+2 -2
View File
@@ -47,7 +47,7 @@ export default {
username: 'Benutzername', username: 'Benutzername',
role: 'Rolle', role: 'Rolle',
statusLabel: 'Status', statusLabel: 'Status',
profiles: 'Profiles', profiles: 'Zugreifbare Profile',
profilesPlaceholder: 'Zugreifbare Profile auswaehlen', profilesPlaceholder: 'Zugreifbare Profile auswaehlen',
allProfiles: 'Alle Profile', allProfiles: 'Alle Profile',
noProfiles: 'Keine Profile zugewiesen', noProfiles: 'Keine Profile zugewiesen',
@@ -679,7 +679,7 @@ jobTriggered: 'Job ausgelost',
stopped: 'Gestoppt', stopped: 'Gestoppt',
restartGateway: 'Gateway neu starten', restartGateway: 'Gateway neu starten',
restartProfile: 'Profil neu starten', restartProfile: 'Profil neu starten',
switchProfile: 'Profil wechseln', switchProfile: 'Frontend-Profil wechseln',
gatewayRestarted: 'Gateway neu gestartet: {name}', gatewayRestarted: 'Gateway neu gestartet: {name}',
gatewayRestartFailed: 'Gateway-Neustart fehlgeschlagen', gatewayRestartFailed: 'Gateway-Neustart fehlgeschlagen',
profileRestarted: 'Profil neu gestartet: {name}', profileRestarted: 'Profil neu gestartet: {name}',
+2 -2
View File
@@ -47,7 +47,7 @@ export default {
username: 'Username', username: 'Username',
role: 'Role', role: 'Role',
statusLabel: 'Status', statusLabel: 'Status',
profiles: 'Profiles', profiles: 'Accessible Profiles',
profilesPlaceholder: 'Select accessible profiles', profilesPlaceholder: 'Select accessible profiles',
allProfiles: 'All profiles', allProfiles: 'All profiles',
noProfiles: 'No profiles assigned', noProfiles: 'No profiles assigned',
@@ -782,7 +782,7 @@ export default {
stopped: 'Stopped', stopped: 'Stopped',
restartGateway: 'Restart Gateway', restartGateway: 'Restart Gateway',
restartProfile: 'Restart Profile', restartProfile: 'Restart Profile',
switchProfile: 'Switch Profile', switchProfile: 'Switch Frontend Profile',
gatewayRestarted: 'Gateway restarted: {name}', gatewayRestarted: 'Gateway restarted: {name}',
gatewayRestartFailed: 'Failed to restart gateway', gatewayRestartFailed: 'Failed to restart gateway',
profileRestarted: 'Profile restarted: {name}', profileRestarted: 'Profile restarted: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: 'Gestion de cuentas', 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', create: 'Crear usuario',
edit: 'Editar usuario', edit: 'Editar usuario',
username: 'Nombre de usuario', username: 'Nombre de usuario',
role: 'Rol', role: 'Rol',
statusLabel: 'Estado', statusLabel: 'Estado',
profiles: 'Profiles', profiles: 'Perfiles accesibles',
profilesPlaceholder: 'Selecciona Profile accesibles', profilesPlaceholder: 'Selecciona perfiles accesibles',
allProfiles: 'Todos los Profile', allProfiles: 'Todos los perfiles',
noProfiles: 'Sin Profile asignados', noProfiles: 'Sin perfiles asignados',
lastLogin: 'Ultimo inicio', lastLogin: 'Ultimo inicio',
newPasswordOptional: 'Nueva contrasena (dejar vacio para conservar)', newPasswordOptional: 'Nueva contrasena (dejar vacio para conservar)',
loadFailed: 'No se pudieron cargar los usuarios', loadFailed: 'No se pudieron cargar los usuarios',
@@ -679,7 +679,7 @@ jobTriggered: 'Job ejecutado',
stopped: 'Detenido', stopped: 'Detenido',
restartGateway: 'Reiniciar gateway', restartGateway: 'Reiniciar gateway',
restartProfile: 'Reiniciar perfil', restartProfile: 'Reiniciar perfil',
switchProfile: 'Cambiar perfil', switchProfile: 'Cambiar perfil frontend',
gatewayRestarted: 'Gateway reiniciado: {name}', gatewayRestarted: 'Gateway reiniciado: {name}',
gatewayRestartFailed: 'No se pudo reiniciar el gateway', gatewayRestartFailed: 'No se pudo reiniciar el gateway',
profileRestarted: 'Perfil reiniciado: {name}', profileRestarted: 'Perfil reiniciado: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: 'Gestion des comptes', 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', create: 'Creer un utilisateur',
edit: 'Modifier l utilisateur', edit: 'Modifier l utilisateur',
username: 'Nom d utilisateur', username: 'Nom d utilisateur',
role: 'Role', role: 'Role',
statusLabel: 'Statut', statusLabel: 'Statut',
profiles: 'Profiles', profiles: 'Profils accessibles',
profilesPlaceholder: 'Selectionner les Profile accessibles', profilesPlaceholder: 'Selectionner les profils accessibles',
allProfiles: 'Tous les Profile', allProfiles: 'Tous les profils',
noProfiles: 'Aucun Profile assigne', noProfiles: 'Aucun profil assigne',
lastLogin: 'Derniere connexion', lastLogin: 'Derniere connexion',
newPasswordOptional: 'Nouveau mot de passe (laisser vide pour conserver)', newPasswordOptional: 'Nouveau mot de passe (laisser vide pour conserver)',
loadFailed: 'Echec du chargement des utilisateurs', loadFailed: 'Echec du chargement des utilisateurs',
@@ -679,7 +679,7 @@ jobTriggered: 'Job declenche',
stopped: 'Arrêté', stopped: 'Arrêté',
restartGateway: 'Redémarrer le gateway', restartGateway: 'Redémarrer le gateway',
restartProfile: 'Redémarrer le profil', restartProfile: 'Redémarrer le profil',
switchProfile: 'Changer de profil', switchProfile: 'Changer le profil frontend',
gatewayRestarted: 'Gateway redémarré : {name}', gatewayRestarted: 'Gateway redémarré : {name}',
gatewayRestartFailed: 'Échec du redémarrage du gateway', gatewayRestartFailed: 'Échec du redémarrage du gateway',
profileRestarted: 'Profil redémarré : {name}', profileRestarted: 'Profil redémarré : {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: 'アカウント管理', title: 'アカウント管理',
description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできる Profile を制御します。', description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできるプロファイルを制御します。',
create: 'ユーザー作成', create: 'ユーザー作成',
edit: 'ユーザー編集', edit: 'ユーザー編集',
username: 'ユーザー名', username: 'ユーザー名',
role: 'ロール', role: 'ロール',
statusLabel: 'ステータス', statusLabel: 'ステータス',
profiles: 'Profiles', profiles: 'アクセス可能なプロファイル',
profilesPlaceholder: 'アクセス可能な Profile を選択', profilesPlaceholder: 'アクセス可能なプロファイルを選択',
allProfiles: 'すべての Profile', allProfiles: 'すべてのプロファイル',
noProfiles: 'Profile 未割り当て', noProfiles: 'プロファイル未割り当て',
lastLogin: '最終ログイン', lastLogin: '最終ログイン',
newPasswordOptional: '新しいパスワード(空欄なら変更なし)', newPasswordOptional: '新しいパスワード(空欄なら変更なし)',
loadFailed: 'ユーザー一覧の読み込みに失敗しました', loadFailed: 'ユーザー一覧の読み込みに失敗しました',
@@ -679,7 +679,7 @@ export default {
stopped: '停止中', stopped: '停止中',
restartGateway: 'Gateway を再起動', restartGateway: 'Gateway を再起動',
restartProfile: 'プロファイルを再起動', restartProfile: 'プロファイルを再起動',
switchProfile: 'プロファイルを切り替え', switchProfile: 'フロントエンドプロファイルを切り替え',
gatewayRestarted: 'Gateway を再起動しました: {name}', gatewayRestarted: 'Gateway を再起動しました: {name}',
gatewayRestartFailed: 'Gateway の再起動に失敗しました', gatewayRestartFailed: 'Gateway の再起動に失敗しました',
profileRestarted: 'プロファイルを再起動しました: {name}', profileRestarted: 'プロファイルを再起動しました: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: '계정 관리', title: '계정 관리',
description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 Profile 을 제어합니다.', description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 프로필을 제어합니다.',
create: '사용자 만들기', create: '사용자 만들기',
edit: '사용자 편집', edit: '사용자 편집',
username: '사용자 이름', username: '사용자 이름',
role: '역할', role: '역할',
statusLabel: '상태', statusLabel: '상태',
profiles: 'Profiles', profiles: '접근 가능한 프로필',
profilesPlaceholder: '접근 가능한 Profile 선택', profilesPlaceholder: '접근 가능한 프로필 선택',
allProfiles: '모든 Profile', allProfiles: '모든 프로필',
noProfiles: '할당된 Profile 없음', noProfiles: '할당된 프로필 없음',
lastLogin: '마지막 로그인', lastLogin: '마지막 로그인',
newPasswordOptional: '새 비밀번호 (비워두면 유지)', newPasswordOptional: '새 비밀번호 (비워두면 유지)',
loadFailed: '사용자 목록을 불러오지 못했습니다', loadFailed: '사용자 목록을 불러오지 못했습니다',
@@ -679,7 +679,7 @@ export default {
stopped: '중지됨', stopped: '중지됨',
restartGateway: 'Gateway 재시작', restartGateway: 'Gateway 재시작',
restartProfile: '프로필 재시작', restartProfile: '프로필 재시작',
switchProfile: '프로필 전환', switchProfile: '프론트엔드 프로필 전환',
gatewayRestarted: 'Gateway가 재시작되었습니다: {name}', gatewayRestarted: 'Gateway가 재시작되었습니다: {name}',
gatewayRestartFailed: 'Gateway 재시작 실패', gatewayRestartFailed: 'Gateway 재시작 실패',
profileRestarted: '프로필이 재시작되었습니다: {name}', profileRestarted: '프로필이 재시작되었습니다: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: 'Gerenciamento de contas', 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', create: 'Criar usuario',
edit: 'Editar usuario', edit: 'Editar usuario',
username: 'Nome de usuario', username: 'Nome de usuario',
role: 'Funcao', role: 'Funcao',
statusLabel: 'Status', statusLabel: 'Status',
profiles: 'Profiles', profiles: 'Perfis acessiveis',
profilesPlaceholder: 'Selecione Profile acessiveis', profilesPlaceholder: 'Selecione perfis acessiveis',
allProfiles: 'Todos os Profile', allProfiles: 'Todos os perfis',
noProfiles: 'Nenhum Profile atribuido', noProfiles: 'Nenhum perfil atribuido',
lastLogin: 'Ultimo login', lastLogin: 'Ultimo login',
newPasswordOptional: 'Nova senha (deixe em branco para manter)', newPasswordOptional: 'Nova senha (deixe em branco para manter)',
loadFailed: 'Falha ao carregar usuarios', loadFailed: 'Falha ao carregar usuarios',
@@ -679,7 +679,7 @@ jobTriggered: 'Job acionado',
stopped: 'Parado', stopped: 'Parado',
restartGateway: 'Reiniciar gateway', restartGateway: 'Reiniciar gateway',
restartProfile: 'Reiniciar perfil', restartProfile: 'Reiniciar perfil',
switchProfile: 'Trocar perfil', switchProfile: 'Trocar perfil frontend',
gatewayRestarted: 'Gateway reiniciado: {name}', gatewayRestarted: 'Gateway reiniciado: {name}',
gatewayRestartFailed: 'Falha ao reiniciar gateway', gatewayRestartFailed: 'Falha ao reiniciar gateway',
profileRestarted: 'Perfil reiniciado: {name}', profileRestarted: 'Perfil reiniciado: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: '帳號管理', title: '帳號管理',
description: '建立使用者、分配角色,並控制一般管理員可存取的 Profile。', description: '建立使用者、分配角色,並控制一般管理員可存取的設定檔。',
create: '建立使用者', create: '建立使用者',
edit: '編輯使用者', edit: '編輯使用者',
username: '使用者名稱', username: '使用者名稱',
role: '角色', role: '角色',
statusLabel: '狀態', statusLabel: '狀態',
profiles: 'Profiles', profiles: '可存取設定檔',
profilesPlaceholder: '選擇可存取的 Profile', profilesPlaceholder: '選擇可存取的設定檔',
allProfiles: '全部 Profile', allProfiles: '全部設定檔',
noProfiles: '未關聯 Profile', noProfiles: '未關聯設定檔',
lastLogin: '最後登入', lastLogin: '最後登入',
newPasswordOptional: '新密碼(留空不修改)', newPasswordOptional: '新密碼(留空不修改)',
loadFailed: '使用者列表載入失敗', loadFailed: '使用者列表載入失敗',
@@ -774,7 +774,7 @@ export default {
stopped: '已停止', stopped: '已停止',
restartGateway: '重啟閘道', restartGateway: '重啟閘道',
restartProfile: '重啟設定檔', restartProfile: '重啟設定檔',
switchProfile: '切換前端 Profile', switchProfile: '切換前端設定檔',
gatewayRestarted: '閘道已重啟:{name}', gatewayRestarted: '閘道已重啟:{name}',
gatewayRestartFailed: '重啟閘道失敗', gatewayRestartFailed: '重啟閘道失敗',
profileRestarted: '設定檔已重啟:{name}', profileRestarted: '設定檔已重啟:{name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: { users: {
title: '账户管理', title: '账户管理',
description: '创建用户、分配角色,并控制普通管理员可访问的 Profile。', description: '创建用户、分配角色,并控制普通管理员可访问的配置。',
create: '创建用户', create: '创建用户',
edit: '编辑用户', edit: '编辑用户',
username: '用户名', username: '用户名',
role: '角色', role: '角色',
statusLabel: '状态', statusLabel: '状态',
profiles: 'Profiles', profiles: '可访问配置',
profilesPlaceholder: '选择可访问的 Profile', profilesPlaceholder: '选择可访问的配置',
allProfiles: '全部 Profile', allProfiles: '全部配置',
noProfiles: '未关联 Profile', noProfiles: '未关联配置',
lastLogin: '最后登录', lastLogin: '最后登录',
newPasswordOptional: '新密码(留空不修改)', newPasswordOptional: '新密码(留空不修改)',
loadFailed: '用户列表加载失败', loadFailed: '用户列表加载失败',
@@ -774,7 +774,7 @@ export default {
stopped: '已停止', stopped: '已停止',
restartGateway: '重启网关', restartGateway: '重启网关',
restartProfile: '重启配置', restartProfile: '重启配置',
switchProfile: '切换配置', switchProfile: '切换前端配置',
gatewayRestarted: '网关已重启:{name}', gatewayRestarted: '网关已重启:{name}',
gatewayRestartFailed: '重启网关失败', gatewayRestartFailed: '重启网关失败',
profileRestarted: '配置已重启:{name}', profileRestarted: '配置已重启:{name}',
+36 -3
View File
@@ -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 { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client' import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -1451,11 +1451,11 @@ export const useChatStore = defineStore('chat', () => {
* Emits 'resume' to join the session room on the server, * Emits 'resume' to join the session room on the server,
* then sets up event listeners to receive ongoing events. * 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 // Don't register duplicate listeners if already streaming
if (streamStates.value.has(sid)) return if (streamStates.value.has(sid)) return
// Only set up listeners if the server reported an active run during resume. // 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 closed = false
let runProducedAssistantText = 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() { function stopStreaming() {
const sid = activeSessionId.value const sid = activeSessionId.value
if (!sid) return if (!sid) return
+7 -1
View File
@@ -3,6 +3,7 @@ import { createHmac, timingSafeEqual } from 'crypto'
import { getToken } from '../services/auth' import { getToken } from '../services/auth'
import { import {
findUserById, findUserById,
listUserProfiles,
touchUserLogin, touchUserLogin,
userCanAccessProfile, userCanAccessProfile,
type UserRecord, type UserRecord,
@@ -13,6 +14,7 @@ export interface AuthenticatedUser {
id: number id: number
username: string username: string
role: UserRole role: UserRole
profiles?: string[]
} }
export interface RequestProfile { export interface RequestProfile {
@@ -110,11 +112,15 @@ export async function issueUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'r
} }
export function toAuthenticatedUser(user: Pick<UserRecord, 'id' | 'username' | 'role'>): AuthenticatedUser { export function toAuthenticatedUser(user: Pick<UserRecord, 'id' | 'username' | 'role'>): AuthenticatedUser {
return { const authenticated: AuthenticatedUser = {
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, 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<AuthenticatedUser | null> { export async function authenticateUserToken(token: string): Promise<AuthenticatedUser | null> {
@@ -196,7 +196,11 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => {
return 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 } ctx.body = { rooms }
}) })
@@ -72,6 +72,17 @@ interface RoomAgent {
invited: number invited: number
} }
interface RoomInfo {
id: string
name: string
inviteCode: string | null
triggerTokens: number
maxHistoryTokens: number
tailMessageCount: number
totalTokens: number
sessionSeed: string
}
interface Member { interface Member {
id: string id: string
userId: string userId: string
@@ -278,18 +289,31 @@ class ChatStorage {
// ─── Rooms ──────────────────────────────────────────────── // ─── 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 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 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[] 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 { saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
this.db()?.prepare( this.db()?.prepare(
'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount) VALUES (?, ?, ?, ?, ?, ?)'
@@ -109,6 +109,7 @@ export async function handleApiRun(
state.source = 'api_server' state.source = 'api_server'
state.activeRunMarker = runMarker state.activeRunMarker = runMarker
let peerUserMessage: { id?: number; role: 'user'; content: string; timestamp: number } | null = null
if (!skipUserMessage) { if (!skipUserMessage) {
const inputStr = contentBlocksToString(input) const inputStr = contentBlocksToString(input)
state.messages.push({ state.messages.push({
@@ -126,12 +127,13 @@ export async function handleApiRun(
createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview }) createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview })
} }
addMessage({ const messageId = addMessage({
session_id, session_id,
role: 'user', role: 'user',
content: inputStr, content: inputStr,
timestamp: now, timestamp: now,
}) })
peerUserMessage = { id: messageId, role: 'user', content: inputStr, timestamp: now }
} else { } else {
const inputStr = contentBlocksToString(input) const inputStr = contentBlocksToString(input)
state.messages.push({ state.messages.push({
@@ -147,15 +149,23 @@ export async function handleApiRun(
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview }) createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview })
} }
addMessage({ const messageId = addMessage({
session_id, session_id,
role: 'user', role: 'user',
content: inputStr, content: inputStr,
timestamp: now, timestamp: now,
}) })
peerUserMessage = { id: messageId, role: 'user', content: inputStr, timestamp: now }
} }
socket.join(`session:${session_id}`) 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) => { const emit = (event: string, payload: any) => {
@@ -173,7 +173,7 @@ export async function handleBridgeRun(
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview }) createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview })
} }
addMessage({ const messageId = addMessage({
session_id, session_id,
role: 'user', role: 'user',
content: inputStr, content: inputStr,
@@ -181,6 +181,16 @@ export async function handleBridgeRun(
}) })
socket.join(`session:${session_id}`) 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 emit = (event: string, payload: any) => {
const tagged = { ...payload, session_id } const tagged = { ...payload, session_id }
nsp.to(`session:${session_id}`).emit(event, tagged) nsp.to(`session:${session_id}`).emit(event, tagged)
@@ -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', () => { it('routes @mentions only from user messages, not agent replies', () => {
const server = Object.create(GroupChatServer.prototype) as any const server = Object.create(GroupChatServer.prototype) as any
const emit = vi.fn() const emit = vi.fn()