++
This commit is contained in:
@@ -788,14 +788,21 @@ export function resumeSession(
|
||||
onResumed: (data: ResumeSessionPayload) => void,
|
||||
profile?: string | null,
|
||||
transport: ChatRunTransport = 'chat-run',
|
||||
signal?: AbortSignal,
|
||||
): Socket {
|
||||
const socket = connectChatRun(profile, transport)
|
||||
|
||||
const handleResumed = (data: ResumeSessionPayload) => {
|
||||
if (data?.session_id !== sessionId) return
|
||||
removeSocketListener(socket, 'resumed', handleResumed)
|
||||
cleanup()
|
||||
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.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) })
|
||||
|
||||
|
||||
@@ -242,3 +242,8 @@ export async function importProfile(file: File): Promise<boolean> {
|
||||
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>
|
||||
<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
|
||||
:key="chatStore.activeSessionId || 'chat-empty'"
|
||||
ref="listRef"
|
||||
@@ -869,6 +874,19 @@ defineExpose({
|
||||
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 {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import type { ProfileAvatar } from '@/api/hermes/profiles'
|
||||
import { fetchProfileAvatar, type ProfileAvatar } from '@/api/hermes/profiles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
@@ -11,6 +11,20 @@ const props = withDefaults(defineProps<{
|
||||
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 generatedSvg = computed(() => multiavatar(props.avatar?.seed || fallbackSeed.value))
|
||||
const style = computed(() => ({
|
||||
@@ -23,9 +37,9 @@ const style = computed(() => ({
|
||||
<template>
|
||||
<span class="profile-avatar-view" :style="style">
|
||||
<img
|
||||
v-if="avatar?.type === 'image' && avatar.dataUrl"
|
||||
v-if="avatar?.type === 'image' && imageDataUrl"
|
||||
class="profile-avatar-image"
|
||||
:src="avatar.dataUrl"
|
||||
:src="imageDataUrl"
|
||||
alt=""
|
||||
draggable="false"
|
||||
>
|
||||
|
||||
@@ -453,6 +453,7 @@ export default {
|
||||
|
||||
// Chat
|
||||
chat: {
|
||||
messageLoadFailed: 'Could not load this conversation. Please retry.',
|
||||
contextRemaining: 'remaining',
|
||||
contextClickToEdit: 'Click to edit context length',
|
||||
contextEditTitle: 'Edit Context Length',
|
||||
|
||||
@@ -453,6 +453,7 @@ export default {
|
||||
|
||||
// 对话
|
||||
chat: {
|
||||
messageLoadFailed: '会话加载失败,请重试。',
|
||||
contextRemaining: '剩余',
|
||||
contextClickToEdit: '点击编辑上下文长度',
|
||||
contextEditTitle: '编辑上下文长度',
|
||||
|
||||
@@ -698,6 +698,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const isLoadingSessions = ref(false)
|
||||
const sessionsLoaded = ref(false)
|
||||
const isLoadingMessages = ref(false)
|
||||
const messageLoadFailed = ref(false)
|
||||
let sessionLoadGeneration = 0
|
||||
let sessionResumeAbort: AbortController | null = null
|
||||
const isRunActive = computed(() => isStreaming.value)
|
||||
|
||||
async function fetchRuntimeSessions(profile?: string | null): Promise<SessionSummary[]> {
|
||||
@@ -805,6 +808,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
function clearActiveSession() {
|
||||
sessionLoadGeneration++
|
||||
sessionResumeAbort?.abort()
|
||||
isLoadingMessages.value = false
|
||||
messageLoadFailed.value = false
|
||||
const sid = activeSessionId.value
|
||||
activeSessionId.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.
|
||||
async function refreshActiveSession(): Promise<boolean> {
|
||||
async function refreshActiveSession(isCurrent: () => boolean = () => true): Promise<boolean> {
|
||||
const sid = activeSessionId.value
|
||||
const generation = sessionLoadGeneration
|
||||
if (!sid) return false
|
||||
try {
|
||||
const target = sessions.value.find(s => s.id === sid)
|
||||
@@ -1093,7 +1101,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
LIVE_CHAT_MAX_LOADED_MESSAGES,
|
||||
)
|
||||
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 || [])
|
||||
target.messages = mapped
|
||||
target.loadedMessageCount = detail.messages.length
|
||||
@@ -1176,6 +1184,12 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
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)
|
||||
activeSessionId.value = sessionId
|
||||
focusMessageId.value = focusId ?? null
|
||||
@@ -1188,17 +1202,43 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (!activeSession.value) return
|
||||
|
||||
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 {
|
||||
// Load messages via Socket.IO resume (server loads from DB if not in memory)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('resume timeout')), 15_000)
|
||||
resumeSession(sessionId, (data) => {
|
||||
let settled = false
|
||||
const cancel = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
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()
|
||||
return
|
||||
}
|
||||
runtimeReceived = true
|
||||
const target = sessions.value.find(s => s.id === sessionId)
|
||||
if (!target) {
|
||||
resolve()
|
||||
@@ -1237,7 +1277,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
target.parentTitle = (data as any).parentTitle || target.parentTitle || null
|
||||
target.parentLastMessage = (data as any).parentLastMessage || target.parentLastMessage || 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[])
|
||||
restoreWorkspaceRunChangeMessages(sessionId)
|
||||
target.loadedMessageCount = data.messageLoadedCount ?? data.messages.length
|
||||
@@ -1253,6 +1293,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
activeSession.value = target
|
||||
messageLoadFailed.value = false
|
||||
// Process replayed events (compression state etc.)
|
||||
if (data.events?.length) {
|
||||
for (const evt of data.events) {
|
||||
@@ -1346,20 +1387,31 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
resumeServerWorkingRun(sessionId)
|
||||
resumeAbort.abort()
|
||||
resolve()
|
||||
}, activeSession.value?.profile, runtimeTransport())
|
||||
}, activeSession.value?.profile, runtimeTransport(), resumeAbort.signal)
|
||||
})
|
||||
if (activeSessionId.value === sessionId) {
|
||||
if (generation === sessionLoadGeneration && activeSessionId.value === sessionId) {
|
||||
await loadWorkspaceRunChangesForSession(sessionId)
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(resumeTimeout)
|
||||
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 {
|
||||
isLoadingMessages.value = false
|
||||
if (!awaitingRuntime) resumeAbort.abort()
|
||||
if (generation === sessionLoadGeneration) isLoadingMessages.value = false
|
||||
}
|
||||
|
||||
// Resume in-flight run event listeners if needed
|
||||
if (activeSessionId.value === sessionId) {
|
||||
if (generation === sessionLoadGeneration && activeSessionId.value === sessionId) {
|
||||
resumeServerWorkingRun(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -3732,7 +3784,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const sid = activeSessionId.value
|
||||
if (sid && !streamStates.value.has(sid)) {
|
||||
// Re-load messages via resume (server loads from DB)
|
||||
const generation = sessionLoadGeneration
|
||||
resumeSession(sid, (data) => {
|
||||
if (generation !== sessionLoadGeneration || activeSessionId.value !== sid) return
|
||||
if (data.isWorking) {
|
||||
serverWorking.value.add(sid)
|
||||
} else {
|
||||
@@ -3904,6 +3958,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
isLoadingSessions,
|
||||
sessionsLoaded,
|
||||
isLoadingMessages,
|
||||
messageLoadFailed,
|
||||
|
||||
newChat,
|
||||
newCliSession,
|
||||
|
||||
@@ -15,7 +15,15 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
const loading = 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
|
||||
try {
|
||||
profiles.value = await profilesApi.fetchProfiles()
|
||||
|
||||
@@ -66,7 +66,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
const weixin = 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
|
||||
try {
|
||||
const data = await configApi.fetchConfig()
|
||||
|
||||
@@ -50,12 +50,8 @@ async function loadRouteSession() {
|
||||
onMounted(async () => {
|
||||
chatStore.setRuntimeMode('default')
|
||||
appStore.loadModels()
|
||||
// 先加载 profile,确保缓存 key 使用正确的 profile name;同时预取显示设置,
|
||||
// 让聊天完成提示音不依赖用户先打开 Settings 页面。
|
||||
await Promise.all([
|
||||
profilesStore.fetchProfiles(),
|
||||
settingsStore.fetchSettings(),
|
||||
])
|
||||
void profilesStore.fetchProfiles()
|
||||
void settingsStore.fetchSettings()
|
||||
await loadRouteSession()
|
||||
initialLoadFinished.value = true
|
||||
})
|
||||
@@ -63,7 +59,9 @@ onMounted(async () => {
|
||||
watch([() => appStore.connected, initialLoadFinished], async ([connected, initialized], [, wasInitialized]) => {
|
||||
if (!connected || !initialized) 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.sessionsLoaded) await loadRouteSession()
|
||||
else await chatStore.refreshSessionListOnly(chatStore.sessionProfileFilter)
|
||||
|
||||
@@ -188,7 +188,7 @@ function profileAvatarImagePath(name: string, file = 'avatar.bin'): string {
|
||||
return join(profileMetadataDir(name), file)
|
||||
}
|
||||
|
||||
function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
||||
function readProfileAvatar(name: string, includeImage = true): ProfileAvatarResponse | null {
|
||||
const metaPath = profileAvatarMetaPath(name)
|
||||
if (!existsSync(metaPath)) return null
|
||||
try {
|
||||
@@ -203,6 +203,7 @@ function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
||||
if (meta.type === 'image' && meta.file && meta.mime) {
|
||||
const imagePath = profileAvatarImagePath(name, meta.file)
|
||||
if (!existsSync(imagePath)) return null
|
||||
if (!includeImage) return { type: 'image', updatedAt: meta.updatedAt }
|
||||
const data = readFileSync(imagePath).toString('base64')
|
||||
return {
|
||||
type: 'image',
|
||||
@@ -219,7 +220,7 @@ function readProfileAvatar(name: string): ProfileAvatarResponse | null {
|
||||
function attachProfileAvatars<T extends HermesProfile>(profiles: T[]): Array<T & { avatar: ProfileAvatarResponse | null }> {
|
||||
return profiles.map(profile => ({
|
||||
...profile,
|
||||
avatar: readProfileAvatar(profile.name),
|
||||
avatar: readProfileAvatar(profile.name, false),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -376,18 +377,7 @@ function scheduleRuntimeStatusRefresh(): void {
|
||||
|
||||
export async function list(ctx: any) {
|
||||
try {
|
||||
let profiles: HermesProfile[]
|
||||
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')
|
||||
}
|
||||
let profiles = hermesCli.listProfilesSnapshot()
|
||||
|
||||
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) {
|
||||
const name = String(ctx.params.name || '').trim() || 'default'
|
||||
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.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
|
||||
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.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
|
||||
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||
|
||||
@@ -591,6 +591,16 @@ function readProfileDefaultModel(name: string): string {
|
||||
/**
|
||||
* 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[]> {
|
||||
const profileNames = listProfileNamesFromDisk()
|
||||
const activeProfileName = getActiveProfileName()
|
||||
|
||||
Reference in New Issue
Block a user