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:
@@ -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>
|
||||
Reference in New Issue
Block a user