refactor: restructure project for multi-agent extensibility
- Migrate source to packages/client and packages/server directories - Namespace all Hermes-specific code under hermes/ subdirectories (api/hermes/, components/hermes/, views/hermes/, stores/hermes/) - Add hermes.* route names and /hermes/* path prefixes - Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax - Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/* - Fix frontend API paths to match backend /api/hermes/* routes - Fix WebSocket terminal path to /api/hermes/terminal - Add proxyMiddleware for reliable unmatched route proxying - Add profiles route module and hermes-cli profile commands - Update CLAUDE.md development guide with new architecture - Add Chinese README (README_zh.md) - Add Web Terminal feature to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/hermes/system'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
const connected = ref(false)
|
||||
const serverVersion = ref('')
|
||||
const modelGroups = ref<AvailableModelGroup[]>([])
|
||||
const selectedModel = ref('')
|
||||
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
||||
|
||||
// Settings
|
||||
const streamEnabled = ref(true)
|
||||
const sessionPersistence = ref(true)
|
||||
const maxTokens = ref(4096)
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
const res = await checkHealth()
|
||||
connected.value = res.status === 'ok'
|
||||
if (res.version) serverVersion.value = res.version
|
||||
} catch {
|
||||
connected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const res = await fetchAvailableModels()
|
||||
modelGroups.value = res.groups
|
||||
selectedModel.value = res.default
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function switchModel(modelId: string, providerOverride?: string) {
|
||||
try {
|
||||
// Find the group containing this model to get provider info
|
||||
const group = modelGroups.value.find(g => g.models.includes(modelId))
|
||||
const provider = providerOverride || group?.provider || ''
|
||||
await updateDefaultModel({ default: modelId, provider })
|
||||
selectedModel.value = modelId
|
||||
} catch (err: any) {
|
||||
console.error('Failed to switch model:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function startHealthPolling(interval = 30000) {
|
||||
stopHealthPolling()
|
||||
checkConnection()
|
||||
healthPollTimer.value = setInterval(checkConnection, interval)
|
||||
}
|
||||
|
||||
function stopHealthPolling() {
|
||||
if (healthPollTimer.value) {
|
||||
clearInterval(healthPollTimer.value)
|
||||
healthPollTimer.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarOpen,
|
||||
toggleSidebar,
|
||||
closeSidebar,
|
||||
connected,
|
||||
serverVersion,
|
||||
modelGroups,
|
||||
selectedModel,
|
||||
streamEnabled,
|
||||
sessionPersistence,
|
||||
maxTokens,
|
||||
checkConnection,
|
||||
loadModels,
|
||||
switchModel,
|
||||
startHealthPolling,
|
||||
stopHealthPolling,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/hermes/chat'
|
||||
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string
|
||||
timestamp: number
|
||||
toolName?: string
|
||||
toolPreview?: string
|
||||
toolArgs?: string
|
||||
toolResult?: string
|
||||
toolStatus?: 'running' | 'done' | 'error'
|
||||
isStreaming?: boolean
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
title: string
|
||||
source?: string
|
||||
messages: Message[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
model?: string
|
||||
provider?: string
|
||||
messageCount?: number
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
}
|
||||
|
||||
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 token = localStorage.getItem('hermes_api_key') || ''
|
||||
const res = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
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 lookups from assistant messages with tool_calls
|
||||
const toolNameMap = new Map<string, string>()
|
||||
const toolArgsMap = 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.id) {
|
||||
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
|
||||
if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
toolArgs: tc.function?.arguments || undefined,
|
||||
toolStatus: 'done',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Tool result messages
|
||||
if (msg.role === 'tool') {
|
||||
const tcId = msg.tool_call_id || ''
|
||||
const toolName = msg.tool_name || toolNameMap.get(tcId) || 'tool'
|
||||
const toolArgs = toolArgsMap.get(tcId) || undefined
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// Find and remove the matching placeholder from tool_calls above
|
||||
const placeholderIdx = result.findIndex(
|
||||
m => m.role === 'tool' && m.toolName === toolName && !m.toolResult && m.id.includes('_' + tcId)
|
||||
)
|
||||
if (placeholderIdx !== -1) {
|
||||
result.splice(placeholderIdx, 1)
|
||||
}
|
||||
result.push({
|
||||
id: String(msg.id),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Math.round(msg.timestamp * 1000),
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
|
||||
toolResult: msg.content || 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
|
||||
}
|
||||
|
||||
function mapHermesSession(s: SessionSummary): Session {
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title || '',
|
||||
source: s.source || undefined,
|
||||
messages: [],
|
||||
createdAt: Math.round(s.started_at * 1000),
|
||||
updatedAt: Math.round((s.ended_at || s.started_at) * 1000),
|
||||
model: s.model,
|
||||
provider: (s as any).billing_provider || '',
|
||||
messageCount: s.message_count,
|
||||
inputTokens: s.input_tokens,
|
||||
outputTokens: s.output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const STORAGE_KEY = 'hermes_active_session'
|
||||
const sessions = ref<Session[]>([])
|
||||
const activeSessionId = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
||||
const streamStates = ref<Map<string, AbortController>>(new Map())
|
||||
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
||||
const isLoadingSessions = ref(false)
|
||||
const isLoadingMessages = ref(false)
|
||||
|
||||
const activeSession = ref<Session | null>(null)
|
||||
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
|
||||
|
||||
async function loadSessions() {
|
||||
isLoadingSessions.value = true
|
||||
try {
|
||||
const list = await fetchSessions()
|
||||
sessions.value = list.map(mapHermesSession)
|
||||
// Restore last active session, fallback to most recent
|
||||
const savedId = activeSessionId.value
|
||||
const targetId = savedId && sessions.value.some(s => s.id === savedId)
|
||||
? savedId
|
||||
: sessions.value[0]?.id
|
||||
if (targetId) {
|
||||
await switchSession(targetId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions:', err)
|
||||
} finally {
|
||||
isLoadingSessions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function createSession(): Session {
|
||||
const session: Session = {
|
||||
id: uid(),
|
||||
title: '',
|
||||
source: 'api_server',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
sessions.value.unshift(session)
|
||||
return session
|
||||
}
|
||||
|
||||
async function switchSession(sessionId: string) {
|
||||
activeSessionId.value = sessionId
|
||||
localStorage.setItem(STORAGE_KEY, 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
|
||||
activeSession.value.inputTokens = detail.input_tokens
|
||||
activeSession.value.outputTokens = detail.output_tokens
|
||||
// Update title: use Hermes title, or fallback to first user message
|
||||
if (detail.title) {
|
||||
activeSession.value.title = detail.title
|
||||
} else {
|
||||
const firstUser = mapped.find(m => m.role === 'user')
|
||||
if (firstUser) {
|
||||
const t = firstUser.content.slice(0, 40)
|
||||
activeSession.value.title = t + (firstUser.content.length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load session messages:', err)
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
if (isStreaming.value) return
|
||||
const session = createSession()
|
||||
// Inherit current global model
|
||||
const appStore = useAppStore()
|
||||
session.model = appStore.selectedModel || undefined
|
||||
switchSession(session.id)
|
||||
}
|
||||
|
||||
async function switchSessionModel(modelId: string, provider?: string) {
|
||||
if (!activeSession.value) return
|
||||
activeSession.value.model = modelId
|
||||
activeSession.value.provider = provider || ''
|
||||
// If provider changed, update global config too (Hermes requires it)
|
||||
if (provider) {
|
||||
const { useAppStore } = await import('./app')
|
||||
await useAppStore().switchModel(modelId, provider)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string) {
|
||||
await deleteSessionApi(sessionId)
|
||||
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
||||
if (activeSessionId.value === sessionId) {
|
||||
if (sessions.value.length > 0) {
|
||||
await switchSession(sessions.value[0].id)
|
||||
} else {
|
||||
const session = createSession()
|
||||
switchSession(session.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMsgs(sessionId: string): Message[] {
|
||||
const s = sessions.value.find(s => s.id === sessionId)
|
||||
return s?.messages || []
|
||||
}
|
||||
|
||||
function addMessage(sessionId: string, msg: Message) {
|
||||
const s = sessions.value.find(s => s.id === sessionId)
|
||||
if (s) s.messages.push(msg)
|
||||
}
|
||||
|
||||
function updateMessage(sessionId: string, id: string, update: Partial<Message>) {
|
||||
const s = sessions.value.find(s => s.id === sessionId)
|
||||
if (!s) return
|
||||
const idx = s.messages.findIndex(m => m.id === id)
|
||||
if (idx !== -1) {
|
||||
s.messages[idx] = { ...s.messages[idx], ...update }
|
||||
}
|
||||
}
|
||||
|
||||
function updateSessionTitle(sessionId: string) {
|
||||
const target = sessions.value.find(s => s.id === sessionId)
|
||||
if (!target) return
|
||||
if (!target.title) {
|
||||
const firstUser = target.messages.find(m => m.role === 'user')
|
||||
if (firstUser) {
|
||||
const title = firstUser.attachments?.length
|
||||
? firstUser.attachments.map(a => a.name).join(', ')
|
||||
: firstUser.content
|
||||
target.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
target.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
|
||||
|
||||
if (!activeSession.value) {
|
||||
const session = createSession()
|
||||
switchSession(session.id)
|
||||
}
|
||||
|
||||
// Capture session ID at send time — all callbacks use this, not activeSessionId
|
||||
const sid = activeSessionId.value!
|
||||
|
||||
const userMsg: Message = {
|
||||
id: uid(),
|
||||
role: 'user',
|
||||
content: content.trim(),
|
||||
timestamp: Date.now(),
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
addMessage(sid, userMsg)
|
||||
updateSessionTitle(sid)
|
||||
|
||||
try {
|
||||
// Build conversation history from past messages
|
||||
const sessionMsgs = getSessionMsgs(sid)
|
||||
const history: ChatMessage[] = sessionMsgs
|
||||
.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')
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
||||
const run = await startRun({
|
||||
input: inputText,
|
||||
conversation_history: history,
|
||||
session_id: sid,
|
||||
model: sessionModel || undefined,
|
||||
})
|
||||
|
||||
const runId = (run as any).run_id || (run as any).id
|
||||
if (!runId) {
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
role: 'system',
|
||||
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to clean up this session's stream state
|
||||
const cleanup = () => {
|
||||
streamStates.value.delete(sid)
|
||||
}
|
||||
|
||||
// Listen to SSE events — all closures capture `sid`
|
||||
const ctrl = streamRunEvents(
|
||||
runId,
|
||||
// onEvent
|
||||
(evt: RunEvent) => {
|
||||
switch (evt.event) {
|
||||
case 'run.started':
|
||||
break
|
||||
|
||||
case 'message.delta': {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const last = msgs[msgs.length - 1]
|
||||
if (last?.role === 'assistant' && last.isStreaming) {
|
||||
last.content += evt.delta || ''
|
||||
} else {
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
role: 'assistant',
|
||||
content: evt.delta || '',
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool.started': {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const last = msgs[msgs.length - 1]
|
||||
if (last?.isStreaming) {
|
||||
updateMessage(sid, last.id, { isStreaming: false })
|
||||
}
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: evt.tool || evt.name,
|
||||
toolPreview: evt.preview,
|
||||
toolStatus: 'running',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool.completed': {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const toolMsgs = msgs.filter(
|
||||
m => m.role === 'tool' && m.toolStatus === 'running',
|
||||
)
|
||||
if (toolMsgs.length > 0) {
|
||||
const last = toolMsgs[toolMsgs.length - 1]
|
||||
updateMessage(sid, last.id, { toolStatus: 'done' })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'run.completed': {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
if (lastMsg?.isStreaming) {
|
||||
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
||||
}
|
||||
cleanup()
|
||||
updateSessionTitle(sid)
|
||||
break
|
||||
}
|
||||
|
||||
case 'run.failed': {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const lastErr = msgs[msgs.length - 1]
|
||||
if (lastErr?.isStreaming) {
|
||||
updateMessage(sid, lastErr.id, {
|
||||
isStreaming: false,
|
||||
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
|
||||
role: 'system',
|
||||
})
|
||||
} else {
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
role: 'system',
|
||||
content: evt.error ? `Error: ${evt.error}` : 'Run failed',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
msgs.forEach((m, i) => {
|
||||
if (m.role === 'tool' && m.toolStatus === 'running') {
|
||||
msgs[i] = { ...m, toolStatus: 'error' }
|
||||
}
|
||||
})
|
||||
cleanup()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
// onDone
|
||||
() => {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const last = msgs[msgs.length - 1]
|
||||
if (last?.isStreaming) {
|
||||
updateMessage(sid, last.id, { isStreaming: false })
|
||||
}
|
||||
cleanup()
|
||||
updateSessionTitle(sid)
|
||||
},
|
||||
// onError
|
||||
(err) => {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const last = msgs[msgs.length - 1]
|
||||
if (last?.isStreaming) {
|
||||
updateMessage(sid, last.id, {
|
||||
isStreaming: false,
|
||||
content: `Error: ${err.message}`,
|
||||
role: 'system',
|
||||
})
|
||||
} else {
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
role: 'system',
|
||||
content: `Error: ${err.message}`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
cleanup()
|
||||
},
|
||||
)
|
||||
|
||||
streamStates.value.set(sid, ctrl)
|
||||
} catch (err: any) {
|
||||
addMessage(sid, {
|
||||
id: uid(),
|
||||
role: 'system',
|
||||
content: `Error: ${err.message}`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
const sid = activeSessionId.value
|
||||
if (!sid) return
|
||||
const ctrl = streamStates.value.get(sid)
|
||||
if (ctrl) {
|
||||
ctrl.abort()
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
if (lastMsg?.isStreaming) {
|
||||
updateMessage(sid, lastMsg.id, { isStreaming: false })
|
||||
}
|
||||
streamStates.value.delete(sid)
|
||||
}
|
||||
}
|
||||
|
||||
// Load sessions on init
|
||||
loadSessions()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
messages,
|
||||
isStreaming,
|
||||
isLoadingSessions,
|
||||
isLoadingMessages,
|
||||
newChat,
|
||||
switchSession,
|
||||
switchSessionModel,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
loadSessions,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as jobsApi from '@/api/hermes/jobs'
|
||||
import type { Job, CreateJobRequest, UpdateJobRequest } from '@/api/hermes/jobs'
|
||||
|
||||
function matchId(job: Job, id: string): boolean {
|
||||
return job.job_id === id || job.id === id
|
||||
}
|
||||
|
||||
export const useJobsStore = defineStore('jobs', () => {
|
||||
const jobs = ref<Job[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchJobs() {
|
||||
loading.value = true
|
||||
try {
|
||||
jobs.value = await jobsApi.listJobs()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch jobs:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createJob(data: CreateJobRequest): Promise<Job> {
|
||||
const job = await jobsApi.createJob(data)
|
||||
jobs.value.unshift(job)
|
||||
return job
|
||||
}
|
||||
|
||||
async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
|
||||
const job = await jobsApi.updateJob(jobId, data)
|
||||
const idx = jobs.value.findIndex(j => matchId(j, jobId))
|
||||
if (idx !== -1) jobs.value[idx] = job
|
||||
return job
|
||||
}
|
||||
|
||||
async function deleteJob(jobId: string) {
|
||||
await jobsApi.deleteJob(jobId)
|
||||
jobs.value = jobs.value.filter(j => !matchId(j, jobId))
|
||||
}
|
||||
|
||||
async function pauseJob(jobId: string) {
|
||||
const job = await jobsApi.pauseJob(jobId)
|
||||
const idx = jobs.value.findIndex(j => matchId(j, jobId))
|
||||
if (idx !== -1) jobs.value[idx] = job
|
||||
}
|
||||
|
||||
async function resumeJob(jobId: string) {
|
||||
const job = await jobsApi.resumeJob(jobId)
|
||||
const idx = jobs.value.findIndex(j => matchId(j, jobId))
|
||||
if (idx !== -1) jobs.value[idx] = job
|
||||
}
|
||||
|
||||
async function runJob(jobId: string) {
|
||||
const job = await jobsApi.runJob(jobId)
|
||||
const idx = jobs.value.findIndex(j => matchId(j, jobId))
|
||||
if (idx !== -1) jobs.value[idx] = job
|
||||
}
|
||||
|
||||
return {
|
||||
jobs,
|
||||
loading,
|
||||
fetchJobs,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
runJob,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as systemApi from '@/api/hermes/system'
|
||||
import type { AvailableModelGroup, CustomProvider } from '@/api/hermes/system'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
export const useModelsStore = defineStore('models', () => {
|
||||
const providers = ref<AvailableModelGroup[]>([])
|
||||
const defaultModel = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const customProviders = computed(() =>
|
||||
providers.value.filter(g => g.provider.startsWith('custom:')),
|
||||
)
|
||||
|
||||
const builtinProviders = computed(() =>
|
||||
providers.value.filter(g => !g.provider.startsWith('custom:')),
|
||||
)
|
||||
|
||||
const allModels = computed(() =>
|
||||
providers.value.flatMap(g =>
|
||||
g.models.map(m => ({
|
||||
id: m,
|
||||
provider: g.provider,
|
||||
label: g.label,
|
||||
base_url: g.base_url,
|
||||
isDefault: m === defaultModel.value,
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
async function fetchProviders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await systemApi.fetchAvailableModels()
|
||||
providers.value = res.groups
|
||||
defaultModel.value = res.default
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch providers:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefaultModel(modelId: string, provider: string) {
|
||||
await systemApi.updateDefaultModel({ default: modelId, provider })
|
||||
defaultModel.value = modelId
|
||||
const appStore = useAppStore()
|
||||
appStore.loadModels()
|
||||
}
|
||||
|
||||
async function addProvider(data: CustomProvider) {
|
||||
await systemApi.addCustomProvider(data)
|
||||
await fetchProviders()
|
||||
const appStore = useAppStore()
|
||||
appStore.loadModels()
|
||||
}
|
||||
|
||||
async function removeProvider(name: string) {
|
||||
await systemApi.removeCustomProvider(name)
|
||||
await fetchProviders()
|
||||
const appStore = useAppStore()
|
||||
appStore.loadModels()
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
defaultModel,
|
||||
loading,
|
||||
customProviders,
|
||||
builtinProviders,
|
||||
allModels,
|
||||
fetchProviders,
|
||||
setDefaultModel,
|
||||
addProvider,
|
||||
removeProvider,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as configApi from '@/api/hermes/config'
|
||||
import type { DisplayConfig, AgentConfig, MemoryConfig, SessionResetConfig, PrivacyConfig } from '@/api/hermes/config'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const display = ref<DisplayConfig>({})
|
||||
const agent = ref<AgentConfig>({})
|
||||
const memory = ref<MemoryConfig>({})
|
||||
const sessionReset = ref<SessionResetConfig>({})
|
||||
const privacy = ref<PrivacyConfig>({})
|
||||
const telegram = ref<Record<string, any>>({})
|
||||
const discord = ref<Record<string, any>>({})
|
||||
const slack = ref<Record<string, any>>({})
|
||||
const whatsapp = ref<Record<string, any>>({})
|
||||
const matrix = ref<Record<string, any>>({})
|
||||
const wecom = ref<Record<string, any>>({})
|
||||
const feishu = ref<Record<string, any>>({})
|
||||
const dingtalk = ref<Record<string, any>>({})
|
||||
const weixin = ref<Record<string, any>>({})
|
||||
const platforms = ref<Record<string, any>>({})
|
||||
|
||||
async function fetchSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await configApi.fetchConfig()
|
||||
display.value = data.display || {}
|
||||
agent.value = data.agent || {}
|
||||
memory.value = data.memory || {}
|
||||
sessionReset.value = data.session_reset || {}
|
||||
privacy.value = data.privacy || {}
|
||||
telegram.value = data.telegram || {}
|
||||
discord.value = data.discord || {}
|
||||
slack.value = data.slack || {}
|
||||
whatsapp.value = data.whatsapp || {}
|
||||
matrix.value = data.matrix || {}
|
||||
wecom.value = data.wecom || {}
|
||||
feishu.value = data.feishu || {}
|
||||
dingtalk.value = data.dingtalk || {}
|
||||
weixin.value = data.weixin || {}
|
||||
platforms.value = data.platforms || {}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSection(section: string, values: Record<string, any>) {
|
||||
saving.value = true
|
||||
try {
|
||||
await configApi.updateConfigSection(section, values)
|
||||
switch (section) {
|
||||
case 'display': display.value = { ...display.value, ...values }; break
|
||||
case 'agent': agent.value = { ...agent.value, ...values }; break
|
||||
case 'memory': memory.value = { ...memory.value, ...values }; break
|
||||
case 'session_reset': sessionReset.value = { ...sessionReset.value, ...values }; break
|
||||
case 'privacy': privacy.value = { ...privacy.value, ...values }; break
|
||||
case 'telegram': telegram.value = { ...telegram.value, ...values }; break
|
||||
case 'discord': discord.value = { ...discord.value, ...values }; break
|
||||
case 'slack': slack.value = { ...slack.value, ...values }; break
|
||||
case 'whatsapp': whatsapp.value = { ...whatsapp.value, ...values }; break
|
||||
case 'matrix': matrix.value = { ...matrix.value, ...values }; break
|
||||
case 'wechat': case 'wecom': wecom.value = { ...wecom.value, ...values }; break
|
||||
case 'feishu': feishu.value = { ...feishu.value, ...values }; break
|
||||
case 'dingtalk': dingtalk.value = { ...dingtalk.value, ...values }; break
|
||||
case 'weixin': weixin.value = { ...weixin.value, ...values }; break
|
||||
case 'platforms': {
|
||||
// Deep-merge each platform's credentials
|
||||
for (const [key, val] of Object.entries(values)) {
|
||||
platforms.value = {
|
||||
...platforms.value,
|
||||
[key]: { ...(platforms.value[key] || {}), ...(val as Record<string, any>) },
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading, saving,
|
||||
display, agent, memory, sessionReset, privacy,
|
||||
telegram, discord, slack, whatsapp, matrix, wecom, feishu, dingtalk, weixin, platforms,
|
||||
fetchSettings, saveSection,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { fetchSessions, type SessionSummary } from '@/api/hermes/sessions'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface DailyUsage {
|
||||
date: string
|
||||
tokens: number
|
||||
cache: number
|
||||
sessions: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
interface ModelUsage {
|
||||
model: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheTokens: number
|
||||
totalTokens: number
|
||||
sessions: number
|
||||
}
|
||||
|
||||
export const useUsageStore = defineStore('usage', () => {
|
||||
const sessions = ref<SessionSummary[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function loadSessions() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
sessions.value = await fetchSessions()
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions for usage:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const totalInputTokens = computed(() =>
|
||||
sessions.value.reduce((sum, s) => sum + (s.input_tokens || 0), 0),
|
||||
)
|
||||
|
||||
const totalOutputTokens = computed(() =>
|
||||
sessions.value.reduce((sum, s) => sum + (s.output_tokens || 0), 0),
|
||||
)
|
||||
|
||||
const totalTokens = computed(() => totalInputTokens.value + totalOutputTokens.value)
|
||||
|
||||
const totalSessions = computed(() => sessions.value.length)
|
||||
|
||||
const totalCacheTokens = computed(() =>
|
||||
sessions.value.reduce((sum, s) => sum + (s.cache_read_tokens || 0), 0),
|
||||
)
|
||||
|
||||
const cacheHitRate = computed(() => {
|
||||
const total = totalInputTokens.value
|
||||
if (total === 0) return null
|
||||
return ((totalCacheTokens.value / total) * 100)
|
||||
})
|
||||
|
||||
const estimatedCost = computed(() =>
|
||||
sessions.value.reduce((sum, s) => {
|
||||
const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0
|
||||
return sum + cost
|
||||
}, 0),
|
||||
)
|
||||
|
||||
const modelUsage = computed<ModelUsage[]>(() => {
|
||||
const map = new Map<string, ModelUsage>()
|
||||
for (const s of sessions.value) {
|
||||
const key = s.model || 'unknown'
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
model: key,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
totalTokens: 0,
|
||||
sessions: 0,
|
||||
})
|
||||
}
|
||||
const entry = map.get(key)!
|
||||
entry.inputTokens += s.input_tokens || 0
|
||||
entry.outputTokens += s.output_tokens || 0
|
||||
entry.cacheTokens += s.cache_read_tokens || 0
|
||||
entry.totalTokens += (s.input_tokens || 0) + (s.output_tokens || 0)
|
||||
entry.sessions += 1
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
})
|
||||
|
||||
const dailyUsage = computed<DailyUsage[]>(() => {
|
||||
const map = new Map<string, DailyUsage>()
|
||||
const now = new Date()
|
||||
|
||||
// Initialize last 30 days
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(now)
|
||||
d.setDate(d.getDate() - i)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
map.set(key, { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 })
|
||||
}
|
||||
|
||||
for (const s of sessions.value) {
|
||||
const d = new Date(s.started_at * 1000)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
const entry = map.get(key)
|
||||
if (entry) {
|
||||
entry.tokens += (s.input_tokens || 0) + (s.output_tokens || 0)
|
||||
entry.cache += s.cache_read_tokens || 0
|
||||
entry.sessions += 1
|
||||
const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0
|
||||
entry.cost += cost
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
const avgSessionsPerDay = computed(() => {
|
||||
const firstDate = sessions.value.length > 0
|
||||
? new Date(sessions.value[sessions.value.length - 1].started_at * 1000)
|
||||
: new Date()
|
||||
const days = Math.max(1, Math.ceil((Date.now() - firstDate.getTime()) / (1000 * 60 * 60 * 24)))
|
||||
return totalSessions.value / days
|
||||
})
|
||||
|
||||
return {
|
||||
sessions,
|
||||
isLoading,
|
||||
loadSessions,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalTokens,
|
||||
totalSessions,
|
||||
totalCacheTokens,
|
||||
cacheHitRate,
|
||||
estimatedCost,
|
||||
modelUsage,
|
||||
dailyUsage,
|
||||
avgSessionsPerDay,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user