2026-04-24 20:41:14 +08:00
|
|
|
import Router from '@koa/router'
|
|
|
|
|
import type { GroupChatServer } from '../../services/hermes/group-chat'
|
2026-05-20 04:21:57 +02:00
|
|
|
import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing'
|
2026-04-24 20:41:14 +08:00
|
|
|
|
|
|
|
|
export const groupChatRoutes = new Router()
|
|
|
|
|
|
|
|
|
|
let chatServer: GroupChatServer | null = null
|
|
|
|
|
|
|
|
|
|
export function setGroupChatServer(server: GroupChatServer) {
|
|
|
|
|
chatServer = server
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getGroupChatServer(): GroupChatServer | null {
|
|
|
|
|
return chatServer
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateId(): string {
|
|
|
|
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 15:52:16 +08:00
|
|
|
function generateInviteCode(): string {
|
|
|
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
|
|
|
let code = ''
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
code += chars[Math.floor(Math.random() * chars.length)]
|
|
|
|
|
}
|
|
|
|
|
return code
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 20:41:14 +08:00
|
|
|
// Create room
|
|
|
|
|
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { name, inviteCode, agents, compression } = ctx.request.body as {
|
|
|
|
|
name?: string
|
|
|
|
|
inviteCode?: string
|
|
|
|
|
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
|
|
|
|
|
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }
|
|
|
|
|
}
|
|
|
|
|
if (!name || !inviteCode) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'name and inviteCode are required' }
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-20 04:21:57 +02:00
|
|
|
const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile))
|
|
|
|
|
if (reservedAgent) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: '`all` is reserved for @all mentions' }
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-24 20:41:14 +08:00
|
|
|
|
|
|
|
|
const roomId = generateId()
|
|
|
|
|
const storage = chatServer.getStorage()
|
|
|
|
|
storage.saveRoom(roomId, name, inviteCode, compression)
|
|
|
|
|
|
|
|
|
|
// Save agents to DB and auto-connect via Socket.IO
|
|
|
|
|
const addedAgents = []
|
|
|
|
|
for (const a of agents || []) {
|
|
|
|
|
const agentId = generateId()
|
|
|
|
|
const agent = storage.addRoomAgent(roomId, agentId, a.profile, a.name || a.profile, a.description || '', a.invited ? 1 : 0)
|
|
|
|
|
addedAgents.push(agent)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const client = await chatServer.agentClients.createAgent({
|
2026-05-20 11:13:15 +02:00
|
|
|
agentId: agent.agentId,
|
2026-04-24 20:41:14 +08:00
|
|
|
profile: agent.profile,
|
|
|
|
|
name: agent.name,
|
|
|
|
|
description: agent.description,
|
|
|
|
|
invited: agent.invited,
|
|
|
|
|
})
|
|
|
|
|
await chatServer.agentClients.addAgentToRoom(roomId, client)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${err.message}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const room = storage.getRoom(roomId)
|
|
|
|
|
ctx.body = { room, agents: addedAgents }
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-15 15:52:16 +08:00
|
|
|
// Clone room roles/config without copying the conversation context.
|
|
|
|
|
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sourceRoom = chatServer.getStorage().getRoom(ctx.params.roomId)
|
|
|
|
|
if (!sourceRoom) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'Room not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { name, inviteCode } = ctx.request.body as { name?: string; inviteCode?: string }
|
|
|
|
|
const roomId = generateId()
|
|
|
|
|
const storage = chatServer.getStorage()
|
|
|
|
|
const code = inviteCode?.trim() || generateInviteCode()
|
|
|
|
|
storage.saveRoom(roomId, name?.trim() || `${sourceRoom.name} Copy`, code, {
|
|
|
|
|
triggerTokens: sourceRoom.triggerTokens,
|
|
|
|
|
maxHistoryTokens: sourceRoom.maxHistoryTokens,
|
|
|
|
|
tailMessageCount: sourceRoom.tailMessageCount,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const addedAgents = []
|
|
|
|
|
for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) {
|
|
|
|
|
const agentId = generateId()
|
|
|
|
|
const agent = storage.addRoomAgent(
|
|
|
|
|
roomId,
|
|
|
|
|
agentId,
|
|
|
|
|
sourceAgent.profile,
|
|
|
|
|
sourceAgent.name,
|
|
|
|
|
sourceAgent.description,
|
|
|
|
|
sourceAgent.invited,
|
|
|
|
|
)
|
|
|
|
|
addedAgents.push(agent)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const client = await chatServer.agentClients.createAgent({
|
2026-05-20 11:13:15 +02:00
|
|
|
agentId: agent.agentId,
|
2026-05-15 15:52:16 +08:00
|
|
|
profile: agent.profile,
|
|
|
|
|
name: agent.name,
|
|
|
|
|
description: agent.description,
|
|
|
|
|
invited: agent.invited,
|
|
|
|
|
})
|
|
|
|
|
await chatServer.agentClients.addAgentToRoom(roomId, client)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
console.error(`[GroupChat] Failed to connect cloned agent ${agent.profile} to room ${roomId}: ${err.message}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const room = storage.getRoom(roomId)
|
|
|
|
|
ctx.body = { room, agents: addedAgents }
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-24 20:41:14 +08:00
|
|
|
// Get room detail and messages
|
|
|
|
|
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const room = chatServer.getStorage().getRoom(ctx.params.roomId)
|
|
|
|
|
if (!room) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'Room not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const messages = chatServer.getStorage().getMessages(ctx.params.roomId)
|
|
|
|
|
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
|
|
|
|
|
const members = chatServer.getStorage().getRoomMembers(ctx.params.roomId)
|
|
|
|
|
ctx.body = { room, messages, agents, members }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// List rooms
|
|
|
|
|
groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rooms = chatServer.getStorage().getAllRooms()
|
|
|
|
|
ctx.body = { rooms }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get room by invite code
|
|
|
|
|
groupChatRoutes.get('/api/hermes/group-chat/rooms/join/:code', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const room = chatServer.getStorage().getRoomByInviteCode(ctx.params.code)
|
|
|
|
|
if (!room) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'Room not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.body = { room }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update room invite code
|
|
|
|
|
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/invite-code', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { inviteCode } = ctx.request.body as { inviteCode?: string }
|
|
|
|
|
if (!inviteCode) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'inviteCode is required' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatServer.getStorage().updateRoomInviteCode(ctx.params.roomId, inviteCode)
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Add agent to room
|
|
|
|
|
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { profile, name, description, invited } = ctx.request.body as { profile?: string; name?: string; description?: string; invited?: boolean }
|
|
|
|
|
if (!profile) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: 'profile is required' }
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-20 04:21:57 +02:00
|
|
|
if (isReservedMentionName(name || profile)) {
|
|
|
|
|
ctx.status = 400
|
|
|
|
|
ctx.body = { error: '`all` is reserved for @all mentions' }
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-24 20:41:14 +08:00
|
|
|
|
|
|
|
|
// Prevent duplicate agent in same room
|
|
|
|
|
const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
|
|
|
|
|
if (existing.find(a => a.profile === profile)) {
|
|
|
|
|
ctx.status = 409
|
|
|
|
|
ctx.body = { error: 'Agent already in room' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const agentId = generateId()
|
|
|
|
|
const agent = chatServer.getStorage().addRoomAgent(ctx.params.roomId, agentId, profile, name || profile, description || '', invited ? 1 : 0)
|
|
|
|
|
|
|
|
|
|
// Auto-connect agent via Socket.IO
|
|
|
|
|
try {
|
|
|
|
|
const client = await chatServer.agentClients.createAgent({
|
2026-05-20 11:13:15 +02:00
|
|
|
agentId: agent.agentId,
|
2026-04-24 20:41:14 +08:00
|
|
|
profile: agent.profile,
|
|
|
|
|
name: agent.name,
|
|
|
|
|
description: agent.description,
|
|
|
|
|
invited: agent.invited,
|
|
|
|
|
})
|
|
|
|
|
await chatServer.agentClients.addAgentToRoom(ctx.params.roomId, client)
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${err.message}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.body = { agent }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// List agents in room
|
|
|
|
|
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
|
|
|
|
|
ctx.body = { agents }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Remove agent from room
|
|
|
|
|
groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 11:13:15 +02:00
|
|
|
const roomId = ctx.params.roomId
|
|
|
|
|
const requestedAgentId = ctx.params.agentId
|
|
|
|
|
const storage = chatServer.getStorage()
|
|
|
|
|
const agent = storage.getRoomAgent(roomId, requestedAgentId)
|
|
|
|
|
if (!agent) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'Agent not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
storage.removeRoomMembersForAgent(roomId, agent)
|
|
|
|
|
storage.removeRoomAgent(roomId, requestedAgentId)
|
|
|
|
|
chatServer.agentClients.removeAgentFromRoom(roomId, agent.agentId)
|
|
|
|
|
ctx.body = {
|
|
|
|
|
success: true,
|
|
|
|
|
agents: storage.getRoomAgents(roomId),
|
|
|
|
|
members: storage.getRoomMembers(roomId),
|
|
|
|
|
}
|
2026-04-24 20:41:14 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Delete room
|
|
|
|
|
groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const roomId = ctx.params.roomId
|
|
|
|
|
// Disconnect all agents in room
|
|
|
|
|
chatServer.agentClients.disconnectRoom(roomId)
|
|
|
|
|
// Delete all data
|
|
|
|
|
chatServer.getStorage().deleteRoom(roomId)
|
|
|
|
|
ctx.body = { success: true }
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-15 15:52:16 +08:00
|
|
|
// Clear current room context while keeping members, agents, and room config.
|
|
|
|
|
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clear-context', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const roomId = ctx.params.roomId
|
|
|
|
|
if (!chatServer.getStorage().getRoom(roomId)) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'Room not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatServer.getStorage().clearRoomContext(roomId)
|
|
|
|
|
chatServer.clearRoomRuntimeState(roomId)
|
|
|
|
|
ctx.body = { success: true, room: chatServer.getStorage().getRoom(roomId) }
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-24 20:41:14 +08:00
|
|
|
// Update room compression config
|
|
|
|
|
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const roomId = ctx.params.roomId
|
|
|
|
|
const { triggerTokens, maxHistoryTokens, tailMessageCount } = ctx.request.body as {
|
|
|
|
|
triggerTokens?: number
|
|
|
|
|
maxHistoryTokens?: number
|
|
|
|
|
tailMessageCount?: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatServer.getStorage().updateRoomConfig(roomId, { triggerTokens, maxHistoryTokens, tailMessageCount })
|
|
|
|
|
const room = chatServer.getStorage().getRoom(roomId)
|
|
|
|
|
ctx.body = { room }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Force compress a room's context
|
|
|
|
|
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/compress', async (ctx) => {
|
|
|
|
|
if (!chatServer) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Group chat not initialized' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const roomId = ctx.params.roomId
|
|
|
|
|
if (!chatServer.getStorage().getRoom(roomId)) {
|
|
|
|
|
ctx.status = 404
|
|
|
|
|
ctx.body = { error: 'Room not found' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const engine = chatServer.getContextEngine()
|
|
|
|
|
if (!engine) {
|
|
|
|
|
ctx.status = 503
|
|
|
|
|
ctx.body = { error: 'Context engine not available' }
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await engine.forceCompress(roomId)
|
|
|
|
|
ctx.body = { success: true, summary: result }
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
ctx.status = 500
|
|
|
|
|
ctx.body = { error: err.message }
|
|
|
|
|
}
|
|
|
|
|
})
|