feat: support concurrent session streaming, persist active session, and improve 401 handling
- Refactor streaming to use Map<string, AbortController> for multi-session concurrency - SSE callbacks capture session ID in closure, no cross-session interference - messages is now computed from activeSession, no manual sync needed - Persist active session ID to localStorage, restore on reload - Auto-expand session group when restoring saved session - Clear auth key and redirect to login on 401 (skip if already on login page) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
"description": "Hermes Agent Web UI - Chat and Job Management Dashboard",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+5
-2
@@ -41,9 +41,12 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
|
|||||||
|
|
||||||
const res = await fetch(url, { ...options, headers })
|
const res = await fetch(url, { ...options, headers })
|
||||||
|
|
||||||
// Global 401 handler — redirect to login
|
// Global 401 handler — clear auth and redirect to login
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.replace({ name: 'login' })
|
clearApiKey()
|
||||||
|
if (router.currentRoute.value.name !== 'login') {
|
||||||
|
router.replace({ name: 'login' })
|
||||||
|
}
|
||||||
throw new Error('Unauthorized')
|
throw new Error('Unauthorized')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,18 @@ function toggleGroup(source: string) {
|
|||||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: expand only the first group if no saved state
|
// Ensure the active session's group is expanded
|
||||||
watch(groupedSessions, (groups) => {
|
watch(groupedSessions, (groups) => {
|
||||||
if (localStorage.getItem('hermes_collapsed_groups') !== null) return
|
if (localStorage.getItem('hermes_collapsed_groups') !== null) {
|
||||||
|
// Has saved state — still ensure active session's group is visible
|
||||||
|
const activeSource = chatStore.activeSession?.source
|
||||||
|
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
||||||
|
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== activeSource))
|
||||||
|
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No saved state: expand only the first group
|
||||||
collapsedGroups.value = new Set(groups.slice(1).map(g => g.source))
|
collapsedGroups.value = new Set(groups.slice(1).map(g => g.source))
|
||||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
|
|||||||
+93
-91
@@ -155,26 +155,29 @@ function mapHermesSession(s: SessionSummary): Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
|
const STORAGE_KEY = 'hermes_active_session'
|
||||||
const sessions = ref<Session[]>([])
|
const sessions = ref<Session[]>([])
|
||||||
const activeSessionId = ref<string | null>(null)
|
const activeSessionId = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
||||||
const streamSessionId = ref<string | null>(null)
|
const streamStates = ref<Map<string, AbortController>>(new Map())
|
||||||
const _isStreaming = ref(false)
|
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
||||||
const abortController = ref<AbortController | null>(null)
|
|
||||||
const isStreaming = computed(() => _isStreaming.value && activeSessionId.value === streamSessionId.value)
|
|
||||||
const isLoadingSessions = ref(false)
|
const isLoadingSessions = ref(false)
|
||||||
const isLoadingMessages = ref(false)
|
const isLoadingMessages = ref(false)
|
||||||
|
|
||||||
const activeSession = ref<Session | null>(null)
|
const activeSession = ref<Session | null>(null)
|
||||||
const messages = ref<Message[]>([])
|
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions() {
|
||||||
isLoadingSessions.value = true
|
isLoadingSessions.value = true
|
||||||
try {
|
try {
|
||||||
const list = await fetchSessions()
|
const list = await fetchSessions()
|
||||||
sessions.value = list.map(mapHermesSession)
|
sessions.value = list.map(mapHermesSession)
|
||||||
// Auto-select the most recent session
|
// Restore last active session, fallback to most recent
|
||||||
if (!activeSessionId.value && sessions.value.length > 0) {
|
const savedId = activeSessionId.value
|
||||||
await switchSession(sessions.value[0].id)
|
const targetId = savedId && sessions.value.some(s => s.id === savedId)
|
||||||
|
? savedId
|
||||||
|
: sessions.value[0]?.id
|
||||||
|
if (targetId) {
|
||||||
|
await switchSession(targetId)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load sessions:', err)
|
console.error('Failed to load sessions:', err)
|
||||||
@@ -198,11 +201,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function switchSession(sessionId: string) {
|
async function switchSession(sessionId: string) {
|
||||||
// Sync current messages back to the streaming session before switching
|
|
||||||
if (streamSessionId.value && sessionId !== streamSessionId.value) {
|
|
||||||
syncMessagesToSession()
|
|
||||||
}
|
|
||||||
activeSessionId.value = sessionId
|
activeSessionId.value = sessionId
|
||||||
|
localStorage.setItem(STORAGE_KEY, sessionId)
|
||||||
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
|
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
|
||||||
|
|
||||||
// If session has no messages loaded, fetch from API
|
// If session has no messages loaded, fetch from API
|
||||||
@@ -232,8 +232,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
isLoadingMessages.value = false
|
isLoadingMessages.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.value = activeSession.value ? [...activeSession.value.messages] : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newChat() {
|
function newChat() {
|
||||||
@@ -269,30 +267,30 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncMessagesToSession() {
|
function getSessionMsgs(sessionId: string): Message[] {
|
||||||
const targetSession = sessions.value.find(s => s.id === streamSessionId.value)
|
const s = sessions.value.find(s => s.id === sessionId)
|
||||||
if (targetSession) {
|
return s?.messages || []
|
||||||
targetSession.messages = [...messages.value]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(msg: Message) {
|
function addMessage(sessionId: string, msg: Message) {
|
||||||
messages.value.push(msg)
|
const s = sessions.value.find(s => s.id === sessionId)
|
||||||
|
if (s) s.messages.push(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMessage(id: string, update: Partial<Message>) {
|
function updateMessage(sessionId: string, id: string, update: Partial<Message>) {
|
||||||
const idx = messages.value.findIndex(m => m.id === id)
|
const s = sessions.value.find(s => s.id === sessionId)
|
||||||
|
if (!s) return
|
||||||
|
const idx = s.messages.findIndex(m => m.id === id)
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
messages.value[idx] = { ...messages.value[idx], ...update }
|
s.messages[idx] = { ...s.messages[idx], ...update }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSessionTitle() {
|
function updateSessionTitle(sessionId: string) {
|
||||||
const target = sessions.value.find(s => s.id === (streamSessionId.value || activeSessionId.value))
|
const target = sessions.value.find(s => s.id === sessionId)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const msgs = target.messages.length > 0 ? target.messages : messages.value
|
|
||||||
if (target.title === 'New Chat') {
|
if (target.title === 'New Chat') {
|
||||||
const firstUser = msgs.find(m => m.role === 'user')
|
const firstUser = target.messages.find(m => m.role === 'user')
|
||||||
if (firstUser) {
|
if (firstUser) {
|
||||||
const title = firstUser.attachments?.length
|
const title = firstUser.attachments?.length
|
||||||
? firstUser.attachments.map(a => a.name).join(', ')
|
? firstUser.attachments.map(a => a.name).join(', ')
|
||||||
@@ -311,6 +309,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
switchSession(session.id)
|
switchSession(session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture session ID at send time — all callbacks use this, not activeSessionId
|
||||||
|
const sid = activeSessionId.value!
|
||||||
|
|
||||||
const userMsg: Message = {
|
const userMsg: Message = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -318,14 +319,13 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||||
}
|
}
|
||||||
addMessage(userMsg)
|
addMessage(sid, userMsg)
|
||||||
updateSessionTitle()
|
updateSessionTitle(sid)
|
||||||
|
|
||||||
_isStreaming.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build conversation history from past messages
|
// Build conversation history from past messages
|
||||||
const history: ChatMessage[] = messages.value
|
const sessionMsgs = getSessionMsgs(sid)
|
||||||
|
const history: ChatMessage[] = sessionMsgs
|
||||||
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
|
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
|
||||||
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
|
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
|
||||||
|
|
||||||
@@ -338,30 +338,32 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
// Use session-level model if set, otherwise fall back to global
|
|
||||||
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
||||||
streamSessionId.value = activeSessionId.value
|
|
||||||
const run = await startRun({
|
const run = await startRun({
|
||||||
input: inputText,
|
input: inputText,
|
||||||
conversation_history: history,
|
conversation_history: history,
|
||||||
session_id: activeSession.value?.id,
|
session_id: sid,
|
||||||
model: sessionModel || undefined,
|
model: sessionModel || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const runId = (run as any).run_id || (run as any).id
|
const runId = (run as any).run_id || (run as any).id
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
addMessage({
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
|
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
_isStreaming.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen to SSE events
|
// Helper to clean up this session's stream state
|
||||||
abortController.value = streamRunEvents(
|
const cleanup = () => {
|
||||||
|
streamStates.value.delete(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to SSE events — all closures capture `sid`
|
||||||
|
const ctrl = streamRunEvents(
|
||||||
runId,
|
runId,
|
||||||
// onEvent
|
// onEvent
|
||||||
(evt: RunEvent) => {
|
(evt: RunEvent) => {
|
||||||
@@ -370,11 +372,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'message.delta': {
|
case 'message.delta': {
|
||||||
const last = messages.value[messages.value.length - 1]
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const last = msgs[msgs.length - 1]
|
||||||
if (last?.role === 'assistant' && last.isStreaming) {
|
if (last?.role === 'assistant' && last.isStreaming) {
|
||||||
last.content += evt.delta || ''
|
last.content += evt.delta || ''
|
||||||
} else {
|
} else {
|
||||||
addMessage({
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: evt.delta || '',
|
content: evt.delta || '',
|
||||||
@@ -386,11 +389,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'tool.started': {
|
case 'tool.started': {
|
||||||
const last = messages.value[messages.value.length - 1]
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const last = msgs[msgs.length - 1]
|
||||||
if (last?.isStreaming) {
|
if (last?.isStreaming) {
|
||||||
updateMessage(last.id, { isStreaming: false })
|
updateMessage(sid, last.id, { isStreaming: false })
|
||||||
}
|
}
|
||||||
addMessage({
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -403,113 +407,111 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'tool.completed': {
|
case 'tool.completed': {
|
||||||
const toolMsgs = messages.value.filter(
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const toolMsgs = msgs.filter(
|
||||||
m => m.role === 'tool' && m.toolStatus === 'running',
|
m => m.role === 'tool' && m.toolStatus === 'running',
|
||||||
)
|
)
|
||||||
if (toolMsgs.length > 0) {
|
if (toolMsgs.length > 0) {
|
||||||
const last = toolMsgs[toolMsgs.length - 1]
|
const last = toolMsgs[toolMsgs.length - 1]
|
||||||
updateMessage(last.id, { toolStatus: 'done' })
|
updateMessage(sid, last.id, { toolStatus: 'done' })
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'run.completed':
|
case 'run.completed': {
|
||||||
const lastMsg = messages.value[messages.value.length - 1]
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
if (lastMsg?.isStreaming) {
|
if (lastMsg?.isStreaming) {
|
||||||
updateMessage(lastMsg.id, { isStreaming: false })
|
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
||||||
}
|
}
|
||||||
_isStreaming.value = false
|
cleanup()
|
||||||
streamSessionId.value = null
|
updateSessionTitle(sid)
|
||||||
abortController.value = null
|
|
||||||
syncMessagesToSession()
|
|
||||||
updateSessionTitle()
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'run.failed':
|
case 'run.failed': {
|
||||||
const lastErr = messages.value[messages.value.length - 1]
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const lastErr = msgs[msgs.length - 1]
|
||||||
if (lastErr?.isStreaming) {
|
if (lastErr?.isStreaming) {
|
||||||
updateMessage(lastErr.id, {
|
updateMessage(sid, lastErr.id, {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
|
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
|
||||||
role: 'system',
|
role: 'system',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addMessage({
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
|
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
messages.value.forEach((m, i) => {
|
msgs.forEach((m, i) => {
|
||||||
if (m.role === 'tool' && m.toolStatus === 'running') {
|
if (m.role === 'tool' && m.toolStatus === 'running') {
|
||||||
messages.value[i] = { ...m, toolStatus: 'error' }
|
msgs[i] = { ...m, toolStatus: 'error' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
_isStreaming.value = false
|
cleanup()
|
||||||
streamSessionId.value = null
|
|
||||||
abortController.value = null
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// onDone
|
// onDone
|
||||||
() => {
|
() => {
|
||||||
const last = messages.value[messages.value.length - 1]
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const last = msgs[msgs.length - 1]
|
||||||
if (last?.isStreaming) {
|
if (last?.isStreaming) {
|
||||||
updateMessage(last.id, { isStreaming: false })
|
updateMessage(sid, last.id, { isStreaming: false })
|
||||||
}
|
}
|
||||||
_isStreaming.value = false
|
cleanup()
|
||||||
streamSessionId.value = null
|
updateSessionTitle(sid)
|
||||||
abortController.value = null
|
|
||||||
syncMessagesToSession()
|
|
||||||
updateSessionTitle()
|
|
||||||
},
|
},
|
||||||
// onError
|
// onError
|
||||||
(err) => {
|
(err) => {
|
||||||
const last = messages.value[messages.value.length - 1]
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const last = msgs[msgs.length - 1]
|
||||||
if (last?.isStreaming) {
|
if (last?.isStreaming) {
|
||||||
updateMessage(last.id, {
|
updateMessage(sid, last.id, {
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
content: `Error: ${err.message}`,
|
content: `Error: ${err.message}`,
|
||||||
role: 'system',
|
role: 'system',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addMessage({
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Error: ${err.message}`,
|
content: `Error: ${err.message}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_isStreaming.value = false
|
cleanup()
|
||||||
streamSessionId.value = null
|
|
||||||
abortController.value = null
|
|
||||||
syncMessagesToSession()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
streamStates.value.set(sid, ctrl)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addMessage({
|
addMessage(sid, {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Error: ${err.message}`,
|
content: `Error: ${err.message}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
_isStreaming.value = false
|
|
||||||
streamSessionId.value = null
|
|
||||||
abortController.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopStreaming() {
|
function stopStreaming() {
|
||||||
abortController.value?.abort()
|
const sid = activeSessionId.value
|
||||||
_isStreaming.value = false
|
if (!sid) return
|
||||||
streamSessionId.value = null
|
const ctrl = streamStates.value.get(sid)
|
||||||
const lastMsg = messages.value[messages.value.length - 1]
|
if (ctrl) {
|
||||||
if (lastMsg?.isStreaming) {
|
ctrl.abort()
|
||||||
updateMessage(lastMsg.id, { isStreaming: false })
|
const msgs = getSessionMsgs(sid)
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
|
if (lastMsg?.isStreaming) {
|
||||||
|
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
||||||
|
}
|
||||||
|
streamStates.value.delete(sid)
|
||||||
}
|
}
|
||||||
abortController.value = null
|
|
||||||
syncMessagesToSession()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load sessions on init
|
// Load sessions on init
|
||||||
|
|||||||
Reference in New Issue
Block a user