Files
Hermes-ui/packages/server/src/services/hermes/conversations.ts
T
Zhicheng Han 3f88553765 feat(web-ui): add pinned sessions and live monitor in Chat (#118)
* feat: add single-page live session monitor and chat pinning

* fix: restore full test green after main merge

* fix: use Array.from instead of Set spread for ts-node compatibility

[...new Set()] requires downlevelIteration which isn't enabled in
ts-node dev mode, causing sonic-boom crash on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ekko <fqsy1416@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:09:58 +08:00

436 lines
16 KiB
TypeScript

import { exportSessionsRaw, type HermesSessionFull } from './hermes-cli'
const LINEAGE_TOLERANCE_SECONDS = 3
const LIVE_WINDOW_SECONDS = 300
const EXPORT_CACHE_TTL_MS = 30000
const DEFAULT_CONVERSATION_LIMIT = 200
const SYNTHETIC_USER_PREFIXES = [
'[system:',
"you've reached the maximum number of tool-calling iterations allowed.",
'you have reached the maximum number of tool-calling iterations allowed.',
]
type HermesMessageLike = {
id?: number | string
session_id?: string
role?: string
content?: unknown
timestamp?: number
}
type ConversationSession = HermesSessionFull & {
parent_session_id?: string | null
preview: string
last_active: number
is_active: boolean
}
type CachedExport = {
expires_at_ms: number
sessions: HermesSessionFull[]
}
const exportCache = new Map<string, CachedExport>()
export interface ConversationSummary {
id: string
source: string
model: string
title: string | null
started_at: number
ended_at: number | null
last_active: number
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
preview: string
is_active: boolean
thread_session_count: number
}
export interface ConversationMessage {
id: number | string
session_id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}
export interface ConversationDetail {
session_id: string
messages: ConversationMessage[]
visible_count: number
thread_session_count: number
}
export interface ConversationListOptions {
source?: string
humanOnly?: boolean
limit?: number
}
function cacheKey(source?: string): string {
return source || '__all__'
}
function safeText(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return ''
}
function textFromContent(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (Array.isArray(value)) {
return value
.map(item => textFromContent(item).trim())
.filter(Boolean)
.join('\n')
}
if (!value || typeof value !== 'object') return ''
const record = value as Record<string, unknown>
const directKeys = ['text', 'content', 'value'] as const
for (const key of directKeys) {
const direct = record[key]
if (typeof direct === 'string') return direct
if (Array.isArray(direct)) {
const nested = textFromContent(direct)
if (nested) return nested
}
}
const nestedKeys = ['parts', 'children', 'items'] as const
for (const key of nestedKeys) {
if (Array.isArray(record[key])) {
const nested = textFromContent(record[key])
if (nested) return nested
}
}
const flattened = Object.values(record)
.map(entry => textFromContent(entry).trim())
.filter(Boolean)
.join('\n')
if (flattened) return flattened
try {
return JSON.stringify(record)
} catch {
return ''
}
}
function normalizeText(value: unknown): string {
return textFromContent(value).replace(/\s+/g, ' ').trim().toLowerCase()
}
function excerpt(value: unknown, width = 80): string {
const text = textFromContent(value).replace(/\s+/g, ' ').trim()
if (!text) return ''
return text.length > width ? `${text.slice(0, width)}` : text
}
function isSyntheticUserText(content: unknown): boolean {
const text = normalizeText(content)
return SYNTHETIC_USER_PREFIXES.some(prefix => text.startsWith(prefix))
}
function visibleHumanMessage(message: HermesMessageLike): boolean {
const role = safeText(message.role)
const content = textFromContent(message.content).trim()
if (!content) return false
if (role !== 'user' && role !== 'assistant') return false
if (role === 'user' && isSyntheticUserText(content)) return false
return true
}
function firstVisibleHumanText(messages: HermesMessageLike[]): string {
const firstVisible = messages.find(visibleHumanMessage)
return firstVisible ? textFromContent(firstVisible.content).trim() : ''
}
function maxMessageTimestamp(messages: HermesMessageLike[]): number {
return messages.reduce((max, message) => {
const timestamp = Number(message.timestamp || 0)
return Number.isFinite(timestamp) && timestamp > max ? timestamp : max
}, 0)
}
function enrichSession(session: HermesSessionFull, nowSeconds: number): ConversationSession {
const messages = Array.isArray(session.messages) ? session.messages : []
const preview = excerpt(firstVisibleHumanText(messages))
const lastActive = maxMessageTimestamp(messages) || Number(session.ended_at || session.started_at || 0)
const endedAt = session.ended_at ?? null
return {
...session,
parent_session_id: (session.parent_session_id as string | null | undefined) ?? null,
preview,
last_active: lastActive,
is_active: endedAt == null && nowSeconds - lastActive <= LIVE_WINDOW_SECONDS,
}
}
function sortByRecency<T extends { last_active: number; started_at: number; id: string }>(items: T[]): T[] {
return [...items].sort((a, b) => {
if (b.last_active !== a.last_active) return b.last_active - a.last_active
if (b.started_at !== a.started_at) return b.started_at - a.started_at
return a.id.localeCompare(b.id)
})
}
function timingMatchesParent(parent: ConversationSession | undefined, child: ConversationSession | undefined): boolean {
if (!parent || !child || parent.ended_at == null) return false
return Math.abs(Number(child.started_at || 0) - Number(parent.ended_at || 0)) <= LINEAGE_TOLERANCE_SECONDS
}
function isBranchRoot(session: ConversationSession | undefined, byId: Map<string, ConversationSession>): boolean {
if (!session?.parent_session_id) return false
const parent = byId.get(session.parent_session_id)
return !!parent && parent.end_reason === 'branched' && timingMatchesParent(parent, session)
}
function isVisibleRoot(session: ConversationSession | undefined, byId: Map<string, ConversationSession>): boolean {
if (!session || session.source === 'tool') return false
return session.parent_session_id == null || isBranchRoot(session, byId)
}
function continuationCandidates(parent: ConversationSession, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSession[] {
const childIds = childrenByParent.get(parent.id) || []
return childIds
.map(childId => byId.get(childId))
.filter((child): child is ConversationSession => !!child)
.filter(child => child.source !== 'tool')
.filter(child => child.source === parent.source)
.filter(child => timingMatchesParent(parent, child))
.sort((a, b) => {
const aDelta = Math.abs(Number(a.started_at || 0) - Number(parent.ended_at || 0))
const bDelta = Math.abs(Number(b.started_at || 0) - Number(parent.ended_at || 0))
if (aDelta !== bDelta) return aDelta - bDelta
return a.id.localeCompare(b.id)
})
}
function nextContinuationChild(parent: ConversationSession, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSession | null {
if (parent.end_reason !== 'compression') return null
const candidates = continuationCandidates(parent, byId, childrenByParent)
if (candidates.length === 1) return candidates[0]
const exactPreviewMatches = candidates.filter(child => {
const childPreview = normalizeText(child.preview)
const parentPreview = normalizeText(parent.preview)
return !!childPreview && childPreview === parentPreview
})
if (exactPreviewMatches.length === 1) return exactPreviewMatches[0]
return null
}
function collectConversationChain(rootId: string, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSession[] {
const chain: ConversationSession[] = []
const seen = new Set<string>()
let current = byId.get(rootId) || null
while (current && !seen.has(current.id)) {
chain.push(current)
seen.add(current.id)
current = nextContinuationChild(current, byId, childrenByParent)
}
return chain
}
function sessionMessages(session: HermesSessionFull): HermesMessageLike[] {
return Array.isArray(session.messages) ? session.messages as HermesMessageLike[] : []
}
function normalizeVisibleMessage(message: HermesMessageLike, session: HermesSessionFull, index: number): ConversationMessage | null {
if (!visibleHumanMessage(message)) return null
const role = safeText(message.role)
const content = textFromContent(message.content).trim()
if (role !== 'user' && role !== 'assistant') return null
if (!content) return null
const rawTimestamp = Number(message.timestamp)
const timestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
? rawTimestamp
: Number(session.ended_at || session.started_at || 0)
const id = message.id ?? `${session.id}:${index}:${timestamp}`
return {
id,
session_id: safeText(message.session_id || session.id),
role,
content,
timestamp,
}
}
function visibleMessagesForSessions(sessions: HermesSessionFull[]): ConversationMessage[] {
return sessions
.flatMap(session => sessionMessages(session).map((message, index) => normalizeVisibleMessage({ ...message, session_id: safeText(message.session_id || session.id) }, session, index)))
.filter((message): message is ConversationMessage => !!message)
.sort((a, b) => {
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return String(a.id).localeCompare(String(b.id))
})
}
function hasVisibleHumanMessages(sessions: HermesSessionFull[]): boolean {
return visibleMessagesForSessions(sessions).length > 0
}
function toSummary(session: ConversationSession): ConversationSummary {
return {
id: session.id,
source: safeText(session.source),
model: safeText(session.model),
title: session.title ?? null,
started_at: Number(session.started_at || 0),
ended_at: session.ended_at ?? null,
last_active: session.last_active,
message_count: Number(session.message_count || 0),
tool_call_count: Number(session.tool_call_count || 0),
input_tokens: Number(session.input_tokens || 0),
output_tokens: Number(session.output_tokens || 0),
cache_read_tokens: Number(session.cache_read_tokens || 0),
cache_write_tokens: Number(session.cache_write_tokens || 0),
reasoning_tokens: Number(session.reasoning_tokens || 0),
billing_provider: session.billing_provider ?? null,
estimated_cost_usd: Number(session.estimated_cost_usd || 0),
actual_cost_usd: session.actual_cost_usd ?? null,
cost_status: safeText(session.cost_status),
preview: session.preview,
is_active: session.is_active,
thread_session_count: 1,
}
}
function aggregateSummary(rootId: string, byId: Map<string, ConversationSession>, childrenByParent: Map<string | null, string[]>): ConversationSummary | null {
const chain = collectConversationChain(rootId, byId, childrenByParent)
if (!chain.length || !hasVisibleHumanMessages(chain)) return null
const root = chain[0]
const last = chain[chain.length - 1]
const title = root.title || excerpt(firstVisibleHumanText(chain.flatMap(sessionMessages)), 72) || null
const preview = root.preview || excerpt(firstVisibleHumanText(chain.flatMap(sessionMessages)))
const costStatuses = Array.from(new Set(chain.map(session => safeText(session.cost_status)).filter(Boolean)))
return {
...toSummary(root),
title,
preview,
model: safeText(last?.model || root.model),
ended_at: last?.ended_at ?? null,
last_active: Math.max(...chain.map(session => session.last_active)),
is_active: chain.some(session => session.is_active),
billing_provider: last?.billing_provider ?? root.billing_provider ?? null,
cost_status: costStatuses.length === 1 ? costStatuses[0] : 'mixed',
thread_session_count: chain.length,
message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0),
tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0),
input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0),
output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0),
cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0),
cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0),
reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0),
estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0),
actual_cost_usd: chain.reduce<number | null>((sum, session) => {
const actual = session.actual_cost_usd
if (actual == null) return sum
return (sum || 0) + Number(actual)
}, null),
}
}
async function loadSessions(source?: string): Promise<ConversationSession[]> {
const key = cacheKey(source)
const nowMs = Date.now()
const cached = exportCache.get(key)
const raws = cached && cached.expires_at_ms > nowMs
? cached.sessions
: await exportSessionsRaw(source)
if (!cached || cached.expires_at_ms <= nowMs) {
exportCache.set(key, {
expires_at_ms: nowMs + EXPORT_CACHE_TTL_MS,
sessions: raws,
})
}
const nowSeconds = nowMs / 1000
return raws.map(raw => enrichSession(raw, nowSeconds))
}
export async function listConversationSummaries(options: ConversationListOptions = {}): Promise<ConversationSummary[]> {
const humanOnly = options.humanOnly !== false
const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CONVERSATION_LIMIT
const sessions = await loadSessions(options.source)
const byId = new Map(sessions.map(session => [session.id, session]))
const childrenByParent = new Map<string | null, string[]>()
for (const session of sessions) {
const key = session.parent_session_id ?? null
const siblings = childrenByParent.get(key) || []
siblings.push(session.id)
childrenByParent.set(key, siblings)
}
if (!humanOnly) {
return sortByRecency(
sessions
.filter(session => session.source !== 'tool')
.map(toSummary),
).slice(0, limit)
}
const summaries = sessions
.filter(session => isVisibleRoot(session, byId))
.map(session => aggregateSummary(session.id, byId, childrenByParent))
.filter((summary): summary is ConversationSummary => !!summary)
return sortByRecency(summaries).slice(0, limit)
}
export async function getConversationDetail(sessionId: string, options: ConversationListOptions = {}): Promise<ConversationDetail | null> {
const humanOnly = options.humanOnly !== false
const sessions = await loadSessions(options.source)
const byId = new Map(sessions.map(session => [session.id, session]))
const childrenByParent = new Map<string | null, string[]>()
for (const session of sessions) {
const key = session.parent_session_id ?? null
const siblings = childrenByParent.get(key) || []
siblings.push(session.id)
childrenByParent.set(key, siblings)
}
if (!humanOnly) {
const session = byId.get(sessionId)
if (!session || session.source === 'tool') return null
const messages = visibleMessagesForSessions([session])
return {
session_id: sessionId,
messages,
visible_count: messages.length,
thread_session_count: 1,
}
}
const root = byId.get(sessionId)
if (!isVisibleRoot(root, byId)) return null
const chain = collectConversationChain(sessionId, byId, childrenByParent)
const messages = visibleMessagesForSessions(chain)
if (!messages.length) return null
return {
session_id: sessionId,
messages,
visible_count: messages.length,
thread_session_count: chain.length,
}
}