333 lines
9.2 KiB
Vue
333 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { fetchConversationDetail, fetchConversationSummaries, type ConversationDetail, type ConversationSummary } from '@/api/hermes/conversations'
|
|
import { formatTimestampSeconds, getSourceLabel } from '@/shared/session-display'
|
|
import { useAppStore } from '@/stores/hermes/app'
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
const props = defineProps<{ humanOnly: boolean }>()
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
|
|
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)
|
|
const selectedSessionModelName = computed(() =>
|
|
selectedSession.value?.model
|
|
? appStore.displayModelName(selectedSession.value.model, selectedSession.value.provider)
|
|
: '',
|
|
)
|
|
|
|
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 :title="selectedSession.model">{{ selectedSessionModelName }}</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;
|
|
scrollbar-gutter: stable;
|
|
|
|
&::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
background: rgba($text-muted, 0.3);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb:hover {
|
|
background: rgba($text-muted, 0.5);
|
|
}
|
|
}
|
|
|
|
.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(var(--accent-primary-rgb), 0.12);
|
|
color: $text-primary;
|
|
font-weight: 500;
|
|
}
|
|
|
|
&.active .conversation-monitor__session-title {
|
|
color: $accent-primary;
|
|
}
|
|
}
|
|
|
|
.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;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.conversation-monitor__detail {
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
}
|
|
</style>
|