diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue index 6fcfcd99..f5d58f0b 100644 --- a/packages/client/src/App.vue +++ b/packages/client/src/App.vue @@ -7,6 +7,7 @@ import { useTheme } from '@/composables/useTheme' import AppSidebar from '@/components/layout/AppSidebar.vue' import { useKeyboard } from '@/composables/useKeyboard' import { useAppStore } from '@/stores/hermes/app' +import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue' const { isDark } = useTheme() const appStore = useAppStore() @@ -58,6 +59,7 @@ useKeyboard() + diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index 1201ac12..240477b4 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -5,6 +5,7 @@ export interface SessionSummary { source: string model: string title: string | null + preview?: string started_at: number ended_at: number | null last_active?: number @@ -25,6 +26,12 @@ export interface SessionDetail extends SessionSummary { messages: HermesMessage[] } +export interface SessionSearchResult extends SessionSummary { + matched_message_id: number | null + snippet: string + rank: number +} + export interface HermesMessage { id: number session_id: string @@ -48,6 +55,16 @@ export async function fetchSessions(source?: string, limit?: number): Promise { + const params = new URLSearchParams() + params.set('q', q) + if (source) params.set('source', source) + if (limit) params.set('limit', String(limit)) + const query = params.toString() + const res = await request<{ results: SessionSearchResult[] }>(`/api/hermes/search/sessions?${query}`) + return res.results +} + export async function fetchSession(id: string): Promise { try { const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}`) diff --git a/packages/client/src/components/hermes/chat/MessageItem.vue b/packages/client/src/components/hermes/chat/MessageItem.vue index ffd6a19d..b23adcae 100644 --- a/packages/client/src/components/hermes/chat/MessageItem.vue +++ b/packages/client/src/components/hermes/chat/MessageItem.vue @@ -11,7 +11,7 @@ import { const TOOL_PAYLOAD_DISPLAY_LIMIT = 2000; -const props = defineProps<{ message: Message }>(); +const props = defineProps<{ message: Message; highlight?: boolean }>(); const { t } = useI18n(); const isSystem = computed(() => props.message.role === "system"); @@ -126,7 +126,11 @@ const renderedToolResult = computed(() => { - + { background-color: rgba(var(--warning-rgb), 0.06); } } + + &.highlight { + .message-bubble { + box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.45); + } + } } .msg-body { diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue index f75f5954..00a8d073 100644 --- a/packages/client/src/components/hermes/chat/MessageList.vue +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -45,15 +45,37 @@ function scrollToBottom() { }); } +function scrollToMessage(messageId: string) { + nextTick(() => { + const el = document.getElementById(`message-${messageId}`); + if (el) { + el.scrollIntoView({ block: 'center' }); + } + }); +} + // Scroll to bottom once when messages are first loaded watch( () => chatStore.activeSessionId, (id) => { - if (id) scrollToBottom(); + if (!id) return; + if (chatStore.focusMessageId) { + scrollToMessage(chatStore.focusMessageId); + return; + } + scrollToBottom(); }, { immediate: true }, ); +watch( + () => chatStore.focusMessageId, + (messageId) => { + if (!messageId) return; + scrollToMessage(messageId); + }, +); + // When a run starts (user just sent a message), always scroll to bottom once watch( () => chatStore.isRunActive, @@ -66,12 +88,20 @@ watch( watch( () => chatStore.messages[chatStore.messages.length - 1]?.content, () => { + if (chatStore.focusMessageId) { + scrollToMessage(chatStore.focusMessageId); + return; + } if (!chatStore.isStreaming) { scrollToBottom(); return; } if (!isNearBottom()) return; scrollToBottom(); }, ); watch(currentToolCalls, () => { + if (chatStore.focusMessageId) { + scrollToMessage(chatStore.focusMessageId); + return; + } if (!chatStore.isStreaming) { scrollToBottom(); return; } if (!isNearBottom()) return; scrollToBottom(); @@ -84,7 +114,12 @@ watch(currentToolCalls, () => { {{ t("chat.emptyState") }} - + +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { useRouter } from 'vue-router' +import { NButton, NInput, NModal, NSpin, useMessage } from 'naive-ui' +import { useI18n } from 'vue-i18n' +import { fetchSessions, searchSessions, type SessionSearchResult, type SessionSummary } from '@/api/hermes/sessions' +import { useChatStore } from '@/stores/hermes/chat' +import { useSessionSearch } from '@/composables/useSessionSearch' + +const { t } = useI18n() +const message = useMessage() +const router = useRouter() +const chatStore = useChatStore() +const { sessionSearchOpen } = useSessionSearch() + +const query = ref('') +const loading = ref(false) +const recentSessions = ref([]) +const searchResults = ref([]) +const activeIndex = ref(0) +const inputRef = ref | null>(null) + +let debounceTimer: ReturnType | null = null +let requestSeq = 0 + +type SearchItem = SessionSearchResult | (SessionSummary & { + snippet?: string + matched_message_id: number | null + rank: number +}) + +const hasQuery = computed(() => query.value.trim().length > 0) + +const items = computed(() => { + if (hasQuery.value) return searchResults.value + return recentSessions.value.map(session => ({ + ...session, + matched_message_id: null, + snippet: session.preview || '', + rank: 0, + })) +}) + +function formatSource(source: string): string { + const map: Record = { + api_server: 'API Server', + cli: 'CLI', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + matrix: 'Matrix', + whatsapp: 'WhatsApp', + signal: 'Signal', + cron: 'Cron', + weixin: 'WeChat', + } + return map[source] || source +} + +function formatTime(ts?: number): string { + if (!ts) return '' + const date = new Date(ts * 1000) + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function getItemTitle(item: SearchItem): string { + const title = item.title?.trim() + if (title) return title + if (item.preview?.trim()) return item.preview.trim() + return item.id +} + +async function loadRecentSessions() { + const seq = ++requestSeq + loading.value = true + try { + const sessions = await fetchSessions(undefined, 8) + if (seq !== requestSeq) return + recentSessions.value = sessions + searchResults.value = [] + activeIndex.value = 0 + } catch (err) { + if (seq !== requestSeq) return + message.error(err instanceof Error ? err.message : t('chat.searchFailed')) + } finally { + if (seq === requestSeq) { + loading.value = false + } + } +} + +async function runSearch(text: string) { + const seq = ++requestSeq + loading.value = true + try { + const results = text.trim() + ? await searchSessions(text.trim(), undefined, 10) + : [] + if (seq !== requestSeq) return + searchResults.value = results + activeIndex.value = 0 + } catch (err) { + if (seq !== requestSeq) return + message.error(err instanceof Error ? err.message : t('chat.searchFailed')) + } finally { + if (seq === requestSeq) { + loading.value = false + } + } +} + +async function ensureChatSessionsLoaded() { + if (chatStore.sessions.length === 0) { + await chatStore.loadSessions() + } +} + +async function openItem(item: SearchItem) { + const messageId = item.matched_message_id != null ? String(item.matched_message_id) : null + sessionSearchOpen.value = false + + await ensureChatSessionsLoaded() + await chatStore.switchSession(item.id, messageId) + if (router.currentRoute.value.name !== 'hermes.chat') { + await router.push({ name: 'hermes.chat' }) + } +} + +function closeModal() { + sessionSearchOpen.value = false +} + +function moveSelection(delta: number) { + const list = items.value + if (list.length === 0) return + const next = activeIndex.value + delta + activeIndex.value = (next + list.length) % list.length +} + +async function handleKeydown(e: KeyboardEvent) { + if (!sessionSearchOpen.value) return + if (e.key === 'ArrowDown') { + e.preventDefault() + moveSelection(1) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + moveSelection(-1) + return + } + if (e.key === 'Enter') { + e.preventDefault() + const item = items.value[activeIndex.value] + if (item) { + await openItem(item) + } + return + } + if (e.key === 'Escape') { + e.preventDefault() + closeModal() + } +} + +watch( + () => sessionSearchOpen.value, + async (open) => { + if (!open) { + query.value = '' + searchResults.value = [] + recentSessions.value = [] + activeIndex.value = 0 + return + } + + query.value = '' + searchResults.value = [] + activeIndex.value = 0 + await loadRecentSessions() + await nextTick() + inputRef.value?.focus?.() + }, +) + +watch(query, (value) => { + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + debounceTimer = setTimeout(() => { + if (!sessionSearchOpen.value) return + void runSearch(value) + }, 160) +}) + +watch(items, () => { + if (activeIndex.value >= items.value.length) { + activeIndex.value = 0 + } +}) + +onMounted(() => { + window.addEventListener('keydown', handleKeydown) +}) + +onUnmounted(() => { + window.removeEventListener('keydown', handleKeydown) + if (debounceTimer) { + clearTimeout(debounceTimer) + } +}) + + + + + + + {{ t('chat.searchSubtitle') }} + {{ t('chat.searchHint') }} + + + + + + + + {{ hasQuery ? t('chat.searchNoResults') : t('chat.searchEmpty') }} + + + + + + {{ getItemTitle(item) }} + {{ formatSource(item.source) }} + + + {{ hasQuery ? item.snippet || t('chat.searchNoSnippet') : item.preview || t('chat.searchRecent') }} + + + + {{ formatTime(item.last_active || item.started_at) }} + + #{{ item.matched_message_id }} + + + + + + + + + + + + + diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index 4476d166..b2f6a6a0 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -8,6 +8,7 @@ import ModelSelector from "./ModelSelector.vue"; import ProfileSelector from "./ProfileSelector.vue"; import LanguageSwitch from "./LanguageSwitch.vue"; import ThemeSwitch from "./ThemeSwitch.vue"; +import { useSessionSearch } from '@/composables/useSessionSearch' import danceVideoLight from "@/assets/dance-light.mp4"; import danceVideoDark from "@/assets/dance-dark.mp4"; @@ -19,7 +20,9 @@ const message = useMessage(); const route = useRoute(); const router = useRouter(); const appStore = useAppStore(); +const { openSessionSearch } = useSessionSearch(); const selectedKey = computed(() => route.name as string); +const logoPath = '/logo.png'; const collapsedGroups = reactive>({}); @@ -48,7 +51,7 @@ async function handleUpdate() {
{{ t("chat.emptyState") }}