[codex] Add group chat room reset and clone (#756)

* Add group chat room reset and clone

* Clean npm cache before self update
This commit is contained in:
ekko
2026-05-15 15:52:16 +08:00
committed by GitHub
parent 94f1061734
commit 8196e49478
18 changed files with 373 additions and 9 deletions
+16 -1
View File
@@ -447,7 +447,22 @@ Options:
function doUpdate() {
console.log(' ⬆ Updating hermes-web-ui...')
const child = spawnCli(getNpmBin(), ['install', '-g', 'hermes-web-ui@latest'], {
const npm = getNpmBin()
try {
console.log(' 🧹 Cleaning npm cache...')
execFileSync(npm, ['cache', 'clean', '--force'], {
stdio: 'inherit',
env: getCurrentNodeEnv(),
})
} catch (err) {
console.log(` ⚠ Failed to clean npm cache, continuing update: ${err?.message || err}`)
}
runUpdateInstall(npm)
}
function runUpdateInstall(npm) {
const child = spawnCli(npm, ['install', '-g', 'hermes-web-ui@latest'], {
stdio: 'inherit',
windowsHide: true,
env: getCurrentNodeEnv(),
@@ -125,6 +125,14 @@ export async function createRoom(data: {
})
}
export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
})
}
export async function listRooms(): Promise<{ rooms: RoomInfo[] }> {
return request('/api/hermes/group-chat/rooms')
}
@@ -174,6 +182,12 @@ export async function deleteRoom(roomId: string): Promise<void> {
})
}
export async function clearRoomContext(roomId: string): Promise<{ success: boolean; room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clear-context`, {
method: 'POST',
})
}
export async function updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): Promise<{ room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/config`, {
method: 'PUT',
@@ -16,6 +16,7 @@ const profilesStore = useProfilesStore()
const showSidebar = ref(window.innerWidth > 768)
const showCreateModal = ref(false)
const showCloneModal = ref(false)
const showAddAgentModal = ref(false)
const showCompressionModal = ref(false)
const compressionConfig = ref({ triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10 })
@@ -23,6 +24,9 @@ const isCompressing = ref(false)
const selectedProfile = ref<string | null>(null)
const agentName = ref('')
const agentDescription = ref('')
const cloneSourceRoomId = ref<string | null>(null)
const cloneRoomName = ref('')
const cloneInviteCode = ref('')
const profileOptions = computed(() =>
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
@@ -48,6 +52,15 @@ function toggleSidebar() {
showSidebar.value = !showSidebar.value
}
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
async function handleCreateRoom(name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }) {
try {
store.setUserInfo(userName, description)
@@ -69,6 +82,46 @@ async function handleDeleteRoom(roomId: string) {
}
}
function handleOpenCloneRoom(roomId: string) {
const room = store.rooms.find(r => r.id === roomId)
cloneSourceRoomId.value = roomId
cloneRoomName.value = room?.name ? `${room.name} Copy` : ''
cloneInviteCode.value = generateCode()
showCloneModal.value = true
}
async function confirmCloneRoom() {
if (!cloneSourceRoomId.value || !cloneRoomName.value.trim()) return
try {
const res = await store.cloneRoom(cloneSourceRoomId.value, {
name: cloneRoomName.value.trim(),
inviteCode: cloneInviteCode.value.trim() || undefined,
})
showCloneModal.value = false
cloneSourceRoomId.value = null
cloneRoomName.value = ''
cloneInviteCode.value = ''
await store.joinRoom(res.room.id)
message.success(t('groupChat.roomCloned'))
} catch {
message.error(t('common.saveFailed'))
}
}
async function handleClearRoomContext() {
if (!store.currentRoomId) return
if (store.contextStatuses.size > 0) {
message.warning(t('groupChat.compressingInProgress'))
return
}
try {
await store.clearCurrentRoomContext()
message.success(t('groupChat.contextCleared'))
} catch {
message.error(t('common.deleteFailed'))
}
}
async function handleSelectRoom(roomId: string) {
try {
await store.joinRoom(roomId)
@@ -204,9 +257,14 @@ watch(() => store.sortedMessages.length, async () => {
<span v-if="room.inviteCode" class="room-code">{{ room.inviteCode }}</span>
<span class="room-tokens">{{ formatTokens(room.totalTokens || 0) }}</span>
</div>
<button class="room-action-btn" :title="t('groupChat.cloneRoom')" @click.stop="handleOpenCloneRoom(room.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" />
</svg>
</button>
<NPopconfirm @positive-click="handleDeleteRoom(room.id)">
<template #trigger>
<button class="room-delete-btn" @click.stop>
<button class="room-action-btn danger" @click.stop>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
@@ -281,6 +339,16 @@ watch(() => store.sortedMessages.length, async () => {
<button class="icon-btn" :title="t('groupChat.compressionConfig')" @click="handleOpenCompressionConfig">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 4.6a1.65 1.65 0 0 0 1.51 1V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1.51 1z"/></svg>
</button>
<NPopconfirm @positive-click="handleClearRoomContext">
<template #trigger>
<button class="icon-btn" :title="t('groupChat.clearContext')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v5" /><path d="M14 11v5" />
</svg>
</button>
</template>
{{ t('groupChat.clearContextConfirm') }}
</NPopconfirm>
<span v-if="store.members.length" class="member-count">
{{ store.members.length }} {{ t('groupChat.members') }}
</span>
@@ -370,6 +438,40 @@ watch(() => store.sortedMessages.length, async () => {
</div>
</div>
</div>
<div v-if="showCloneModal" class="modal-backdrop" @click.self="showCloneModal = false">
<div class="modal">
<h3>{{ t('groupChat.cloneRoom') }}</h3>
<div class="form-group">
<label class="form-label">{{ t('groupChat.roomName') }}</label>
<NInput
v-model:value="cloneRoomName"
:placeholder="t('groupChat.roomNamePlaceholder')"
@keyup.enter="confirmCloneRoom"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.inviteCode') }}</label>
<div class="code-row">
<NInput
v-model:value="cloneInviteCode"
:placeholder="t('groupChat.autoGenerate')"
@keyup.enter="confirmCloneRoom"
/>
<NButton size="small" @click="cloneInviteCode = generateCode()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</NButton>
</div>
</div>
<div class="modal-actions">
<NSpace justify="end">
<NButton @click="showCloneModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="!cloneRoomName.trim()" @click="confirmCloneRoom">{{ t('groupChat.cloneRoom') }}</NButton>
</NSpace>
</div>
</div>
</div>
<div v-if="showCompressionModal" class="modal-backdrop" @click.self="showCompressionModal = false">
<div class="modal">
<h3>{{ t('groupChat.compressionConfig') }}</h3>
@@ -597,7 +699,7 @@ export default defineComponent({ components: { CreateRoomForm } })
color: $text-muted;
}
.room-delete-btn {
.room-action-btn {
flex-shrink: 0;
display: flex;
align-items: center;
@@ -613,12 +715,17 @@ export default defineComponent({ components: { CreateRoomForm } })
transition: opacity $transition-fast, color $transition-fast, background-color $transition-fast;
&:hover {
color: $text-primary;
background-color: rgba(var(--accent-primary-rgb), 0.08);
}
&.danger:hover {
color: $error;
background-color: rgba(var(--error-rgb), 0.1);
}
}
&:hover .room-delete-btn {
&:hover .room-action-btn {
opacity: 1;
}
}
@@ -875,6 +982,20 @@ export default defineComponent({ components: { CreateRoomForm } })
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: $text-secondary;
margin-bottom: 6px;
}
.code-row {
display: flex;
gap: 8px;
align-items: center;
}
.modal-actions {
margin-top: 12px;
display: flex;
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import GroupMessageItem from './GroupMessageItem.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const listRef = ref<HTMLDivElement>()
const isNearBottom = ref(true)
@@ -35,10 +37,8 @@ defineExpose({ scrollToBottom })
<template>
<div ref="listRef" class="message-list" @scroll="handleScroll">
<div v-if="store.sortedMessages.length === 0" class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<p>No messages yet</p>
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
<GroupMessageItem
v-for="msg in store.sortedMessages"
@@ -61,6 +61,11 @@ defineExpose({ scrollToBottom })
flex-direction: column;
gap: 12px;
background-color: $bg-card;
position: relative;
.dark & {
background-color: #333333;
}
}
.empty-state {
@@ -71,7 +76,12 @@ defineExpose({ scrollToBottom })
justify-content: center;
gap: 12px;
color: $text-muted;
opacity: 0.4;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
+5
View File
@@ -996,7 +996,12 @@ jobTriggered: 'Job ausgelost',
members: 'Mitglieder',
roomCreated: 'Raum erstellt',
roomDeleted: 'Raum gelöscht',
roomCloned: 'Raum geklont',
cloneRoom: 'Raum klonen',
deleteRoomConfirm: 'Diesen Raum löschen?',
clearContext: 'Kontext löschen',
clearContextConfirm: 'Diesen Raumkontext löschen? Nachrichten und Komprimierungs-Snapshots werden entfernt, Agenten und Mitglieder bleiben.',
contextCleared: 'Kontext gelöscht',
you: 'Du',
joined: 'Raum beigetreten',
joinFailed: 'Beitreten fehlgeschlagen',
+5
View File
@@ -953,7 +953,12 @@ export default {
members: 'members',
roomCreated: 'Room created',
roomDeleted: 'Room deleted',
roomCloned: 'Room cloned',
cloneRoom: 'Clone room',
deleteRoomConfirm: 'Delete this room?',
clearContext: 'Clear context',
clearContextConfirm: 'Clear this room context? Messages and compression snapshots will be removed, but agents and members stay.',
contextCleared: 'Context cleared',
you: 'You',
joined: 'Joined room',
joinFailed: 'Failed to join room',
+5
View File
@@ -992,7 +992,12 @@ jobTriggered: 'Job ejecutado',
members: 'Miembros',
roomCreated: 'Sala creada',
roomDeleted: 'Sala eliminada',
roomCloned: 'Sala clonada',
cloneRoom: 'Clonar sala',
deleteRoomConfirm: '¿Eliminar esta sala?',
clearContext: 'Limpiar contexto',
clearContextConfirm: '¿Limpiar el contexto de esta sala? Se eliminarán mensajes e instantáneas de compresión, pero se conservan agentes y miembros.',
contextCleared: 'Contexto limpiado',
you: 'Tú',
joined: 'Se unio a la sala',
joinFailed: 'Error al unirse a la sala',
+5
View File
@@ -991,7 +991,12 @@ jobTriggered: 'Job declenche',
members: 'Membres',
roomCreated: 'Salon cree',
roomDeleted: 'Salon supprime',
roomCloned: 'Salon clone',
cloneRoom: 'Cloner le salon',
deleteRoomConfirm: 'Supprimer ce salon ?',
clearContext: 'Effacer le contexte',
clearContextConfirm: 'Effacer le contexte de ce salon ? Les messages et instantanés de compression seront supprimés, les agents et membres restent.',
contextCleared: 'Contexte effacé',
you: 'Vous',
joined: 'Vous avez rejoint le salon',
joinFailed: 'Echec de la connexion au salon',
+5
View File
@@ -992,7 +992,12 @@ export default {
members: 'メンバー',
roomCreated: 'ルームが作成されました',
roomDeleted: 'ルームを削除しました',
roomCloned: 'ルームを複製しました',
cloneRoom: 'ルームを複製',
deleteRoomConfirm: 'このルームを削除しますか?',
clearContext: 'コンテキストを削除',
clearContextConfirm: 'このルームのコンテキストを削除しますか?メッセージと圧縮スナップショットは削除されますが、エージェントとメンバーは残ります。',
contextCleared: 'コンテキストを削除しました',
you: 'あなた',
joined: 'ルームに参加しました',
joinFailed: 'ルームへの参加に失敗しました',
+5
View File
@@ -992,7 +992,12 @@ export default {
members: '멤버',
roomCreated: '방이 생성되었습니다',
roomDeleted: '방이 삭제되었습니다',
roomCloned: '방이 복제되었습니다',
cloneRoom: '방 복제',
deleteRoomConfirm: '이 방을 삭제하시겠습니까?',
clearContext: '컨텍스트 지우기',
clearContextConfirm: '이 방의 컨텍스트를 지우시겠습니까? 메시지와 압축 스냅샷은 삭제되고 에이전트와 멤버는 유지됩니다.',
contextCleared: '컨텍스트가 지워졌습니다',
you: '나',
joined: '방에 참여했습니다',
joinFailed: '방 참여에 실패했습니다',
+5
View File
@@ -992,7 +992,12 @@ jobTriggered: 'Job acionado',
members: 'Membros',
roomCreated: 'Sala criada',
roomDeleted: 'Sala excluída',
roomCloned: 'Sala clonada',
cloneRoom: 'Clonar sala',
deleteRoomConfirm: 'Excluir esta sala?',
clearContext: 'Limpar contexto',
clearContextConfirm: 'Limpar o contexto desta sala? Mensagens e snapshots de compactação serão removidos, mas agentes e membros ficam.',
contextCleared: 'Contexto limpo',
you: 'Você',
joined: 'Entrou na sala',
joinFailed: 'Falha ao entrar na sala',
@@ -953,7 +953,12 @@ export default {
members: '成員',
roomCreated: '房間已建立',
roomDeleted: '房間已刪除',
roomCloned: '房間已複製',
cloneRoom: '複製房間',
deleteRoomConfirm: '確定刪除這個房間嗎?',
clearContext: '清理上下文',
clearContextConfirm: '確定清理目前房間上下文嗎?訊息和壓縮快照會被刪除,智慧代理和成員會保留。',
contextCleared: '上下文已清理',
you: '你',
joined: '已加入房間',
joinFailed: '加入房間失敗',
+5
View File
@@ -955,7 +955,12 @@ export default {
members: '成员',
roomCreated: '房间已创建',
roomDeleted: '房间已删除',
roomCloned: '房间已克隆',
cloneRoom: '克隆房间',
deleteRoomConfirm: '确定删除这个房间吗?',
clearContext: '清理上下文',
clearContextConfirm: '确定清理当前房间上下文吗?消息和压缩快照会被删除,智能体和成员会保留。',
contextCleared: '上下文已清理',
you: '你',
joined: '已加入房间',
joinFailed: '加入房间失败',
@@ -17,7 +17,9 @@ import {
addAgent,
listAgents,
removeAgent,
cloneRoom as cloneRoomApi,
deleteRoom as deleteRoomApi,
clearRoomContext,
} from '@/api/hermes/group-chat'
export const useGroupChatStore = defineStore('groupChat', () => {
@@ -139,6 +141,16 @@ export const useGroupChatStore = defineStore('groupChat', () => {
const room = rooms.value.find(r => r.id === data.roomId)
if (room) room.totalTokens = data.totalTokens
})
socket.on('room_cleared', (data: { roomId: string; totalTokens: number }) => {
const room = rooms.value.find(r => r.id === data.roomId)
if (room) room.totalTokens = data.totalTokens
if (data.roomId === currentRoomId.value) {
messages.value = []
typingUsers.value.clear()
contextStatuses.value.clear()
}
})
}
function disconnect() {
@@ -279,6 +291,33 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
}
async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }) {
try {
const res = await cloneRoomApi(roomId, data)
rooms.value.push(res.room)
return res
} catch (err: any) {
error.value = err.message
throw err
}
}
async function clearCurrentRoomContext() {
if (!currentRoomId.value) return
try {
const res = await clearRoomContext(currentRoomId.value)
messages.value = []
typingUsers.value.clear()
contextStatuses.value.clear()
const idx = rooms.value.findIndex(r => r.id === currentRoomId.value)
if (idx >= 0 && res.room) rooms.value[idx] = res.room
return res
} catch (err: any) {
error.value = err.message
throw err
}
}
// ─── Agent Actions ─────────────────────────────────────
async function loadAgents(roomId: string) {
try {
@@ -358,6 +397,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
createNewRoom,
joinByCode,
deleteRoom,
cloneRoom,
clearCurrentRoomContext,
loadAgents,
addAgentToRoom,
removeAgentFromRoom,
@@ -81,6 +81,12 @@ function getGlobalCliScript() {
}
function runUpdateInstall() {
try {
runNpm(['cache', 'clean', '--force'], { timeout: 2 * 60 * 1000 })
} catch (err) {
console.warn('[update] failed to clean npm cache, continuing update:', err)
}
return runNpm(['install', '-g', 'hermes-web-ui@latest'], { timeout: 10 * 60 * 1000 })
}
@@ -17,6 +17,15 @@ function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
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
}
// Create room
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) {
@@ -65,6 +74,61 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
ctx.body = { room, agents: addedAgents }
})
// 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({
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 }
})
// Get room detail and messages
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
if (!chatServer) {
@@ -218,6 +282,26 @@ groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
ctx.body = { success: true }
})
// 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) }
})
// Update room compression config
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => {
if (!chatServer) {
@@ -574,6 +574,14 @@ export class AgentClients {
}
}
resetRoomContext(roomId: string): void {
this._mentionQueue.delete(roomId)
this._processingRooms.delete(roomId)
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
}
/**
* Disconnect all agents in all rooms.
*/
@@ -233,6 +233,14 @@ class ChatStorage {
).run(msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp)
}
clearRoomContext(roomId: string): void {
const db = this.db()
if (!db) return
db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId)
db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0 WHERE id = ?').run(roomId)
}
pruneMessages(roomId: string, keep = 500): void {
const db = this.db()
if (!db) return
@@ -483,6 +491,18 @@ export class GroupChatServer {
return Array.from(this.rooms.keys())
}
clearRoomRuntimeState(roomId: string): void {
const roomTyping = this.typingState.get(roomId)
if (roomTyping) {
for (const entry of roomTyping.values()) clearTimeout(entry.timer)
this.typingState.delete(roomId)
}
this.contextStatusState.delete(roomId)
this.agentClients.resetRoomContext(roomId)
this.nsp.to(roomId).emit('room_cleared', { roomId, totalTokens: 0 })
this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens: 0 })
}
// ─── Restore Agents ─────────────────────────────────────────
/**