++
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-24
|
||||||
|
pr: pending
|
||||||
|
feature: Conversation startup and profile loading
|
||||||
|
impact: Load conversations independently of metadata and recover failed resume requests via HTTP.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-10-24: Conversation startup and profile loading
|
||||||
|
|
||||||
|
- PR/commit: pending local review.
|
||||||
|
- Touched feature: client chat startup, session switching, resume listener lifecycle, and profile list metadata.
|
||||||
|
- Behavior impact: conversation loading no longer waits for profile/settings/model metadata. Session switches ignore obsolete replies (including A/B/A), cancel obsolete resume listeners, and fall back to paginated HTTP history after a three-second resume timeout. Failed history loads show a retry action. Profile inventory uses disk metadata instead of spawning Hermes CLI; uploaded avatar bodies are fetched on demand through an access-checked endpoint.
|
||||||
|
- The `/chat-run` event names, fields and server emission timing remain unchanged. Only the client's wait/fallback behavior changes.
|
||||||
|
- Validation: focused store/view/profile tests, Playwright startup/retry/multi-tab/profile tests, full Vitest coverage, harness and production build. See task results for actual outcomes.
|
||||||
@@ -7375,6 +7375,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/hermes/profiles/{name}/avatar": {
|
"/api/hermes/profiles/{name}/avatar": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Profiles"
|
||||||
|
],
|
||||||
|
"summary": "Get avatar",
|
||||||
|
"description": "GET /api/hermes/profiles/:name/avatar",
|
||||||
|
"operationId": "getAvatar",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"$ref": "#/components/responses/Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"put": {
|
"put": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Profiles"
|
"Profiles"
|
||||||
|
|||||||
@@ -788,14 +788,21 @@ export function resumeSession(
|
|||||||
onResumed: (data: ResumeSessionPayload) => void,
|
onResumed: (data: ResumeSessionPayload) => void,
|
||||||
profile?: string | null,
|
profile?: string | null,
|
||||||
transport: ChatRunTransport = 'chat-run',
|
transport: ChatRunTransport = 'chat-run',
|
||||||
|
signal?: AbortSignal,
|
||||||
): Socket {
|
): Socket {
|
||||||
const socket = connectChatRun(profile, transport)
|
const socket = connectChatRun(profile, transport)
|
||||||
|
|
||||||
const handleResumed = (data: ResumeSessionPayload) => {
|
const handleResumed = (data: ResumeSessionPayload) => {
|
||||||
if (data?.session_id !== sessionId) return
|
if (data?.session_id !== sessionId) return
|
||||||
removeSocketListener(socket, 'resumed', handleResumed)
|
cleanup()
|
||||||
onResumed(data)
|
onResumed(data)
|
||||||
}
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
removeSocketListener(socket, 'resumed', handleResumed)
|
||||||
|
signal?.removeEventListener('abort', cleanup)
|
||||||
|
}
|
||||||
|
if (signal?.aborted) return socket
|
||||||
|
signal?.addEventListener('abort', cleanup, { once: true })
|
||||||
socket.on('resumed', handleResumed)
|
socket.on('resumed', handleResumed)
|
||||||
socket.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) })
|
socket.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) })
|
||||||
|
|
||||||
|
|||||||
@@ -242,3 +242,8 @@ export async function importProfile(file: File): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchProfileAvatar(name: string): Promise<ProfileAvatar | null> {
|
||||||
|
const res = await request<{ avatar: ProfileAvatar | null }>(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`)
|
||||||
|
return res.avatar
|
||||||
|
}
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ defineExpose({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-list-shell">
|
<div class="message-list-shell">
|
||||||
|
<div v-if="chatStore.messageLoadFailed" class="message-load-status" role="alert">
|
||||||
|
{{ t('chat.messageLoadFailed') }}
|
||||||
|
<a-button size="small" @click="chatStore.activeSessionId && chatStore.switchSession(chatStore.activeSessionId)">{{ t('common.retry') }}</a-button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="chatStore.isLoadingMessages" class="message-load-status" role="status">{{ t('common.loading') }}</div>
|
||||||
<VirtualMessageList
|
<VirtualMessageList
|
||||||
:key="chatStore.activeSessionId || 'chat-empty'"
|
:key="chatStore.activeSessionId || 'chat-empty'"
|
||||||
ref="listRef"
|
ref="listRef"
|
||||||
@@ -869,6 +874,19 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-load-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9;
|
||||||
|
max-width: calc(100% - 24px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.message-float-stack {
|
.message-float-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import multiavatar from '@multiavatar/multiavatar'
|
import multiavatar from '@multiavatar/multiavatar'
|
||||||
import type { ProfileAvatar } from '@/api/hermes/profiles'
|
import { fetchProfileAvatar, type ProfileAvatar } from '@/api/hermes/profiles'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
name: string
|
name: string
|
||||||
@@ -11,6 +11,20 @@ const props = withDefaults(defineProps<{
|
|||||||
size: 24,
|
size: 24,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const imageDataUrl = ref('')
|
||||||
|
let imageRequest = 0
|
||||||
|
watch(() => [props.name, props.avatar?.type, props.avatar?.updatedAt, props.avatar?.dataUrl], async () => {
|
||||||
|
const requestId = ++imageRequest
|
||||||
|
imageDataUrl.value = props.avatar?.dataUrl || ''
|
||||||
|
if (props.avatar?.type !== 'image' || imageDataUrl.value) return
|
||||||
|
try {
|
||||||
|
const avatar = await fetchProfileAvatar(props.name)
|
||||||
|
if (requestId === imageRequest) imageDataUrl.value = avatar?.dataUrl || ''
|
||||||
|
} catch {
|
||||||
|
// Keep the generated avatar when the image is unavailable.
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const fallbackSeed = computed(() => props.name || 'default')
|
const fallbackSeed = computed(() => props.name || 'default')
|
||||||
const generatedSvg = computed(() => multiavatar(props.avatar?.seed || fallbackSeed.value))
|
const generatedSvg = computed(() => multiavatar(props.avatar?.seed || fallbackSeed.value))
|
||||||
const style = computed(() => ({
|
const style = computed(() => ({
|
||||||
@@ -23,9 +37,9 @@ const style = computed(() => ({
|
|||||||
<template>
|
<template>
|
||||||
<span class="profile-avatar-view" :style="style">
|
<span class="profile-avatar-view" :style="style">
|
||||||
<img
|
<img
|
||||||
v-if="avatar?.type === 'image' && avatar.dataUrl"
|
v-if="avatar?.type === 'image' && imageDataUrl"
|
||||||
class="profile-avatar-image"
|
class="profile-avatar-image"
|
||||||
:src="avatar.dataUrl"
|
:src="imageDataUrl"
|
||||||
alt=""
|
alt=""
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ export default {
|
|||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
chat: {
|
chat: {
|
||||||
|
messageLoadFailed: 'Could not load this conversation. Please retry.',
|
||||||
contextRemaining: 'remaining',
|
contextRemaining: 'remaining',
|
||||||
contextClickToEdit: 'Click to edit context length',
|
contextClickToEdit: 'Click to edit context length',
|
||||||
contextEditTitle: 'Edit Context Length',
|
contextEditTitle: 'Edit Context Length',
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ export default {
|
|||||||
|
|
||||||
// 对话
|
// 对话
|
||||||
chat: {
|
chat: {
|
||||||
|
messageLoadFailed: '会话加载失败,请重试。',
|
||||||
contextRemaining: '剩余',
|
contextRemaining: '剩余',
|
||||||
contextClickToEdit: '点击编辑上下文长度',
|
contextClickToEdit: '点击编辑上下文长度',
|
||||||
contextEditTitle: '编辑上下文长度',
|
contextEditTitle: '编辑上下文长度',
|
||||||
|
|||||||
@@ -698,6 +698,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const isLoadingSessions = ref(false)
|
const isLoadingSessions = ref(false)
|
||||||
const sessionsLoaded = ref(false)
|
const sessionsLoaded = ref(false)
|
||||||
const isLoadingMessages = ref(false)
|
const isLoadingMessages = ref(false)
|
||||||
|
const messageLoadFailed = ref(false)
|
||||||
|
let sessionLoadGeneration = 0
|
||||||
|
let sessionResumeAbort: AbortController | null = null
|
||||||
const isRunActive = computed(() => isStreaming.value)
|
const isRunActive = computed(() => isStreaming.value)
|
||||||
|
|
||||||
async function fetchRuntimeSessions(profile?: string | null): Promise<SessionSummary[]> {
|
async function fetchRuntimeSessions(profile?: string | null): Promise<SessionSummary[]> {
|
||||||
@@ -805,6 +808,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearActiveSession() {
|
function clearActiveSession() {
|
||||||
|
sessionLoadGeneration++
|
||||||
|
sessionResumeAbort?.abort()
|
||||||
|
isLoadingMessages.value = false
|
||||||
|
messageLoadFailed.value = false
|
||||||
const sid = activeSessionId.value
|
const sid = activeSessionId.value
|
||||||
activeSessionId.value = null
|
activeSessionId.value = null
|
||||||
activeSession.value = null
|
activeSession.value = null
|
||||||
@@ -1082,8 +1089,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-pull active session from server. Used on tab-visible events.
|
// Re-pull active session from server. Used on tab-visible events.
|
||||||
async function refreshActiveSession(): Promise<boolean> {
|
async function refreshActiveSession(isCurrent: () => boolean = () => true): Promise<boolean> {
|
||||||
const sid = activeSessionId.value
|
const sid = activeSessionId.value
|
||||||
|
const generation = sessionLoadGeneration
|
||||||
if (!sid) return false
|
if (!sid) return false
|
||||||
try {
|
try {
|
||||||
const target = sessions.value.find(s => s.id === sid)
|
const target = sessions.value.find(s => s.id === sid)
|
||||||
@@ -1093,7 +1101,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
LIVE_CHAT_MAX_LOADED_MESSAGES,
|
LIVE_CHAT_MAX_LOADED_MESSAGES,
|
||||||
)
|
)
|
||||||
const detail = await fetchSessionMessagesPage(sid, 0, limit, activeSession.value?.profile)
|
const detail = await fetchSessionMessagesPage(sid, 0, limit, activeSession.value?.profile)
|
||||||
if (!detail) return false
|
if (!detail || !isCurrent() || generation !== sessionLoadGeneration || activeSessionId.value !== sid) return false
|
||||||
const mapped = mapHermesMessages(detail.messages || [])
|
const mapped = mapHermesMessages(detail.messages || [])
|
||||||
target.messages = mapped
|
target.messages = mapped
|
||||||
target.loadedMessageCount = detail.messages.length
|
target.loadedMessageCount = detail.messages.length
|
||||||
@@ -1176,6 +1184,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function switchSession(sessionId: string, focusId?: string | null) {
|
async function switchSession(sessionId: string, focusId?: string | null) {
|
||||||
|
const generation = ++sessionLoadGeneration
|
||||||
|
sessionResumeAbort?.abort()
|
||||||
|
const resumeAbort = new AbortController()
|
||||||
|
sessionResumeAbort = resumeAbort
|
||||||
|
messageLoadFailed.value = false
|
||||||
|
isLoadingMessages.value = false
|
||||||
clearThinkingObservationFor(sessionId)
|
clearThinkingObservationFor(sessionId)
|
||||||
activeSessionId.value = sessionId
|
activeSessionId.value = sessionId
|
||||||
focusMessageId.value = focusId ?? null
|
focusMessageId.value = focusId ?? null
|
||||||
@@ -1188,17 +1202,43 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
if (!activeSession.value) return
|
if (!activeSession.value) return
|
||||||
|
|
||||||
isLoadingMessages.value = true
|
isLoadingMessages.value = true
|
||||||
|
let awaitingRuntime = false
|
||||||
|
let runtimeReceived = false
|
||||||
|
let resumeTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let httpHistoryLoaded = false
|
||||||
|
let runtimeExpiry: ReturnType<typeof setTimeout> | undefined
|
||||||
|
resumeAbort.signal.addEventListener('abort', () => clearTimeout(runtimeExpiry), { once: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load messages via Socket.IO resume (server loads from DB if not in memory)
|
// Load messages via Socket.IO resume (server loads from DB if not in memory)
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => reject(new Error('resume timeout')), 15_000)
|
let settled = false
|
||||||
resumeSession(sessionId, (data) => {
|
const cancel = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
if (data.session_id !== sessionId || activeSessionId.value !== sessionId) {
|
resolve()
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
settled = true
|
||||||
|
// Recover history now, while allowing a late resume to restore live run state.
|
||||||
|
awaitingRuntime = true
|
||||||
|
runtimeExpiry = setTimeout(() => resumeAbort.abort(), 12_000)
|
||||||
|
reject(new Error('resume timeout'))
|
||||||
|
}, 3000)
|
||||||
|
resumeTimeout = timeout
|
||||||
|
resumeAbort.signal.addEventListener('abort', cancel, { once: true })
|
||||||
|
resumeSession(sessionId, (data) => {
|
||||||
|
if (settled && !awaitingRuntime) return
|
||||||
|
awaitingRuntime = false
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
clearTimeout(runtimeExpiry)
|
||||||
|
if (generation !== sessionLoadGeneration || data.session_id !== sessionId || activeSessionId.value !== sessionId) {
|
||||||
resolve()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
runtimeReceived = true
|
||||||
const target = sessions.value.find(s => s.id === sessionId)
|
const target = sessions.value.find(s => s.id === sessionId)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
resolve()
|
resolve()
|
||||||
@@ -1237,7 +1277,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
target.parentTitle = (data as any).parentTitle || target.parentTitle || null
|
target.parentTitle = (data as any).parentTitle || target.parentTitle || null
|
||||||
target.parentLastMessage = (data as any).parentLastMessage || target.parentLastMessage || null
|
target.parentLastMessage = (data as any).parentLastMessage || target.parentLastMessage || null
|
||||||
target.parentLastMessageRole = (data as any).parentLastMessageRole || target.parentLastMessageRole || null
|
target.parentLastMessageRole = (data as any).parentLastMessageRole || target.parentLastMessageRole || null
|
||||||
if (data.messages?.length) {
|
if (!httpHistoryLoaded && data.messages?.length) {
|
||||||
target.messages = mapHermesMessages(data.messages as any[])
|
target.messages = mapHermesMessages(data.messages as any[])
|
||||||
restoreWorkspaceRunChangeMessages(sessionId)
|
restoreWorkspaceRunChangeMessages(sessionId)
|
||||||
target.loadedMessageCount = data.messageLoadedCount ?? data.messages.length
|
target.loadedMessageCount = data.messageLoadedCount ?? data.messages.length
|
||||||
@@ -1253,6 +1293,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeSession.value = target
|
activeSession.value = target
|
||||||
|
messageLoadFailed.value = false
|
||||||
// Process replayed events (compression state etc.)
|
// Process replayed events (compression state etc.)
|
||||||
if (data.events?.length) {
|
if (data.events?.length) {
|
||||||
for (const evt of data.events) {
|
for (const evt of data.events) {
|
||||||
@@ -1346,20 +1387,31 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resumeServerWorkingRun(sessionId)
|
||||||
|
resumeAbort.abort()
|
||||||
resolve()
|
resolve()
|
||||||
}, activeSession.value?.profile, runtimeTransport())
|
}, activeSession.value?.profile, runtimeTransport(), resumeAbort.signal)
|
||||||
})
|
})
|
||||||
if (activeSessionId.value === sessionId) {
|
if (generation === sessionLoadGeneration && activeSessionId.value === sessionId) {
|
||||||
await loadWorkspaceRunChangesForSession(sessionId)
|
await loadWorkspaceRunChangesForSession(sessionId)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearTimeout(resumeTimeout)
|
||||||
console.error('Failed to load session messages via resume:', err)
|
console.error('Failed to load session messages via resume:', err)
|
||||||
|
if (generation === sessionLoadGeneration && activeSessionId.value === sessionId) {
|
||||||
|
const loaded = await refreshActiveSession(() => !runtimeReceived)
|
||||||
|
if (generation === sessionLoadGeneration && !runtimeReceived) {
|
||||||
|
httpHistoryLoaded = loaded
|
||||||
|
messageLoadFailed.value = !loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingMessages.value = false
|
if (!awaitingRuntime) resumeAbort.abort()
|
||||||
|
if (generation === sessionLoadGeneration) isLoadingMessages.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume in-flight run event listeners if needed
|
// Resume in-flight run event listeners if needed
|
||||||
if (activeSessionId.value === sessionId) {
|
if (generation === sessionLoadGeneration && activeSessionId.value === sessionId) {
|
||||||
resumeServerWorkingRun(sessionId)
|
resumeServerWorkingRun(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3732,7 +3784,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const sid = activeSessionId.value
|
const sid = activeSessionId.value
|
||||||
if (sid && !streamStates.value.has(sid)) {
|
if (sid && !streamStates.value.has(sid)) {
|
||||||
// Re-load messages via resume (server loads from DB)
|
// Re-load messages via resume (server loads from DB)
|
||||||
|
const generation = sessionLoadGeneration
|
||||||
resumeSession(sid, (data) => {
|
resumeSession(sid, (data) => {
|
||||||
|
if (generation !== sessionLoadGeneration || activeSessionId.value !== sid) return
|
||||||
if (data.isWorking) {
|
if (data.isWorking) {
|
||||||
serverWorking.value.add(sid)
|
serverWorking.value.add(sid)
|
||||||
} else {
|
} else {
|
||||||
@@ -3904,6 +3958,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
isLoadingSessions,
|
isLoadingSessions,
|
||||||
sessionsLoaded,
|
sessionsLoaded,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
|
messageLoadFailed,
|
||||||
|
|
||||||
newChat,
|
newChat,
|
||||||
newCliSession,
|
newCliSession,
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ export const useProfilesStore = defineStore('profiles', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const switching = ref(false)
|
const switching = ref(false)
|
||||||
|
|
||||||
async function fetchProfiles() {
|
let profilesLoadPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
function fetchProfiles(): Promise<void> {
|
||||||
|
if (profilesLoadPromise) return profilesLoadPromise
|
||||||
|
profilesLoadPromise = loadProfiles().finally(() => { profilesLoadPromise = null })
|
||||||
|
return profilesLoadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
profiles.value = await profilesApi.fetchProfiles()
|
profiles.value = await profilesApi.fetchProfiles()
|
||||||
|
|||||||
@@ -66,7 +66,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const weixin = ref<Record<string, any>>({})
|
const weixin = ref<Record<string, any>>({})
|
||||||
const platforms = ref<Record<string, any>>({})
|
const platforms = ref<Record<string, any>>({})
|
||||||
|
|
||||||
async function fetchSettings() {
|
let settingsLoadPromise: Promise<void> | null = null
|
||||||
|
function fetchSettings(): Promise<void> {
|
||||||
|
if (settingsLoadPromise) return settingsLoadPromise
|
||||||
|
settingsLoadPromise = loadSettings().finally(() => { settingsLoadPromise = null })
|
||||||
|
return settingsLoadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await configApi.fetchConfig()
|
const data = await configApi.fetchConfig()
|
||||||
|
|||||||
@@ -50,12 +50,8 @@ async function loadRouteSession() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
chatStore.setRuntimeMode('default')
|
chatStore.setRuntimeMode('default')
|
||||||
appStore.loadModels()
|
appStore.loadModels()
|
||||||
// 先加载 profile,确保缓存 key 使用正确的 profile name;同时预取显示设置,
|
void profilesStore.fetchProfiles()
|
||||||
// 让聊天完成提示音不依赖用户先打开 Settings 页面。
|
void settingsStore.fetchSettings()
|
||||||
await Promise.all([
|
|
||||||
profilesStore.fetchProfiles(),
|
|
||||||
settingsStore.fetchSettings(),
|
|
||||||
])
|
|
||||||
await loadRouteSession()
|
await loadRouteSession()
|
||||||
initialLoadFinished.value = true
|
initialLoadFinished.value = true
|
||||||
})
|
})
|
||||||
@@ -63,7 +59,9 @@ onMounted(async () => {
|
|||||||
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
|
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
|
||||||
if (!connected || !initialized) return
|
if (!connected || !initialized) return
|
||||||
if (!wasInitialized && chatStore.sessionsLoaded) return
|
if (!wasInitialized && chatStore.sessionsLoaded) return
|
||||||
await Promise.all([profilesStore.fetchProfiles(), settingsStore.fetchSettings(), appStore.loadModels()])
|
void profilesStore.fetchProfiles()
|
||||||
|
void settingsStore.fetchSettings()
|
||||||
|
void appStore.loadModels()
|
||||||
if (chatStore.isLoadingSessions) return
|
if (chatStore.isLoadingSessions) return
|
||||||
if (!chatStore.sessionsLoaded) await loadRouteSession()
|
if (!chatStore.sessionsLoaded) await loadRouteSession()
|
||||||
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
|
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ function profileAvatarImagePath(name: string, file = 'avatar.bin'): string {
|
|||||||
return join(profileMetadataDir(name), file)
|
return join(profileMetadataDir(name), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
function readProfileAvatar(name: string, includeImage = true): ProfileAvatarResponse | null {
|
||||||
const metaPath = profileAvatarMetaPath(name)
|
const metaPath = profileAvatarMetaPath(name)
|
||||||
if (!existsSync(metaPath)) return null
|
if (!existsSync(metaPath)) return null
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +203,7 @@ function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
|||||||
if (meta.type === 'image' && meta.file && meta.mime) {
|
if (meta.type === 'image' && meta.file && meta.mime) {
|
||||||
const imagePath = profileAvatarImagePath(name, meta.file)
|
const imagePath = profileAvatarImagePath(name, meta.file)
|
||||||
if (!existsSync(imagePath)) return null
|
if (!existsSync(imagePath)) return null
|
||||||
|
if (!includeImage) return { type: 'image', updatedAt: meta.updatedAt }
|
||||||
const data = readFileSync(imagePath).toString('base64')
|
const data = readFileSync(imagePath).toString('base64')
|
||||||
return {
|
return {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
@@ -219,7 +220,7 @@ function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
|||||||
function attachProfileAvatars<T extends HermesProfile>(profiles: T[]): Array<T & { avatar: ProfileAvatarResponse | null }> {
|
function attachProfileAvatars<T extends HermesProfile>(profiles: T[]): Array<T & { avatar: ProfileAvatarResponse | null }> {
|
||||||
return profiles.map(profile => ({
|
return profiles.map(profile => ({
|
||||||
...profile,
|
...profile,
|
||||||
avatar: readProfileAvatar(profile.name),
|
avatar: readProfileAvatar(profile.name, false),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,18 +377,7 @@ function scheduleRuntimeStatusRefresh(): void {
|
|||||||
|
|
||||||
export async function list(ctx: any) {
|
export async function list(ctx: any) {
|
||||||
try {
|
try {
|
||||||
let profiles: HermesProfile[]
|
let profiles = hermesCli.listProfilesSnapshot()
|
||||||
try {
|
|
||||||
profiles = await hermesCli.listProfiles()
|
|
||||||
} catch (err: any) {
|
|
||||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
|
||||||
const activeProfileName = getActiveProfileName()
|
|
||||||
if (!isForbiddenProfileName(activeProfileName)) throw err
|
|
||||||
|
|
||||||
logger.warn(err, '[listProfiles] active_profile "%s" is invalid/reserved; resetting to default and listing profiles from disk', activeProfileName)
|
|
||||||
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
|
|
||||||
profiles = listProfilesFromDisk('default')
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProfileName = requestedProfileName(ctx)
|
const activeProfileName = requestedProfileName(ctx)
|
||||||
|
|
||||||
@@ -485,6 +475,12 @@ export async function get(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAvatar(ctx: any) {
|
||||||
|
const name = String(ctx.params.name || '').trim()
|
||||||
|
if (denyProfile(ctx, name)) return
|
||||||
|
ctx.body = { avatar: readProfileAvatar(name) }
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAvatar(ctx: any) {
|
export async function updateAvatar(ctx: any) {
|
||||||
const name = String(ctx.params.name || '').trim() || 'default'
|
const name = String(ctx.params.name || '').trim() || 'default'
|
||||||
if (denyProfile(ctx, name)) return
|
if (denyProfile(ctx, name)) return
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ profileRoutes.post('/api/hermes/profiles/restart-all', ctrl.restartAllProfiles)
|
|||||||
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
|
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
|
||||||
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
||||||
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
|
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
|
||||||
|
profileRoutes.get('/api/hermes/profiles/:name/avatar', ctrl.getAvatar)
|
||||||
profileRoutes.put('/api/hermes/profiles/:name/avatar', ctrl.updateAvatar)
|
profileRoutes.put('/api/hermes/profiles/:name/avatar', ctrl.updateAvatar)
|
||||||
profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
|
profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
|
||||||
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||||
|
|||||||
@@ -591,6 +591,16 @@ function readProfileDefaultModel(name: string): string {
|
|||||||
/**
|
/**
|
||||||
* List all profiles
|
* List all profiles
|
||||||
*/
|
*/
|
||||||
|
export function listProfilesSnapshot(): HermesProfile[] {
|
||||||
|
const activeProfileName = getActiveProfileName()
|
||||||
|
return listProfileNamesFromDisk().map(name => ({
|
||||||
|
name,
|
||||||
|
active: name === activeProfileName,
|
||||||
|
model: readProfileDefaultModel(name),
|
||||||
|
alias: '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function listProfiles(): Promise<HermesProfile[]> {
|
export async function listProfiles(): Promise<HermesProfile[]> {
|
||||||
const profileNames = listProfileNamesFromDisk()
|
const profileNames = listProfileNamesFromDisk()
|
||||||
const activeProfileName = getActiveProfileName()
|
const activeProfileName = getActiveProfileName()
|
||||||
|
|||||||
@@ -85,6 +85,22 @@ describe('chat-run socket reconnect handling', () => {
|
|||||||
socketState.sockets = []
|
socketState.sockets = []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes cancelled resume listeners without disturbing the current request', async () => {
|
||||||
|
const { resumeSession } = await import('../../packages/client/src/api/hermes/chat')
|
||||||
|
const controller = new AbortController()
|
||||||
|
const oldReply = vi.fn()
|
||||||
|
resumeSession('session-1', oldReply, 'default', 'chat-run', controller.signal)
|
||||||
|
const socket = socketState.sockets[0]
|
||||||
|
controller.abort()
|
||||||
|
expect(socket.__listenerCount('resumed')).toBe(0)
|
||||||
|
const currentReply = vi.fn()
|
||||||
|
resumeSession('session-1', currentReply, 'default')
|
||||||
|
socket.__trigger('resumed', { session_id: 'session-1', messages: [], isWorking: false, events: [] })
|
||||||
|
expect(oldReply).not.toHaveBeenCalled()
|
||||||
|
expect(currentReply).toHaveBeenCalledOnce()
|
||||||
|
expect(socket.__listenerCount('resumed')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps transient mobile disconnects alive and resumes after reconnect', async () => {
|
it('keeps transient mobile disconnects alive and resumes after reconnect', async () => {
|
||||||
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
|
const { startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
|
||||||
const onEvent = vi.fn()
|
const onEvent = vi.fn()
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ describe('chat store session.command fanout', () => {
|
|||||||
workspace: '/repo',
|
workspace: '/repo',
|
||||||
})
|
})
|
||||||
expect(store.activeSessionId).toBe('branch-1')
|
expect(store.activeSessionId).toBe('branch-1')
|
||||||
expect(chatApi.resumeSession).toHaveBeenCalledWith('branch-1', expect.any(Function), 'default', 'chat-run')
|
expect(chatApi.resumeSession).toHaveBeenCalledWith('branch-1', expect.any(Function), 'default', 'chat-run', expect.any(AbortSignal))
|
||||||
|
|
||||||
await store.switchSession('session-1')
|
await store.switchSession('session-1')
|
||||||
expect(store.activeSessionId).toBe('session-1')
|
expect(store.activeSessionId).toBe('session-1')
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const chatApi = vi.hoisted(() => ({
|
||||||
|
startRunViaSocket: vi.fn(),
|
||||||
|
resumeSession: vi.fn(),
|
||||||
|
registerSessionHandlers: vi.fn(),
|
||||||
|
unregisterSessionHandlers: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/chat', () => ({
|
||||||
|
startRunViaSocket: chatApi.startRunViaSocket,
|
||||||
|
resumeSession: chatApi.resumeSession,
|
||||||
|
registerSessionHandlers: chatApi.registerSessionHandlers,
|
||||||
|
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
|
||||||
|
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
|
||||||
|
respondToolApproval: vi.fn(),
|
||||||
|
respondClarify: vi.fn(),
|
||||||
|
onPeerUserMessage: vi.fn(() => vi.fn()),
|
||||||
|
onSessionCommand: vi.fn(() => vi.fn()),
|
||||||
|
onSessionTitleUpdated: vi.fn(() => vi.fn()),
|
||||||
|
onSessionWorkspaceUpdated: vi.fn(() => vi.fn()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
getActiveProfileName: () => 'default',
|
||||||
|
hasApiKey: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/sessions', () => ({
|
||||||
|
archiveSession: vi.fn(),
|
||||||
|
deleteSession: vi.fn(),
|
||||||
|
fetchSessionMessagesPage: vi.fn(),
|
||||||
|
fetchSessions: vi.fn(),
|
||||||
|
fetchWorkspaceRunChangesForSession: vi.fn(async () => []),
|
||||||
|
fetchWorkspaceRunChangeFile: vi.fn(async () => null),
|
||||||
|
setSessionModel: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/download', () => ({
|
||||||
|
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/system', () => ({
|
||||||
|
checkHealth: vi.fn(),
|
||||||
|
fetchAvailableModels: vi.fn(),
|
||||||
|
addCustomModel: vi.fn(),
|
||||||
|
removeCustomModel: vi.fn(),
|
||||||
|
updateDefaultModel: vi.fn(),
|
||||||
|
updateModelVisibility: vi.fn(),
|
||||||
|
updateModelAlias: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/completion-sound', () => ({
|
||||||
|
primeCompletionSound: vi.fn(),
|
||||||
|
playCompletionSound: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useChatStore, type Message, type Session } from '@/stores/hermes/chat'
|
||||||
|
|
||||||
|
function makeSession(id: string): Session {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
messages: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { fetchSessionMessagesPage } from '@/api/hermes/sessions'
|
||||||
|
|
||||||
|
describe('session loading recovery', () => {
|
||||||
|
let callbacks: Array<(data: any) => void>
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
callbacks = []
|
||||||
|
chatApi.resumeSession.mockImplementation((_id: string, callback: any) => { callbacks.push(callback) })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() })
|
||||||
|
|
||||||
|
it('ignores old replies and keeps loading the latest request during A/B/A switching', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.sessions = [makeSession('a'), makeSession('b')]
|
||||||
|
const first = store.switchSession('a')
|
||||||
|
const second = store.switchSession('b')
|
||||||
|
const third = store.switchSession('a')
|
||||||
|
callbacks[0]({ session_id: 'a', messages: [{ role: 'user', content: 'old' }], events: [], isWorking: false })
|
||||||
|
await Promise.all([first, second])
|
||||||
|
expect(store.isLoadingMessages).toBe(true)
|
||||||
|
expect(store.messages).toEqual([])
|
||||||
|
callbacks[2]({ session_id: 'a', messages: [{ role: 'user', content: 'latest' }], events: [], isWorking: false })
|
||||||
|
await third
|
||||||
|
expect(store.messages[0]?.content).toBe('latest')
|
||||||
|
expect(store.isLoadingMessages).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads history via HTTP when the socket does not reply', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.sessions = [{ ...makeSession('a'), profile: 'research' }]
|
||||||
|
vi.mocked(fetchSessionMessagesPage).mockResolvedValue({ session: { title: 'Recovered' }, messages: [{ id: 1, role: 'user', content: 'HTTP history' }], total: 1, hasMore: false } as any)
|
||||||
|
const pending = store.switchSession('a')
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await pending
|
||||||
|
expect(fetchSessionMessagesPage).toHaveBeenCalledWith('a', 0, expect.any(Number), 'research')
|
||||||
|
expect(store.messages[0]?.content).toBe('HTTP history')
|
||||||
|
expect(store.messageLoadFailed).toBe(false)
|
||||||
|
expect(store.isStreaming).toBe(false)
|
||||||
|
callbacks[0]({ session_id: 'a', messages: [{ role: 'user', content: 'stale socket' }], isWorking: true, events: [] })
|
||||||
|
expect(store.messages[0]?.content).toBe('HTTP history')
|
||||||
|
expect(store.isStreaming).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back immediately if the socket cannot be opened', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.sessions = [makeSession('a')]
|
||||||
|
chatApi.resumeSession.mockImplementation(() => { throw new Error('socket unavailable') })
|
||||||
|
vi.mocked(fetchSessionMessagesPage).mockResolvedValue({ session: {}, messages: [{ role: 'user', content: 'HTTP fallback' }], total: 1, hasMore: false } as any)
|
||||||
|
await store.switchSession('a')
|
||||||
|
expect(store.messages[0]?.content).toBe('HTTP fallback')
|
||||||
|
expect(store.messageLoadFailed).toBe(false)
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
expect(fetchSessionMessagesPage).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes failure and supports retrying the same session', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.sessions = [makeSession('a')]
|
||||||
|
vi.mocked(fetchSessionMessagesPage).mockRejectedValue(new Error('offline'))
|
||||||
|
const pending = store.switchSession('a')
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
await pending
|
||||||
|
expect(store.messageLoadFailed).toBe(true)
|
||||||
|
expect(store.isLoadingMessages).toBe(false)
|
||||||
|
const retry = store.switchSession('a')
|
||||||
|
expect(store.messageLoadFailed).toBe(false)
|
||||||
|
callbacks.at(-1)!({ session_id: 'a', messages: [{ role: 'user', content: 'Recovered' }], events: [], isWorking: false })
|
||||||
|
await retry
|
||||||
|
expect(store.messages[0]?.content).toBe('Recovered')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not apply a late HTTP fallback after another selection', async () => {
|
||||||
|
const store = useChatStore()
|
||||||
|
store.sessions = [makeSession('a'), makeSession('b')]
|
||||||
|
let finish!: (data: any) => void
|
||||||
|
vi.mocked(fetchSessionMessagesPage).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||||
|
const first = store.switchSession('a')
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
const second = store.switchSession('b')
|
||||||
|
callbacks[1]({ session_id: 'b', messages: [{ role: 'user', content: 'B' }], events: [], isWorking: false })
|
||||||
|
await second
|
||||||
|
finish({ session: {}, messages: [{ role: 'user', content: 'A' }], total: 1, hasMore: false })
|
||||||
|
await first
|
||||||
|
expect(store.activeSessionId).toBe('b')
|
||||||
|
expect(store.messages[0]?.content).toBe('B')
|
||||||
|
expect(store.messageLoadFailed).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -94,6 +94,17 @@ describe('ChatView tab title', () => {
|
|||||||
vi.spyOn(chatStore, 'loadSessions').mockResolvedValue()
|
vi.spyOn(chatStore, 'loadSessions').mockResolvedValue()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('loads sessions while profiles, settings and models are still pending', async () => {
|
||||||
|
const pending = new Promise<void>(() => {})
|
||||||
|
vi.mocked(useProfilesStore().fetchProfiles).mockReturnValue(pending)
|
||||||
|
vi.mocked(useSettingsStore().fetchSettings).mockReturnValue(pending)
|
||||||
|
vi.mocked(useAppStore().loadModels).mockReturnValue(pending)
|
||||||
|
const wrapper = mount(ChatView)
|
||||||
|
await nextTick()
|
||||||
|
expect(useChatStore().loadSessions).toHaveBeenCalledOnce()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('reflects the active session title in the browser tab', async () => {
|
it('reflects the active session title in the browser tab', async () => {
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
chatStore.activeSession = makeSession('Research Plan')
|
chatStore.activeSession = makeSession('Research Plan')
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||||
|
import { fetchProfileAvatar } from '@/api/hermes/profiles'
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/profiles', () => ({ fetchProfileAvatar: vi.fn() }))
|
||||||
|
|
||||||
|
describe('profile avatar loading', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('fetches an uploaded image only when displayed and ignores old profile responses', async () => {
|
||||||
|
let finish!: (data: any) => void
|
||||||
|
vi.mocked(fetchProfileAvatar).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||||
|
vi.mocked(fetchProfileAvatar).mockResolvedValueOnce({ type: 'image', dataUrl: 'data:image/png;base64,Yg==' })
|
||||||
|
const wrapper = mount(ProfileAvatar, { props: { name: 'a', avatar: { type: 'image', updatedAt: 1 } } })
|
||||||
|
expect(fetchProfileAvatar).toHaveBeenCalledWith('a')
|
||||||
|
await wrapper.setProps({ name: 'b', avatar: { type: 'image', updatedAt: 2 } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,Yg==')
|
||||||
|
finish({ type: 'image', dataUrl: 'data:image/png;base64,YQ==' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,Yg==')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fetch generated or already loaded avatars', async () => {
|
||||||
|
const wrapper = mount(ProfileAvatar, { props: { name: 'a', avatar: { type: 'generated', seed: 'a' } } })
|
||||||
|
await wrapper.setProps({ avatar: { type: 'image', dataUrl: 'data:image/png;base64,YQ==' } })
|
||||||
|
expect(fetchProfileAvatar).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,YQ==')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,20 @@ describe('Profiles Store', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shares an in-flight profile request and allows later refreshes', async () => {
|
||||||
|
let resolve!: (value: any[]) => void
|
||||||
|
mockProfilesApi.fetchProfiles.mockReturnValue(new Promise(done => { resolve = done }))
|
||||||
|
const store = useProfilesStore()
|
||||||
|
const first = store.fetchProfiles()
|
||||||
|
const second = store.fetchProfiles()
|
||||||
|
expect(mockProfilesApi.fetchProfiles).toHaveBeenCalledOnce()
|
||||||
|
resolve([])
|
||||||
|
await Promise.all([first, second])
|
||||||
|
mockProfilesApi.fetchProfiles.mockResolvedValue([])
|
||||||
|
await store.fetchProfiles()
|
||||||
|
expect(mockProfilesApi.fetchProfiles).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('fetchProfiles loads profiles and sets active', async () => {
|
it('fetchProfiles loads profiles and sets active', async () => {
|
||||||
const profiles = [
|
const profiles = [
|
||||||
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
{ name: 'default', active: true, model: 'gpt-4', alias: '' },
|
||||||
|
|||||||
@@ -45,3 +45,44 @@ test('batch-delete text appears in its confirmation popup without leaking into t
|
|||||||
await expect(popup).toContainText(confirmation)
|
await expect(popup).toContainText(confirmation)
|
||||||
await expect(popup.getByRole('button', { name: /cancel/i })).toBeVisible()
|
await expect(popup.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const path of ['/hermes/chat', '/hermes/session/restored-chat?profile=research']) {
|
||||||
|
test(`shows conversation before slow profile, settings and model requests finish: ${path}`, async ({ page }) => {
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
await mockHermesApi(page, { sessions: [session] })
|
||||||
|
await mockChatSocket(page)
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
;(window as any).__PW_CHAT_SOCKET_RESUMES__ = {
|
||||||
|
'restored-chat': { session_id: 'restored-chat', messages: [{ id: 1, role: 'user', content: 'History without waiting for profiles', timestamp: 1000 }], isWorking: false, events: [] },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let release!: () => void
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
|
await page.route(url => ['/api/hermes/profiles', '/api/hermes/config', '/api/hermes/available-models'].includes(url.pathname), async route => {
|
||||||
|
await gate
|
||||||
|
await route.fallback()
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await page.goto(`/#${path}`)
|
||||||
|
await expect(page.getByText('History without waiting for profiles', { exact: true })).toBeVisible()
|
||||||
|
} finally { release() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('falls back to HTTP history and offers retry after a failed load', async ({ page }) => {
|
||||||
|
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||||
|
await mockHermesApi(page, { sessions: [session] })
|
||||||
|
await mockChatSocket(page)
|
||||||
|
let fail = true
|
||||||
|
await page.route('**/sessions/conversations/restored-chat/messages/paginated?*', async route => {
|
||||||
|
await route.fulfill(fail
|
||||||
|
? { status: 503, json: { error: 'temporarily unavailable' } }
|
||||||
|
: { json: { session, messages: [{ id: 1, role: 'user', content: 'History recovered by retry', timestamp: 1000 }], total: 1, hasMore: false } })
|
||||||
|
})
|
||||||
|
await page.goto('/#/hermes/chat')
|
||||||
|
await expect(page.getByRole('alert')).toContainText('Could not load this conversation', { timeout: 12000 })
|
||||||
|
fail = false
|
||||||
|
await page.getByRole('alert').getByRole('button', { name: 'Retry' }).click()
|
||||||
|
await expect(page.getByText('History recovered by retry', { exact: true })).toBeVisible({ timeout: 12000 })
|
||||||
|
await expect(page.getByRole('alert')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { performance } from 'perf_hooks'
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-process', () => ({
|
||||||
|
execHermesWithBin: vi.fn(() => { throw new Error('Inventory must not invoke CLI') }),
|
||||||
|
spawnHermesWithBin: vi.fn(),
|
||||||
|
}))
|
||||||
|
import { listProfilesSnapshot } from '../../packages/server/src/services/hermes/hermes-cli'
|
||||||
|
import { execHermesWithBin } from '../../packages/server/src/services/hermes/hermes-process'
|
||||||
|
|
||||||
|
const originalHome = process.env.HERMES_HOME
|
||||||
|
let home: string | undefined
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (originalHome === undefined) delete process.env.HERMES_HOME
|
||||||
|
else process.env.HERMES_HOME = originalHome
|
||||||
|
if (home) await rm(home, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disk profile inventory', () => {
|
||||||
|
it('reads models and active selection for 20 profiles without scanning histories or skills', async () => {
|
||||||
|
home = await mkdtemp(join(tmpdir(), 'hermes-profile-snapshot-'))
|
||||||
|
process.env.HERMES_HOME = home
|
||||||
|
await writeFile(join(home, 'config.yaml'), 'model: default-model\n')
|
||||||
|
await writeFile(join(home, 'active_profile'), 'perf-19\n')
|
||||||
|
for (let index = 0; index < 20; index++) {
|
||||||
|
const dir = join(home, 'profiles', `perf-${index}`)
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
await writeFile(join(dir, 'config.yaml'), `model:\n default: model-${index}\n`)
|
||||||
|
// These are deliberately invalid; inventory must not parse session history.
|
||||||
|
await writeFile(join(dir, 'state.db'), 'not a database')
|
||||||
|
}
|
||||||
|
const started = performance.now()
|
||||||
|
const profiles = listProfilesSnapshot()
|
||||||
|
console.info(`Disk inventory for 21 profiles: ${(performance.now() - started).toFixed(2)} ms`)
|
||||||
|
expect(profiles).toHaveLength(21)
|
||||||
|
expect(profiles.find(profile => profile.name === 'perf-19')).toMatchObject({ model: 'model-19', active: true })
|
||||||
|
expect(profiles[0]).toMatchObject({ name: 'default', model: 'default-model', active: false })
|
||||||
|
expect(execHermesWithBin).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -27,6 +27,7 @@ const gatewayAutostartMocks = vi.hoisted(() => ({
|
|||||||
// Mock hermes-cli
|
// Mock hermes-cli
|
||||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||||
listProfiles: vi.fn(),
|
listProfiles: vi.fn(),
|
||||||
|
listProfilesSnapshot: vi.fn(),
|
||||||
getProfile: vi.fn(),
|
getProfile: vi.fn(),
|
||||||
createProfile: vi.fn(),
|
createProfile: vi.fn(),
|
||||||
deleteProfile: vi.fn(),
|
deleteProfile: vi.fn(),
|
||||||
@@ -313,6 +314,28 @@ describe('Profile Routes', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lists 20 profiles without waiting for CLI or embedding uploaded images', async () => {
|
||||||
|
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-ui-profile-list-'))
|
||||||
|
tempHomes.push(webUiHome)
|
||||||
|
process.env.HERMES_WEB_UI_HOME = webUiHome
|
||||||
|
const { list, updateAvatar, getAvatar } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||||
|
await updateAvatar({ params: { name: 'work-0' }, request: { body: { type: 'image', dataUrl: 'data:image/png;base64,YQ==' } } })
|
||||||
|
vi.mocked(hermesCli.listProfiles).mockReturnValue(new Promise(() => {}))
|
||||||
|
vi.mocked(hermesCli.listProfilesSnapshot).mockReturnValue(Array.from({ length: 20 }, (_, index) => ({ name: `work-${index}`, active: false, model: 'test', alias: '' })))
|
||||||
|
const ctx: any = { state: { profile: { name: 'work-0' } } }
|
||||||
|
await list(ctx)
|
||||||
|
expect(hermesCli.listProfiles).not.toHaveBeenCalled()
|
||||||
|
expect(ctx.body.profiles).toHaveLength(20)
|
||||||
|
expect(ctx.body.profiles[0]).toMatchObject({ active: true, avatar: { type: 'image' } })
|
||||||
|
expect(ctx.body.profiles[0].avatar.dataUrl).toBeUndefined()
|
||||||
|
const imageCtx: any = { params: { name: 'work-0' } }
|
||||||
|
await getAvatar(imageCtx)
|
||||||
|
expect(imageCtx.body.avatar.dataUrl).toBe('data:image/png;base64,YQ==')
|
||||||
|
const denied: any = { params: { name: 'work-0' }, state: { user: { id: -123, role: 'admin' } } }
|
||||||
|
await getAvatar(denied)
|
||||||
|
expect(denied.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
describe('profile avatars', () => {
|
describe('profile avatars', () => {
|
||||||
it('stores generated avatar metadata under the Web UI home', async () => {
|
it('stores generated avatar metadata under the Web UI home', async () => {
|
||||||
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-ui-avatar-'))
|
const webUiHome = await mkdtemp(join(tmpdir(), 'hermes-ui-avatar-'))
|
||||||
|
|||||||
Reference in New Issue
Block a user