[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() {
|
function doUpdate() {
|
||||||
console.log(' ⬆ Updating hermes-web-ui...')
|
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',
|
stdio: 'inherit',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
env: getCurrentNodeEnv(),
|
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[] }> {
|
export async function listRooms(): Promise<{ rooms: RoomInfo[] }> {
|
||||||
return request('/api/hermes/group-chat/rooms')
|
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 }> {
|
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`, {
|
return request(`/api/hermes/group-chat/rooms/${roomId}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const profilesStore = useProfilesStore()
|
|||||||
|
|
||||||
const showSidebar = ref(window.innerWidth > 768)
|
const showSidebar = ref(window.innerWidth > 768)
|
||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
|
const showCloneModal = ref(false)
|
||||||
const showAddAgentModal = ref(false)
|
const showAddAgentModal = ref(false)
|
||||||
const showCompressionModal = ref(false)
|
const showCompressionModal = ref(false)
|
||||||
const compressionConfig = ref({ triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10 })
|
const compressionConfig = ref({ triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10 })
|
||||||
@@ -23,6 +24,9 @@ const isCompressing = ref(false)
|
|||||||
const selectedProfile = ref<string | null>(null)
|
const selectedProfile = ref<string | null>(null)
|
||||||
const agentName = ref('')
|
const agentName = ref('')
|
||||||
const agentDescription = ref('')
|
const agentDescription = ref('')
|
||||||
|
const cloneSourceRoomId = ref<string | null>(null)
|
||||||
|
const cloneRoomName = ref('')
|
||||||
|
const cloneInviteCode = ref('')
|
||||||
|
|
||||||
const profileOptions = computed(() =>
|
const profileOptions = computed(() =>
|
||||||
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
|
profilesStore.profiles.map(p => ({ label: p.name, value: p.name }))
|
||||||
@@ -48,6 +52,15 @@ function toggleSidebar() {
|
|||||||
showSidebar.value = !showSidebar.value
|
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 }) {
|
async function handleCreateRoom(name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }) {
|
||||||
try {
|
try {
|
||||||
store.setUserInfo(userName, description)
|
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) {
|
async function handleSelectRoom(roomId: string) {
|
||||||
try {
|
try {
|
||||||
await store.joinRoom(roomId)
|
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 v-if="room.inviteCode" class="room-code">{{ room.inviteCode }}</span>
|
||||||
<span class="room-tokens">{{ formatTokens(room.totalTokens || 0) }}</span>
|
<span class="room-tokens">{{ formatTokens(room.totalTokens || 0) }}</span>
|
||||||
</div>
|
</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)">
|
<NPopconfirm @positive-click="handleDeleteRoom(room.id)">
|
||||||
<template #trigger>
|
<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>
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -281,6 +339,16 @@ watch(() => store.sortedMessages.length, async () => {
|
|||||||
<button class="icon-btn" :title="t('groupChat.compressionConfig')" @click="handleOpenCompressionConfig">
|
<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>
|
<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>
|
</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">
|
<span v-if="store.members.length" class="member-count">
|
||||||
{{ store.members.length }} {{ t('groupChat.members') }}
|
{{ store.members.length }} {{ t('groupChat.members') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -370,6 +438,40 @@ watch(() => store.sortedMessages.length, async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 v-if="showCompressionModal" class="modal-backdrop" @click.self="showCompressionModal = false">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>{{ t('groupChat.compressionConfig') }}</h3>
|
<h3>{{ t('groupChat.compressionConfig') }}</h3>
|
||||||
@@ -597,7 +699,7 @@ export default defineComponent({ components: { CreateRoomForm } })
|
|||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-delete-btn {
|
.room-action-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -613,12 +715,17 @@ export default defineComponent({ components: { CreateRoomForm } })
|
|||||||
transition: opacity $transition-fast, color $transition-fast, background-color $transition-fast;
|
transition: opacity $transition-fast, color $transition-fast, background-color $transition-fast;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
color: $text-primary;
|
||||||
|
background-color: rgba(var(--accent-primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger:hover {
|
||||||
color: $error;
|
color: $error;
|
||||||
background-color: rgba(var(--error-rgb), 0.1);
|
background-color: rgba(var(--error-rgb), 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .room-delete-btn {
|
&:hover .room-action-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -875,6 +982,20 @@ export default defineComponent({ components: { CreateRoomForm } })
|
|||||||
margin-bottom: 16px;
|
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 {
|
.modal-actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||||
import GroupMessageItem from './GroupMessageItem.vue'
|
import GroupMessageItem from './GroupMessageItem.vue'
|
||||||
|
|
||||||
const store = useGroupChatStore()
|
const store = useGroupChatStore()
|
||||||
|
const { t } = useI18n()
|
||||||
const listRef = ref<HTMLDivElement>()
|
const listRef = ref<HTMLDivElement>()
|
||||||
const isNearBottom = ref(true)
|
const isNearBottom = ref(true)
|
||||||
|
|
||||||
@@ -35,10 +37,8 @@ defineExpose({ scrollToBottom })
|
|||||||
<template>
|
<template>
|
||||||
<div ref="listRef" class="message-list" @scroll="handleScroll">
|
<div ref="listRef" class="message-list" @scroll="handleScroll">
|
||||||
<div v-if="store.sortedMessages.length === 0" class="empty-state">
|
<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">
|
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<p>{{ t("chat.emptyState") }}</p>
|
||||||
</svg>
|
|
||||||
<p>No messages yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
<GroupMessageItem
|
<GroupMessageItem
|
||||||
v-for="msg in store.sortedMessages"
|
v-for="msg in store.sortedMessages"
|
||||||
@@ -61,6 +61,11 @@ defineExpose({ scrollToBottom })
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
background-color: $bg-card;
|
background-color: $bg-card;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -71,7 +76,12 @@ defineExpose({ scrollToBottom })
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
opacity: 0.4;
|
|
||||||
|
.empty-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -996,7 +996,12 @@ jobTriggered: 'Job ausgelost',
|
|||||||
members: 'Mitglieder',
|
members: 'Mitglieder',
|
||||||
roomCreated: 'Raum erstellt',
|
roomCreated: 'Raum erstellt',
|
||||||
roomDeleted: 'Raum gelöscht',
|
roomDeleted: 'Raum gelöscht',
|
||||||
|
roomCloned: 'Raum geklont',
|
||||||
|
cloneRoom: 'Raum klonen',
|
||||||
deleteRoomConfirm: 'Diesen Raum löschen?',
|
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',
|
you: 'Du',
|
||||||
joined: 'Raum beigetreten',
|
joined: 'Raum beigetreten',
|
||||||
joinFailed: 'Beitreten fehlgeschlagen',
|
joinFailed: 'Beitreten fehlgeschlagen',
|
||||||
|
|||||||
@@ -953,7 +953,12 @@ export default {
|
|||||||
members: 'members',
|
members: 'members',
|
||||||
roomCreated: 'Room created',
|
roomCreated: 'Room created',
|
||||||
roomDeleted: 'Room deleted',
|
roomDeleted: 'Room deleted',
|
||||||
|
roomCloned: 'Room cloned',
|
||||||
|
cloneRoom: 'Clone room',
|
||||||
deleteRoomConfirm: 'Delete this 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',
|
you: 'You',
|
||||||
joined: 'Joined room',
|
joined: 'Joined room',
|
||||||
joinFailed: 'Failed to join room',
|
joinFailed: 'Failed to join room',
|
||||||
|
|||||||
@@ -992,7 +992,12 @@ jobTriggered: 'Job ejecutado',
|
|||||||
members: 'Miembros',
|
members: 'Miembros',
|
||||||
roomCreated: 'Sala creada',
|
roomCreated: 'Sala creada',
|
||||||
roomDeleted: 'Sala eliminada',
|
roomDeleted: 'Sala eliminada',
|
||||||
|
roomCloned: 'Sala clonada',
|
||||||
|
cloneRoom: 'Clonar sala',
|
||||||
deleteRoomConfirm: '¿Eliminar esta 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ú',
|
you: 'Tú',
|
||||||
joined: 'Se unio a la sala',
|
joined: 'Se unio a la sala',
|
||||||
joinFailed: 'Error al unirse a la sala',
|
joinFailed: 'Error al unirse a la sala',
|
||||||
|
|||||||
@@ -991,7 +991,12 @@ jobTriggered: 'Job declenche',
|
|||||||
members: 'Membres',
|
members: 'Membres',
|
||||||
roomCreated: 'Salon cree',
|
roomCreated: 'Salon cree',
|
||||||
roomDeleted: 'Salon supprime',
|
roomDeleted: 'Salon supprime',
|
||||||
|
roomCloned: 'Salon clone',
|
||||||
|
cloneRoom: 'Cloner le salon',
|
||||||
deleteRoomConfirm: 'Supprimer ce 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',
|
you: 'Vous',
|
||||||
joined: 'Vous avez rejoint le salon',
|
joined: 'Vous avez rejoint le salon',
|
||||||
joinFailed: 'Echec de la connexion au salon',
|
joinFailed: 'Echec de la connexion au salon',
|
||||||
|
|||||||
@@ -992,7 +992,12 @@ export default {
|
|||||||
members: 'メンバー',
|
members: 'メンバー',
|
||||||
roomCreated: 'ルームが作成されました',
|
roomCreated: 'ルームが作成されました',
|
||||||
roomDeleted: 'ルームを削除しました',
|
roomDeleted: 'ルームを削除しました',
|
||||||
|
roomCloned: 'ルームを複製しました',
|
||||||
|
cloneRoom: 'ルームを複製',
|
||||||
deleteRoomConfirm: 'このルームを削除しますか?',
|
deleteRoomConfirm: 'このルームを削除しますか?',
|
||||||
|
clearContext: 'コンテキストを削除',
|
||||||
|
clearContextConfirm: 'このルームのコンテキストを削除しますか?メッセージと圧縮スナップショットは削除されますが、エージェントとメンバーは残ります。',
|
||||||
|
contextCleared: 'コンテキストを削除しました',
|
||||||
you: 'あなた',
|
you: 'あなた',
|
||||||
joined: 'ルームに参加しました',
|
joined: 'ルームに参加しました',
|
||||||
joinFailed: 'ルームへの参加に失敗しました',
|
joinFailed: 'ルームへの参加に失敗しました',
|
||||||
|
|||||||
@@ -992,7 +992,12 @@ export default {
|
|||||||
members: '멤버',
|
members: '멤버',
|
||||||
roomCreated: '방이 생성되었습니다',
|
roomCreated: '방이 생성되었습니다',
|
||||||
roomDeleted: '방이 삭제되었습니다',
|
roomDeleted: '방이 삭제되었습니다',
|
||||||
|
roomCloned: '방이 복제되었습니다',
|
||||||
|
cloneRoom: '방 복제',
|
||||||
deleteRoomConfirm: '이 방을 삭제하시겠습니까?',
|
deleteRoomConfirm: '이 방을 삭제하시겠습니까?',
|
||||||
|
clearContext: '컨텍스트 지우기',
|
||||||
|
clearContextConfirm: '이 방의 컨텍스트를 지우시겠습니까? 메시지와 압축 스냅샷은 삭제되고 에이전트와 멤버는 유지됩니다.',
|
||||||
|
contextCleared: '컨텍스트가 지워졌습니다',
|
||||||
you: '나',
|
you: '나',
|
||||||
joined: '방에 참여했습니다',
|
joined: '방에 참여했습니다',
|
||||||
joinFailed: '방 참여에 실패했습니다',
|
joinFailed: '방 참여에 실패했습니다',
|
||||||
|
|||||||
@@ -992,7 +992,12 @@ jobTriggered: 'Job acionado',
|
|||||||
members: 'Membros',
|
members: 'Membros',
|
||||||
roomCreated: 'Sala criada',
|
roomCreated: 'Sala criada',
|
||||||
roomDeleted: 'Sala excluída',
|
roomDeleted: 'Sala excluída',
|
||||||
|
roomCloned: 'Sala clonada',
|
||||||
|
cloneRoom: 'Clonar sala',
|
||||||
deleteRoomConfirm: 'Excluir esta 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ê',
|
you: 'Você',
|
||||||
joined: 'Entrou na sala',
|
joined: 'Entrou na sala',
|
||||||
joinFailed: 'Falha ao entrar na sala',
|
joinFailed: 'Falha ao entrar na sala',
|
||||||
|
|||||||
@@ -953,7 +953,12 @@ export default {
|
|||||||
members: '成員',
|
members: '成員',
|
||||||
roomCreated: '房間已建立',
|
roomCreated: '房間已建立',
|
||||||
roomDeleted: '房間已刪除',
|
roomDeleted: '房間已刪除',
|
||||||
|
roomCloned: '房間已複製',
|
||||||
|
cloneRoom: '複製房間',
|
||||||
deleteRoomConfirm: '確定刪除這個房間嗎?',
|
deleteRoomConfirm: '確定刪除這個房間嗎?',
|
||||||
|
clearContext: '清理上下文',
|
||||||
|
clearContextConfirm: '確定清理目前房間上下文嗎?訊息和壓縮快照會被刪除,智慧代理和成員會保留。',
|
||||||
|
contextCleared: '上下文已清理',
|
||||||
you: '你',
|
you: '你',
|
||||||
joined: '已加入房間',
|
joined: '已加入房間',
|
||||||
joinFailed: '加入房間失敗',
|
joinFailed: '加入房間失敗',
|
||||||
|
|||||||
@@ -955,7 +955,12 @@ export default {
|
|||||||
members: '成员',
|
members: '成员',
|
||||||
roomCreated: '房间已创建',
|
roomCreated: '房间已创建',
|
||||||
roomDeleted: '房间已删除',
|
roomDeleted: '房间已删除',
|
||||||
|
roomCloned: '房间已克隆',
|
||||||
|
cloneRoom: '克隆房间',
|
||||||
deleteRoomConfirm: '确定删除这个房间吗?',
|
deleteRoomConfirm: '确定删除这个房间吗?',
|
||||||
|
clearContext: '清理上下文',
|
||||||
|
clearContextConfirm: '确定清理当前房间上下文吗?消息和压缩快照会被删除,智能体和成员会保留。',
|
||||||
|
contextCleared: '上下文已清理',
|
||||||
you: '你',
|
you: '你',
|
||||||
joined: '已加入房间',
|
joined: '已加入房间',
|
||||||
joinFailed: '加入房间失败',
|
joinFailed: '加入房间失败',
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
addAgent,
|
addAgent,
|
||||||
listAgents,
|
listAgents,
|
||||||
removeAgent,
|
removeAgent,
|
||||||
|
cloneRoom as cloneRoomApi,
|
||||||
deleteRoom as deleteRoomApi,
|
deleteRoom as deleteRoomApi,
|
||||||
|
clearRoomContext,
|
||||||
} from '@/api/hermes/group-chat'
|
} from '@/api/hermes/group-chat'
|
||||||
|
|
||||||
export const useGroupChatStore = defineStore('groupChat', () => {
|
export const useGroupChatStore = defineStore('groupChat', () => {
|
||||||
@@ -139,6 +141,16 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
const room = rooms.value.find(r => r.id === data.roomId)
|
const room = rooms.value.find(r => r.id === data.roomId)
|
||||||
if (room) room.totalTokens = data.totalTokens
|
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() {
|
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 ─────────────────────────────────────
|
// ─── Agent Actions ─────────────────────────────────────
|
||||||
async function loadAgents(roomId: string) {
|
async function loadAgents(roomId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -358,6 +397,8 @@ export const useGroupChatStore = defineStore('groupChat', () => {
|
|||||||
createNewRoom,
|
createNewRoom,
|
||||||
joinByCode,
|
joinByCode,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
|
cloneRoom,
|
||||||
|
clearCurrentRoomContext,
|
||||||
loadAgents,
|
loadAgents,
|
||||||
addAgentToRoom,
|
addAgentToRoom,
|
||||||
removeAgentFromRoom,
|
removeAgentFromRoom,
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ function getGlobalCliScript() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runUpdateInstall() {
|
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 })
|
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)
|
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
|
// Create room
|
||||||
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
|
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
|
||||||
if (!chatServer) {
|
if (!chatServer) {
|
||||||
@@ -65,6 +74,61 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
|
|||||||
ctx.body = { room, agents: addedAgents }
|
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
|
// Get room detail and messages
|
||||||
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
|
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
|
||||||
if (!chatServer) {
|
if (!chatServer) {
|
||||||
@@ -218,6 +282,26 @@ groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
|
|||||||
ctx.body = { success: true }
|
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
|
// Update room compression config
|
||||||
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => {
|
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => {
|
||||||
if (!chatServer) {
|
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.
|
* 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)
|
).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 {
|
pruneMessages(roomId: string, keep = 500): void {
|
||||||
const db = this.db()
|
const db = this.db()
|
||||||
if (!db) return
|
if (!db) return
|
||||||
@@ -483,6 +491,18 @@ export class GroupChatServer {
|
|||||||
return Array.from(this.rooms.keys())
|
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 ─────────────────────────────────────────
|
// ─── Restore Agents ─────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user