[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
@@ -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,