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
@@ -0,0 +1,357 @@
import type {
StoredMessage,
CompressionConfig,
CompressedContext,
BuildContextInput,
MessageFetcher,
GatewayCaller,
SessionCleaner,
} from './types'
import { DEFAULT_COMPRESSION_CONFIG } from './types'
import { GatewaySummarizer } from './gateway-client'
import { buildAgentInstructions, buildSummarizationSystemPrompt } from './prompt'
export class ContextEngine {
private config: CompressionConfig
private messageFetcher: MessageFetcher
private gatewayCaller: GatewayCaller
/** Per-room compression lock to prevent concurrent snapshot overwrites */
private _compressLocks = new Map<string, Promise<void>>()
private _upstream = ''
private _apiKey: string | null = null
constructor(opts: {
config?: Partial<CompressionConfig>
messageFetcher: MessageFetcher
gatewayCaller?: GatewayCaller
sessionCleaner?: SessionCleaner
}) {
this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts.config }
this.messageFetcher = opts.messageFetcher
this.gatewayCaller = opts.gatewayCaller || new GatewaySummarizer(this.config.summarizationTimeoutMs)
this.sessionCleaner = opts.sessionCleaner
}
private sessionCleaner?: SessionCleaner
setUpstream(upstream: string, apiKey: string | null): void {
this._upstream = upstream
this._apiKey = apiKey
}
/**
* Build context for an agent reply.
*
* Flow:
* 1. Read persisted snapshot (summary + lastMessageId) from SQLite
* 2. If snapshot exists:
* a. Collect new messages after lastMessageId
* b. Estimate tokens = summary + new messages
* c. Under threshold → return as-is
* d. Over threshold → incremental compress, update snapshot, return
* 3. If no snapshot:
* a. Estimate tokens for all messages
* b. Under threshold → return all verbatim
* c. Over threshold → full compress, save snapshot, return
*/
async buildContext(input: BuildContextInput): Promise<CompressedContext> {
// Serialize compression per room to prevent concurrent snapshot overwrites
const existing = this._compressLocks.get(input.roomId)
if (existing) {
await existing
}
let resolveLock!: () => void
const lock = new Promise<void>(r => { resolveLock = r })
this._compressLocks.set(input.roomId, lock)
try {
return await this._buildContextImpl(input)
} finally {
resolveLock()
this._compressLocks.delete(input.roomId)
}
}
private async _buildContextImpl(input: BuildContextInput): Promise<CompressedContext> {
const config = { ...this.config, ...input.compression }
const allMessages = this.messageFetcher.getMessages(input.roomId)
// Filter out messages newer than the current one
const messages = allMessages.filter(m => m.timestamp <= input.currentMessage.timestamp)
const total = messages.length
console.log(`[ContextEngine] buildContext START — room=${input.roomId}, agent=${input.agentName}, totalMessagesInDb=${allMessages.length}, afterFilter=${total}`)
const instructions = buildAgentInstructions({
agentName: input.agentName,
roomName: input.roomName,
agentDescription: input.agentDescription,
memberNames: input.memberNames,
members: input.members,
})
const meta: CompressedContext['meta'] = {
totalMessages: total,
verbatimCount: 0,
hadSnapshot: false,
compressed: false,
summaryTokenEstimate: 0,
}
const snapshot = this.messageFetcher.getContextSnapshot(input.roomId)
console.log(`[ContextEngine] snapshot=${snapshot ? `EXISTS (lastMsgId=${snapshot.lastMessageId}, summaryLen=${snapshot.summary.length})` : 'NONE'}`)
// ── Path A: Snapshot exists — incremental ────────────
if (snapshot) {
meta.hadSnapshot = true
// Find the position of lastMessageId in messages
const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId)
// Collect messages after the snapshot position
const newMessages = snapshotIdx >= 0
? messages.slice(snapshotIdx + 1)
: messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp)
const summaryTokens = this.countTokens(snapshot.summary)
const newTokens = this.estimateTokensFromMessages(newMessages)
const totalTokens = summaryTokens + newTokens
meta.verbatimCount = newMessages.length
meta.summaryTokenEstimate = summaryTokens
console.log(`[ContextEngine] [Path A] snapshotIdx=${snapshotIdx}, newMessages=${newMessages.length}, summaryTokens=~${summaryTokens}, newTokens=~${newTokens}, totalTokens=~${totalTokens}, threshold=${config.triggerTokens}`)
console.log(`[ContextEngine] [Path A] EXISTING SUMMARY (${snapshot.summary.length} chars):`, snapshot.summary.slice(0, 300))
if (newMessages.length > 0) {
console.log(`[ContextEngine] [Path A] NEW MESSAGES (${newMessages.length}):`, newMessages.map(m => `[${m.senderName}]: ${m.content.slice(0, 80)}`).join(' | '))
}
// Under threshold — return summary + new messages directly
if (totalTokens <= config.triggerTokens) {
console.log(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
this.logHistory('Path A (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
// Over threshold — incremental compress
console.log(`[ContextEngine] [Path A] OVER threshold — starting INCREMENTAL compression of ${newMessages.length} msgs...`)
console.log(`[ContextEngine] [Path A] CONTEXT BEFORE COMPRESSION: summary(${snapshot.summary.length} chars) + ${newMessages.length} new msgs`)
meta.compressed = true
const t0 = Date.now()
const result = await this.summarize(
input.roomId,
newMessages,
input.upstream,
input.apiKey,
snapshot.summary,
)
const elapsed = Date.now() - t0
if (result.summary) {
const lastMsg = newMessages[newMessages.length - 1]
this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastMsg.id, lastMsg.timestamp)
meta.summaryTokenEstimate = this.countTokens(result.summary)
console.log(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`)
console.log(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars):`, result.summary.slice(0, 300))
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId)
this.logHistory('Path A (after incremental compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
}
// Compression failed — degrade
console.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
this.trimToBudget(history, summaryTokens, config.maxHistoryTokens)
return { conversationHistory: history, instructions, meta }
}
// ── Path B: No snapshot — full context ───────────────
const totalTokens = this.estimateTokensFromMessages(messages)
meta.verbatimCount = total
console.log(`[ContextEngine] [Path B] no snapshot, totalMessages=${total}, totalTokens=~${totalTokens}, threshold=${config.triggerTokens}`)
// Under threshold — pass all messages verbatim
if (totalTokens <= config.triggerTokens) {
console.log(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
this.logHistory('Path B (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
// Over threshold — full compress
console.log(`[ContextEngine] [Path B] OVER threshold — starting FULL compression of ${total} msgs...`)
console.log(`[ContextEngine] [Path B] CONTEXT BEFORE COMPRESSION: ${total} msgs, ~${totalTokens} tokens`)
meta.compressed = true
const t0 = Date.now()
const result = await this.summarize(
input.roomId,
messages,
input.upstream,
input.apiKey,
)
const elapsed = Date.now() - t0
if (result.summary) {
// Keep recent tail messages verbatim, compress the rest
const { tailMessageCount } = config
const toCompress = messages.length > tailMessageCount ? messages.slice(0, -tailMessageCount) : messages
const tail = messages.length > tailMessageCount ? messages.slice(-tailMessageCount) : []
const lastCompressedMsg = toCompress[toCompress.length - 1]
this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp)
meta.summaryTokenEstimate = this.countTokens(result.summary)
console.log(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`)
console.log(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars):`, result.summary.slice(0, 300))
const history = this.buildHistory(result.summary, tail, input.agentSocketId)
this.logHistory('Path B (after full compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
}
// Compression failed — degrade
console.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
this.trimToBudget(history, 0, config.maxHistoryTokens)
meta.verbatimCount = history.length
return { conversationHistory: history, instructions, meta }
}
invalidateRoom(roomId: string): void {
this.messageFetcher.deleteContextSnapshot(roomId)
}
/**
* Force compress all messages in a room (full compression).
* Used when user manually triggers compression.
*/
async forceCompress(roomId: string): Promise<string> {
const allMessages = this.messageFetcher.getMessages(roomId)
if (allMessages.length === 0) return ''
const config = { ...this.config }
console.log(`[ContextEngine] forceCompress room=${roomId}, messages=${allMessages.length}`)
const t0 = Date.now()
const result = await this.summarize(roomId, allMessages, this._upstream, this._apiKey)
const elapsed = Date.now() - t0
if (result.summary) {
const { tailMessageCount } = config
const toCompress = allMessages.length > tailMessageCount ? allMessages.slice(0, -tailMessageCount) : allMessages
const tail = allMessages.length > tailMessageCount ? allMessages.slice(-tailMessageCount) : []
const lastCompressedMsg = toCompress[toCompress.length - 1]
this.messageFetcher.saveContextSnapshot(roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp)
console.log(`[ContextEngine] forceCompress DONE in ${elapsed}ms`)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return result.summary
}
throw new Error('Compression failed')
}
// ─── Private ─────────────────────────────────────────────
/**
* Build history array: optional summary prefix + verbatim messages.
*/
private buildHistory(
summary: string,
messages: StoredMessage[],
agentSocketId: string,
): Array<{ role: 'user' | 'assistant'; content: string }> {
const history: Array<{ role: 'user' | 'assistant'; content: string }> = []
if (summary) {
history.push(
{ role: 'user', content: '[Previous conversation summary]\n' + summary },
{ role: 'assistant', content: 'I have reviewed the conversation history and understand the context.' },
)
}
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId)))
return history
}
/**
* Summarize messages. If previousSummary is provided, do incremental update.
*/
private async summarize(
roomId: string,
messages: StoredMessage[],
upstream: string,
apiKey: string | null,
previousSummary?: string,
): Promise<{ summary: string | null; sessionId: string | null }> {
if (messages.length === 0 && !previousSummary) return { summary: null, sessionId: null }
try {
const result = await this.gatewayCaller.summarize(
upstream,
apiKey,
buildSummarizationSystemPrompt(),
messages,
previousSummary,
)
return { summary: result.summary, sessionId: result.sessionId }
} catch (err: any) {
console.warn(`[ContextEngine] Summarization failed for room ${roomId}: ${err.message}`)
return { summary: null, sessionId: null }
} finally {
// Session cleanup handled here if sessionCleaner is provided
}
}
private mapToHistory(
msg: StoredMessage,
agentSocketId: string,
): { role: 'user' | 'assistant'; content: string } {
if (msg.senderId === agentSocketId) {
return { role: 'assistant', content: msg.content }
}
return { role: 'user', content: `[${msg.senderName}]: ${msg.content}` }
}
private trimToBudget(
history: Array<{ role: 'user' | 'assistant'; content: string }>,
summaryTokens: number,
maxTokens: number,
): void {
let totalTokens = summaryTokens + this.estimateTokens(history)
while (totalTokens > maxTokens && history.length > 0) {
history.pop()
totalTokens = summaryTokens + this.estimateTokens(history)
}
}
private estimateTokens(history: Array<{ role: string; content: string }>): number {
const text = history.map(m => m.content).join('')
return this.countTokens(text)
}
private estimateTokensFromMessages(messages: StoredMessage[]): number {
const text = messages.map(m => m.content + m.senderName).join('')
return this.countTokens(text)
}
/** Estimate tokens distinguishing CJK (~1.5 tok/char) from Latin (~0.25 tok/char) */
private countTokens(text: string): number {
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
const other = text.length - cjk
return Math.ceil(cjk * 1.5 + other / 4)
}
/** Log assembled history for debugging */
private logHistory(label: string, history: Array<{ role: string; content: string }>): void {
const totalTokens = this.estimateTokens(history)
console.log(`[ContextEngine] ASSEMBLED HISTORY (${label}): ${history.length} entries, ~${totalTokens} tokens`)
for (const entry of history) {
const preview = entry.content.length > 150 ? entry.content.slice(0, 150) + '...' : entry.content
console.log(` [${entry.role}] ${preview}`)
}
}
}
@@ -0,0 +1,117 @@
import { EventSource } from 'eventsource'
import type { StoredMessage, GatewayCaller } from './types'
import {
buildSummarizationSystemPrompt,
buildFullSummaryPrompt,
buildIncrementalUpdatePrompt,
} from './prompt'
/**
* Calls Hermes /v1/runs to produce LLM-generated summaries.
* Uses non-streaming EventSource to wait for run.completed.
*/
export class GatewaySummarizer implements GatewayCaller {
private timeoutMs: number
constructor(timeoutMs = 30_000) {
this.timeoutMs = timeoutMs
}
async summarize(
upstream: string,
apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
previousSummary?: string,
): Promise<{ summary: string; sessionId: string }> {
// Build conversation_history from messages
const history: Array<{ role: string; content: string }> = messages.map(m => ({
role: 'user',
content: `[${m.senderName}]: ${m.content}`,
}))
// Inject previous summary for incremental update
if (previousSummary) {
history.unshift(
{ role: 'user', content: `[Previous summary]\n${previousSummary}` },
{ role: 'assistant', content: 'Understood, I will update the summary.' },
)
}
const userPrompt = previousSummary
? buildIncrementalUpdatePrompt()
: buildFullSummaryPrompt()
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
// POST /v1/runs
const res = await fetch(`${upstream}/v1/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
input: userPrompt,
instructions: systemPrompt || buildSummarizationSystemPrompt(),
conversation_history: history,
session_id: sessionId,
}),
signal: AbortSignal.timeout(this.timeoutMs),
})
if (!res.ok) {
throw new Error(`Summarization run failed: ${res.status}`)
}
const { run_id } = await res.json() as { run_id: string }
try {
const output = await this.pollForResult(upstream, apiKey, run_id)
return { summary: output, sessionId }
} finally {
// Note: session cleanup is handled by the caller (compressor.ts)
}
}
private pollForResult(upstream: string, apiKey: string | null, runId: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
source.close()
reject(new Error('Summarization timed out'))
}, this.timeoutMs)
const eventsUrl = new URL(`${upstream}/v1/runs/${runId}/events`)
if (apiKey) eventsUrl.searchParams.set('token', apiKey)
const source = new EventSource(eventsUrl.toString())
source.onmessage = (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data)
if (parsed.event === 'run.completed') {
clearTimeout(timer)
source.close()
const output = parsed.output
if (!output || typeof output !== 'string' || output.trim() === '') {
reject(new Error('Empty summarization response'))
return
}
resolve(output.trim())
} else if (parsed.event === 'run.failed') {
clearTimeout(timer)
source.close()
reject(new Error(parsed.error || 'Summarization run failed'))
}
} catch { /* ignore parse errors for non-JSON events */ }
}
source.onerror = () => {
clearTimeout(timer)
source.close()
reject(new Error('Summarization SSE connection error'))
}
})
}
}
@@ -0,0 +1,13 @@
export { ContextEngine } from './compressor'
export { GatewaySummarizer } from './gateway-client'
export { buildAgentInstructions, buildSummarizationSystemPrompt, buildFullSummaryPrompt, buildIncrementalUpdatePrompt } from './prompt'
export { DEFAULT_COMPRESSION_CONFIG } from './types'
export type {
StoredMessage,
CompressionConfig,
CompressedContext,
ContextSnapshot,
MessageFetcher,
GatewayCaller,
BuildContextInput,
} from './types'
@@ -0,0 +1,82 @@
// ─── Agent Identity Instructions ────────────────────────────
import type { MemberInfo } from './types'
interface AgentInstructionsParams {
agentName: string
roomName: string
agentDescription: string
memberNames: string[]
members: MemberInfo[]
}
export function buildAgentInstructions(params: AgentInstructionsParams): string {
let memberSection: string
if (params.members.length > 0) {
memberSection = params.members
.map(m => m.description ? `- ${m.name}: ${m.description}` : `- ${m.name}`)
.join('\n')
} else if (params.memberNames.length > 0) {
memberSection = params.memberNames.map(n => `- ${n}`).join('\n')
} else {
memberSection = '- 未知'
}
return `你是"${params.agentName}",群聊房间"${params.roomName}"中的 AI 助手。
你的角色:${params.agentDescription}
当前房间成员:
${memberSection}
规则:
- 有人用 @${params.agentName} 提及你时才需要回复,重点回应提及你的人。
- 回答简洁、对群聊有帮助。
- 不要假装是人类,需要时明确表明自己是 AI。
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文。
- 回复最新一条提及你的消息。
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方。
- 自行判断对话是否已经结束——如果问题已解决、达成共识、或对方只是陈述不需要回复,则不要再 @任何人,直接结束回复,避免产生无意义的循环对话。`
}
// ─── Summarization Prompts ─────────────────────────────────
export function buildSummarizationSystemPrompt(): string {
return `你是一个群聊对话的摘要助手。请创建一份结构化摘要,帮助 AI 助手快速理解完整的对话上下文并智能回复。
使用以下格式:
当前话题:
- 现在在聊什么,目标是什么
已知结论:
- 已达成哪些共识,哪些问题已经回答过
待回复消息:
- 还剩谁的问题没回,下一步要做什么
关键人物:
- 人名、角色、引用关系
重要上下文:
- 不要丢时间线和立场变化
- 少写废话,多保留"可行动信息"
- 重点保留:谁说了什么、结论是什么、下一步是什么
- 关键的 URL、代码片段、错误信息、约束条件
规则:
- 基于事实,不要编造信息。
- 保持简洁(500 字以内)。
- 聚焦于帮助 AI 回复下一条消息的可行动信息。
- 使用与对话相同的语言。
- 不要回复对话内容,只输出摘要。`
}
export function buildFullSummaryPrompt(): string {
return '请对上方对话创建一份简洁的摘要。只输出摘要内容。'
}
export function buildIncrementalUpdatePrompt(): string {
return '对话自上次摘要后有了新的内容。请更新摘要,整合新消息。保持相同格式,更新所有部分。只输出更新后的摘要。'
}
@@ -0,0 +1,49 @@
import type { SummaryCacheEntry } from './types'
const MAX_ENTRIES = 200
export class SummaryCache {
private cache = new Map<string, SummaryCacheEntry>()
private ttlMs: number
constructor(ttlMs = 120_000) {
this.ttlMs = ttlMs
}
get(roomId: string): SummaryCacheEntry | undefined {
const entry = this.cache.get(roomId)
if (!entry) return undefined
if (Date.now() - entry.createdAt >= this.ttlMs) {
this.cache.delete(roomId)
return undefined
}
return entry
}
set(roomId: string, entry: SummaryCacheEntry): void {
if (this.cache.size >= MAX_ENTRIES) {
let oldestKey = ''
let oldestTime = Infinity
for (const [k, v] of this.cache) {
if (v.createdAt < oldestTime) {
oldestTime = v.createdAt
oldestKey = k
}
}
if (oldestKey) this.cache.delete(oldestKey)
}
this.cache.set(roomId, entry)
}
invalidate(roomId: string): void {
this.cache.delete(roomId)
}
clear(): void {
this.cache.clear()
}
get size(): number {
return this.cache.size
}
}
@@ -0,0 +1,111 @@
// ─── Message Types ──────────────────────────────────────────
/** Raw message from SQLite messages table */
export interface StoredMessage {
id: string
roomId: string
senderId: string
senderName: string
content: string
timestamp: number
}
// ─── Compression Config ────────────────────────────────────
export interface CompressionConfig {
/** Token threshold to trigger compression (estimate all messages) */
triggerTokens: number
/** Max tokens for the final compressed context sent to LLM */
maxHistoryTokens: number
/** Number of recent messages to keep verbatim after compression */
tailMessageCount: number
/** Characters per token for estimation */
charsPerToken: number
/** Timeout for summarization LLM call in ms */
summarizationTimeoutMs: number
}
export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
triggerTokens: 100_000,
maxHistoryTokens: 32_000,
tailMessageCount: 20,
charsPerToken: 4,
summarizationTimeoutMs: 30_000,
}
// ─── Compression Output ────────────────────────────────────
export interface CompressedContext {
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
instructions: string
meta: {
totalMessages: number
verbatimCount: number
hadSnapshot: boolean
compressed: boolean
summaryTokenEstimate: number
}
}
// ─── Context Snapshot (persisted in SQLite) ────────────────
export interface ContextSnapshot {
roomId: string
summary: string
lastMessageId: string
lastMessageTimestamp: number
updatedAt: number
}
// ─── Summary Cache ──────────────────────────────────────────
export interface SummaryCacheEntry {
summary: string
lastMessageId: string
lastMessageTimestamp: number
createdAt: number
}
// ─── Dependency Injection ──────────────────────────────────
export interface MessageFetcher {
getMessages(roomId: string, limit?: number): StoredMessage[]
getContextSnapshot(roomId: string): ContextSnapshot | null
saveContextSnapshot(roomId: string, summary: string, lastMessageId: string, lastMessageTimestamp: number): void
deleteContextSnapshot(roomId: string): void
}
export interface GatewayCaller {
summarize(
upstream: string,
apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
previousSummary?: string,
): Promise<{ summary: string; sessionId: string }>
}
export type SessionCleaner = (sessionId: string) => void
// ─── Build Context Input ───────────────────────────────────
export interface MemberInfo {
userId: string
name: string
description: string
}
export interface BuildContextInput {
roomId: string
agentId: string
agentName: string
agentDescription: string
agentSocketId: string
roomName: string
memberNames: string[]
members: MemberInfo[]
upstream: string
apiKey: string | null
currentMessage: StoredMessage
compression?: Partial<CompressionConfig>
}