feat: add Koa2 BFF server, CLI management, sessions CLI integration, and logs page
- Add Koa2 BFF layer for API proxy, file upload, session management - Auto-check and enable api_server in ~/.hermes/config.yaml on startup - Integrate sessions with Hermes CLI (list, get, delete) - Add Logs page with level filtering, log file selection, and search - Add CLI commands: start/stop/restart/status for daemon management - Unify package.json for frontend and server dependencies - Default port changed to 8648 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { request } from './client'
|
||||
|
||||
export interface LogFileInfo {
|
||||
name: string
|
||||
size: string
|
||||
modified: string
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export async function fetchLogFiles(): Promise<LogFileInfo[]> {
|
||||
const res = await request<{ files: LogFileInfo[] }>('/api/logs')
|
||||
return res.files
|
||||
}
|
||||
|
||||
export async function fetchLogs(name: string, params?: {
|
||||
lines?: number
|
||||
level?: string
|
||||
session?: string
|
||||
since?: string
|
||||
}): Promise<LogEntry[]> {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.lines) query.set('lines', String(params.lines))
|
||||
if (params?.level) query.set('level', params.level)
|
||||
if (params?.session) query.set('session', params.session)
|
||||
if (params?.since) query.set('since', params.since)
|
||||
const qs = query.toString()
|
||||
const res = await request<{ entries: (LogEntry | null)[] }>(`/api/logs/${name}${qs ? `?${qs}` : ''}`)
|
||||
return res.entries.filter((e): e is LogEntry => e !== null)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { request } from './client'
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string
|
||||
source: string
|
||||
model: string
|
||||
title: string | null
|
||||
started_at: number
|
||||
ended_at: number | null
|
||||
message_count: number
|
||||
tool_call_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
billing_provider: string | null
|
||||
estimated_cost_usd: number
|
||||
}
|
||||
|
||||
export interface SessionDetail extends SessionSummary {
|
||||
messages: HermesMessage[]
|
||||
}
|
||||
|
||||
export interface HermesMessage {
|
||||
id: number
|
||||
session_id: string
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string
|
||||
tool_call_id: string | null
|
||||
tool_calls: any[] | null
|
||||
tool_name: string | null
|
||||
timestamp: number
|
||||
token_count: number | null
|
||||
finish_reason: string | null
|
||||
reasoning: string | null
|
||||
}
|
||||
|
||||
export async function fetchSessions(source?: string, limit?: number): Promise<SessionSummary[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (source) params.set('source', source)
|
||||
if (limit) params.set('limit', String(limit))
|
||||
const query = params.toString()
|
||||
const res = await request<{ sessions: SessionSummary[] }>(`/api/sessions${query ? `?${query}` : ''}`)
|
||||
return res.sessions
|
||||
}
|
||||
|
||||
export async function fetchSession(id: string): Promise<SessionDetail | null> {
|
||||
try {
|
||||
const res = await request<{ session: SessionDetail }>(`/api/sessions/${id}`)
|
||||
return res.session
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
await request(`/api/sessions/${id}`, { method: 'DELETE' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const attachments = ref<Attachment[]>([])
|
||||
|
||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||
|
||||
function handleAttachClick() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text && attachments.value.length === 0) return
|
||||
@@ -39,10 +43,6 @@ function handleInput(e: Event) {
|
||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||
}
|
||||
|
||||
function handleAttachClick() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const files = input.files
|
||||
@@ -128,7 +128,7 @@ function isImage(type: string): boolean {
|
||||
@input="handleInput"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<!-- <NTooltip trigger="hover">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="handleAttachClick" circle>
|
||||
<template #icon>
|
||||
@@ -137,7 +137,7 @@ function isImage(type: string): boolean {
|
||||
</NButton>
|
||||
</template>
|
||||
Attach files
|
||||
</NTooltip> -->
|
||||
</NTooltip>
|
||||
<NButton
|
||||
v-if="chatStore.isStreaming"
|
||||
size="small"
|
||||
|
||||
@@ -13,11 +13,11 @@ const message = useMessage()
|
||||
const showSessions = ref(true)
|
||||
|
||||
const sortedSessions = computed(() => {
|
||||
return [...chatStore.sessions].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
|
||||
})
|
||||
|
||||
const activeSessionLabel = computed(() =>
|
||||
chatStore.activeSession?.title || 'New Chat',
|
||||
chatStore.activeSession?.id || 'New Chat',
|
||||
)
|
||||
|
||||
function handleNewChat() {
|
||||
@@ -58,6 +58,8 @@ function formatTime(ts: number) {
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="chatStore.isLoadingSessions" class="session-loading">Loading...</div>
|
||||
<div v-else-if="sortedSessions.length === 0" class="session-empty">No sessions</div>
|
||||
<button
|
||||
v-for="s in sortedSessions"
|
||||
:key="s.id"
|
||||
@@ -66,8 +68,8 @@ function formatTime(ts: number) {
|
||||
@click="chatStore.switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-time">{{ formatTime(s.updatedAt) }}</span>
|
||||
<span class="session-item-title">{{ s.id }}</span>
|
||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
v-if="s.id !== chatStore.activeSessionId || sortedSessions.length > 1"
|
||||
@@ -169,6 +171,14 @@ function formatTime(ts: number) {
|
||||
padding: 0 6px 12px;
|
||||
}
|
||||
|
||||
.session-loading,
|
||||
.session-empty {
|
||||
padding: 16px 10px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -46,12 +46,29 @@ function handleNav(key: string) {
|
||||
</svg>
|
||||
<span>Jobs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'logs' }"
|
||||
@click="handleNav('logs')"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<span>Logs</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
<div class="status-row">
|
||||
<div class="status-indicator" :class="{ connected: appStore.connected, disconnected: !appStore.connected }">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-info">Hermes {{ appStore.serverVersion || 'v0.1.0' }}</div>
|
||||
</div>
|
||||
@@ -133,11 +150,17 @@ function handleNav(key: string) {
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
|
||||
.status-dot {
|
||||
@@ -162,8 +185,9 @@ function handleNav(key: string) {
|
||||
}
|
||||
|
||||
.version-info {
|
||||
padding: 4px 12px;
|
||||
padding: 2px 12px 8px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,11 @@ const router = createRouter({
|
||||
name: 'jobs',
|
||||
component: () => import('@/views/JobsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'logs',
|
||||
component: () => import('@/views/LogsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
|
||||
+148
-79
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
||||
import { useAppStore } from './app'
|
||||
import { fetchSessions, fetchSession, deleteSession as deleteSessionApi, type SessionSummary, type HermesMessage } from '@/api/sessions'
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
@@ -24,12 +24,14 @@ export interface Message {
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
interface Session {
|
||||
export interface Session {
|
||||
id: string
|
||||
title: string
|
||||
messages: Message[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
model?: string
|
||||
messageCount?: number
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
@@ -42,43 +44,117 @@ async function uploadFiles(attachments: Attachment[]): Promise<{ name: string; p
|
||||
for (const att of attachments) {
|
||||
if (att.file) formData.append('file', att.file, att.name)
|
||||
}
|
||||
const res = await fetch('/__upload', { method: 'POST', body: formData })
|
||||
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
|
||||
}
|
||||
|
||||
const SESSIONS_KEY = 'hermes_chat_sessions'
|
||||
const ACTIVE_SESSION_KEY = 'hermes_active_session'
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSessions(): Session[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SESSIONS_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function saveSessions(sessions: Session[]) {
|
||||
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions))
|
||||
}
|
||||
|
||||
function loadActiveSessionId(): string | null {
|
||||
return localStorage.getItem(ACTIVE_SESSION_KEY)
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const appStore = useAppStore()
|
||||
const sessions = ref<Session[]>(loadSessions())
|
||||
const activeSessionId = ref<string | null>(loadActiveSessionId())
|
||||
const sessions = ref<Session[]>([])
|
||||
const activeSessionId = ref<string | null>(null)
|
||||
const isStreaming = ref(false)
|
||||
const abortController = ref<AbortController | null>(null)
|
||||
const isLoadingSessions = ref(false)
|
||||
const isLoadingMessages = ref(false)
|
||||
|
||||
const activeSession = ref<Session | null>(
|
||||
sessions.value.find(s => s.id === activeSessionId.value) || null,
|
||||
)
|
||||
const activeSession = ref<Session | null>(null)
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
const messages = ref<Message[]>(activeSession.value?.messages || [])
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function createSession(): Session {
|
||||
const session: Session = {
|
||||
@@ -89,14 +165,33 @@ export const useChatStore = defineStore('chat', () => {
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
sessions.value.unshift(session)
|
||||
saveSessions(sessions.value)
|
||||
return session
|
||||
}
|
||||
|
||||
function switchSession(sessionId: string) {
|
||||
async function switchSession(sessionId: string) {
|
||||
activeSessionId.value = sessionId
|
||||
localStorage.setItem(ACTIVE_SESSION_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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
messages.value = activeSession.value ? [...activeSession.value.messages] : []
|
||||
}
|
||||
|
||||
@@ -106,12 +201,12 @@ export const useChatStore = defineStore('chat', () => {
|
||||
switchSession(session.id)
|
||||
}
|
||||
|
||||
function deleteSession(sessionId: string) {
|
||||
async function deleteSession(sessionId: string) {
|
||||
await deleteSessionApi(sessionId)
|
||||
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
||||
saveSessions(sessions.value)
|
||||
if (activeSessionId.value === sessionId) {
|
||||
if (sessions.value.length > 0) {
|
||||
switchSession(sessions.value[0].id)
|
||||
await switchSession(sessions.value[0].id)
|
||||
} else {
|
||||
const session = createSession()
|
||||
switchSession(session.id)
|
||||
@@ -119,33 +214,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function stripNonSerializable(msgs: Message[]): Message[] {
|
||||
return msgs.map(m => ({
|
||||
...m,
|
||||
attachments: m.attachments?.map(a => ({ ...a, file: undefined, url: '' })),
|
||||
}))
|
||||
}
|
||||
|
||||
function persistMessages() {
|
||||
if (!activeSession.value || !appStore.sessionPersistence) return
|
||||
activeSession.value.messages = stripNonSerializable(messages.value)
|
||||
activeSession.value.updatedAt = Date.now()
|
||||
|
||||
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 ? '...' : '')
|
||||
}
|
||||
}
|
||||
|
||||
const idx = sessions.value.findIndex(s => s.id === activeSession.value!.id)
|
||||
if (idx !== -1) sessions.value[idx] = activeSession.value
|
||||
saveSessions(sessions.value)
|
||||
}
|
||||
|
||||
function addMessage(msg: Message) {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
@@ -157,6 +225,20 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSessionTitle() {
|
||||
if (!activeSession.value) return
|
||||
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 ? '...' : '')
|
||||
}
|
||||
}
|
||||
activeSession.value.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||
if ((!content.trim() && !(attachments && attachments.length > 0)) || isStreaming.value) return
|
||||
|
||||
@@ -173,7 +255,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
addMessage(userMsg)
|
||||
persistMessages()
|
||||
updateSessionTitle()
|
||||
|
||||
isStreaming.value = true
|
||||
|
||||
@@ -206,7 +288,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
isStreaming.value = false
|
||||
persistMessages()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,11 +298,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
(evt: RunEvent) => {
|
||||
switch (evt.event) {
|
||||
case 'run.started':
|
||||
// run started, nothing to render yet
|
||||
break
|
||||
|
||||
case 'message.delta': {
|
||||
// Find or create the assistant message
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
if (last?.role === 'assistant' && last.isStreaming) {
|
||||
last.content += evt.delta || ''
|
||||
@@ -238,12 +317,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
case 'tool.started': {
|
||||
// Close any streaming assistant message first
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
if (last?.isStreaming) {
|
||||
updateMessage(last.id, { isStreaming: false })
|
||||
}
|
||||
// Add tool message
|
||||
addMessage({
|
||||
id: uid(),
|
||||
role: 'tool',
|
||||
@@ -257,7 +334,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
case 'tool.completed': {
|
||||
// Find the running tool message and mark done
|
||||
const toolMsgs = messages.value.filter(
|
||||
m => m.role === 'tool' && m.toolStatus === 'running',
|
||||
)
|
||||
@@ -269,18 +345,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
case 'run.completed':
|
||||
// Close any streaming message
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
if (lastMsg?.isStreaming) {
|
||||
updateMessage(lastMsg.id, { isStreaming: false })
|
||||
}
|
||||
isStreaming.value = false
|
||||
abortController.value = null
|
||||
persistMessages()
|
||||
updateSessionTitle()
|
||||
break
|
||||
|
||||
case 'run.failed':
|
||||
// Mark error
|
||||
const lastErr = messages.value[messages.value.length - 1]
|
||||
if (lastErr?.isStreaming) {
|
||||
updateMessage(lastErr.id, {
|
||||
@@ -296,7 +370,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
// Mark any running tools as error
|
||||
messages.value.forEach((m, i) => {
|
||||
if (m.role === 'tool' && m.toolStatus === 'running') {
|
||||
messages.value[i] = { ...m, toolStatus: 'error' }
|
||||
@@ -304,7 +377,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
})
|
||||
isStreaming.value = false
|
||||
abortController.value = null
|
||||
persistMessages()
|
||||
break
|
||||
}
|
||||
},
|
||||
@@ -316,7 +388,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
isStreaming.value = false
|
||||
abortController.value = null
|
||||
persistMessages()
|
||||
updateSessionTitle()
|
||||
},
|
||||
// onError
|
||||
(err) => {
|
||||
@@ -337,7 +409,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
isStreaming.value = false
|
||||
abortController.value = null
|
||||
persistMessages()
|
||||
},
|
||||
)
|
||||
} catch (err: any) {
|
||||
@@ -349,7 +420,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
})
|
||||
isStreaming.value = false
|
||||
abortController.value = null
|
||||
persistMessages()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,12 +433,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
abortController.value = null
|
||||
}
|
||||
|
||||
if (sessions.value.length === 0) {
|
||||
const session = createSession()
|
||||
switchSession(session.id)
|
||||
} else if (!activeSession.value) {
|
||||
switchSession(sessions.value[0].id)
|
||||
}
|
||||
// Load sessions on init
|
||||
loadSessions()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@@ -376,10 +442,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
activeSession,
|
||||
messages,
|
||||
isStreaming,
|
||||
isLoadingSessions,
|
||||
isLoadingMessages,
|
||||
newChat,
|
||||
switchSession,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
loadSessions,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
import { onMounted } from 'vue'
|
||||
import ChatPanel from '@/components/chat/ChatPanel.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
onMounted(() => {
|
||||
appStore.loadModels()
|
||||
chatStore.loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/logs'
|
||||
|
||||
const message = useMessage()
|
||||
const logFiles = ref<{ name: string; size: string; modified: string }[]>([])
|
||||
const selectedLog = ref('agent')
|
||||
const entries = ref<LogEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const lineCount = ref(100)
|
||||
const levelFilter = ref<string>('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const logOptions = computed(() =>
|
||||
logFiles.value.map(f => ({ label: `${f.name} (${f.size})`, value: f.name })),
|
||||
)
|
||||
|
||||
const levelOptions = [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
{ label: 'WARNING', value: 'WARNING' },
|
||||
{ label: 'INFO', value: 'INFO' },
|
||||
{ label: 'DEBUG', value: 'DEBUG' },
|
||||
]
|
||||
|
||||
const lineOptions = [
|
||||
{ label: '50', value: 50 },
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '200', value: 200 },
|
||||
{ label: '500', value: 500 },
|
||||
]
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
if (!searchQuery.value) return entries.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return entries.value.filter(e =>
|
||||
e.message.toLowerCase().includes(q) ||
|
||||
e.logger.toLowerCase().includes(q) ||
|
||||
e.raw.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function levelClass(level: string): string {
|
||||
switch (level) {
|
||||
case 'ERROR': return 'level-error'
|
||||
case 'WARNING': return 'level-warning'
|
||||
case 'DEBUG': return 'level-debug'
|
||||
default: return 'level-info'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const match = ts.match(/\d{2}:\d{2}:\d{2}/)
|
||||
return match ? match[0] : ts
|
||||
}
|
||||
|
||||
function parseAccessLog(msg: string) {
|
||||
const match = msg.match(/"(\w+)\s+(\S+)\s+HTTP\/[^"]+"\s+(\d+)/)
|
||||
if (match) return { method: match[1], path: match[2], status: match[3] }
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchLogs(selectedLog.value, {
|
||||
lines: lineCount.value,
|
||||
level: levelFilter.value || undefined,
|
||||
})
|
||||
entries.value = data.filter((e): e is LogEntry => e !== null)
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
logFiles.value = await fetchLogFiles()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logs-view">
|
||||
<header class="logs-header">
|
||||
<h2 class="header-title">Logs</h2>
|
||||
<div class="header-actions">
|
||||
<NSelect
|
||||
v-model:value="selectedLog"
|
||||
:options="logOptions"
|
||||
size="small"
|
||||
style="width: 200px"
|
||||
@update:value="loadLogs"
|
||||
/>
|
||||
<NSelect
|
||||
:value="levelFilter"
|
||||
:options="levelOptions"
|
||||
size="small"
|
||||
style="width: 110px"
|
||||
@update:value="(v: string) => { levelFilter = v; loadLogs() }"
|
||||
/>
|
||||
<NSelect
|
||||
:value="lineCount"
|
||||
:options="lineOptions"
|
||||
size="small"
|
||||
style="width: 80px"
|
||||
@update:value="(v: number) => { lineCount = v; loadLogs() }"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<NButton size="small" :loading="loading" @click="loadLogs">Refresh</NButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="logs-body">
|
||||
<NSpin :show="loading">
|
||||
<div v-if="filteredEntries.length === 0 && !loading" class="logs-empty">
|
||||
No log entries
|
||||
</div>
|
||||
<div class="log-list">
|
||||
<div
|
||||
v-for="(entry, idx) in filteredEntries"
|
||||
:key="idx"
|
||||
class="log-entry"
|
||||
:class="levelClass(entry.level)"
|
||||
>
|
||||
<span class="log-time">{{ formatTime(entry.timestamp) }}</span>
|
||||
<span class="log-level" :class="levelClass(entry.level)">{{ entry.level }}</span>
|
||||
<span class="log-logger">{{ entry.logger }}</span>
|
||||
<template v-if="parseAccessLog(entry.message)">
|
||||
<span class="access-method">{{ parseAccessLog(entry.message)!.method }}</span>
|
||||
<span class="access-path">{{ parseAccessLog(entry.message)!.path }}</span>
|
||||
<span class="access-status" :class="'status-' + (parseAccessLog(entry.message)!.status?.[0] || 'x')">
|
||||
{{ parseAccessLog(entry.message)!.status }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="log-message">{{ entry.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.logs-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-input;
|
||||
color: $text-primary;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 160px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:focus { border-color: $accent-primary; }
|
||||
&::placeholder { color: $text-muted; }
|
||||
}
|
||||
|
||||
.logs-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 20px;
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
border-left: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($accent-primary, 0.03);
|
||||
}
|
||||
|
||||
&.level-error {
|
||||
border-left-color: $error;
|
||||
.log-message { color: $error; }
|
||||
}
|
||||
|
||||
&.level-warning {
|
||||
border-left-color: $warning;
|
||||
.log-message { color: #d9720f; }
|
||||
}
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
|
||||
&.level-error { background: rgba($error, 0.12); color: $error; }
|
||||
&.level-warning { background: rgba($warning, 0.12); color: #d9720f; }
|
||||
&.level-debug { background: rgba($accent-primary, 0.06); color: $text-muted; }
|
||||
&.level-info { background: rgba($accent-primary, 0.06); color: $text-muted; }
|
||||
}
|
||||
|
||||
.log-logger {
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: $text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-method {
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.access-path {
|
||||
color: $accent-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-status {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
|
||||
&.status-2 { color: $success; }
|
||||
&.status-3 { color: $warning; }
|
||||
&.status-4 { color: $error; }
|
||||
&.status-5 { color: $error; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user