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:
Zhicheng Han
2026-04-22 02:09:58 +02:00
committed by GitHub
parent 83ad9642e2
commit 3f88553765
34 changed files with 2497 additions and 278 deletions
@@ -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>
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+16 -1
View File
@@ -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: {
+16 -1
View File
@@ -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 lhumain.',
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 : nafficher 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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+16 -1
View File
@@ -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: {
+15
View File
@@ -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,