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>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { request } from '../client'
|
||||
|
||||
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 async function fetchConversationSummaries(params: { humanOnly?: boolean; source?: string; limit?: number } = {}): Promise<ConversationSummary[]> {
|
||||
const query = new URLSearchParams()
|
||||
if (params.humanOnly === false) query.set('humanOnly', 'false')
|
||||
if (params.source) query.set('source', params.source)
|
||||
if (params.limit != null) query.set('limit', String(params.limit))
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
const res = await request<{ sessions: ConversationSummary[] }>(`/api/hermes/sessions/conversations${suffix}`)
|
||||
return res.sessions
|
||||
}
|
||||
|
||||
export async function fetchConversationDetail(sessionId: string, params: { humanOnly?: boolean; source?: string } = {}): Promise<ConversationDetail> {
|
||||
const query = new URLSearchParams()
|
||||
if (params.humanOnly === false) query.set('humanOnly', 'false')
|
||||
if (params.source) query.set('source', params.source)
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
return request<ConversationDetail>(`/api/hermes/sessions/conversations/${encodeURIComponent(sessionId)}/messages${suffix}`)
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { renameSession } from '@/api/hermes/sessions'
|
||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
||||
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getSourceLabel } from '@/shared/session-display'
|
||||
import ChatInput from './ChatInput.vue'
|
||||
import ConversationMonitorPane from './ConversationMonitorPane.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import SessionListItem from './SessionListItem.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentMode = ref<'chat' | 'live'>('chat')
|
||||
|
||||
// Initialize synchronously from the media query so first paint is correct.
|
||||
// On narrow viewports the session list is an absolute-positioned overlay
|
||||
// (z-index 10) on top of the chat area; if we default to `true`, onMounted
|
||||
@@ -20,6 +27,7 @@ const { t } = useI18n()
|
||||
const showSessions = ref(
|
||||
typeof window === 'undefined' || !window.matchMedia('(max-width: 768px)').matches,
|
||||
)
|
||||
const lastChatSessionsVisibility = ref(showSessions.value)
|
||||
let mobileQuery: MediaQueryList | null = null
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
@@ -27,6 +35,17 @@ function handleSessionClick(sessionId: string) {
|
||||
if (mobileQuery?.matches) showSessions.value = false
|
||||
}
|
||||
|
||||
function handleModeChange(mode: 'chat' | 'live') {
|
||||
if (mode === currentMode.value) return
|
||||
if (mode === 'live') {
|
||||
lastChatSessionsVisibility.value = showSessions.value
|
||||
showSessions.value = false
|
||||
} else {
|
||||
showSessions.value = mobileQuery?.matches ? false : lastChatSessionsVisibility.value
|
||||
}
|
||||
currentMode.value = mode
|
||||
}
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches && showSessions.value) {
|
||||
showSessions.value = false
|
||||
@@ -48,31 +67,6 @@ const renameSessionId = ref<string | null>(null)
|
||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
||||
|
||||
const sourceLabelKeys: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
api_server: 'API Server',
|
||||
cli: 'CLI',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
matrix: 'Matrix',
|
||||
whatsapp: 'WhatsApp',
|
||||
signal: 'Signal',
|
||||
email: 'Email',
|
||||
sms: 'SMS',
|
||||
dingtalk: 'DingTalk',
|
||||
feishu: 'Feishu',
|
||||
wecom: 'WeCom',
|
||||
weixin: 'WeChat',
|
||||
bluebubbles: 'iMessage',
|
||||
mattermost: 'Mattermost',
|
||||
cron: 'Cron',
|
||||
}
|
||||
|
||||
function getSourceLabel(source?: string): string {
|
||||
if (!source) return ''
|
||||
return sourceLabelKeys[source] || source
|
||||
}
|
||||
|
||||
// Source sort order: api_server first, cron last, others alphabetical
|
||||
function sourceSortKey(source: string): number {
|
||||
if (source === 'api_server') return -1
|
||||
@@ -96,9 +90,14 @@ interface SessionGroup {
|
||||
sessions: Session[]
|
||||
}
|
||||
|
||||
const pinnedSessions = computed(() =>
|
||||
sortSessionsWithActiveFirst(chatStore.sessions.filter(session => sessionBrowserPrefsStore.isPinned(session.id))),
|
||||
)
|
||||
|
||||
const groupedSessions = computed<SessionGroup[]>(() => {
|
||||
const map = new Map<string, Session[]>()
|
||||
for (const s of chatStore.sessions) {
|
||||
if (sessionBrowserPrefsStore.isPinned(s.id)) continue
|
||||
const key = s.source || ''
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(s)
|
||||
@@ -127,9 +126,8 @@ function toggleGroup(source: string) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value, source])
|
||||
} else {
|
||||
collapsedGroups.value = new Set(
|
||||
groupedSessions.value.map(g => g.source).filter(s => s !== source)
|
||||
groupedSessions.value.map(g => g.source).filter(s => s !== source),
|
||||
)
|
||||
// Auto-select the first session in the expanded group
|
||||
const group = groupedSessions.value.find(g => g.source === source)
|
||||
if (group?.sessions.length) {
|
||||
chatStore.switchSession(group.sessions[0].id)
|
||||
@@ -138,26 +136,37 @@ function toggleGroup(source: string) {
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}
|
||||
|
||||
// Ensure the active session's group is expanded
|
||||
watch(groupedSessions, (groups) => {
|
||||
watch(groupedSessions, groups => {
|
||||
if (localStorage.getItem('hermes_collapsed_groups') !== null) {
|
||||
// Has saved state — still ensure active session's group is visible
|
||||
const activeSource = chatStore.activeSession?.source
|
||||
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== activeSource))
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(source => source !== activeSource))
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}
|
||||
return
|
||||
}
|
||||
// No saved state: expand only the first group
|
||||
collapsedGroups.value = new Set(groups.slice(1).map(g => g.source))
|
||||
collapsedGroups.value = new Set(groups.slice(1).map(group => group.source))
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}, { once: true })
|
||||
|
||||
watch(
|
||||
() => [chatStore.sessionsLoaded, ...chatStore.sessions.map(session => session.id)],
|
||||
value => {
|
||||
const sessionIds = value.slice(1) as string[]
|
||||
if (!value[0] || sessionIds.length === 0) return
|
||||
sessionBrowserPrefsStore.pruneMissingSessions(sessionIds)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const activeSessionTitle = computed(() =>
|
||||
chatStore.activeSession?.title || t('chat.newChat'),
|
||||
)
|
||||
|
||||
const headerTitle = computed(() =>
|
||||
currentMode.value === 'live' ? t('chat.liveSessions') : activeSessionTitle.value,
|
||||
)
|
||||
|
||||
const totalTokens = computed(() => {
|
||||
const input = chatStore.activeSession?.inputTokens ?? 0
|
||||
const output = chatStore.activeSession?.outputTokens ?? 0
|
||||
@@ -210,7 +219,7 @@ function formatTokens(n: number): string {
|
||||
}
|
||||
|
||||
const activeSessionSource = computed(() =>
|
||||
chatStore.activeSession?.source || '',
|
||||
currentMode.value === 'chat' ? (chatStore.activeSession?.source || '') : '',
|
||||
)
|
||||
|
||||
function handleNewChat() {
|
||||
@@ -226,24 +235,21 @@ function copySessionId(id?: string) {
|
||||
}
|
||||
|
||||
function handleDeleteSession(id: string) {
|
||||
sessionBrowserPrefsStore.removePinned(id)
|
||||
chatStore.deleteSession(id)
|
||||
message.success(t('chat.sessionDeleted'))
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const isToday = d.toDateString() === now.toDateString()
|
||||
if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
const contextSessionId = ref<string | null>(null)
|
||||
const contextSessionPinned = computed(() =>
|
||||
contextSessionId.value ? sessionBrowserPrefsStore.isPinned(contextSessionId.value) : false,
|
||||
)
|
||||
|
||||
// Context menu
|
||||
const contextMenuOptions = computed(() => [
|
||||
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
|
||||
{ label: t('chat.rename'), key: 'rename' },
|
||||
{ label: t('chat.copySessionId'), key: 'copy-id' },
|
||||
])
|
||||
const contextSessionId = ref<string | null>(null)
|
||||
|
||||
function handleContextMenu(e: MouseEvent, sessionId: string) {
|
||||
e.preventDefault()
|
||||
@@ -260,6 +266,10 @@ const contextMenuY = ref(0)
|
||||
function handleContextMenuSelect(key: string) {
|
||||
showContextMenu.value = false
|
||||
if (!contextSessionId.value) return
|
||||
if (key === 'pin') {
|
||||
sessionBrowserPrefsStore.togglePinned(contextSessionId.value)
|
||||
return
|
||||
}
|
||||
if (key === 'copy-id') {
|
||||
copySessionId(contextSessionId.value)
|
||||
} else if (key === 'rename') {
|
||||
@@ -296,9 +306,8 @@ async function handleRenameConfirm() {
|
||||
|
||||
<template>
|
||||
<div class="chat-panel">
|
||||
<!-- Session List -->
|
||||
<div class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
|
||||
<aside class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div v-if="currentMode === 'chat'" class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
|
||||
<aside v-if="currentMode === 'chat'" class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span>
|
||||
<div class="session-list-actions">
|
||||
@@ -306,15 +315,35 @@ async function handleRenameConfirm() {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">{{ t('chat.noSessions') }}</div>
|
||||
|
||||
<template v-if="pinnedSessions.length > 0">
|
||||
<div class="session-group-header session-group-header--static">
|
||||
<span class="session-group-label">{{ t('chat.pinned') }}</span>
|
||||
<span class="session-group-count">{{ pinnedSessions.length }}</span>
|
||||
</div>
|
||||
<SessionListItem
|
||||
v-for="s in pinnedSessions"
|
||||
:key="`pinned-${s.id}`"
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:live="chatStore.isSessionLive(s.id)"
|
||||
:pinned="true"
|
||||
:can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="group in groupedSessions" :key="group.source">
|
||||
<div class="session-group-header" @click="toggleGroup(group.source)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
@@ -322,60 +351,23 @@ async function handleRenameConfirm() {
|
||||
<span class="session-group-count">{{ group.sessions.length }}</span>
|
||||
</div>
|
||||
<template v-if="!collapsedGroups.has(group.source)">
|
||||
<button
|
||||
<SessionListItem
|
||||
v-for="s in group.sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === chatStore.activeSessionId, live: chatStore.isSessionLive(s.id) }"
|
||||
@click="handleSessionClick(s.id)"
|
||||
:session="s"
|
||||
:active="s.id === chatStore.activeSessionId"
|
||||
:live="chatStore.isSessionLive(s.id)"
|
||||
:pinned="false"
|
||||
:can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
@select="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title-row">
|
||||
<span
|
||||
v-if="chatStore.isSessionLive(s.id)"
|
||||
class="session-item-active-indicator"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
class="session-item-active-spinner"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="8" opacity="0.2" />
|
||||
<path d="M20 12a8 8 0 0 0-8-8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
</span>
|
||||
<span class="session-item-meta">
|
||||
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
v-if="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
@positive-click="handleDeleteSession(s.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
{{ t('chat.deleteSession') }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
@delete="handleDeleteSession(s.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<NDropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
@@ -387,7 +379,6 @@ async function handleRenameConfirm() {
|
||||
@clickoutside="handleClickOutside"
|
||||
/>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<NModal
|
||||
v-model:show="showRenameModal"
|
||||
preset="dialog"
|
||||
@@ -404,43 +395,61 @@ async function handleRenameConfirm() {
|
||||
/>
|
||||
</NModal>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="chat-main">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<NButton quaternary size="small" @click="showSessions = !showSessions" circle>
|
||||
<NButton v-if="currentMode === 'chat'" quaternary size="small" @click="showSessions = !showSessions" circle>
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ activeSessionTitle }}</span>
|
||||
<span class="header-session-title">{{ headerTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.copySessionId') }}
|
||||
</NTooltip>
|
||||
<NButton size="small" @click="handleNewChat">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
{{ t('chat.newChat') }}
|
||||
</NButton>
|
||||
<div class="chat-mode-toggle">
|
||||
<NButton
|
||||
size="small"
|
||||
:type="currentMode === 'chat' ? 'primary' : 'default'"
|
||||
:aria-pressed="currentMode === 'chat'"
|
||||
@click="handleModeChange('chat')"
|
||||
>{{ t('chat.chatMode') }}</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
:type="currentMode === 'live' ? 'primary' : 'default'"
|
||||
:aria-pressed="currentMode === 'live'"
|
||||
@click="handleModeChange('live')"
|
||||
>{{ t('chat.liveMode') }}</NButton>
|
||||
</div>
|
||||
<template v-if="currentMode === 'chat'">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.copySessionId') }}
|
||||
</NTooltip>
|
||||
<NButton size="small" @click="handleNewChat">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
{{ t('chat.newChat') }}
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MessageList />
|
||||
<div v-if="contextWindow !== null" class="context-info">
|
||||
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
|
||||
</div>
|
||||
<ChatInput />
|
||||
<template v-if="currentMode === 'chat'">
|
||||
<MessageList />
|
||||
<div v-if="contextWindow !== null" class="context-info">
|
||||
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
|
||||
</div>
|
||||
<ChatInput />
|
||||
</template>
|
||||
<ConversationMonitorPane v-else :human-only="sessionBrowserPrefsStore.humanOnly" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -553,6 +562,10 @@ async function handleRenameConfirm() {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.session-group-header--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.group-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
@@ -591,7 +604,7 @@ async function handleRenameConfirm() {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
:deep(.session-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -630,19 +643,19 @@ async function handleRenameConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
:deep(.session-item-content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
:deep(.session-item-title-row) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
:deep(.session-item-title) {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
@@ -650,7 +663,7 @@ async function handleRenameConfirm() {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-active-indicator {
|
||||
:deep(.session-item-active-indicator) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -658,24 +671,32 @@ async function handleRenameConfirm() {
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
.session-item-active-spinner {
|
||||
:deep(.session-item-active-spinner) {
|
||||
animation: session-spin 1.1s linear infinite;
|
||||
filter: drop-shadow(0 0 6px rgba(var(--accent-primary-rgb), 0.35));
|
||||
}
|
||||
|
||||
.session-item-time {
|
||||
:deep(.session-item-pin) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
:deep(.session-item-time) {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
:deep(.session-item-meta) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-model {
|
||||
:deep(.session-item-model) {
|
||||
font-size: 10px;
|
||||
color: $accent-primary;
|
||||
background: rgba($accent-primary, 0.08);
|
||||
@@ -689,7 +710,7 @@ async function handleRenameConfirm() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
:deep(.session-item-delete) {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
@@ -769,6 +790,13 @@ async function handleRenameConfirm() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.context-info {
|
||||
padding: 0 20px 4px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { fetchConversationDetail, fetchConversationSummaries, type ConversationDetail, type ConversationSummary } from '@/api/hermes/conversations'
|
||||
import { formatTimestampSeconds, getSourceLabel } from '@/shared/session-display'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ humanOnly: boolean }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const POLL_INTERVAL_MS = 15000
|
||||
|
||||
const sessions = ref<ConversationSummary[]>([])
|
||||
const selectedSessionId = ref<string | null>(null)
|
||||
const detail = ref<ConversationDetail | null>(null)
|
||||
const sessionsLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const error = ref('')
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sessionsRequestId = 0
|
||||
let detailRequestId = 0
|
||||
|
||||
const selectedSession = computed(() => sessions.value.find(session => session.id === selectedSessionId.value) || null)
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
return role === 'user' ? t('chat.monitorRoleUser') : t('chat.monitorRoleAssistant')
|
||||
}
|
||||
|
||||
function linkedSessionsLabel(count: number): string {
|
||||
return t('chat.linkedSessions', { count })
|
||||
}
|
||||
|
||||
function invalidateRequests() {
|
||||
sessionsRequestId += 1
|
||||
detailRequestId += 1
|
||||
}
|
||||
|
||||
async function loadSessions(silent = false) {
|
||||
const requestId = ++sessionsRequestId
|
||||
if (!silent) {
|
||||
sessionsLoading.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = await fetchConversationSummaries({ humanOnly: props.humanOnly })
|
||||
if (requestId !== sessionsRequestId) return
|
||||
|
||||
sessions.value = loaded
|
||||
if (!loaded.length) {
|
||||
selectedSessionId.value = null
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedSessionId.value || !loaded.some(session => session.id === selectedSessionId.value)) {
|
||||
selectedSessionId.value = loaded[0].id
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (requestId !== sessionsRequestId || silent) return
|
||||
error.value = err?.message || String(err)
|
||||
sessions.value = []
|
||||
selectedSessionId.value = null
|
||||
detail.value = null
|
||||
} finally {
|
||||
if (!silent && requestId === sessionsRequestId) sessionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(sessionId: string | null, silent = false) {
|
||||
const requestId = ++detailRequestId
|
||||
if (!sessionId) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const requestedHumanOnly = props.humanOnly
|
||||
if (!silent) {
|
||||
detailLoading.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = await fetchConversationDetail(sessionId, { humanOnly: requestedHumanOnly })
|
||||
if (
|
||||
requestId !== detailRequestId
|
||||
|| sessionId !== selectedSessionId.value
|
||||
|| requestedHumanOnly !== props.humanOnly
|
||||
) {
|
||||
return
|
||||
}
|
||||
detail.value = loaded
|
||||
} catch (err: any) {
|
||||
if (requestId !== detailRequestId || silent) return
|
||||
error.value = err?.message || String(err)
|
||||
detail.value = null
|
||||
} finally {
|
||||
if (!silent && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedSessionId, async sessionId => {
|
||||
await loadDetail(sessionId, false)
|
||||
})
|
||||
|
||||
watch(() => props.humanOnly, async () => {
|
||||
invalidateRequests()
|
||||
selectedSessionId.value = null
|
||||
detail.value = null
|
||||
await loadSessions(false)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSessions(false)
|
||||
refreshTimer = setInterval(async () => {
|
||||
await loadSessions(true)
|
||||
if (selectedSessionId.value) {
|
||||
await loadDetail(selectedSessionId.value, true)
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
invalidateRequests()
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversation-monitor">
|
||||
<aside class="conversation-monitor__sidebar">
|
||||
<div v-if="sessionsLoading && sessions.length === 0" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="sessions.length === 0" class="conversation-monitor__empty">{{ t('chat.noSessions') }}</div>
|
||||
<button
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="conversation-monitor__session"
|
||||
:class="{ active: session.id === selectedSessionId }"
|
||||
:aria-pressed="session.id === selectedSessionId"
|
||||
@click="selectedSessionId = session.id"
|
||||
>
|
||||
<div class="conversation-monitor__session-title-row">
|
||||
<span class="conversation-monitor__session-title">{{ session.title || session.preview || session.id }}</span>
|
||||
<span v-if="session.is_active" class="conversation-monitor__session-live">{{ t('chat.recentBadge') }}</span>
|
||||
</div>
|
||||
<div class="conversation-monitor__session-meta">{{ getSourceLabel(session.source) }} · {{ formatTimestampSeconds(session.last_active) }}</div>
|
||||
<div v-if="session.preview" class="conversation-monitor__session-preview">{{ session.preview }}</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="conversation-monitor__detail">
|
||||
<header v-if="selectedSession" class="conversation-monitor__detail-header">
|
||||
<div class="conversation-monitor__detail-title">{{ selectedSession.title || selectedSession.preview || selectedSession.id }}</div>
|
||||
<div class="conversation-monitor__detail-meta">
|
||||
<span>{{ getSourceLabel(selectedSession.source) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ selectedSession.model }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ linkedSessionsLabel(selectedSession.thread_session_count) }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="conversation-monitor__empty conversation-monitor__empty--error">{{ error }}</div>
|
||||
<div v-else-if="detailLoading && !detail" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="!detail || detail.messages.length === 0" class="conversation-monitor__empty">{{ t('chat.noVisibleMessages') }}</div>
|
||||
<div v-else class="conversation-monitor__messages">
|
||||
<article
|
||||
v-for="message in detail.messages"
|
||||
:key="`${message.session_id}-${message.id}`"
|
||||
class="conversation-monitor__message"
|
||||
:class="`role-${message.role}`"
|
||||
>
|
||||
<div class="conversation-monitor__message-meta">{{ roleLabel(message.role) }} · {{ formatTimestampSeconds(message.timestamp) }}</div>
|
||||
<div class="conversation-monitor__message-content">{{ message.content }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.conversation-monitor {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conversation-monitor__sidebar {
|
||||
width: 260px;
|
||||
border-right: 1px solid $border-color;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-monitor__session {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba($border-color, 0.6);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: rgba($accent-primary, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-monitor__session-title-row,
|
||||
.conversation-monitor__detail-meta,
|
||||
.conversation-monitor__message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-title,
|
||||
.conversation-monitor__detail-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-live {
|
||||
font-size: 11px;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-meta,
|
||||
.conversation-monitor__session-preview,
|
||||
.conversation-monitor__detail-meta,
|
||||
.conversation-monitor__message-meta {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-preview,
|
||||
.conversation-monitor__message-content {
|
||||
margin-top: 6px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.conversation-monitor__detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-monitor__detail-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.conversation-monitor__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conversation-monitor__message {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba($bg-secondary, 0.8);
|
||||
|
||||
&.role-user {
|
||||
border: 1px solid rgba($accent-primary, 0.18);
|
||||
}
|
||||
|
||||
&.role-assistant {
|
||||
border: 1px solid rgba($border-color, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-monitor__empty {
|
||||
padding: 24px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.conversation-monitor__empty--error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.conversation-monitor {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-monitor__sidebar {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { NPopconfirm } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Session } from '@/stores/hermes/chat'
|
||||
import { formatTimestampMs } from '@/shared/session-display'
|
||||
|
||||
const props = defineProps<{
|
||||
session: Session
|
||||
active: boolean
|
||||
live: boolean
|
||||
pinned: boolean
|
||||
canDelete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
contextmenu: [event: MouseEvent]
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="session-item"
|
||||
:class="{ active, live }"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
@click="emit('select')"
|
||||
@contextmenu="emit('contextmenu', $event)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title-row">
|
||||
<span v-if="live" class="session-item-active-indicator" aria-hidden="true">
|
||||
<svg class="session-item-active-spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.2" />
|
||||
<path d="M20 12a8 8 0 0 0-8-8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-if="pinned" class="session-item-pin" aria-hidden="true">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M5 8l14 0" />
|
||||
<path d="M8 3l8 0 0 5 3 5-14 0 3-5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="session-item-title">{{ session.title }}</span>
|
||||
</span>
|
||||
<span class="session-item-meta">
|
||||
<span v-if="session.model" class="session-item-model">{{ session.model }}</span>
|
||||
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="canDelete" @positive-click="emit('delete')">
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
{{ t('chat.deleteSession') }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
|
||||
import { NInputNumber, NSelect, NSwitch, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -48,6 +50,12 @@ async function save(values: Record<string, any>) {
|
||||
@update:value="v => v != null && save({ at_hour: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.session.liveMonitorHumanOnly')" :hint="t('settings.session.liveMonitorHumanOnlyHint')">
|
||||
<NSwitch
|
||||
:value="sessionBrowserPrefsStore.humanOnly"
|
||||
@update:value="value => sessionBrowserPrefsStore.setHumanOnly(value)"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -73,6 +73,17 @@ export default {
|
||||
deleteSession: 'Diese Sitzung loschen?',
|
||||
sessionDeleted: 'Sitzung geloscht',
|
||||
rename: 'Umbenennen',
|
||||
pin: 'Anheften',
|
||||
unpin: 'Lösen',
|
||||
pinned: 'Angeheftet',
|
||||
chatMode: 'Chat',
|
||||
liveMode: 'Live',
|
||||
liveSessions: 'Live-Sitzungen',
|
||||
recentBadge: 'Kürzlich',
|
||||
linkedSessions: '{count} verknüpft',
|
||||
noVisibleMessages: 'Keine für Menschen sichtbaren Nachrichten.',
|
||||
monitorRoleUser: 'Benutzer',
|
||||
monitorRoleAssistant: 'Assistent',
|
||||
copySessionId: 'Sitzungs-ID kopieren',
|
||||
renamed: 'Umbenannt',
|
||||
renameFailed: 'Umbenennung fehlgeschlagen',
|
||||
@@ -338,6 +349,10 @@ export default {
|
||||
idleMinutes: 'Inaktivitats-Timeout',
|
||||
idleMinutesHint: 'Wartezeit vor automatischer Zurucksetzung (Minuten)',
|
||||
atHour: 'Geplante Zurucksetzungszeit',
|
||||
humanOnly: 'Nur menschliche Sitzungen anzeigen',
|
||||
humanOnlyHint: 'Unteragenten- und Sitzungsmonitor-Rauschen standardmäßig ausblenden',
|
||||
liveMonitorHumanOnly: 'Live-Monitor: nur menschliche Sitzungen anzeigen',
|
||||
liveMonitorHumanOnlyHint: 'Im Live-Monitor Unteragenten- und Sitzungsmonitor-Rauschen standardmäßig ausblenden',
|
||||
atHourHint: 'Sitzung taglich zu dieser Stunde zurucksetzen',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -86,6 +86,17 @@ export default {
|
||||
deleteSession: 'Delete this session?',
|
||||
sessionDeleted: 'Session deleted',
|
||||
rename: 'Rename',
|
||||
pin: 'Pin',
|
||||
unpin: 'Unpin',
|
||||
pinned: 'Pinned',
|
||||
chatMode: 'Chat',
|
||||
liveMode: 'Live',
|
||||
liveSessions: 'Live Sessions',
|
||||
recentBadge: 'Recent',
|
||||
linkedSessions: '{count} linked',
|
||||
noVisibleMessages: 'No human-visible messages.',
|
||||
monitorRoleUser: 'User',
|
||||
monitorRoleAssistant: 'Assistant',
|
||||
copySessionId: 'Copy Session ID',
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
@@ -369,6 +380,10 @@ export default {
|
||||
idleMinutes: 'Idle Timeout',
|
||||
idleMinutesHint: 'Wait time before auto-reset (minutes)',
|
||||
atHour: 'Scheduled Reset Time',
|
||||
humanOnly: 'Show human sessions only',
|
||||
humanOnlyHint: 'Hide sub-agent/session monitor noise by default',
|
||||
liveMonitorHumanOnly: 'Live monitor: show human sessions only',
|
||||
liveMonitorHumanOnlyHint: 'Hide sub-agent/session monitor noise in the Live monitor by default',
|
||||
atHourHint: 'Reset session at this hour daily',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -73,7 +73,18 @@ export default {
|
||||
deleteSession: 'Eliminar esta sesion?',
|
||||
sessionDeleted: 'Sesion eliminada',
|
||||
rename: 'Renombrar',
|
||||
copySessionId: 'Copiar ID de sesion',
|
||||
pin: 'Fijar',
|
||||
unpin: 'Desfijar',
|
||||
pinned: 'Fijados',
|
||||
chatMode: 'Chat',
|
||||
liveMode: 'En vivo',
|
||||
liveSessions: 'Sesiones en vivo',
|
||||
recentBadge: 'Reciente',
|
||||
linkedSessions: '{count} vinculadas',
|
||||
noVisibleMessages: 'No hay mensajes visibles para humanos.',
|
||||
monitorRoleUser: 'Usuario',
|
||||
monitorRoleAssistant: 'Asistente',
|
||||
copySessionId: 'Copiar ID de sesión',
|
||||
renamed: 'Renombrada',
|
||||
renameFailed: 'Error al renombrar',
|
||||
renameSession: 'Renombrar sesion',
|
||||
@@ -338,6 +349,10 @@ export default {
|
||||
idleMinutes: 'Tiempo de inactividad',
|
||||
idleMinutesHint: 'Tiempo de espera antes del reinicio automatico (minutos)',
|
||||
atHour: 'Hora de reinicio programado',
|
||||
humanOnly: 'Mostrar solo sesiones humanas',
|
||||
humanOnlyHint: 'Oculta por defecto el ruido de subagentes y del monitor de sesiones',
|
||||
liveMonitorHumanOnly: 'Monitor en vivo: mostrar solo sesiones humanas',
|
||||
liveMonitorHumanOnlyHint: 'Oculta por defecto el ruido de subagentes y del monitor de sesiones en el monitor en vivo',
|
||||
atHourHint: 'Reiniciar sesion a esta hora todos los dias',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -73,7 +73,18 @@ export default {
|
||||
deleteSession: 'Supprimer cette session ?',
|
||||
sessionDeleted: 'Session supprimee',
|
||||
rename: 'Renommer',
|
||||
copySessionId: 'Copier l\'ID de session',
|
||||
pin: 'Épingler',
|
||||
unpin: 'Désépingler',
|
||||
pinned: 'Épinglés',
|
||||
chatMode: 'Chat',
|
||||
liveMode: 'Direct',
|
||||
liveSessions: 'Sessions en direct',
|
||||
recentBadge: 'Récent',
|
||||
linkedSessions: '{count} sessions liées',
|
||||
noVisibleMessages: 'Aucun message visible par l’humain.',
|
||||
monitorRoleUser: 'Utilisateur',
|
||||
monitorRoleAssistant: 'Assistant',
|
||||
copySessionId: "Copier l'ID de session",
|
||||
renamed: 'Renomme',
|
||||
renameFailed: 'Echec du renommage',
|
||||
renameSession: 'Renommer la session',
|
||||
@@ -338,6 +349,10 @@ export default {
|
||||
idleMinutes: 'Delai d\'inactivite',
|
||||
idleMinutesHint: 'Temps d\'attente avant reinitialisation automatique (minutes)',
|
||||
atHour: 'Heure de reinitialisation planifiee',
|
||||
humanOnly: 'Afficher uniquement les sessions humaines',
|
||||
humanOnlyHint: 'Masquer par défaut le bruit des sous-agents et du moniteur de session',
|
||||
liveMonitorHumanOnly: 'Moniteur live : n’afficher que les sessions humaines',
|
||||
liveMonitorHumanOnlyHint: 'Masquer par défaut le bruit des sous-agents et du moniteur de session dans le moniteur live',
|
||||
atHourHint: 'Reinitialiser la session a cette heure chaque jour',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -73,6 +73,17 @@ export default {
|
||||
deleteSession: 'このセッションを削除しますか?',
|
||||
sessionDeleted: 'セッションを削除しました',
|
||||
rename: '名前変更',
|
||||
pin: 'ピン留め',
|
||||
unpin: 'ピン留め解除',
|
||||
pinned: 'ピン留め',
|
||||
chatMode: 'チャット',
|
||||
liveMode: 'ライブ',
|
||||
liveSessions: 'ライブセッション',
|
||||
recentBadge: '最近',
|
||||
linkedSessions: '{count} 件の関連',
|
||||
noVisibleMessages: '人間向けに表示できるメッセージはありません。',
|
||||
monitorRoleUser: 'ユーザー',
|
||||
monitorRoleAssistant: 'アシスタント',
|
||||
copySessionId: 'セッション ID をコピー',
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
@@ -338,6 +349,10 @@ export default {
|
||||
idleMinutes: 'アイドルタイムアウト',
|
||||
idleMinutesHint: '自動リセットまでの待機時間(分)',
|
||||
atHour: 'スケジュールリセット時刻',
|
||||
humanOnly: '人間のセッションのみ表示',
|
||||
humanOnlyHint: 'サブエージェントやセッション監視ノイズを既定で隠します',
|
||||
liveMonitorHumanOnly: 'ライブモニター: 人間のセッションのみ表示',
|
||||
liveMonitorHumanOnlyHint: 'ライブモニターでサブエージェントやセッション監視ノイズを既定で隠します',
|
||||
atHourHint: '毎日指定時刻にセッションをリセット',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -73,6 +73,17 @@ export default {
|
||||
deleteSession: '이 세션을 삭제하시겠습니까?',
|
||||
sessionDeleted: '세션이 삭제되었습니다',
|
||||
rename: '이름 변경',
|
||||
pin: '고정',
|
||||
unpin: '고정 해제',
|
||||
pinned: '고정됨',
|
||||
chatMode: '채팅',
|
||||
liveMode: '라이브',
|
||||
liveSessions: '라이브 세션',
|
||||
recentBadge: '최근',
|
||||
linkedSessions: '{count}개 연결됨',
|
||||
noVisibleMessages: '사람이 볼 수 있는 메시지가 없습니다.',
|
||||
monitorRoleUser: '사용자',
|
||||
monitorRoleAssistant: '어시스턴트',
|
||||
copySessionId: '세션 ID 복사',
|
||||
renamed: '이름이 변경되었습니다',
|
||||
renameFailed: '이름 변경 실패',
|
||||
@@ -338,6 +349,10 @@ export default {
|
||||
idleMinutes: '유휴 시간초과',
|
||||
idleMinutesHint: '자동 초기화 대기 시간 (분)',
|
||||
atHour: '예약 초기화 시간',
|
||||
humanOnly: '사람 세션만 표시',
|
||||
humanOnlyHint: '하위 에이전트 및 세션 모니터 노이즈를 기본으로 숨깁니다',
|
||||
liveMonitorHumanOnly: '라이브 모니터: 사람 세션만 표시',
|
||||
liveMonitorHumanOnlyHint: '라이브 모니터에서 하위 에이전트 및 세션 모니터 노이즈를 기본으로 숨깁니다',
|
||||
atHourHint: '매일 지정한 시간에 세션 초기화',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -73,7 +73,18 @@ export default {
|
||||
deleteSession: 'Excluir esta sessao?',
|
||||
sessionDeleted: 'Sessao excluida',
|
||||
rename: 'Renomear',
|
||||
copySessionId: 'Copiar ID da sessao',
|
||||
pin: 'Fixar',
|
||||
unpin: 'Desafixar',
|
||||
pinned: 'Fixadas',
|
||||
chatMode: 'Chat',
|
||||
liveMode: 'Ao vivo',
|
||||
liveSessions: 'Sessões ao vivo',
|
||||
recentBadge: 'Recente',
|
||||
linkedSessions: '{count} vinculadas',
|
||||
noVisibleMessages: 'Nenhuma mensagem visível para humanos.',
|
||||
monitorRoleUser: 'Usuário',
|
||||
monitorRoleAssistant: 'Assistente',
|
||||
copySessionId: 'Copiar ID da sessão',
|
||||
renamed: 'Renomeado',
|
||||
renameFailed: 'Falha ao renomear',
|
||||
renameSession: 'Renomear sessao',
|
||||
@@ -338,6 +349,10 @@ export default {
|
||||
idleMinutes: 'Timeout de inatividade',
|
||||
idleMinutesHint: 'Tempo de espera antes da reinicializacao automatica (minutos)',
|
||||
atHour: 'Horario de reinicializacao agendada',
|
||||
humanOnly: 'Mostrar apenas sessões humanas',
|
||||
humanOnlyHint: 'Oculta por padrão o ruído de subagentes e do monitor de sessões',
|
||||
liveMonitorHumanOnly: 'Monitor ao vivo: mostrar apenas sessões humanas',
|
||||
liveMonitorHumanOnlyHint: 'Oculta por padrão o ruído de subagentes e do monitor de sessões no monitor ao vivo',
|
||||
atHourHint: 'Reiniciar sessao neste horario diariamente',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -86,6 +86,17 @@ export default {
|
||||
deleteSession: '确定删除此会话?',
|
||||
sessionDeleted: '会话已删除',
|
||||
rename: '重命名',
|
||||
pin: '置顶',
|
||||
unpin: '取消置顶',
|
||||
pinned: '已置顶',
|
||||
chatMode: '聊天',
|
||||
liveMode: '实时',
|
||||
liveSessions: '实时会话',
|
||||
recentBadge: '最近',
|
||||
linkedSessions: '关联 {count} 个会话',
|
||||
noVisibleMessages: '没有人类可见消息。',
|
||||
monitorRoleUser: '用户',
|
||||
monitorRoleAssistant: '助手',
|
||||
copySessionId: '复制会话 ID',
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
@@ -361,6 +372,10 @@ export default {
|
||||
idleMinutes: '空闲超时',
|
||||
idleMinutesHint: '无操作后自动重置的等待时间(分钟)',
|
||||
atHour: '定时重置时间',
|
||||
humanOnly: '仅显示人类会话',
|
||||
humanOnlyHint: '默认隐藏子代理和会话监看噪音',
|
||||
liveMonitorHumanOnly: '实时监看:仅显示人类会话',
|
||||
liveMonitorHumanOnlyHint: '在实时监看中默认隐藏子代理和会话监看噪音',
|
||||
atHourHint: '每天在指定小时重置会话',
|
||||
},
|
||||
privacy: {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
api_server: 'API Server',
|
||||
cli: 'CLI',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
matrix: 'Matrix',
|
||||
whatsapp: 'WhatsApp',
|
||||
signal: 'Signal',
|
||||
email: 'Email',
|
||||
sms: 'SMS',
|
||||
dingtalk: 'DingTalk',
|
||||
feishu: 'Feishu',
|
||||
wecom: 'WeCom',
|
||||
weixin: 'WeChat',
|
||||
bluebubbles: 'iMessage',
|
||||
mattermost: 'Mattermost',
|
||||
cron: 'Cron',
|
||||
}
|
||||
|
||||
export function getSourceLabel(source?: string): string {
|
||||
if (!source) return ''
|
||||
return SOURCE_LABELS[source] || source
|
||||
}
|
||||
|
||||
export function formatTimestampMs(timestamp: number): string {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function formatTimestampSeconds(timestamp: number): string {
|
||||
return formatTimestampMs(timestamp * 1000)
|
||||
}
|
||||
@@ -234,6 +234,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const streamStates = ref<Map<string, AbortController>>(new Map())
|
||||
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
||||
const isLoadingSessions = ref(false)
|
||||
const sessionsLoaded = ref(false)
|
||||
const isLoadingMessages = ref(false)
|
||||
// tmux-like resume state: true when we recovered an in-flight run from
|
||||
// localStorage after a refresh and are polling fetchSession for progress.
|
||||
@@ -428,6 +429,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
console.error('Failed to load sessions:', err)
|
||||
} finally {
|
||||
isLoadingSessions.value = false
|
||||
sessionsLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,7 +920,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
isRunActive,
|
||||
isSessionLive,
|
||||
isLoadingSessions,
|
||||
sessionsLoaded,
|
||||
isLoadingMessages,
|
||||
|
||||
newChat,
|
||||
switchSession,
|
||||
switchSessionModel,
|
||||
|
||||
@@ -66,6 +66,8 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
`hermes_session_msgs_v1_${profileName}_`,
|
||||
`hermes_in_flight_v1_${profileName}_`,
|
||||
`hermes_active_session_${profileName}`,
|
||||
`hermes_session_pins_v1_${profileName}`,
|
||||
`hermes_human_only_v1_${profileName}`,
|
||||
]
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useProfilesStore } from './profiles'
|
||||
|
||||
const PIN_KEY_PREFIX = 'hermes_session_pins_v1_'
|
||||
const HUMAN_ONLY_KEY_PREFIX = 'hermes_human_only_v1_'
|
||||
|
||||
function currentProfileName(): string {
|
||||
try {
|
||||
return useProfilesStore().activeProfileName || localStorage.getItem('hermes_active_profile_name') || 'default'
|
||||
} catch {
|
||||
return localStorage.getItem('hermes_active_profile_name') || 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function pinsKey(profileName: string): string {
|
||||
return `${PIN_KEY_PREFIX}${profileName}`
|
||||
}
|
||||
|
||||
function humanOnlyKey(profileName: string): string {
|
||||
return `${HUMAN_ONLY_KEY_PREFIX}${profileName}`
|
||||
}
|
||||
|
||||
function loadJson<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) as T : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function saveJson(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore quota/storage errors — fall back to in-memory only
|
||||
}
|
||||
}
|
||||
|
||||
function sameIds(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||
}
|
||||
|
||||
export const useSessionBrowserPrefsStore = defineStore('session-browser-prefs', () => {
|
||||
const profileName = ref(currentProfileName())
|
||||
const pinnedIds = ref<string[]>(loadJson<string[]>(pinsKey(profileName.value), []))
|
||||
const humanOnly = ref<boolean>(loadJson<boolean>(humanOnlyKey(profileName.value), true))
|
||||
|
||||
function reload() {
|
||||
profileName.value = currentProfileName()
|
||||
pinnedIds.value = loadJson<string[]>(pinsKey(profileName.value), [])
|
||||
humanOnly.value = loadJson<boolean>(humanOnlyKey(profileName.value), true)
|
||||
}
|
||||
|
||||
function persistPins() {
|
||||
saveJson(pinsKey(profileName.value), pinnedIds.value)
|
||||
}
|
||||
|
||||
function persistHumanOnly() {
|
||||
saveJson(humanOnlyKey(profileName.value), humanOnly.value)
|
||||
}
|
||||
|
||||
function isPinned(sessionId: string): boolean {
|
||||
return pinnedIds.value.includes(sessionId)
|
||||
}
|
||||
|
||||
function togglePinned(sessionId: string) {
|
||||
if (isPinned(sessionId)) {
|
||||
pinnedIds.value = pinnedIds.value.filter(id => id !== sessionId)
|
||||
} else {
|
||||
pinnedIds.value = [...pinnedIds.value, sessionId]
|
||||
}
|
||||
persistPins()
|
||||
}
|
||||
|
||||
function removePinned(sessionId: string): boolean {
|
||||
if (!isPinned(sessionId)) return false
|
||||
pinnedIds.value = pinnedIds.value.filter(id => id !== sessionId)
|
||||
persistPins()
|
||||
return true
|
||||
}
|
||||
|
||||
function setHumanOnly(value: boolean) {
|
||||
if (humanOnly.value === value) return
|
||||
humanOnly.value = value
|
||||
persistHumanOnly()
|
||||
}
|
||||
|
||||
function pruneMissingSessions(existingIds: string[]): boolean {
|
||||
if (existingIds.length === 0) return false
|
||||
const existing = new Set(existingIds)
|
||||
const nextPinnedIds = pinnedIds.value.filter(id => existing.has(id))
|
||||
if (sameIds(nextPinnedIds, pinnedIds.value)) return false
|
||||
pinnedIds.value = nextPinnedIds
|
||||
persistPins()
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => useProfilesStore().activeProfileName,
|
||||
() => reload(),
|
||||
)
|
||||
|
||||
return {
|
||||
profileName,
|
||||
pinnedIds,
|
||||
humanOnly,
|
||||
reload,
|
||||
isPinned,
|
||||
togglePinned,
|
||||
removePinned,
|
||||
setHumanOnly,
|
||||
pruneMissingSessions,
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,39 @@
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { getConversationDetail, listConversationSummaries } from '../../services/hermes/conversations'
|
||||
import { listSessionSummaries } from '../../services/hermes/sessions-db'
|
||||
import { logger } from '../../services/logger'
|
||||
|
||||
function parseHumanOnly(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return true
|
||||
return value !== 'false' && value !== '0'
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const parsed = parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
export async function listConversations(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const humanOnly = parseHumanOnly(ctx.query.humanOnly)
|
||||
const limit = parseLimit(ctx.query.limit)
|
||||
const sessions = await listConversationSummaries({ source, humanOnly, limit })
|
||||
ctx.body = { sessions }
|
||||
}
|
||||
|
||||
export async function getConversationMessages(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const humanOnly = parseHumanOnly(ctx.query.humanOnly)
|
||||
const detail = await getConversationDetail(ctx.params.id, { source, humanOnly })
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = detail
|
||||
}
|
||||
|
||||
export async function list(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
|
||||
@@ -55,7 +55,10 @@ export async function proxy(ctx: Context) {
|
||||
// /api/hermes/v1/* -> /v1/* (upstream uses /v1/ prefix)
|
||||
// /api/hermes/* -> /api/* (upstream uses /api/ prefix)
|
||||
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
|
||||
const url = `${upstream}${upstreamPath}${ctx.search || ''}`
|
||||
const params = new URLSearchParams(ctx.search || '')
|
||||
params.delete('token')
|
||||
const search = params.toString()
|
||||
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
|
||||
|
||||
// Build headers — forward most, strip browser/web-ui specific ones
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -64,7 +67,7 @@ export async function proxy(ctx: Context) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'host') {
|
||||
headers['host'] = new URL(upstream).host
|
||||
} else if (lower === 'origin' || lower === 'referer' || lower === 'connection') {
|
||||
} else if (lower === 'origin' || lower === 'referer' || lower === 'connection' || lower === 'authorization') {
|
||||
continue
|
||||
} else {
|
||||
const v = Array.isArray(value) ? value[0] : value
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as ctrl from '../../controllers/hermes/sessions'
|
||||
|
||||
export const sessionRoutes = new Router()
|
||||
|
||||
sessionRoutes.get('/api/hermes/sessions/conversations', ctrl.listConversations)
|
||||
sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages', ctrl.getConversationMessages)
|
||||
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
|
||||
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
|
||||
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export interface HermesSession {
|
||||
messages?: any[]
|
||||
}
|
||||
|
||||
interface HermesSessionFull {
|
||||
export interface HermesSessionFull {
|
||||
id: string
|
||||
source: string
|
||||
user_id: string | null
|
||||
@@ -67,10 +67,21 @@ interface HermesSessionFull {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from Hermes CLI (without messages)
|
||||
*/
|
||||
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
||||
function parseSessionExport(stdout: string): HermesSessionFull[] {
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const sessions: HermesSessionFull[] = []
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw: HermesSessionFull = JSON.parse(line)
|
||||
sessions.push(raw)
|
||||
} catch {
|
||||
// Skip non-JSON lines such as "Session 'x' not found."
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
export async function exportSessionsRaw(source?: string): Promise<HermesSessionFull[]> {
|
||||
const args = ['sessions', 'export', '-']
|
||||
if (source) args.push('--source', source)
|
||||
|
||||
@@ -80,58 +91,61 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
|
||||
timeout: 30000,
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const sessions: HermesSession[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw: HermesSessionFull = JSON.parse(line)
|
||||
let title = raw.title
|
||||
if (!title && raw.messages) {
|
||||
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
||||
if (firstUser?.content) {
|
||||
const t = String(firstUser.content).slice(0, 40)
|
||||
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
sessions.push({
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
cache_read_tokens: raw.cache_read_tokens || 0,
|
||||
cache_write_tokens: raw.cache_write_tokens || 0,
|
||||
reasoning_tokens: raw.reasoning_tokens || 0,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
actual_cost_usd: raw.actual_cost_usd ?? null,
|
||||
cost_status: raw.cost_status || '',
|
||||
})
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
|
||||
// Sort by started_at descending
|
||||
sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return sessions.slice(0, limit)
|
||||
}
|
||||
return sessions
|
||||
return parseSessionExport(stdout)
|
||||
} catch (err: any) {
|
||||
logger.error(err, 'Hermes CLI: sessions export failed')
|
||||
throw new Error(`Failed to list sessions: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from Hermes CLI (without messages)
|
||||
*/
|
||||
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
||||
const raws = await exportSessionsRaw(source)
|
||||
const sessions: HermesSession[] = []
|
||||
|
||||
for (const raw of raws) {
|
||||
let title = raw.title
|
||||
if (!title && raw.messages) {
|
||||
const firstUser = raw.messages.find((m: any) => m.role === 'user')
|
||||
if (firstUser?.content) {
|
||||
const t = String(firstUser.content).slice(0, 40)
|
||||
title = t + (String(firstUser.content).length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
sessions.push({
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
cache_read_tokens: raw.cache_read_tokens || 0,
|
||||
cache_write_tokens: raw.cache_write_tokens || 0,
|
||||
reasoning_tokens: raw.reasoning_tokens || 0,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
actual_cost_usd: raw.actual_cost_usd ?? null,
|
||||
cost_status: raw.cost_status || '',
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by started_at descending
|
||||
sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return sessions.slice(0, limit)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session with messages from Hermes CLI
|
||||
*/
|
||||
@@ -145,12 +159,10 @@ export async function getSession(id: string): Promise<HermesSession | null> {
|
||||
...execOpts,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
const raws = parseSessionExport(stdout)
|
||||
if (raws.length === 0) return null
|
||||
|
||||
if (!lines[0].startsWith('{')) return null
|
||||
|
||||
const raw: HermesSessionFull = JSON.parse(lines[0])
|
||||
const raw: HermesSessionFull = raws[0]
|
||||
return {
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const mockChatStore = vi.hoisted(() => ({
|
||||
sessions: [] as Array<Record<string, any>>,
|
||||
activeSessionId: null as string | null,
|
||||
activeSession: null as Record<string, any> | null,
|
||||
isLoadingSessions: false,
|
||||
sessionsLoaded: true,
|
||||
isSessionLive: vi.fn((sessionId: string) => sessionId === 'discord-active'),
|
||||
newChat: vi.fn(),
|
||||
switchSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/chat', () => ({
|
||||
useChatStore: () => mockChatStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
renameSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/MessageList.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="message-list-mock" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="chat-input-mock" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/ConversationMonitorPane.vue', () => ({
|
||||
default: {
|
||||
props: ['humanOnly'],
|
||||
template: '<div class="conversation-monitor-mock">monitor {{ humanOnly }}</div>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
|
||||
function makeSession(id: string, overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
source: 'api_server',
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
model: 'gpt-4o',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const NButtonStub = {
|
||||
emits: ['click'],
|
||||
template: '<button class="n-button-stub" v-bind="$attrs" @click="$emit(\'click\')"><slot /><slot name="icon" /></button>',
|
||||
}
|
||||
|
||||
const NDropdownStub = {
|
||||
props: ['options', 'show'],
|
||||
emits: ['select', 'clickoutside'],
|
||||
template: `
|
||||
<div v-if="show" class="dropdown-stub">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.key"
|
||||
class="dropdown-option"
|
||||
@click="$emit('select', option.key)"
|
||||
>{{ option.label }}</button>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
describe('ChatPanel modes and pinning', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
vi.clearAllMocks()
|
||||
|
||||
const activeDiscord = makeSession('discord-active', {
|
||||
title: 'Discord Active',
|
||||
source: 'discord',
|
||||
createdAt: 100,
|
||||
updatedAt: 500,
|
||||
})
|
||||
const olderDiscord = makeSession('discord-older', {
|
||||
title: 'Discord Older',
|
||||
source: 'discord',
|
||||
createdAt: 200,
|
||||
updatedAt: 400,
|
||||
})
|
||||
const slackSession = makeSession('slack-1', {
|
||||
title: 'Slack Selected',
|
||||
source: 'slack',
|
||||
createdAt: 50,
|
||||
updatedAt: 50,
|
||||
})
|
||||
const apiSession = makeSession('api-1', {
|
||||
title: 'API Session',
|
||||
source: 'api_server',
|
||||
createdAt: 300,
|
||||
updatedAt: 300,
|
||||
})
|
||||
|
||||
mockChatStore.sessions = [apiSession, slackSession, olderDiscord, activeDiscord]
|
||||
mockChatStore.activeSessionId = apiSession.id
|
||||
mockChatStore.activeSession = apiSession
|
||||
mockChatStore.isLoadingSessions = false
|
||||
mockChatStore.sessionsLoaded = true
|
||||
mockChatStore.isSessionLive.mockImplementation((sessionId: string) => sessionId === activeDiscord.id)
|
||||
mockChatStore.switchSession.mockImplementation((sessionId: string) => {
|
||||
mockChatStore.activeSessionId = sessionId
|
||||
mockChatStore.activeSession = mockChatStore.sessions.find(s => s.id === sessionId) ?? null
|
||||
})
|
||||
})
|
||||
|
||||
it('pins and unpins a session through the context menu without duplicating it', async () => {
|
||||
const prefsStore = useSessionBrowserPrefsStore()
|
||||
const wrapper = mount(ChatPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
NButton: NButtonStub,
|
||||
NDropdown: NDropdownStub,
|
||||
NInput: true,
|
||||
NModal: true,
|
||||
NPopconfirm: true,
|
||||
NTooltip: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const slackRow = wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))
|
||||
expect(slackRow).toBeTruthy()
|
||||
await slackRow!.trigger('contextmenu')
|
||||
;(wrapper.vm as any).handleContextMenuSelect('pin')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(prefsStore.pinnedIds).toEqual(['slack-1'])
|
||||
const groupLabelsAfterPin = wrapper.findAll('.session-group-label').map(node => node.text())
|
||||
expect(groupLabelsAfterPin[0]).toBe('chat.pinned')
|
||||
expect(wrapper.findAll('.session-item-title').map(node => node.text()).filter(text => text === 'Slack Selected')).toHaveLength(1)
|
||||
|
||||
const pinnedRow = wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))
|
||||
await pinnedRow!.trigger('contextmenu')
|
||||
;(wrapper.vm as any).handleContextMenuSelect('pin')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(prefsStore.pinnedIds).toEqual([])
|
||||
expect(wrapper.findAll('.session-group-label').map(node => node.text())).not.toContain('chat.pinned')
|
||||
expect(wrapper.findAll('.session-item-title').map(node => node.text()).filter(text => text === 'Slack Selected')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not prune saved pins before sessions have completed loading or when the list is empty', () => {
|
||||
const prefsStore = useSessionBrowserPrefsStore()
|
||||
const pruneSpy = vi.spyOn(prefsStore, 'pruneMissingSessions')
|
||||
mockChatStore.sessions = []
|
||||
mockChatStore.activeSessionId = null
|
||||
mockChatStore.activeSession = null
|
||||
mockChatStore.sessionsLoaded = false
|
||||
|
||||
mount(ChatPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
NButton: NButtonStub,
|
||||
NDropdown: NDropdownStub,
|
||||
NInput: true,
|
||||
NModal: true,
|
||||
NPopconfirm: true,
|
||||
NTooltip: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(pruneSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches between live and chat mode with accessible pressed state and restores sidebar visibility', async () => {
|
||||
const wrapper = mount(ChatPanel, {
|
||||
global: {
|
||||
stubs: {
|
||||
NDropdown: NDropdownStub,
|
||||
NInput: true,
|
||||
NModal: true,
|
||||
NPopconfirm: true,
|
||||
NTooltip: true,
|
||||
NButton: NButtonStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modeButtons = wrapper.findAll('.chat-mode-toggle button')
|
||||
expect(modeButtons[0].attributes('aria-pressed')).toBe('true')
|
||||
expect(modeButtons[1].attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.find('.session-list').classes()).not.toContain('collapsed')
|
||||
|
||||
await modeButtons[1].trigger('click')
|
||||
const liveButtons = wrapper.findAll('.chat-mode-toggle button')
|
||||
expect(liveButtons[0].attributes('aria-pressed')).toBe('false')
|
||||
expect(liveButtons[1].attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.conversation-monitor-mock').exists()).toBe(true)
|
||||
|
||||
await liveButtons[0].trigger('click')
|
||||
const chatButtons = wrapper.findAll('.chat-mode-toggle button')
|
||||
expect(chatButtons[0].attributes('aria-pressed')).toBe('true')
|
||||
expect(chatButtons[1].attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.find('.session-list').classes()).not.toContain('collapsed')
|
||||
expect(wrapper.find('.chat-input-mock').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,10 +13,23 @@ const mockChatStore = vi.hoisted(() => ({
|
||||
deleteSession: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockPrefsStore = vi.hoisted(() => ({
|
||||
pinnedIds: [] as string[],
|
||||
humanOnly: true,
|
||||
isPinned: vi.fn(() => false),
|
||||
togglePinned: vi.fn(),
|
||||
setHumanOnly: vi.fn(),
|
||||
pruneMissingSessions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/chat', () => ({
|
||||
useChatStore: () => mockChatStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/session-browser-prefs', () => ({
|
||||
useSessionBrowserPrefsStore: () => mockPrefsStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/sessions', () => ({
|
||||
renameSession: vi.fn(),
|
||||
}))
|
||||
@@ -33,6 +46,12 @@ vi.mock('@/components/hermes/chat/ChatInput.vue', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/ConversationMonitorPane.vue', () => ({
|
||||
default: {
|
||||
template: '<div class="conversation-monitor-mock" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -128,8 +147,8 @@ describe('ChatPanel session list', () => {
|
||||
const sessionTitles = wrapper.findAll('.session-item-title').map(node => node.text())
|
||||
expect(sessionTitles.slice(0, 2)).toEqual(['Discord Active', 'Discord Older'])
|
||||
|
||||
const activeIndicator = wrapper.find('.session-item.active .session-item-active-indicator')
|
||||
expect(activeIndicator.exists()).toBe(true)
|
||||
const liveRow = wrapper.findAll('.session-item').find(node => node.text().includes('Discord Active'))
|
||||
expect(liveRow?.find('.session-item-active-indicator').exists()).toBe(true)
|
||||
|
||||
await wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))!.trigger('click')
|
||||
|
||||
@@ -137,8 +156,5 @@ describe('ChatPanel session list', () => {
|
||||
|
||||
const groupLabelsAfterClick = wrapper.findAll('.session-group-label').map(node => node.text())
|
||||
expect(groupLabelsAfterClick[0]).toBe('Discord')
|
||||
|
||||
const activeTitlesAfterClick = wrapper.findAll('.session-item.active .session-item-title').map(node => node.text())
|
||||
expect(activeTitlesAfterClick).toEqual(['Discord Active'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,12 @@ async function flushPromises() {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const PROFILE = 'default'
|
||||
const ACTIVE_SESSION_KEY = `hermes_active_session_${PROFILE}`
|
||||
const SESSIONS_CACHE_KEY = `hermes_sessions_cache_v1_${PROFILE}`
|
||||
const sessionMessagesKey = (sessionId: string) => `hermes_session_msgs_v1_${PROFILE}_${sessionId}_`
|
||||
const inFlightKey = (sessionId: string) => `hermes_in_flight_v1_${PROFILE}_${sessionId}`
|
||||
|
||||
describe('Chat Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -82,19 +88,20 @@ describe('Chat Store', () => {
|
||||
{ id: 'm1', role: 'user', content: 'draft', timestamp: 1 },
|
||||
]
|
||||
|
||||
window.localStorage.setItem('hermes_active_session', 'local-1')
|
||||
window.localStorage.setItem('hermes_sessions_cache_v1', JSON.stringify([cachedSession]))
|
||||
window.localStorage.setItem('hermes_session_msgs_v1_local-1', JSON.stringify(cachedMessages))
|
||||
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'local-1')
|
||||
window.localStorage.setItem(SESSIONS_CACHE_KEY, JSON.stringify([cachedSession]))
|
||||
window.localStorage.setItem(sessionMessagesKey('local-1'), JSON.stringify(cachedMessages))
|
||||
|
||||
mockSessionsApi.fetchSessions.mockResolvedValue([makeSummary('remote-1', 'Remote Session')])
|
||||
mockSessionsApi.fetchSession.mockResolvedValue(null)
|
||||
|
||||
const store = useChatStore()
|
||||
const loadPromise = store.loadSessions()
|
||||
|
||||
expect(store.activeSessionId).toBe('local-1')
|
||||
expect(store.messages.map(m => m.content)).toEqual(['draft'])
|
||||
|
||||
await flushPromises()
|
||||
await loadPromise
|
||||
|
||||
expect(store.sessions.map(s => s.id)).toEqual(['local-1', 'remote-1'])
|
||||
expect(store.activeSession?.id).toBe('local-1')
|
||||
@@ -109,10 +116,10 @@ describe('Chat Store', () => {
|
||||
|
||||
const sid = store.activeSessionId
|
||||
expect(sid).toBeTruthy()
|
||||
expect(window.localStorage.getItem('hermes_active_session')).toBe(sid)
|
||||
expect(window.localStorage.getItem(ACTIVE_SESSION_KEY)).toBe(sid)
|
||||
|
||||
const cachedMessages = JSON.parse(
|
||||
window.localStorage.getItem(`hermes_session_msgs_v1_${sid}`) || '[]',
|
||||
window.localStorage.getItem(sessionMessagesKey(sid!)) || '[]',
|
||||
)
|
||||
expect(cachedMessages).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -127,9 +134,9 @@ describe('Chat Store', () => {
|
||||
it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
window.localStorage.setItem('hermes_active_session', 'sess-1')
|
||||
window.localStorage.setItem(ACTIVE_SESSION_KEY, 'sess-1')
|
||||
window.localStorage.setItem(
|
||||
'hermes_sessions_cache_v1',
|
||||
SESSIONS_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'sess-1',
|
||||
@@ -142,7 +149,7 @@ describe('Chat Store', () => {
|
||||
]),
|
||||
)
|
||||
window.localStorage.setItem(
|
||||
'hermes_session_msgs_v1_sess-1',
|
||||
sessionMessagesKey('sess-1'),
|
||||
JSON.stringify([
|
||||
{ id: 'old-user', role: 'user', content: 'old prompt', timestamp: 1 },
|
||||
]),
|
||||
@@ -221,6 +228,6 @@ describe('Chat Store', () => {
|
||||
expect(store.messages.some(m => m.role === 'system' && m.content.includes('SSE connection error'))).toBe(false)
|
||||
expect(store.messages.some(m => m.role === 'assistant' && m.content === 'final answer')).toBe(true)
|
||||
expect(store.isRunActive).toBe(false)
|
||||
expect(window.localStorage.getItem('hermes_in_flight_v1_sess-1')).toBeNull()
|
||||
expect(window.localStorage.getItem(inFlightKey('sess-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockConversationsApi = vi.hoisted(() => ({
|
||||
fetchConversationSummaries: vi.fn(),
|
||||
fetchConversationDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/conversations', () => mockConversationsApi)
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'chat.linkedSessions' && params?.count != null) return `${params.count} linked`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import ConversationMonitorPane from '@/components/hermes/chat/ConversationMonitorPane.vue'
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(res => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('ConversationMonitorPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockConversationsApi.fetchConversationSummaries.mockResolvedValue([
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: 'First conversation',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
started_at: 10,
|
||||
ended_at: 20,
|
||||
last_active: 20,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
preview: 'preview',
|
||||
is_active: true,
|
||||
thread_session_count: 1,
|
||||
},
|
||||
{
|
||||
id: 'conv-2',
|
||||
title: 'Second conversation',
|
||||
source: 'discord',
|
||||
model: 'openai/gpt-5.4',
|
||||
started_at: 30,
|
||||
ended_at: 40,
|
||||
last_active: 40,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 3,
|
||||
output_tokens: 5,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
preview: 'preview-2',
|
||||
is_active: false,
|
||||
thread_session_count: 2,
|
||||
},
|
||||
])
|
||||
mockConversationsApi.fetchConversationDetail.mockResolvedValue({
|
||||
session_id: 'conv-1',
|
||||
visible_count: 2,
|
||||
thread_session_count: 1,
|
||||
messages: [
|
||||
{ id: 1, session_id: 'conv-1', role: 'user', content: 'hello', timestamp: 11 },
|
||||
{ id: 2, session_id: 'conv-1', role: 'assistant', content: 'world', timestamp: 12 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads conversations and the first transcript using the humanOnly preference', async () => {
|
||||
const wrapper = mount(ConversationMonitorPane, {
|
||||
props: { humanOnly: true },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockConversationsApi.fetchConversationSummaries).toHaveBeenCalledWith({ humanOnly: true })
|
||||
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true })
|
||||
expect(wrapper.text()).toContain('First conversation')
|
||||
expect(wrapper.text()).toContain('hello')
|
||||
expect(wrapper.text()).toContain('world')
|
||||
})
|
||||
|
||||
it('ignores stale detail responses when selection changes quickly', async () => {
|
||||
const first = deferred<any>()
|
||||
const second = deferred<any>()
|
||||
mockConversationsApi.fetchConversationDetail
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const wrapper = mount(ConversationMonitorPane, {
|
||||
props: { humanOnly: true },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenCalledWith('conv-1', { humanOnly: true })
|
||||
|
||||
const sessionButtons = wrapper.findAll('.conversation-monitor__session')
|
||||
expect(sessionButtons).toHaveLength(2)
|
||||
await sessionButtons[1].trigger('click')
|
||||
|
||||
expect(mockConversationsApi.fetchConversationDetail).toHaveBeenLastCalledWith('conv-2', { humanOnly: true })
|
||||
|
||||
second.resolve({
|
||||
session_id: 'conv-2',
|
||||
visible_count: 1,
|
||||
thread_session_count: 2,
|
||||
messages: [
|
||||
{ id: 21, session_id: 'conv-2', role: 'assistant', content: 'newer detail wins', timestamp: 41 },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
first.resolve({
|
||||
session_id: 'conv-1',
|
||||
visible_count: 1,
|
||||
thread_session_count: 1,
|
||||
messages: [
|
||||
{ id: 11, session_id: 'conv-1', role: 'assistant', content: 'stale detail loses', timestamp: 12 },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const renderedMessages = wrapper.findAll('.conversation-monitor__message-content').map(node => node.text())
|
||||
expect(renderedMessages).toEqual(['newer detail wins'])
|
||||
})
|
||||
|
||||
it('clears the polling interval on unmount', async () => {
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
|
||||
const wrapper = mount(ConversationMonitorPane, {
|
||||
props: { humanOnly: true },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockRequest = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
request: mockRequest,
|
||||
}))
|
||||
|
||||
import { fetchConversationDetail, fetchConversationSummaries } from '@/api/hermes/conversations'
|
||||
|
||||
describe('conversations api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('builds summaries URLs with optional params', async () => {
|
||||
mockRequest.mockResolvedValue({ sessions: [] })
|
||||
|
||||
await fetchConversationSummaries()
|
||||
await fetchConversationSummaries({ humanOnly: false, source: 'cli', limit: 25 })
|
||||
|
||||
expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/hermes/sessions/conversations')
|
||||
expect(mockRequest).toHaveBeenNthCalledWith(2, '/api/hermes/sessions/conversations?humanOnly=false&source=cli&limit=25')
|
||||
})
|
||||
|
||||
it('encodes detail URLs and forwards optional params', async () => {
|
||||
mockRequest.mockResolvedValue({ session_id: 'conv', messages: [], visible_count: 0, thread_session_count: 1 })
|
||||
|
||||
await fetchConversationDetail('folder/with spaces', { humanOnly: false, source: 'discord' })
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledWith('/api/hermes/sessions/conversations/folder%2Fwith%20spaces/messages?humanOnly=false&source=discord')
|
||||
})
|
||||
|
||||
it('propagates conversation detail errors so the monitor can render an error state', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('boom'))
|
||||
|
||||
await expect(fetchConversationDetail('conv-1', { humanOnly: true })).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
@@ -72,12 +72,21 @@ describe('Profiles Store', () => {
|
||||
{ name: 'default', active: true, model: 'gpt-4', gateway: 'running', alias: '' },
|
||||
])
|
||||
|
||||
window.localStorage.setItem('hermes_sessions_cache_v1_test', '[]')
|
||||
window.localStorage.setItem('hermes_session_msgs_v1_test_session-1', '[]')
|
||||
window.localStorage.setItem('hermes_in_flight_v1_test_session-1', '{}')
|
||||
window.localStorage.setItem('hermes_active_session_test', 'session-1')
|
||||
window.localStorage.setItem('hermes_session_pins_v1_test', '[]')
|
||||
window.localStorage.setItem('hermes_human_only_v1_test', 'false')
|
||||
|
||||
const store = useProfilesStore()
|
||||
store.detailMap['test'] = { name: 'test', path: '/tmp/test', model: '', provider: '', gateway: '', skills: 0, hasEnv: false, hasSoulMd: false }
|
||||
|
||||
await store.deleteProfile('test')
|
||||
|
||||
expect(store.detailMap['test']).toBeUndefined()
|
||||
expect(window.localStorage.getItem('hermes_session_pins_v1_test')).toBeNull()
|
||||
expect(window.localStorage.getItem('hermes_human_only_v1_test')).toBeNull()
|
||||
})
|
||||
|
||||
it('fetchProfileDetail uses cache', async () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
|
||||
|
||||
describe('session browser prefs store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('persists pins per profile and prunes missing sessions', () => {
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
|
||||
const store = useSessionBrowserPrefsStore()
|
||||
expect(store.pinnedIds).toEqual([])
|
||||
|
||||
store.togglePinned('session-1')
|
||||
store.togglePinned('session-2')
|
||||
expect(store.pinnedIds).toEqual(['session-1', 'session-2'])
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-1', 'session-2'])
|
||||
|
||||
expect(store.pruneMissingSessions(['session-2'])).toBe(true)
|
||||
expect(store.pinnedIds).toEqual(['session-2'])
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-2'])
|
||||
})
|
||||
|
||||
it('does not erase saved pins when the current session list is transiently empty', () => {
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
const store = useSessionBrowserPrefsStore()
|
||||
|
||||
store.togglePinned('session-1')
|
||||
expect(store.pruneMissingSessions([])).toBe(false)
|
||||
expect(store.pinnedIds).toEqual(['session-1'])
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes_session_pins_v1_default') || '[]')).toEqual(['session-1'])
|
||||
})
|
||||
|
||||
it('reloads pin and human-only preferences automatically when the active profile changes', async () => {
|
||||
const profilesStore = useProfilesStore()
|
||||
profilesStore.activeProfileName = 'default'
|
||||
const store = useSessionBrowserPrefsStore()
|
||||
|
||||
expect(store.humanOnly).toBe(true)
|
||||
store.togglePinned('default-session')
|
||||
store.setHumanOnly(false)
|
||||
|
||||
window.localStorage.setItem('hermes_session_pins_v1_work', JSON.stringify(['work-session']))
|
||||
window.localStorage.setItem('hermes_human_only_v1_work', JSON.stringify(true))
|
||||
|
||||
profilesStore.activeProfileName = 'work'
|
||||
await nextTick()
|
||||
|
||||
expect(store.profileName).toBe('work')
|
||||
expect(store.pinnedIds).toEqual(['work-session'])
|
||||
expect(store.humanOnly).toBe(true)
|
||||
|
||||
profilesStore.activeProfileName = 'default'
|
||||
await nextTick()
|
||||
|
||||
expect(store.pinnedIds).toEqual(['default-session'])
|
||||
expect(store.humanOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const mockSettingsStore = vi.hoisted(() => ({
|
||||
sessionReset: { mode: 'both', idle_minutes: 60, at_hour: 0 },
|
||||
saveSection: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockPrefsStore = vi.hoisted(() => ({
|
||||
humanOnly: true,
|
||||
setHumanOnly: vi.fn((value: boolean) => {
|
||||
mockPrefsStore.humanOnly = value
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/settings', () => ({
|
||||
useSettingsStore: () => mockSettingsStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/session-browser-prefs', () => ({
|
||||
useSessionBrowserPrefsStore: () => mockPrefsStore,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
useMessage: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
|
||||
|
||||
describe('SessionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrefsStore.humanOnly = true
|
||||
})
|
||||
|
||||
it('surfaces the human-only preference in the Session tab', async () => {
|
||||
const wrapper = mount(SessionSettings, {
|
||||
global: {
|
||||
stubs: {
|
||||
SettingRow: {
|
||||
props: ['label', 'hint'],
|
||||
template: '<div class="setting-row"><div class="setting-row-label">{{ label }}</div><slot /></div>',
|
||||
},
|
||||
NSelect: true,
|
||||
NInputNumber: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('settings.session.liveMonitorHumanOnly')
|
||||
|
||||
const toggle = wrapper.find('.n-switch')
|
||||
expect(toggle.exists()).toBe(true)
|
||||
|
||||
await toggle.trigger('click')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockPrefsStore.setHumanOnly).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
+110
-66
@@ -1,21 +1,40 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
type FsMocks = {
|
||||
readFile: ReturnType<typeof vi.fn>
|
||||
writeFile: ReturnType<typeof vi.fn>
|
||||
mkdir: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Mock config
|
||||
vi.mock('../../packages/server/src/config', () => ({
|
||||
config: { dataDir: '/tmp/hermes-test-data' },
|
||||
}))
|
||||
async function loadAuth(overrides: Partial<FsMocks> & { home?: string } = {}) {
|
||||
const readFile = overrides.readFile ?? vi.fn()
|
||||
const writeFile = overrides.writeFile ?? vi.fn()
|
||||
const mkdir = overrides.mkdir ?? vi.fn()
|
||||
const home = overrides.home ?? '/tmp/hermes-home'
|
||||
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { getToken, authMiddleware } from '../../packages/server/src/services/auth'
|
||||
vi.resetModules()
|
||||
vi.doMock('fs/promises', () => ({ readFile, writeFile, mkdir }))
|
||||
vi.doMock('os', () => ({ homedir: () => home }))
|
||||
|
||||
const mockedReadFile = vi.mocked(readFile)
|
||||
const mockedWriteFile = vi.mocked(writeFile)
|
||||
const mod = await import('../../packages/server/src/services/auth')
|
||||
return {
|
||||
...mod,
|
||||
mocks: { readFile, writeFile, mkdir },
|
||||
appHome: `${home}/.hermes-web-ui`,
|
||||
tokenFile: `${home}/.hermes-web-ui/.token`,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCtx(path: string, headers: Record<string, string> = {}, query: Record<string, string> = {}) {
|
||||
return {
|
||||
path,
|
||||
headers,
|
||||
query,
|
||||
status: 200,
|
||||
body: null,
|
||||
set: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('Auth Service', () => {
|
||||
const originalEnv = process.env
|
||||
@@ -32,98 +51,125 @@ describe('Auth Service', () => {
|
||||
describe('getToken', () => {
|
||||
it('returns null when AUTH_DISABLED=1', async () => {
|
||||
process.env.AUTH_DISABLED = '1'
|
||||
const { getToken, mocks } = await loadAuth()
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockedReadFile).not.toHaveBeenCalled()
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when AUTH_DISABLED=true', async () => {
|
||||
process.env.AUTH_DISABLED = 'true'
|
||||
const { getToken } = await loadAuth()
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeNull()
|
||||
await expect(getToken()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns AUTH_TOKEN env var if set', async () => {
|
||||
process.env.AUTH_TOKEN = 'my-custom-token'
|
||||
const { getToken, mocks } = await loadAuth()
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBe('my-custom-token')
|
||||
expect(mockedReadFile).not.toHaveBeenCalled()
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads token from file if exists', async () => {
|
||||
mockedReadFile.mockResolvedValue('file-token\n')
|
||||
it('reads token from file if it exists', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue('file-token\n')
|
||||
const { getToken, tokenFile } = await loadAuth({ readFile })
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBe('file-token')
|
||||
expect(mockedReadFile).toHaveBeenCalledWith('/tmp/hermes-test-data/.token', 'utf-8')
|
||||
expect(readFile).toHaveBeenCalledWith(tokenFile, 'utf-8')
|
||||
})
|
||||
|
||||
it('generates and saves new token if file missing', async () => {
|
||||
mockedReadFile.mockRejectedValue(new Error('ENOENT'))
|
||||
it('generates and saves a token if the token file is missing', async () => {
|
||||
const readFile = vi.fn().mockRejectedValue(new Error('ENOENT'))
|
||||
const writeFile = vi.fn()
|
||||
const mkdir = vi.fn()
|
||||
const { getToken, appHome, tokenFile } = await loadAuth({ readFile, writeFile, mkdir })
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
expect(token).toBeTruthy()
|
||||
expect(token).toHaveLength(64) // 32 bytes hex
|
||||
expect(mockedWriteFile).toHaveBeenCalledWith(
|
||||
'/tmp/hermes-test-data/.token',
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(mkdir).toHaveBeenCalledWith(appHome, { recursive: true })
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
tokenFile,
|
||||
expect.stringMatching(/^[a-f0-9]{64}\n$/),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
function createMockCtx(path: string, headers: Record<string, string> = {}, query: Record<string, string> = {}) {
|
||||
return {
|
||||
path,
|
||||
headers,
|
||||
query,
|
||||
status: 200,
|
||||
body: null,
|
||||
set: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
const next = vi.fn()
|
||||
|
||||
describe('requireAuth', () => {
|
||||
it('allows all requests when auth is disabled (null token)', async () => {
|
||||
const middleware = await authMiddleware(null)
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth(null)
|
||||
const ctx = createMockCtx('/api/hermes/sessions')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips /health path', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('skips /health', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/health')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(ctx.status).toBe(200)
|
||||
})
|
||||
|
||||
it('skips /webhook because it is treated as a public non-API path', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/webhook')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(ctx.status).toBe(200)
|
||||
})
|
||||
|
||||
it('skips non-API paths', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/index.html')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(ctx.status).toBe(200)
|
||||
})
|
||||
|
||||
it('requires auth for /webhook path (it is an API-like endpoint)', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/webhook', {})
|
||||
it('requires auth for /upload', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/upload')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(ctx.body).toEqual({ error: 'Unauthorized' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request without auth header for protected API routes', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions')
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
@@ -131,19 +177,11 @@ describe('Auth Service', () => {
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request without auth header', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(ctx.status).toBe(401)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects request with wrong token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('rejects request with the wrong bearer token', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
@@ -151,18 +189,22 @@ describe('Auth Service', () => {
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows request with correct Bearer token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('allows request with the correct bearer token', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer secret' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows request with correct query token', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
it('allows request with the correct query token', async () => {
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', {}, { token: 'secret' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
@@ -170,8 +212,10 @@ describe('Auth Service', () => {
|
||||
})
|
||||
|
||||
it('returns 401 JSON on auth failure', async () => {
|
||||
const middleware = await authMiddleware('secret')
|
||||
const { requireAuth } = await loadAuth()
|
||||
const middleware = requireAuth('secret')
|
||||
const ctx = createMockCtx('/api/hermes/sessions', { authorization: 'Bearer wrong' })
|
||||
const next = vi.fn(async () => {})
|
||||
|
||||
await middleware(ctx, next)
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const exportSessionsRawMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
exportSessionsRaw: exportSessionsRawMock,
|
||||
}))
|
||||
|
||||
describe('conversations service', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-20T00:00:00Z'))
|
||||
exportSessionsRawMock.mockReset()
|
||||
})
|
||||
|
||||
it('aggregates a single compression continuation even when the child preview differs', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'root',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: null,
|
||||
started_at: 100,
|
||||
ended_at: 110,
|
||||
end_reason: 'compression',
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 5,
|
||||
output_tokens: 8,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0.1,
|
||||
actual_cost_usd: 0.1,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{ id: 1, session_id: 'root', role: 'user', content: 'Start here', timestamp: 101 },
|
||||
{ id: 2, session_id: 'root', role: 'assistant', content: 'Assistant reply', timestamp: 102 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'root-cont',
|
||||
parent_session_id: 'root',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Continuation',
|
||||
started_at: 110,
|
||||
ended_at: 111,
|
||||
end_reason: null,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 3,
|
||||
output_tokens: 4,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0.2,
|
||||
actual_cost_usd: 0.2,
|
||||
cost_status: 'final',
|
||||
messages: [
|
||||
{ id: 3, session_id: 'root-cont', role: 'user', content: 'Continue with more detail', timestamp: 110 },
|
||||
{ id: 4, session_id: 'root-cont', role: 'assistant', content: 'Continued answer', timestamp: 111 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const summaries = await mod.listConversationSummaries({ humanOnly: true })
|
||||
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(summaries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'root',
|
||||
thread_session_count: 2,
|
||||
ended_at: 111,
|
||||
cost_status: 'mixed',
|
||||
actual_cost_usd: 0.30000000000000004,
|
||||
}),
|
||||
)
|
||||
|
||||
const detail = await mod.getConversationDetail('root', { humanOnly: true })
|
||||
expect(detail?.thread_session_count).toBe(2)
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual([
|
||||
'Start here',
|
||||
'Assistant reply',
|
||||
'Continue with more detail',
|
||||
'Continued answer',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats branched children as their own visible conversations', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'root',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Root',
|
||||
started_at: 100,
|
||||
ended_at: 200,
|
||||
end_reason: 'branched',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [{ id: 1, session_id: 'root', role: 'user', content: 'Root prompt', timestamp: 101 }],
|
||||
},
|
||||
{
|
||||
id: 'branch-child',
|
||||
parent_session_id: 'root',
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Branch child',
|
||||
started_at: 201,
|
||||
ended_at: 210,
|
||||
end_reason: null,
|
||||
message_count: 2,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{ id: 2, session_id: 'branch-child', role: 'user', content: 'Branch prompt', timestamp: 202 },
|
||||
{ id: 3, session_id: 'branch-child', role: 'assistant', content: 'Branch answer', timestamp: 203 },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const summaries = await mod.listConversationSummaries({ humanOnly: true })
|
||||
|
||||
expect(summaries.map((summary: any) => summary.id)).toEqual(['branch-child', 'root'])
|
||||
|
||||
const detail = await mod.getConversationDetail('branch-child', { humanOnly: true })
|
||||
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
|
||||
})
|
||||
|
||||
it('excludes human-only conversations with no visible human messages', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'synthetic-root',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: null,
|
||||
started_at: 100,
|
||||
ended_at: 101,
|
||||
end_reason: null,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
session_id: 'synthetic-root',
|
||||
role: 'user',
|
||||
content: "You've reached the maximum number of tool-calling iterations allowed.",
|
||||
timestamp: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const summaries = await mod.listConversationSummaries({ humanOnly: true })
|
||||
const detail = await mod.getConversationDetail('synthetic-root', { humanOnly: true })
|
||||
|
||||
expect(summaries).toEqual([])
|
||||
expect(detail).toBeNull()
|
||||
})
|
||||
|
||||
it('caches raw exports briefly and normalizes structured message content', async () => {
|
||||
exportSessionsRawMock.mockResolvedValue([
|
||||
{
|
||||
id: 'recent-open',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Recent open',
|
||||
started_at: 1776643190,
|
||||
ended_at: null,
|
||||
end_reason: null,
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [
|
||||
{
|
||||
id: 11,
|
||||
session_id: 'recent-open',
|
||||
role: 'assistant',
|
||||
content: [{ text: 'hello' }, { text: 'world' }],
|
||||
timestamp: 1776643198,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stale-open',
|
||||
parent_session_id: null,
|
||||
source: 'cli',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Stale open',
|
||||
started_at: 1776642000,
|
||||
ended_at: null,
|
||||
end_reason: null,
|
||||
message_count: 0,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: 'openai',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: 0,
|
||||
cost_status: 'estimated',
|
||||
messages: [],
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/services/hermes/conversations')
|
||||
const firstSummaries = await mod.listConversationSummaries({ humanOnly: false })
|
||||
const detail = await mod.getConversationDetail('recent-open', { humanOnly: false })
|
||||
const secondSummaries = await mod.listConversationSummaries({ humanOnly: false })
|
||||
|
||||
expect(exportSessionsRawMock).toHaveBeenCalledTimes(1)
|
||||
expect(firstSummaries.find((summary: any) => summary.id === 'recent-open')?.is_active).toBe(true)
|
||||
expect(secondSummaries.find((summary: any) => summary.id === 'stale-open')?.is_active).toBe(false)
|
||||
expect(detail?.messages[0].content).toBe('hello\nworld')
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,16 @@ vi.mock('../../packages/server/src/config', () => ({
|
||||
config: { upstream: 'http://127.0.0.1:8642' },
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
|
||||
getGatewayManagerInstance: () => null,
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
import { proxy } from '../../packages/server/src/routes/hermes/proxy-handler'
|
||||
|
||||
function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
let headersSent = false
|
||||
const ctx: any = {
|
||||
path: '/api/hermes/jobs',
|
||||
method: 'GET',
|
||||
@@ -31,6 +34,11 @@ function createMockCtx(overrides: Record<string, any> = {}) {
|
||||
body: null,
|
||||
...overrides,
|
||||
}
|
||||
ctx.get = (name: string) => {
|
||||
const match = Object.entries(ctx.headers).find(([key]) => key.toLowerCase() === name.toLowerCase())
|
||||
const value = match?.[1]
|
||||
return Array.isArray(value) ? value[0] : value || ''
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -104,7 +112,7 @@ describe('Proxy Handler', () => {
|
||||
expect(options.headers.host).toBe('127.0.0.1:8642')
|
||||
})
|
||||
|
||||
it('forwards query string', async () => {
|
||||
it('forwards query string while stripping the web-ui token parameter', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'text/event-stream' }),
|
||||
@@ -112,11 +120,13 @@ describe('Proxy Handler', () => {
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
|
||||
const ctx = createMockCtx({ search: '?include_disabled=true' })
|
||||
const ctx = createMockCtx({ search: '?include_disabled=true&token=web-ui-token&profile=work' })
|
||||
await proxy(ctx)
|
||||
|
||||
const url = mockFetch.mock.calls[0][0]
|
||||
expect(url).toContain('?include_disabled=true')
|
||||
expect(url).toContain('profile=work')
|
||||
expect(url).not.toContain('token=')
|
||||
})
|
||||
|
||||
it('returns 502 on connection failure', async () => {
|
||||
|
||||
@@ -2,11 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const listSessionSummariesMock = vi.fn()
|
||||
const listSessionsMock = vi.fn()
|
||||
const listConversationSummariesMock = vi.fn()
|
||||
const getConversationDetailMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: listSessionSummariesMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
|
||||
listConversationSummaries: listConversationSummariesMock,
|
||||
getConversationDetail: getConversationDetailMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
listSessions: listSessionsMock,
|
||||
getSession: vi.fn(),
|
||||
@@ -19,6 +26,8 @@ describe('session routes', () => {
|
||||
vi.resetModules()
|
||||
listSessionSummariesMock.mockReset()
|
||||
listSessionsMock.mockReset()
|
||||
listConversationSummariesMock.mockReset()
|
||||
getConversationDetailMock.mockReset()
|
||||
})
|
||||
|
||||
it('serves summaries from sqlite-backed helper when available', async () => {
|
||||
@@ -49,4 +58,54 @@ describe('session routes', () => {
|
||||
expect(listSessionsMock).toHaveBeenCalledWith(undefined, 7)
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback' }] })
|
||||
})
|
||||
|
||||
it('serves live conversations with humanOnly defaulting to true', async () => {
|
||||
listConversationSummariesMock.mockResolvedValue([{ id: 'conversation-1' }])
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
|
||||
const handler = layer.stack[0]
|
||||
const ctx: any = { query: {}, body: null }
|
||||
|
||||
await handler(ctx)
|
||||
|
||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: true, source: undefined, limit: undefined })
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
|
||||
})
|
||||
|
||||
it('supports disabling humanOnly and forwarding limit/source for live conversations', async () => {
|
||||
listConversationSummariesMock.mockResolvedValue([{ id: 'child-session' }])
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
|
||||
|
||||
const listCtx: any = { query: { humanOnly: 'false', source: 'cli', limit: '25' }, body: null }
|
||||
await listLayer.stack[0](listCtx)
|
||||
|
||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: false, source: 'cli', limit: 25 })
|
||||
expect(listCtx.body).toEqual({ sessions: [{ id: 'child-session' }] })
|
||||
})
|
||||
|
||||
it('returns conversation detail and forwards humanOnly/source', async () => {
|
||||
getConversationDetailMock.mockResolvedValue({ session_id: 'child-session', messages: [] })
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
|
||||
|
||||
const detailCtx: any = { params: { id: 'child-session' }, query: { humanOnly: 'false', source: 'discord' }, body: null, status: 200 }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
|
||||
expect(getConversationDetailMock).toHaveBeenCalledWith('child-session', { humanOnly: false, source: 'discord' })
|
||||
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
|
||||
})
|
||||
|
||||
it('returns 404 when a conversation detail is not found', async () => {
|
||||
getConversationDetailMock.mockResolvedValue(null)
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
|
||||
|
||||
const detailCtx: any = { params: { id: 'missing' }, query: {}, body: null, status: 200 }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
|
||||
expect(getConversationDetailMock).toHaveBeenCalledWith('missing', { humanOnly: true, source: undefined })
|
||||
expect(detailCtx.status).toBe(404)
|
||||
expect(detailCtx.body).toEqual({ error: 'Conversation not found' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user