Files
Hermes-ui/src/stores/chat.ts
T

455 lines
14 KiB
TypeScript
Raw Normal View History

2026-04-11 15:59:14 +08:00
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
2026-04-11 15:59:14 +08:00
export interface Attachment {
id: string
name: string
type: string
size: number
url: string
file?: File
}
2026-04-11 15:59:14 +08:00
export interface Message {
id: string
role: 'user' | 'assistant' | 'system' | 'tool'
content: string
timestamp: number
toolName?: string
toolPreview?: string
toolStatus?: 'running' | 'done' | 'error'
isStreaming?: boolean
attachments?: Attachment[]
2026-04-11 15:59:14 +08:00
}
export interface Session {
2026-04-11 15:59:14 +08:00
id: string
title: string
messages: Message[]
createdAt: number
updatedAt: number
model?: string
messageCount?: number
2026-04-11 15:59:14 +08:00
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
if (attachments.length === 0) return []
const formData = new FormData()
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const res = await fetch('/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
function mapHermesMessages(msgs: HermesMessage[]): Message[] {
// Build a lookup of tool_call_id -> tool name from assistant messages with tool_calls
const toolNameMap = new Map<string, string>()
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.tool_calls) {
for (const tc of msg.tool_calls) {
if (tc.function?.name && tc.id) {
toolNameMap.set(tc.id, tc.function.name)
}
}
}
2026-04-11 15:59:14 +08:00
}
const result: Message[] = []
for (const msg of msgs) {
// Skip assistant messages that only contain tool_calls (no meaningful content)
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
// Emit a tool.started message for each tool call
for (const tc of msg.tool_calls) {
result.push({
id: String(msg.id) + '_' + tc.id,
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName: tc.function?.name || 'Tool',
toolStatus: 'done',
})
}
continue
}
// Tool result messages
if (msg.role === 'tool') {
const toolName = msg.tool_name || toolNameMap.get(msg.tool_call_id || '') || 'Tool'
// Extract a short preview from the content
let preview = ''
if (msg.content) {
try {
const parsed = JSON.parse(msg.content)
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
} catch {
preview = msg.content.slice(0, 80)
}
}
result.push({
id: String(msg.id),
role: 'tool',
content: '',
timestamp: Math.round(msg.timestamp * 1000),
toolName,
toolPreview: preview.slice(0, 100) || undefined,
toolStatus: 'done',
})
continue
}
// Normal user/assistant messages
result.push({
id: String(msg.id),
role: msg.role,
content: msg.content || '',
timestamp: Math.round(msg.timestamp * 1000),
})
}
return result
2026-04-11 15:59:14 +08:00
}
function mapHermesSession(s: SessionSummary): Session {
return {
id: s.id,
title: s.title || 'New Chat',
messages: [],
createdAt: Math.round(s.started_at * 1000),
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
model: s.model,
messageCount: s.message_count,
}
2026-04-11 15:59:14 +08:00
}
export const useChatStore = defineStore('chat', () => {
const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null)
2026-04-11 15:59:14 +08:00
const isStreaming = ref(false)
const abortController = ref<AbortController | null>(null)
const isLoadingSessions = ref(false)
const isLoadingMessages = ref(false)
2026-04-11 15:59:14 +08:00
const activeSession = ref<Session | null>(null)
const messages = ref<Message[]>([])
2026-04-11 15:59:14 +08:00
async function loadSessions() {
isLoadingSessions.value = true
try {
const list = await fetchSessions('api_server')
sessions.value = list.map(mapHermesSession)
// Auto-select the most recent session
if (!activeSessionId.value && sessions.value.length > 0) {
await switchSession(sessions.value[0].id)
}
} catch (err) {
console.error('Failed to load sessions:', err)
} finally {
isLoadingSessions.value = false
}
}
2026-04-11 15:59:14 +08:00
function createSession(): Session {
const session: Session = {
id: uid(),
title: 'New Chat',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
sessions.value.unshift(session)
return session
}
async function switchSession(sessionId: string) {
2026-04-11 15:59:14 +08:00
activeSessionId.value = sessionId
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
// If session has no messages loaded, fetch from API
if (activeSession.value && activeSession.value.messages.length === 0) {
isLoadingMessages.value = true
try {
const detail = await fetchSession(sessionId)
if (detail && detail.messages) {
const mapped = mapHermesMessages(detail.messages)
activeSession.value.messages = mapped
// Update title from Hermes data
if (detail.title) {
activeSession.value.title = detail.title
}
}
} catch (err) {
console.error('Failed to load session messages:', err)
} finally {
isLoadingMessages.value = false
}
}
2026-04-11 15:59:14 +08:00
messages.value = activeSession.value ? [...activeSession.value.messages] : []
}
function newChat() {
if (isStreaming.value) return
const session = createSession()
switchSession(session.id)
}
async function deleteSession(sessionId: string) {
await deleteSessionApi(sessionId)
2026-04-11 15:59:14 +08:00
sessions.value = sessions.value.filter(s => s.id !== sessionId)
if (activeSessionId.value === sessionId) {
if (sessions.value.length > 0) {
await switchSession(sessions.value[0].id)
2026-04-11 15:59:14 +08:00
} else {
const session = createSession()
switchSession(session.id)
}
}
}
function addMessage(msg: Message) {
messages.value.push(msg)
}
function updateMessage(id: string, update: Partial<Message>) {
const idx = messages.value.findIndex(m => m.id === id)
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx], ...update }
}
}
2026-04-11 15:59:14 +08:00
function updateSessionTitle() {
if (!activeSession.value) return
2026-04-11 15:59:14 +08:00
if (activeSession.value.title === 'New Chat') {
const firstUser = messages.value.find(m => m.role === 'user')
if (firstUser) {
const title = firstUser.attachments?.length
? firstUser.attachments.map(a => a.name).join(', ')
: firstUser.content
activeSession.value.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
2026-04-11 15:59:14 +08:00
}
}
activeSession.value.updatedAt = Date.now()
2026-04-11 15:59:14 +08:00
}
async function sendMessage(content: string, attachments?: Attachment[]) {
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
2026-04-11 15:59:14 +08:00
if (!activeSession.value) {
const session = createSession()
switchSession(session.id)
}
const userMsg: Message = {
id: uid(),
role: 'user',
content: content.trim(),
timestamp: Date.now(),
attachments: attachments && attachments.length > 0 ? attachments : undefined,
2026-04-11 15:59:14 +08:00
}
addMessage(userMsg)
updateSessionTitle()
2026-04-11 15:59:14 +08:00
isStreaming.value = true
try {
// Build conversation history from past messages
const history: ChatMessage[] = messages.value
.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content.trim())
.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }))
// Upload attachments and build input with file paths
let inputText = content.trim()
if (attachments && attachments.length > 0) {
const uploaded = await uploadFiles(attachments)
const pathParts = uploaded.map(f => `[File: ${f.name}](${f.path})`)
inputText = inputText ? inputText + '\n\n' + pathParts.join('\n') : pathParts.join('\n')
}
2026-04-11 15:59:14 +08:00
const run = await startRun({
input: inputText,
2026-04-11 15:59:14 +08:00
conversation_history: history,
session_id: activeSession.value?.id,
})
const runId = (run as any).run_id || (run as any).id
if (!runId) {
addMessage({
id: uid(),
role: 'system',
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
timestamp: Date.now(),
})
isStreaming.value = false
return
}
// Listen to SSE events
abortController.value = streamRunEvents(
runId,
// onEvent
(evt: RunEvent) => {
switch (evt.event) {
case 'run.started':
break
case 'message.delta': {
const last = messages.value[messages.value.length - 1]
if (last?.role === 'assistant' && last.isStreaming) {
last.content += evt.delta || ''
} else {
addMessage({
id: uid(),
role: 'assistant',
content: evt.delta || '',
timestamp: Date.now(),
isStreaming: true,
})
}
break
}
case 'tool.started': {
const last = messages.value[messages.value.length - 1]
if (last?.isStreaming) {
updateMessage(last.id, { isStreaming: false })
}
addMessage({
id: uid(),
role: 'tool',
content: '',
timestamp: Date.now(),
toolName: evt.tool || evt.name,
toolPreview: evt.preview,
toolStatus: 'running',
})
break
}
case 'tool.completed': {
const toolMsgs = messages.value.filter(
m => m.role === 'tool' && m.toolStatus === 'running',
)
if (toolMsgs.length > 0) {
const last = toolMsgs[toolMsgs.length - 1]
updateMessage(last.id, { toolStatus: 'done' })
}
break
}
case 'run.completed':
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(lastMsg.id, { isStreaming: false })
}
isStreaming.value = false
abortController.value = null
updateSessionTitle()
2026-04-11 15:59:14 +08:00
break
case 'run.failed':
const lastErr = messages.value[messages.value.length - 1]
if (lastErr?.isStreaming) {
updateMessage(lastErr.id, {
isStreaming: false,
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
role: 'system',
})
} else {
addMessage({
id: uid(),
role: 'system',
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
timestamp: Date.now(),
})
}
messages.value.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
messages.value[i] = { ...m, toolStatus: 'error' }
}
})
isStreaming.value = false
abortController.value = null
break
}
},
// onDone
() => {
const last = messages.value[messages.value.length - 1]
if (last?.isStreaming) {
updateMessage(last.id, { isStreaming: false })
}
isStreaming.value = false
abortController.value = null
updateSessionTitle()
2026-04-11 15:59:14 +08:00
},
// onError
(err) => {
const last = messages.value[messages.value.length - 1]
if (last?.isStreaming) {
updateMessage(last.id, {
isStreaming: false,
content: `Error: ${err.message}`,
role: 'system',
})
} else {
addMessage({
id: uid(),
role: 'system',
content: `Error: ${err.message}`,
timestamp: Date.now(),
})
}
isStreaming.value = false
abortController.value = null
},
)
} catch (err: any) {
addMessage({
id: uid(),
role: 'system',
content: `Error: ${err.message}`,
timestamp: Date.now(),
})
isStreaming.value = false
abortController.value = null
}
}
function stopStreaming() {
abortController.value?.abort()
isStreaming.value = false
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(lastMsg.id, { isStreaming: false })
}
abortController.value = null
}
// Load sessions on init
loadSessions()
2026-04-11 15:59:14 +08:00
return {
sessions,
activeSessionId,
activeSession,
messages,
isStreaming,
isLoadingSessions,
isLoadingMessages,
2026-04-11 15:59:14 +08:00
newChat,
switchSession,
deleteSession,
sendMessage,
stopStreaming,
loadSessions,
2026-04-11 15:59:14 +08:00
}
})