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:
ekko
2026-04-24 20:41:14 +08:00
committed by GitHub
parent 82965ae6e2
commit ba72264542
47 changed files with 7590 additions and 141 deletions
+362 -67
View File
@@ -6,8 +6,8 @@ const contentAllMock = vi.fn()
const likeAllMock = vi.fn()
const prepareMock = vi.fn((sql: string) => {
if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock })
if (sql.includes('m.content LIKE ?')) return ({ all: likeAllMock })
if (sql.includes("LOWER(COALESCE(base.title, '')) LIKE ?")) return ({ all: titleAllMock })
if (sql.includes('JOIN messages m') && sql.includes('LIKE')) return ({ all: likeAllMock })
if (sql.includes('base.title') && sql.includes('LIKE')) return ({ all: titleAllMock })
return ({ all: allMock })
})
const closeMock = vi.fn()
@@ -231,25 +231,25 @@ describe('session DB summaries', () => {
expect(rows[1].snippet).toContain('docker')
})
it('falls back to LIKE search when messages_fts is missing for numeric queries', async () => {
it('falls back to literal content search for punctuation-only queries instead of unsafe FTS', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
throw new Error('fts5: syntax error near "."')
})
likeAllMock.mockReturnValue([
{
id: 'numeric-1',
id: 'dot-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002800,
started_at: 1710004000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
@@ -257,42 +257,39 @@ describe('session DB summaries', () => {
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'numeric preview',
last_active: 1710002805,
matched_message_id: 9,
snippet: 'ticket 12345',
preview: 'punctuation preview',
last_active: 1710004001,
matched_message_id: 21,
snippet: 'value.with.dot',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
const rows = await mod.searchSessionSummaries('.', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('123', '%123%')
expect(contentAllMock).not.toHaveBeenCalled()
expect(likeAllMock).toHaveBeenCalled()
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('numeric-1')
expect(rows[0].snippet).toContain('123')
expect(rows[0].id).toBe('dot-1')
})
it('keeps the source filter when messages_fts is missing for numeric queries', async () => {
it('keeps safe dotted queries on the FTS path', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
likeAllMock.mockReturnValue([
contentAllMock.mockReturnValue([
{
id: 'numeric-telegram-1',
source: 'telegram',
id: 'node-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002850,
title: 'Node.js notes',
started_at: 1710004500,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
@@ -300,38 +297,38 @@ describe('session DB summaries', () => {
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'telegram numeric preview',
last_active: 1710002855,
matched_message_id: 12,
snippet: 'telegram 123 body',
rank: 0,
preview: 'dotted preview',
last_active: 1710004501,
matched_message_id: 22,
snippet: '>>>node.js<<< runtime',
rank: 0.2,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', 'telegram', 10)
const rows = await mod.searchSessionSummaries('node.js', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('telegram', '123', '%123%')
expect(contentAllMock).toHaveBeenCalled()
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(1)
expect(rows[0].source).toBe('telegram')
expect(rows[0].id).toBe('numeric-telegram-1')
expect(rows[0].id).toBe('node-1')
})
it('preserves title matches when messages_fts is missing for numeric queries', async () => {
it('keeps explicit wildcard dotted queries on the FTS path with valid syntax', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-123',
id: 'node-wildcard-title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Issue 123',
started_at: 1710002900,
title: 'Node.js wildcard notes',
started_at: 1710004590,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
@@ -339,30 +336,27 @@ describe('session DB summaries', () => {
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title numeric preview',
last_active: 1710002910,
preview: 'wildcard title preview',
last_active: 1710004595,
matched_message_id: null,
snippet: 'Issue 123',
snippet: 'Node.js wildcard notes',
rank: 0,
},
])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
likeAllMock.mockReturnValue([
contentAllMock.mockReturnValue([
{
id: 'content-123',
id: 'node-wildcard-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002890,
title: 'Node.js wildcard notes',
started_at: 1710004600,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
@@ -370,26 +364,243 @@ describe('session DB summaries', () => {
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'content numeric preview',
last_active: 1710002895,
matched_message_id: 10,
snippet: 'content 123 body',
preview: 'wildcard dotted preview',
last_active: 1710004601,
matched_message_id: 24,
snippet: '>>>node.js<<< runtime',
rank: 0.15,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('node.js*', undefined, 10)
expect(titleAllMock).toHaveBeenCalledWith('%node.js%', 10)
expect(contentAllMock).toHaveBeenCalledWith('"node.js"*', 40)
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('node-wildcard-title-1')
expect(rows[1].id).toBe('node-wildcard-1')
})
it('keeps quoted wildcard dotted queries on the FTS path with valid syntax', async () => {
titleAllMock.mockReturnValue([
{
id: 'node-quoted-title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Quoted Node.js wildcard notes',
started_at: 1710004640,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'quoted title preview',
last_active: 1710004645,
matched_message_id: null,
snippet: 'Quoted Node.js wildcard notes',
rank: 0,
},
])
contentAllMock.mockReturnValue([
{
id: 'node-quoted-wildcard-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Quoted Node.js wildcard notes',
started_at: 1710004650,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'quoted wildcard dotted preview',
last_active: 1710004651,
matched_message_id: 25,
snippet: '>>>node.js<<< runtime',
rank: 0.12,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('"node.js"*', undefined, 10)
expect(titleAllMock).toHaveBeenCalledWith('%node.js%', 10)
expect(contentAllMock).toHaveBeenCalledWith('"node.js"*', 40)
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('node-quoted-title-1')
expect(rows[1].id).toBe('node-quoted-wildcard-1')
})
it('keeps non-ASCII dotted queries on the safe quoted FTS path', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockReturnValue([
{
id: 'unicode-dot-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'naïve.js note',
started_at: 1710004700,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'unicode dotted preview',
last_active: 1710004701,
matched_message_id: 23,
snippet: 'naïve.js runtime',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
const rows = await mod.searchSessionSummaries('naïve.js', undefined, 10)
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('title-123')
expect(rows[0].matched_message_id).toBeNull()
expect(rows[1].id).toBe('content-123')
expect(rows[1].matched_message_id).toBe(10)
expect(contentAllMock).toHaveBeenCalledWith('"naïve.js"', 40)
expect(likeAllMock).not.toHaveBeenCalled()
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('unicode-dot-1')
})
it('falls back to LIKE search for CJK queries', async () => {
it('escapes LIKE wildcards for literal special-character searches', async () => {
titleAllMock.mockReturnValue([])
likeAllMock.mockReturnValue([
{
id: 'percent-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '100% reproducible',
started_at: 1710005000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'literal percent preview',
last_active: 1710005001,
matched_message_id: 31,
snippet: '100% reproducible',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('100%', undefined, 10)
expect(titleAllMock).toHaveBeenCalledWith('%100\\%%', 10)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('percent-1')
})
it('uses literal search for CJK queries even when FTS returns no rows', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockReturnValue([])
likeAllMock.mockReturnValue([
{
id: 'cjk-literal-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002980,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '中文内容预览',
last_active: 1710002985,
matched_message_id: 10,
snippet: '这里也有记忆断裂',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
expect(contentAllMock).not.toHaveBeenCalled()
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%', 40)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('cjk-literal-1')
})
it('falls back to LIKE search for CJK queries while preserving title matches', async () => {
titleAllMock.mockReturnValue([
{
id: 'cjk-title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '记忆断裂标题',
started_at: 1710002990,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710002995,
matched_message_id: null,
snippet: '记忆断裂标题',
rank: 0,
},
])
contentAllMock.mockImplementation(() => {
throw new Error('fts5 tokenizer miss')
})
@@ -425,12 +636,96 @@ describe('session DB summaries', () => {
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%')
expect(rows).toHaveLength(1)
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%', 40)
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('cjk-1')
expect(rows[1].id).toBe('cjk-title-1')
expect(rows[0].snippet).toContain('记忆断裂')
})
it('does not hide real database failures for safe FTS queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('database malformed')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow(
'Failed to search sessions: database malformed',
)
expect(likeAllMock).not.toHaveBeenCalled()
})
it('throws when messages_fts is missing for numeric queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
})
it('throws when messages_fts is missing for numeric queries with source filter', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', 'telegram', 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
})
it('throws when messages_fts is missing for numeric queries even with title matches', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-123',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Issue 123',
started_at: 1710002900,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title numeric preview',
last_active: 1710002910,
matched_message_id: null,
snippet: 'Issue 123',
rank: 0,
},
])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
})
it('does not fall back to LIKE when messages_fts is missing for non-numeric queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {