[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:
+16
-1
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -992,7 +992,12 @@ export default {
|
||||
members: 'メンバー',
|
||||
roomCreated: 'ルームが作成されました',
|
||||
roomDeleted: 'ルームを削除しました',
|
||||
roomCloned: 'ルームを複製しました',
|
||||
cloneRoom: 'ルームを複製',
|
||||
deleteRoomConfirm: 'このルームを削除しますか?',
|
||||
clearContext: 'コンテキストを削除',
|
||||
clearContextConfirm: 'このルームのコンテキストを削除しますか?メッセージと圧縮スナップショットは削除されますが、エージェントとメンバーは残ります。',
|
||||
contextCleared: 'コンテキストを削除しました',
|
||||
you: 'あなた',
|
||||
joined: 'ルームに参加しました',
|
||||
joinFailed: 'ルームへの参加に失敗しました',
|
||||
|
||||
@@ -992,7 +992,12 @@ export default {
|
||||
members: '멤버',
|
||||
roomCreated: '방이 생성되었습니다',
|
||||
roomDeleted: '방이 삭제되었습니다',
|
||||
roomCloned: '방이 복제되었습니다',
|
||||
cloneRoom: '방 복제',
|
||||
deleteRoomConfirm: '이 방을 삭제하시겠습니까?',
|
||||
clearContext: '컨텍스트 지우기',
|
||||
clearContextConfirm: '이 방의 컨텍스트를 지우시겠습니까? 메시지와 압축 스냅샷은 삭제되고 에이전트와 멤버는 유지됩니다.',
|
||||
contextCleared: '컨텍스트가 지워졌습니다',
|
||||
you: '나',
|
||||
joined: '방에 참여했습니다',
|
||||
joinFailed: '방 참여에 실패했습니다',
|
||||
|
||||
@@ -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: '加入房間失敗',
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user