Files
hermes-ui/packages/client/src/stores/hermes/group-chat.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

1052 lines
41 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getActiveProfileName, getApiKey, getStoredUsername } from '@/api/client'
import { fetchCurrentUser } from '@/api/auth'
import { getDownloadUrl } from '@/api/hermes/download'
import { responseErrorMessage } from '@/utils/http-error'
import type { Attachment, ContentBlock } from './chat'
import {
connectGroupChat,
disconnectGroupChat,
getSocket,
getStoredUserId,
getStoredUserName,
type RoomInfo,
type RoomAgent,
type ChatMessage,
type MemberInfo,
createRoom,
listRooms,
getRoomDetail,
joinRoomByCode,
addAgent,
listAgents,
removeAgent,
cloneRoom as cloneRoomApi,
deleteRoom as deleteRoomApi,
clearRoomContext,
updateRoomWorkspace as updateRoomWorkspaceApi,
} from '@/api/hermes/group-chat'
type GroupChatSocket = ReturnType<typeof connectGroupChat>
async function uploadGroupFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
const formData = new FormData()
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const token = getApiKey()
const profileName = getActiveProfileName()
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
if (profileName) headers['X-Hermes-Profile'] = profileName
const res = await fetch('/upload', {
method: 'POST',
body: formData,
headers,
})
if (!res.ok) throw new Error(await responseErrorMessage(res, 'Upload failed'))
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
function buildGroupContentBlocks(content: string, attachments: Attachment[], files: { name: string; path: string }[]): ContentBlock[] {
const blocks: ContentBlock[] = []
if (content.trim()) blocks.push({ type: 'text', text: content.trim() })
for (let i = 0; i < files.length; i += 1) {
const file = files[i]
const attachment = attachments[i]
if (attachment?.type.startsWith('image/')) {
blocks.push({
type: 'image',
name: file.name,
path: file.path,
media_type: attachment.type,
})
} else {
blocks.push({
type: 'file',
name: file.name,
path: file.path,
media_type: attachment?.type,
})
}
}
return blocks
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
const STREAM_FINAL_CONTENT_RECOVERY_DELAY_MS = 300
export const GROUP_CHAT_MESSAGE_PAGE_SIZE = 150
export const GROUP_CHAT_MAX_DISPLAY_MESSAGES = 600
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
function hasText(value?: string | null): boolean {
return !!value?.trim()
}
function authenticatedGroupUserId(authUserId: number): string {
return `auth:${authUserId}`
}
function getStoredGroupUserName(): string {
return getStoredUserName()?.trim() || ''
}
function hasToolCalls(message: ChatMessage): boolean {
return !!message.tool_calls?.length
}
function needsFinalContentRecovery(message: ChatMessage): boolean {
return message.role === 'assistant' && !hasText(message.content) && hasText(message.reasoning) && !hasToolCalls(message)
}
function mergeFinalMessage(existing: ChatMessage | null, msg: ChatMessage): ChatMessage {
return {
...msg,
content: hasText(msg.content) ? msg.content : existing?.content || msg.content || '',
reasoning: hasText(msg.reasoning) ? msg.reasoning : existing?.reasoning ?? msg.reasoning ?? null,
reasoning_content: hasText(msg.reasoning_content) ? msg.reasoning_content : existing?.reasoning_content ?? msg.reasoning_content ?? null,
isStreaming: false,
firstSeenAt: existing?.firstSeenAt ?? msg.firstSeenAt,
attachments: existing?.attachments || msg.attachments,
}
}
export interface GroupPendingApproval {
roomId: string
agentName: string
approvalId: string
command: string
description: string
choices: Array<'once' | 'session' | 'always' | 'deny'>
allowPermanent: boolean
isMemoryWrite: boolean
requestedAt: number
}
export const useGroupChatStore = defineStore('groupChat', () => {
// ─── State ─────────────────────────────────────────────
const connected = ref(false)
const currentRoomId = ref<string | null>(null)
const rooms = ref<RoomInfo[]>([])
const messages = ref<ChatMessage[]>([])
const members = ref<MemberInfo[]>([])
const agents = ref<RoomAgent[]>([])
const roomName = ref('')
const isJoining = ref(false)
const error = ref<string | null>(null)
const typingUsers = ref<Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>>(new Map())
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
const autoPlaySpeechEnabled = ref(false)
const pendingApprovals = ref<Map<string, GroupPendingApproval>>(new Map())
const totalMessages = ref(0)
const loadedMessageCount = ref(0)
const hasMoreBefore = ref(false)
const isLoadingOlderMessages = ref(false)
const hasReachedMessageDisplayLimit = computed(() =>
hasMoreBefore.value && loadedMessageCount.value >= GROUP_CHAT_MAX_DISPLAY_MESSAGES,
)
const currentUserAvatar = ref('')
function resetMessagePaging() {
totalMessages.value = 0
loadedMessageCount.value = 0
hasMoreBefore.value = false
isLoadingOlderMessages.value = false
}
function applyMessagePaging(res: { messages: ChatMessage[]; total?: number; hasMore?: boolean }) {
loadedMessageCount.value = res.messages.length
totalMessages.value = res.total ?? res.messages.length
hasMoreBefore.value = res.hasMore ?? loadedMessageCount.value < totalMessages.value
}
function setAutoPlaySpeech(enabled: boolean) {
autoPlaySpeechEnabled.value = enabled
}
function playMessageSpeech(messageId: string, content: string) {
window.dispatchEvent(new CustomEvent('auto-play-speech', {
detail: { messageId, content },
}))
}
async function recoverMissingFinalContent(roomId: string, messageId: string) {
if (currentRoomId.value !== roomId) return
const idx = messages.value.findIndex(m => m.id === messageId)
if (idx < 0 || !needsFinalContentRecovery(messages.value[idx])) return
try {
const res = await getRoomDetail(roomId)
const recovered = res.messages.find(m => m.id === messageId)
if (!recovered || !hasText(recovered.content)) return
const currentIdx = messages.value.findIndex(m => m.id === messageId)
if (currentIdx < 0 || !needsFinalContentRecovery(messages.value[currentIdx])) return
messages.value[currentIdx] = mergeFinalMessage(messages.value[currentIdx], recovered)
messages.value = [...messages.value]
} catch {
// Keep the reasoning-only bubble visible; a later final message event can still merge it.
}
}
function scheduleMissingFinalContentRecovery(roomId: string, messageId: string) {
setTimeout(() => {
void recoverMissingFinalContent(roomId, messageId)
}, STREAM_FINAL_CONTENT_RECOVERY_DELAY_MS)
}
// Computed: returns first active status for backward compat
const contextStatus = computed(() => {
for (const [, status] of contextStatuses.value) {
return status
}
return null
})
const activePendingApproval = computed(() => {
if (!currentRoomId.value) return null
for (const approval of pendingApprovals.value.values()) {
if (approval.roomId === currentRoomId.value) return approval
}
return null
})
const userId = ref(getStoredUserId())
const userName = ref(getStoredGroupUserName() || getStoredUsername() || '')
function upsertRoom(room: RoomInfo | undefined | null) {
if (!room) return
const idx = rooms.value.findIndex(existing => existing.id === room.id)
if (idx >= 0) rooms.value[idx] = room
else rooms.value.push(room)
}
function applyRealtimeJoinState(res: any, options: { syncMessages?: boolean } = {}) {
members.value = res.members || []
if (res.agents) agents.value = res.agents
if (res.roomName) roomName.value = res.roomName
const currentMember = members.value.find(member => member.userId === userId.value)
if (currentMember?.name) userName.value = currentMember.name
if (currentMember?.avatar) currentUserAvatar.value = currentMember.avatar
if (options.syncMessages && Array.isArray(res.messages)) {
const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of res.messages) {
const existing = byId.get(message.id)
byId.set(message.id, existing ? { ...existing, ...message } : message)
}
messages.value = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp)
if (typeof res.total === 'number' || typeof res.hasMore === 'boolean') {
applyMessagePaging(res)
} else {
loadedMessageCount.value = Math.max(loadedMessageCount.value, messages.value.length)
totalMessages.value = Math.max(totalMessages.value, loadedMessageCount.value)
}
}
// Restore typing state from server. Replace the local transient map so
// a reconnect cannot leave stale typers from the pre-reconnect socket.
for (const entry of typingUsers.value.values()) clearTimeout(entry.timer)
typingUsers.value.clear()
if (res.typingUsers) {
for (const u of res.typingUsers) {
const timer = setTimeout(() => typingUsers.value.delete(u.userId), 5000)
typingUsers.value.set(u.userId, { name: u.userName, timer })
}
}
// Restore context statuses from server
if (res.contextStatuses) {
contextStatuses.value = new Map(
res.contextStatuses.map((s: any) => [s.agentName, s])
)
} else {
contextStatuses.value.clear()
}
}
async function waitForRealtimeSocket(socket: GroupChatSocket): Promise<void> {
if (socket.connected) return
await new Promise<void>((resolve, reject) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (timeout) clearTimeout(timeout)
socket.off?.('connect', onConnect)
socket.off?.('connect_error', onError)
}
const finish = (fn: () => void) => {
if (settled) return
settled = true
cleanup()
fn()
}
const onConnect = () => finish(resolve)
const onError = (err: Error) => finish(() => reject(err))
timeout = setTimeout(() => finish(() => reject(new Error('Group chat socket connection timed out'))), 30000)
socket.once('connect', onConnect)
socket.once('connect_error', onError)
})
}
async function ensureRealtimeSocket(): Promise<GroupChatSocket> {
let socket = getSocket()
if (socket) return socket
await connect()
socket = getSocket({ requireConnected: false })
if (!socket) throw new Error('Group chat socket not connected')
await waitForRealtimeSocket(socket)
const connectedSocket = getSocket()
if (!connectedSocket) throw new Error('Group chat socket not connected')
return connectedSocket
}
async function joinRealtimeRoom(roomId: string, options: { syncMessages?: boolean; inviteCode?: string } = {}) {
const socket = await ensureRealtimeSocket()
const storedName = getStoredGroupUserName()
await new Promise<void>((resolve) => {
socket.emit('join', {
roomId,
inviteCode: options.inviteCode,
name: storedName || undefined,
description: localStorage.getItem('gc_user_description') || undefined,
}, (res: any) => {
if (currentRoomId.value !== roomId) {
resolve()
return
}
if (!res?.error) {
applyRealtimeJoinState(res, options)
} else {
error.value = res.error
}
resolve()
})
})
}
// ─── Computed ───────────────────────────────────────────
const sortedMessages = computed(() => mapGroupMessages([...messages.value].sort((a, b) => (a.firstSeenAt ?? a.timestamp) - (b.firstSeenAt ?? b.timestamp))))
const memberNames = computed(() => {
return members.value.map(m => m.name)
})
const typingNames = computed(() => {
return Array.from(typingUsers.value.values()).map(u => u.name)
})
const typingText = computed(() => {
const names = typingNames.value
if (names.length === 0) return ''
if (names.length === 1) return `${names[0]} is typing...`
if (names.length === 2) return `${names[0]} and ${names[1]} are typing...`
return `${names[0]} and ${names.length - 1} others are typing...`
})
// ─── Connection ────────────────────────────────────────
async function connect() {
let authUserId: number | undefined
const connectionName = getStoredGroupUserName()
try {
const user = await fetchCurrentUser()
authUserId = user.id
userId.value = authenticatedGroupUserId(user.id)
if (!connectionName) userName.value = user.username
currentUserAvatar.value = user.avatar || ''
} catch { /* non-critical: avatar fallback handles missing id */ }
const socket = connectGroupChat({
userId: userId.value,
userName: connectionName || undefined,
authUserId,
})
console.log('[GroupChat] connecting...', { userId: userId.value, userName: userName.value })
socket.on('connect', () => {
console.log('[GroupChat] connected, socket id:', socket.id)
connected.value = true
error.value = null
const roomId = currentRoomId.value
if (roomId) {
void joinRealtimeRoom(roomId, { syncMessages: true }).catch((err: any) => {
error.value = err.message
})
}
})
socket.on('disconnect', (reason) => {
console.log('[GroupChat] disconnected:', reason)
connected.value = false
})
socket.on('connect_error', (err: Error) => {
console.error('[GroupChat] connect_error:', err.message)
error.value = err.message
connected.value = false
})
socket.on('message', (msg: ChatMessage) => {
if (msg.roomId === currentRoomId.value) {
const idx = messages.value.findIndex(m => m.id === msg.id)
const existing = idx >= 0 ? messages.value[idx] : null
const resolvedMsg = mergeFinalMessage(existing, msg)
if (idx >= 0) {
messages.value[idx] = resolvedMsg
messages.value = [...messages.value]
} else {
messages.value.push(resolvedMsg)
loadedMessageCount.value += 1
totalMessages.value = Math.max(totalMessages.value + 1, loadedMessageCount.value)
}
if (autoPlaySpeechEnabled.value && resolvedMsg.role === 'assistant' && resolvedMsg.content?.trim()) {
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
}
}
})
socket.on('message_stream_start', (msg: ChatMessage) => {
if (msg.roomId !== currentRoomId.value) return
messages.value = messages.value.filter(m => !(
m.roomId === msg.roomId &&
m.senderId === msg.senderId &&
m.id !== msg.id &&
m.isStreaming &&
!m.content?.trim() &&
!m.reasoning?.trim() &&
!m.tool_calls?.length
))
msg.isStreaming = true
msg.firstSeenAt = msg.firstSeenAt ?? msg.timestamp ?? Date.now()
const idx = messages.value.findIndex(m => m.id === msg.id)
if (idx >= 0) {
const existing = messages.value[idx]
if (!existing.isStreaming) return
messages.value[idx] = {
...existing,
...msg,
content: hasText(msg.content) ? msg.content : existing.content || '',
reasoning: hasText(msg.reasoning) ? msg.reasoning : existing.reasoning,
reasoning_content: hasText(msg.reasoning_content) ? msg.reasoning_content : existing.reasoning_content,
isStreaming: true,
}
messages.value = [...messages.value]
} else {
messages.value.push(msg)
loadedMessageCount.value += 1
totalMessages.value = Math.max(totalMessages.value + 1, loadedMessageCount.value)
}
})
socket.on('message_stream_delta', (data: { roomId: string; id: string; delta: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (idx < 0 || !messages.value[idx].isStreaming) return
messages.value[idx] = {
...messages.value[idx],
content: messages.value[idx].content + data.delta,
}
messages.value = [...messages.value]
})
socket.on('message_reasoning_delta', (data: { roomId: string; id: string; delta: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (idx < 0 || !messages.value[idx].isStreaming) return
messages.value[idx] = {
...messages.value[idx],
reasoning: (messages.value[idx].reasoning || '') + data.delta,
reasoning_content: (messages.value[idx].reasoning_content || '') + data.delta,
isStreaming: true,
}
messages.value = [...messages.value]
})
socket.on('message_stream_end', (data: { roomId: string; id: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (
idx >= 0 &&
!messages.value[idx].content?.trim() &&
!messages.value[idx].reasoning?.trim() &&
!messages.value[idx].tool_calls?.length
) {
messages.value.splice(idx, 1)
} else if (idx >= 0) {
messages.value[idx] = {
...messages.value[idx],
isStreaming: false,
}
messages.value = [...messages.value]
if (needsFinalContentRecovery(messages.value[idx])) {
scheduleMissingFinalContentRecovery(data.roomId, data.id)
}
}
})
socket.on('member_joined', (data: { roomId: string; members: MemberInfo[] }) => {
if (data.roomId === currentRoomId.value) {
members.value = data.members
}
})
socket.on('member_left', (data: { roomId: string; members: MemberInfo[] }) => {
if (data.roomId === currentRoomId.value) {
members.value = data.members
}
})
socket.on('typing', (data: { roomId: string; userId: string; userName: string }) => {
if (data.roomId === currentRoomId.value && !typingUsers.value.has(data.userId)) {
const timer = setTimeout(() => typingUsers.value.delete(data.userId), 5000)
typingUsers.value.set(data.userId, { name: data.userName, timer })
}
})
socket.on('stop_typing', (data: { roomId: string; userId: string }) => {
if (data.roomId === currentRoomId.value && typingUsers.value.has(data.userId)) {
const entry = typingUsers.value.get(data.userId)!
clearTimeout(entry.timer)
typingUsers.value.delete(data.userId)
}
})
socket.on('context_status', (data: { roomId: string; agentName: string; status: string }) => {
if (data.roomId === currentRoomId.value) {
if (data.status === 'ready') {
contextStatuses.value.delete(data.agentName)
messages.value = messages.value
.map(m => (
m.senderName === data.agentName && m.isStreaming
? { ...m, isStreaming: false }
: m
))
.filter(m => !(
m.senderName === data.agentName &&
!m.content?.trim() &&
!m.reasoning?.trim() &&
!m.tool_calls?.length
))
} else {
contextStatuses.value.set(data.agentName, { agentName: data.agentName, status: data.status })
}
// Trigger reactivity
contextStatuses.value = new Map(contextStatuses.value)
}
})
socket.on('approval.requested', (data: { roomId: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => {
if (!data.approval_id) return
const description = data.description || ''
const normalizedDescription = description.trim().toLowerCase().replace(/\s+/g, ' ')
const isMemoryWrite = !Boolean(data.allow_permanent) && (
normalizedDescription === 'save to memory' ||
normalizedDescription.startsWith('save to memory:') ||
normalizedDescription.startsWith('save to memory?')
)
const choices = (Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'])
.filter((choice): choice is GroupPendingApproval['choices'][number] =>
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
pendingApprovals.value.set(data.approval_id, {
roomId: data.roomId,
agentName: data.agentName || '',
approvalId: data.approval_id,
command: data.command || '',
description,
choices: isMemoryWrite ? ['once', 'deny'] : choices.length ? choices : ['once', 'session', 'deny'],
allowPermanent: Boolean(data.allow_permanent),
isMemoryWrite,
requestedAt: Date.now(),
})
pendingApprovals.value = new Map(pendingApprovals.value)
})
socket.on('approval.resolved', (data: { approval_id?: string }) => {
if (!data.approval_id) return
pendingApprovals.value.delete(data.approval_id)
pendingApprovals.value = new Map(pendingApprovals.value)
})
socket.on('room_updated', (data: { roomId: string; totalTokens: number }) => {
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 = []
resetMessagePaging()
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
}
})
}
function disconnect() {
disconnectGroupChat()
connected.value = false
currentRoomId.value = null
messages.value = []
resetMessagePaging()
members.value = []
agents.value = []
roomName.value = ''
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
}
function setUserInfo(name: string, description: string) {
userName.value = name
localStorage.setItem('gc_user_name', name)
localStorage.setItem('gc_user_description', description)
}
// ─── Room Actions ──────────────────────────────────────
async function joinRoom(roomId: string) {
isJoining.value = true
error.value = null
try {
const res = await getRoomDetail(roomId)
upsertRoom(res.room)
currentRoomId.value = res.room.id
roomName.value = res.room.name
messages.value = res.messages
applyMessagePaging(res)
agents.value = res.agents
members.value = res.members || []
} catch (err: any) {
error.value = err.message
throw err
} finally {
isJoining.value = false
}
// Join via socket for real-time updates. Reconnect uses the same path
// so the browser socket is a room member before the next send.
await joinRealtimeRoom(roomId)
}
async function loadOlderMessages(): Promise<boolean> {
const roomId = currentRoomId.value
if (!roomId || isLoadingOlderMessages.value || !hasMoreBefore.value) return false
const offset = loadedMessageCount.value
if (offset >= GROUP_CHAT_MAX_DISPLAY_MESSAGES) return false
isLoadingOlderMessages.value = true
try {
const limit = Math.min(GROUP_CHAT_MESSAGE_PAGE_SIZE, GROUP_CHAT_MAX_DISPLAY_MESSAGES - offset)
const res = await getRoomDetail(roomId, { offset, limit })
const existingIds = new Set(messages.value.map(message => message.id))
const olderMessages = res.messages.filter(message => !existingIds.has(message.id))
messages.value = [...olderMessages, ...messages.value]
loadedMessageCount.value = offset + res.messages.length
totalMessages.value = res.total ?? totalMessages.value
hasMoreBefore.value = res.hasMore ?? loadedMessageCount.value < totalMessages.value
return olderMessages.length > 0
} catch (err: any) {
error.value = err.message
return false
} finally {
isLoadingOlderMessages.value = false
}
}
async function sendMessage(content: string, attachments?: Attachment[]) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
emitStopTyping()
const messageId = uid()
let finalContent: string | ContentBlock[] = content.trim()
if (attachments?.length) {
const uploaded = await uploadGroupFiles(attachments)
finalContent = buildGroupContentBlocks(content, attachments, uploaded)
const urlMap = new Map(uploaded.map(f => {
return [f.name, getDownloadUrl(normalizeLocalFilePath(f.path), f.name)]
}))
messages.value.push({
id: messageId,
roomId: currentRoomId.value,
senderId: userId.value,
senderName: userName.value || 'You',
content: JSON.stringify(finalContent),
timestamp: Date.now(),
role: 'user',
attachments: attachments.map(att => ({ ...att, url: urlMap.get(att.name) || att.url, file: undefined })),
})
loadedMessageCount.value += 1
totalMessages.value = Math.max(totalMessages.value + 1, loadedMessageCount.value)
}
return new Promise<void>((resolve, reject) => {
socket!.emit('message', { roomId: currentRoomId.value, id: messageId, content: finalContent }, (res: { id?: string; error?: string }) => {
if (res.error) {
messages.value = messages.value.filter(m => m.id !== messageId)
reject(new Error(res.error))
return
}
resolve()
})
})
}
async function loadRooms() {
try {
const res = await listRooms()
rooms.value = res.rooms
} catch (err: any) {
error.value = err.message
}
}
async function createNewRoom(name: string, inviteCode: string, agentList?: { profile: string; name?: string; description?: string; invited?: boolean }[], compression?: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }, workspace?: string) {
try {
const res = await createRoom({
name,
inviteCode,
agents: agentList,
compression: compression || { triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10 },
workspace: workspace || undefined,
})
upsertRoom(res.room)
return res
} catch (err: any) {
error.value = err.message
throw err
}
}
async function joinByCode(code: string) {
try {
const res = await joinRoomByCode(code)
upsertRoom(res.room)
await ensureRealtimeSocket()
currentRoomId.value = res.room.id
roomName.value = res.room.name
await joinRealtimeRoom(res.room.id, { syncMessages: true, inviteCode: code })
await joinRoom(res.room.id)
return res.room
} catch (err: any) {
error.value = err.message
throw err
}
}
async function deleteRoom(roomId: string) {
try {
await deleteRoomApi(roomId)
rooms.value = rooms.value.filter(r => r.id !== roomId)
if (currentRoomId.value === roomId) {
currentRoomId.value = null
messages.value = []
resetMessagePaging()
members.value = []
agents.value = []
roomName.value = ''
}
} catch (err: any) {
error.value = err.message
throw err
}
}
async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }) {
try {
const res = await cloneRoomApi(roomId, data)
upsertRoom(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 = []
resetMessagePaging()
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
}
}
async function setRoomWorkspace(roomId: string, workspace: string) {
try {
const res = await updateRoomWorkspaceApi(roomId, workspace)
if (res.room) {
upsertRoom(res.room)
if (currentRoomId.value === roomId) roomName.value = res.room.name
}
return res.room
} catch (err: any) {
error.value = err.message
throw err
}
}
// ─── Agent Actions ─────────────────────────────────────
async function loadAgents(roomId: string) {
try {
const res = await listAgents(roomId)
agents.value = res.agents
} catch { /* ignore */ }
}
async function addAgentToRoom(roomId: string, data: { profile: string; name?: string; description?: string; invited?: boolean }) {
try {
const res = await addAgent(roomId, data)
agents.value.push(res.agent)
return res.agent
} catch (err: any) {
error.value = err.message
throw err
}
}
async function removeAgentFromRoom(roomId: string, agentId: string) {
try {
const res = await removeAgent(roomId, agentId)
agents.value = res.agents ?? agents.value.filter(a => a.id !== agentId && a.agentId !== agentId)
if (res.members) members.value = res.members
} catch (err: any) {
error.value = err.message
throw err
}
}
// ─── Typing ────────────────────────────────────────────
let _typingTimer: ReturnType<typeof setTimeout> | null = null
function emitTyping() {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
socket.emit('typing', { roomId: currentRoomId.value })
if (_typingTimer) clearTimeout(_typingTimer)
_typingTimer = setTimeout(() => emitStopTyping(), 4000)
}
function emitStopTyping() {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
socket.emit('stop_typing', { roomId: currentRoomId.value })
if (_typingTimer) { clearTimeout(_typingTimer); _typingTimer = null }
}
async function interruptAgent(agentName: string) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
await new Promise<void>((resolve, reject) => {
socket.emit('interrupt_agent', { roomId: currentRoomId.value, agentName }, (res: any) => {
if (res?.error) reject(new Error(res.error))
else resolve()
})
})
}
async function respondApproval(choice: GroupPendingApproval['choices'][number]) {
const socket = getSocket()
const pending = activePendingApproval.value
if (!socket || !pending) return
await new Promise<void>((resolve, reject) => {
socket.emit('approval.respond', {
roomId: pending.roomId,
approval_id: pending.approvalId,
choice,
}, (res: any) => {
if (res?.error) reject(new Error(res.error))
else resolve()
})
})
pendingApprovals.value.delete(pending.approvalId)
pendingApprovals.value = new Map(pendingApprovals.value)
}
return {
// State
connected,
currentRoomId,
rooms,
messages,
members,
agents,
roomName,
isJoining,
error,
contextStatus,
contextStatuses,
pendingApprovals,
activePendingApproval,
autoPlaySpeechEnabled,
totalMessages,
loadedMessageCount,
hasMoreBefore,
isLoadingOlderMessages,
hasReachedMessageDisplayLimit,
userId,
userName,
currentUserAvatar,
// Computed
sortedMessages,
memberNames,
typingNames,
typingText,
// Actions
connect,
disconnect,
setUserInfo,
setAutoPlaySpeech,
joinRoom,
loadOlderMessages,
sendMessage,
loadRooms,
emitTyping,
emitStopTyping,
interruptAgent,
respondApproval,
createNewRoom,
joinByCode,
deleteRoom,
cloneRoom,
clearCurrentRoomContext,
setRoomWorkspace,
loadAgents,
addAgentToRoom,
removeAgentFromRoom,
}
})
function hasRuntimeToolPayload(value: unknown): boolean {
return value !== null && value !== undefined && value !== ''
}
function runtimeToolPayloadOrUndefined(value: unknown): unknown | undefined {
return hasRuntimeToolPayload(value) ? value : undefined
}
function runtimePayloadText(value: unknown): string {
if (!hasRuntimeToolPayload(value)) return ''
if (typeof value === 'string') return value
try {
const serialized = JSON.stringify(value)
if (serialized !== undefined) return serialized
} catch {
// Fall through to String(value) for non-serializable runtime payloads.
}
return String(value)
}
function parseWorkspaceDiffPayload(value: unknown): unknown {
const text = runtimePayloadText(value)
if (!text) return undefined
try {
const parsed = JSON.parse(text)
return parsed?.kind === 'workspace_diff' ? parsed : value
} catch {
return value
}
}
function mapGroupMessages(msgs: ChatMessage[]): ChatMessage[] {
const toolNameMap = new Map<string, string>()
const toolArgsMap = new Map<string, unknown>()
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.tool_calls?.length) {
for (const tc of msg.tool_calls) {
if (!tc?.id) continue
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
if (hasRuntimeToolPayload(tc.function?.arguments)) toolArgsMap.set(tc.id, tc.function.arguments)
}
}
}
const result: ChatMessage[] = []
for (const msg of msgs) {
if (
msg.role !== 'tool' &&
!msg.tool_calls?.length &&
!runtimePayloadText((msg as any).content).trim() &&
!msg.reasoning?.trim() &&
(!msg.isStreaming || msg.finish_reason === 'streaming')
) {
continue
}
if (msg.role === 'assistant' && msg.tool_calls?.length && !runtimePayloadText((msg as any).content).trim()) {
for (const tc of msg.tool_calls) {
result.push({
...msg,
id: `${msg.id}_${tc.id}`,
role: 'tool',
content: '',
toolName: tc.function?.name || undefined,
toolCallId: tc.id,
toolArgs: runtimeToolPayloadOrUndefined(tc.function?.arguments),
toolStatus: 'running',
})
}
continue
}
if (msg.role === 'tool') {
const tcId = msg.tool_call_id || ''
const toolName = msg.tool_name || toolNameMap.get(tcId) || undefined
const toolArgs = toolArgsMap.has(tcId) ? toolArgsMap.get(tcId) : undefined
let preview = ''
const toolResult = toolName === 'workspace_diff'
? parseWorkspaceDiffPayload((msg as any).content)
: runtimeToolPayloadOrUndefined((msg as any).content)
const contentText = runtimePayloadText((msg as any).content)
if (contentText) {
try {
const parsed = typeof (msg as any).content === 'string'
? JSON.parse(contentText)
: (msg as any).content
preview = parsed?.url || parsed?.title || parsed?.preview || parsed?.summary || ''
} catch {
preview = contentText.slice(0, 80)
}
}
const placeholderIdx = result.findIndex(
m => m.role === 'tool' && m.toolCallId === tcId && !m.toolResult
)
const merged: ChatMessage = {
...msg,
id: placeholderIdx !== -1 ? result[placeholderIdx].id : msg.id,
senderId: placeholderIdx !== -1 ? result[placeholderIdx].senderId : msg.senderId,
senderName: placeholderIdx !== -1 ? result[placeholderIdx].senderName : msg.senderName,
timestamp: placeholderIdx !== -1 ? result[placeholderIdx].timestamp : msg.timestamp,
role: 'tool',
content: '',
toolName: toolName || (placeholderIdx !== -1 ? result[placeholderIdx].toolName : undefined),
toolCallId: tcId || undefined,
toolArgs: toolArgs !== undefined ? toolArgs : (placeholderIdx !== -1 ? result[placeholderIdx].toolArgs : undefined),
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
toolResult,
toolStatus: 'done',
}
if (placeholderIdx !== -1) result[placeholderIdx] = merged
else result.push(merged)
continue
}
result.push(msg)
}
return result
}