feat: group chat session lifecycle, typing recovery, mention highlighting (#186)
* feat: restore group chat system with Socket.IO and SQLite persistence - GroupChatServer: Socket.IO server with room management, message history, typing indicators - SQLite storage for rooms, messages, and agent configuration - AgentClients: manages AI agent connections via socket.io-client, forwards @mentions to Hermes gateway - REST API: room CRUD, agent management, invite codes - Agent auto-restoration on server restart - Tests for all REST endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add context-engine design document for group chat compression Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle special-character session search * fix: keep unicode dotted session search on quoted FTS path * feat: add context engine and group chat frontend UI - Context engine: three-zone compression (head/tail/summary) with LLM summarization, incremental updates, TTL cache, and graceful degradation - Frontend: group chat page with Socket.IO client, room sidebar, message list, agent/member display, create/join-by-code modals - Integration: wire context engine into agent-clients before /v1/runs - Refactor ChatStorage to use global DB (getDb/ensureTable) with gc_ prefix - Add i18n keys for group chat to all 8 locales - Add sidebar nav entry and router for group chat page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove leftover main branch code from merge conflict resolution The `isNumericQuery`, `hasUnsafeChars`, and `runLikeContentSearch` functions no longer exist — they were replaced by HEAD's `shouldUseLiteralContentSearch` and `runLiteralContentSearch`. This dead code block caused a TypeScript compile error after the merge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: install missing socket.io dep and type ack params Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: enable WebSocket proxy and fix socket.io transport for group chat - Add ws: true to Vite proxy config so WebSocket upgrade requests are forwarded to the backend - Allow both polling and websocket transports on server and client (polling as fallback when WebSocket upgrade fails through proxy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: separate socket.io path from REST routes for group chat socket.io was mounted at /api/hermes/group-chat which intercepted all REST requests to /api/hermes/group-chat/rooms etc, returning "Transport unknown". Changed socket.io path to /api/hermes/group-chat/ws to avoid conflicts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: improve group chat UI, agent management, and socket.io reliability - Redesign GroupChatPanel with Naive UI, stacked agent avatars, and popover management - Match GroupChatInput style with single chat input, add IME composition handling - Add agent add/remove per room with profile selection and duplicate prevention - Use @multiavatar for SVG avatar generation with caching - Decouple joinRoom from socket.io, use REST API for data loading - Switch socket.io to default path with /group-chat namespace to avoid proxy conflicts - Restore agent connections after server is listening - Add getRoomDetail REST endpoint and duplicate agent prevention (409) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: server-side @mention routing with context compression status and queue - Move @mention detection from agent socket listeners to server-side processMentions() - Add per-room processing lock to block mention dispatch during compression - Queue mentions during processing, drain only the latest when ready - Emit context_status events (compressing/replying/ready) to room via Socket.IO - Frontend displays compression status indicator above input - Token-based compression trigger (100k threshold) with CJK-aware estimation - Fix compressor type errors (countTokens parameter type) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: improve group chat profile handling and session sync Refine group chat room/session behavior with per-room compression controls, sidebar updates, and better stale session cleanup so multi-profile group chat state stays consistent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: group chat improvements — session lifecycle, typing recovery, mention highlighting - Fix cross-profile session deletion with deferred delete queue - Move saveSessionProfile to after gateway response confirmation - Replace all console.log with logger in group-chat modules - Add server-side typing/context_status state tracking for room rejoin - Fix @ mention popup position to follow cursor - Add @ mention highlighting (blue) in chat message content - Fix mention regex to match all occurrences after HTML tags - Enable esbuild minify and treeShaking - Move @multiavatar/multiavatar to devDependencies - Add i18n keys for group chat features - Update tests for new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version to 0.4.5 and move @multiavatar to devDependencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
This commit is contained in:
@@ -492,7 +492,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
// that was just created and whose first run is still in-flight. Without
|
||||
// this, refreshing mid-run would wipe the session and fall back to
|
||||
// sessions[0], which is exactly what the user reported.
|
||||
const localOnly = sessions.value.filter(s => !freshIds.has(s.id))
|
||||
// Sessions without an active in-flight run are considered deleted and
|
||||
// cleaned up along with their cached messages.
|
||||
const localOnly = sessions.value.filter(s => {
|
||||
if (freshIds.has(s.id)) return false
|
||||
if (readInFlight(s.id)) return true
|
||||
// Session no longer exists on server and no active run — clean up cache
|
||||
removeItemWithLegacy(msgsCacheKey(s.id), legacyMsgsCacheKey(s.id))
|
||||
removeItemWithLegacy(inFlightKey(s.id), legacyInFlightKey(s.id))
|
||||
return false
|
||||
})
|
||||
sessions.value = [...localOnly, ...fresh]
|
||||
persistSessionsList()
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
connectGroupChat,
|
||||
disconnectGroupChat,
|
||||
getSocket,
|
||||
getStoredUserId,
|
||||
getStoredUserName,
|
||||
type RoomInfo,
|
||||
type RoomAgent,
|
||||
type ChatMessage,
|
||||
type MemberInfo,
|
||||
createRoom,
|
||||
listRooms,
|
||||
getRoomDetail,
|
||||
joinRoomByCode,
|
||||
addAgent,
|
||||
listAgents,
|
||||
removeAgent,
|
||||
deleteRoom as deleteRoomApi,
|
||||
} from '@/api/hermes/group-chat'
|
||||
|
||||
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())
|
||||
|
||||
// Computed: returns first active status for backward compat
|
||||
const contextStatus = computed(() => {
|
||||
for (const [, status] of contextStatuses.value) {
|
||||
return status
|
||||
}
|
||||
return null
|
||||
})
|
||||
const userId = ref(getStoredUserId())
|
||||
const userName = ref(getStoredUserName() || '')
|
||||
|
||||
// ─── Computed ───────────────────────────────────────────
|
||||
const sortedMessages = computed(() => {
|
||||
return [...messages.value].sort((a, b) => a.timestamp - 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 ────────────────────────────────────────
|
||||
function connect() {
|
||||
const socket = connectGroupChat({
|
||||
userId: userId.value,
|
||||
userName: userName.value || undefined,
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
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) {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
} else {
|
||||
contextStatuses.value.set(data.agentName, { agentName: data.agentName, status: data.status })
|
||||
}
|
||||
// Trigger reactivity
|
||||
contextStatuses.value = new Map(contextStatuses.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
|
||||
})
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
disconnectGroupChat()
|
||||
connected.value = false
|
||||
currentRoomId.value = null
|
||||
messages.value = []
|
||||
members.value = []
|
||||
agents.value = []
|
||||
roomName.value = ''
|
||||
typingUsers.value.clear()
|
||||
contextStatuses.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)
|
||||
currentRoomId.value = res.room.id
|
||||
roomName.value = res.room.name
|
||||
messages.value = res.messages
|
||||
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
|
||||
const socket = getSocket()
|
||||
if (socket) {
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.emit('join', { roomId, name: userName.value || undefined }, (res: any) => {
|
||||
if (!res?.error) {
|
||||
members.value = res.members || []
|
||||
if (res.agents) agents.value = res.agents
|
||||
|
||||
// Restore typing state from server
|
||||
if (res.typingUsers) {
|
||||
for (const u of res.typingUsers) {
|
||||
if (!typingUsers.value.has(u.userId)) {
|
||||
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])
|
||||
)
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content: string) {
|
||||
const socket = getSocket()
|
||||
if (!socket || !currentRoomId.value) return
|
||||
emitStopTyping()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket!.emit('message', { roomId: currentRoomId.value, content }, (res: { id?: string; error?: string }) => {
|
||||
if (res.error) {
|
||||
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 }) {
|
||||
try {
|
||||
const res = await createRoom({
|
||||
name,
|
||||
inviteCode,
|
||||
agents: agentList,
|
||||
compression: compression || { triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 20 },
|
||||
})
|
||||
rooms.value.push(res.room)
|
||||
return res
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function joinByCode(code: string) {
|
||||
try {
|
||||
const res = await joinRoomByCode(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 = []
|
||||
members.value = []
|
||||
agents.value = []
|
||||
roomName.value = ''
|
||||
}
|
||||
} 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 {
|
||||
await removeAgent(roomId, agentId)
|
||||
agents.value = agents.value.filter(a => a.id !== agentId)
|
||||
} 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 }
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
connected,
|
||||
currentRoomId,
|
||||
rooms,
|
||||
messages,
|
||||
members,
|
||||
agents,
|
||||
roomName,
|
||||
isJoining,
|
||||
error,
|
||||
contextStatus,
|
||||
contextStatuses,
|
||||
userId,
|
||||
userName,
|
||||
// Computed
|
||||
sortedMessages,
|
||||
memberNames,
|
||||
typingNames,
|
||||
typingText,
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
setUserInfo,
|
||||
joinRoom,
|
||||
sendMessage,
|
||||
loadRooms,
|
||||
emitTyping,
|
||||
emitStopTyping,
|
||||
createNewRoom,
|
||||
joinByCode,
|
||||
deleteRoom,
|
||||
loadAgents,
|
||||
addAgentToRoom,
|
||||
removeAgentFromRoom,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user