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 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 = {} 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(null) const rooms = ref([]) const messages = ref([]) const members = ref([]) const agents = ref([]) const roomName = ref('') const isJoining = ref(false) const error = ref(null) const typingUsers = ref }>>(new Map()) const contextStatuses = ref>(new Map()) const autoPlaySpeechEnabled = ref(false) const pendingApprovals = ref>(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 { if (socket.connected) return await new Promise((resolve, reject) => { let settled = false let timeout: ReturnType | 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 { 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((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 { 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((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 | 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((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((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() const toolArgsMap = new Map() 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 }