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:
ekko
2026-04-16 08:38:18 +08:00
parent 4917242dca
commit 351c861777
106 changed files with 1409 additions and 317 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NConfigProvider, NMessageProvider, NDialogProvider, NNotificationProvider } from 'naive-ui'
import { themeOverrides } from '@/styles/theme'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import { useKeyboard } from '@/composables/useKeyboard'
import { useAppStore } from '@/stores/hermes/app'
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const ready = ref(false)
const isLoginPage = computed(() => route.name === 'login')
// Close mobile sidebar on route change
watch(() => route.path, () => {
appStore.closeSidebar()
})
// Wait for router to resolve before rendering layout
router.isReady().then(() => {
ready.value = true
})
onMounted(() => {
if (!isLoginPage.value) {
appStore.loadModels()
appStore.startHealthPolling()
}
})
onUnmounted(() => {
appStore.stopHealthPolling()
})
useKeyboard()
</script>
<template>
<NConfigProvider :theme-overrides="themeOverrides">
<NMessageProvider>
<NDialogProvider>
<NNotificationProvider>
<div v-if="ready" class="app-layout" :class="{ 'no-sidebar': isLoginPage }">
<button v-if="!isLoginPage" class="hamburger-btn" @click="appStore.toggleSidebar">
<img src="/logo.png" alt="Menu" style="width: 24px; height: 24px;" />
</button>
<div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" />
<AppSidebar v-if="!isLoginPage" />
<main class="app-main">
<router-view />
</main>
</div>
</NNotificationProvider>
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.app-layout {
display: flex;
height: calc(100 * var(--vh));
width: 100vw;
overflow: hidden;
&.no-sidebar {
display: block;
}
}
.app-main {
flex: 1;
overflow-y: auto;
background-color: $bg-primary;
.no-sidebar & {
height: calc(100 * var(--vh));
}
}
</style>
+63
View File
@@ -0,0 +1,63 @@
import router from '@/router'
const DEFAULT_BASE_URL = ''
function getBaseUrl(): string {
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
}
export function getApiKey(): string {
return localStorage.getItem('hermes_api_key') || ''
}
export function setServerUrl(url: string) {
localStorage.setItem('hermes_server_url', url)
}
export function setApiKey(key: string) {
localStorage.setItem('hermes_api_key', key)
}
export function clearApiKey() {
localStorage.removeItem('hermes_api_key')
}
export function hasApiKey(): boolean {
return !!getApiKey()
}
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const base = getBaseUrl()
const url = `${base}${path}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers as Record<string, string>,
}
const apiKey = getApiKey()
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
const res = await fetch(url, { ...options, headers })
// Global 401 handler — clear auth and redirect to login
if (res.status === 401) {
clearApiKey()
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
throw new Error('Unauthorized')
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
}
return res.json()
}
export function getBaseUrlValue(): string {
return getBaseUrl()
}
+89
View File
@@ -0,0 +1,89 @@
import { request, getBaseUrlValue, getApiKey } from '../client'
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string
}
export interface StartRunRequest {
input: string | ChatMessage[]
instructions?: string
conversation_history?: ChatMessage[]
session_id?: string
model?: string
}
export interface StartRunResponse {
run_id: string
status: string
}
// SSE event types from /v1/runs/{id}/events
export interface RunEvent {
event: string
run_id?: string
delta?: string
tool?: string
name?: string
preview?: string
timestamp?: number
error?: string
}
export async function startRun(body: StartRunRequest): Promise<StartRunResponse> {
return request<StartRunResponse>('/api/hermes/v1/runs', {
method: 'POST',
body: JSON.stringify(body),
})
}
export function streamRunEvents(
runId: string,
onEvent: (event: RunEvent) => void,
onDone: () => void,
onError: (err: Error) => void,
) {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${token ? `?token=${encodeURIComponent(token)}` : ''}`
let closed = false
const source = new EventSource(url)
source.onmessage = (e) => {
if (closed) return
try {
const parsed = JSON.parse(e.data)
onEvent(parsed)
if (parsed.event === 'run.completed' || parsed.event === 'run.failed') {
closed = true
source.close()
onDone()
}
} catch {
onEvent({ event: 'message', delta: e.data })
}
}
source.onerror = () => {
if (closed) return
closed = true
source.close()
onError(new Error('SSE connection error'))
}
// Return AbortController-compatible object
return {
abort: () => {
if (!closed) {
closed = true
source.close()
}
},
} as unknown as AbortController
}
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
return request('/api/hermes/v1/models')
}
+114
View File
@@ -0,0 +1,114 @@
import { request } from '../client'
export interface DisplayConfig {
compact?: boolean
personality?: string
resume_display?: string
busy_input_mode?: string
bell_on_complete?: boolean
show_reasoning?: boolean
streaming?: boolean
inline_diffs?: boolean
show_cost?: boolean
skin?: string
}
export interface AgentConfig {
max_turns?: number
gateway_timeout?: number
restart_drain_timeout?: number
service_tier?: string
tool_use_enforcement?: string
}
export interface MemoryConfig {
memory_enabled?: boolean
user_profile_enabled?: boolean
memory_char_limit?: number
user_char_limit?: number
}
export interface SessionResetConfig {
mode?: string
idle_minutes?: number
at_hour?: number
}
export interface PrivacyConfig {
redact_pii?: boolean
}
export interface AppConfig {
display?: DisplayConfig
agent?: AgentConfig
memory?: MemoryConfig
session_reset?: SessionResetConfig
privacy?: PrivacyConfig
telegram?: Record<string, any>
discord?: Record<string, any>
slack?: Record<string, any>
whatsapp?: Record<string, any>
matrix?: Record<string, any>
weixin?: Record<string, any>
wecom?: Record<string, any>
feishu?: Record<string, any>
dingtalk?: Record<string, any>
platforms?: Record<string, any>
[key: string]: any
}
export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
const query = sections ? `?sections=${sections.join(',')}` : ''
return request<AppConfig>(`/api/hermes/config${query}`)
}
export async function updateConfigSection(
section: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/hermes/config', {
method: 'PUT',
body: JSON.stringify({ section, values }),
})
}
export async function saveCredentials(
platform: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/hermes/config/credentials', {
method: 'PUT',
body: JSON.stringify({ platform, values }),
})
}
export interface WeixinQrCode {
qrcode: string
qrcode_url: string
}
export interface WeixinQrStatus {
status: 'wait' | 'scaned' | 'scaned_but_redirect' | 'expired' | 'confirmed'
account_id?: string
token?: string
base_url?: string
}
export async function fetchWeixinQrCode(): Promise<WeixinQrCode> {
return request<WeixinQrCode>('/api/hermes/weixin/qrcode')
}
export async function pollWeixinQrStatus(qrcode: string): Promise<WeixinQrStatus> {
return request<WeixinQrStatus>(`/api/hermes/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`)
}
export async function saveWeixinCredentials(data: {
account_id: string
token: string
base_url?: string
}): Promise<void> {
await request('/api/hermes/weixin/save', {
method: 'POST',
body: JSON.stringify(data),
})
}
+100
View File
@@ -0,0 +1,100 @@
import { request } from '../client'
export interface Job {
job_id: string
id: string
name: string
prompt: string
prompt_preview?: string
skills: string[]
skill: string | null
model: string | null
provider: string | null
base_url: string | null
script: string | null
schedule: string | { kind: string; expr: string; display: string }
schedule_display: string
repeat: string | { times: number | null; completed: number }
enabled: boolean
state: string
paused_at: string | null
paused_reason: string | null
created_at: string
next_run_at: string | null
last_run_at: string | null
last_status: string | null
last_error: string | null
deliver: string
origin: {
platform: string
chat_id: string
chat_name: string
thread_id: string | null
} | null
last_delivery_error: string | null
}
export interface CreateJobRequest {
name: string
schedule: string
prompt?: string
deliver?: string
skills?: string[]
repeat?: number
}
export interface UpdateJobRequest {
name?: string
schedule?: string
prompt?: string
deliver?: string
skills?: string[]
skill?: string
repeat?: number
enabled?: boolean
}
function unwrap(res: { job: Job }): Job {
return res.job
}
export async function listJobs(): Promise<Job[]> {
const res = await request<{ jobs: Job[] }>('/api/hermes/jobs?include_disabled=true')
return res.jobs
}
export async function getJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`))
}
export async function createJob(data: CreateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>('/api/hermes/jobs', {
method: 'POST',
body: JSON.stringify(data),
}))
}
export async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}))
}
export async function deleteJob(jobId: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/jobs/${jobId}`, {
method: 'DELETE',
})
}
export async function pauseJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/pause`, { method: 'POST' }))
}
export async function resumeJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/resume`, { method: 'POST' }))
}
export async function runJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/run`, { method: 'POST' }))
}
+36
View File
@@ -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/hermes/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/hermes/logs/${name}${qs ? `?${qs}` : ''}`)
return res.entries.filter((e): e is LogEntry => e !== null)
}
@@ -0,0 +1,78 @@
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
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
}
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/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
}
export async function fetchSession(id: string): Promise<SessionDetail | null> {
try {
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}`)
return res.session
} catch {
return null
}
}
export async function deleteSession(id: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/rename`, {
method: 'POST',
body: JSON.stringify({ title }),
})
return true
} catch {
return false
}
}
+63
View File
@@ -0,0 +1,63 @@
import { request } from '../client'
export interface SkillInfo {
name: string
description: string
enabled?: boolean
}
export interface SkillCategory {
name: string
description: string
skills: SkillInfo[]
}
export interface SkillListResponse {
categories: SkillCategory[]
}
export interface SkillFileEntry {
path: string
name: string
isDir: boolean
}
export interface MemoryData {
memory: string
user: string
memory_mtime: number | null
user_mtime: number | null
}
export async function fetchSkills(): Promise<SkillCategory[]> {
const res = await request<SkillListResponse>('/api/hermes/skills')
return res.categories
}
export async function fetchSkillContent(skillPath: string): Promise<string> {
const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`)
return res.content
}
export async function fetchSkillFiles(category: string, skill: string): Promise<SkillFileEntry[]> {
const res = await request<{ files: SkillFileEntry[] }>(`/api/hermes/skills/${category}/${skill}/files`)
return res.files
}
export async function fetchMemory(): Promise<MemoryData> {
return request<MemoryData>('/api/hermes/memory')
}
export async function saveMemory(section: 'memory' | 'user', content: string): Promise<void> {
await request('/api/hermes/memory', {
method: 'POST',
body: JSON.stringify({ section, content }),
})
}
export async function toggleSkill(name: string, enabled: boolean): Promise<void> {
await request('/api/hermes/skills/toggle', {
method: 'PUT',
body: JSON.stringify({ name, enabled }),
})
}
+79
View File
@@ -0,0 +1,79 @@
import { request } from '../client'
export interface HealthResponse {
status: string
version?: string
}
// Config-based model types
export interface ModelInfo {
id: string
label: string
}
export interface ModelGroup {
provider: string
models: ModelInfo[]
}
export interface ConfigModelsResponse {
default: string
groups: ModelGroup[]
}
export interface AvailableModelGroup {
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
label: string // display name (e.g. "zai", "subrouter.ai")
base_url: string
models: string[]
}
export interface AvailableModelsResponse {
default: string
groups: AvailableModelGroup[]
}
export interface CustomProvider {
name: string
base_url: string
api_key: string
model: string
providerKey?: string | null
}
export async function checkHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/health')
}
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
return request<ConfigModelsResponse>('/api/hermes/config/models')
}
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
return request<AvailableModelsResponse>('/api/hermes/available-models')
}
export async function updateDefaultModel(data: {
default: string
provider?: string
base_url?: string
api_key?: string
}): Promise<void> {
await request('/api/hermes/config/model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomProvider(data: CustomProvider): Promise<void> {
await request('/api/hermes/config/providers', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function removeCustomProvider(name: string): Promise<void> {
await request(`/api/hermes/config/providers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,401 @@
<script setup lang="ts">
import type { Attachment } from '@/stores/hermes/chat'
import { useChatStore } from '@/stores/hermes/chat'
import { NButton, NTooltip } from 'naive-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const chatStore = useChatStore()
const { t } = useI18n()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
// --- Voice input (Web Speech API) ---
// TODO: re-enable when needed — browser-native speech-to-text
// const hasSpeechRecognition = ref(false)
// let recognition: SpeechRecognition | null = null
// let finalTranscript = ''
// let prefixText = ''
// onMounted(() => {
// const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
// if (!SR) return
// recognition = new SR()
// recognition.continuous = false
// recognition.interimResults = true
// recognition.lang = 'en-US'
// hasSpeechRecognition.value = true
// recognition.onresult = (event: SpeechRecognitionEvent) => { ... }
// recognition.onend = () => { ... }
// recognition.onerror = (event: SpeechRecognitionErrorEvent) => { ... }
// })
// onUnmounted(() => { if (recognition && isRecording.value) recognition.stop() })
// --- File attachment helpers ---
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
const url = URL.createObjectURL(file)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url,
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
// --- Paste image ---
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type })
addFile(file)
}
}
// --- Drag and drop ---
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
const files = Array.from(e.dataTransfer?.files || [])
if (!files.length) return
for (const file of files) addFile(file)
textareaRef.value?.focus()
}
// --- Send ---
function handleSend() {
const text = inputText.value.trim()
if (!text && attachments.value.length === 0) return
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
attachments.value = []
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
requestAnimationFrame(() => {
isComposing.value = false
})
}
function isImeEnter(e: KeyboardEvent): boolean {
return isComposing.value || e.isComposing || e.keyCode === 229
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || e.shiftKey) return
if (isImeEnter(e)) return
e.preventDefault()
handleSend()
}
function handleInput(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
</script>
<template>
<div class="chat-input-area">
<!-- Attachment previews -->
<div v-if="attachments.length > 0" class="attachment-previews">
<div
v-for="att in attachments"
:key="att.id"
class="attachment-preview"
:class="{ image: isImage(att.type) }"
>
<template v-if="isImage(att.type)">
<img :src="att.url" :alt="att.name" class="attachment-thumb" />
</template>
<template v-else>
<div class="attachment-file">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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"/></svg>
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
</template>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input
ref="fileInputRef"
type="file"
multiple
class="file-input-hidden"
@change="handleFileChange"
/>
<textarea
ref="textareaRef"
v-model="inputText"
class="input-textarea"
:placeholder="t('chat.inputPlaceholder')"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
></textarea>
<div class="input-actions">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="small" @click="handleAttachClick" circle>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<NButton
v-if="chatStore.isStreaming"
size="small"
type="error"
@click="chatStore.stopStreaming()"
>
{{ t('chat.stop') }}
</NButton>
<NButton
size="small"
type="primary"
:disabled="!canSend || chatStore.isStreaming"
@click="handleSend"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</template>
{{ t('chat.send') }}
</NButton>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.chat-input-area {
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 10px;
background-color: $bg-input;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 10px 12px;
transition: border-color $transition-fast;
&:focus-within {
border-color: $accent-primary;
}
}
.input-textarea {
flex: 1;
background: none;
border: none;
outline: none;
color: $text-primary;
font-family: $font-ui;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 100px;
min-height: 20px;
overflow-y: auto;
&::placeholder {
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.input-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
}
// Drag-over state
.input-wrapper.drag-over {
border-color: #4a90d9;
border-style: dashed;
background-color: rgba(74, 144, 217, 0.04);
}
</style>
@@ -0,0 +1,711 @@
<script setup lang="ts">
import { renameSession } from '@/api/hermes/sessions'
import { useChatStore, type Session } from '@/stores/hermes/chat'
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatInput from './ChatInput.vue'
import MessageList from './MessageList.vue'
const chatStore = useChatStore()
const message = useMessage()
const { t } = useI18n()
const showSessions = ref(true)
let mobileQuery: MediaQueryList | null = null
function handleSessionClick(sessionId: string) {
chatStore.switchSession(sessionId)
if (mobileQuery?.matches) showSessions.value = false
}
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && showSessions.value) {
showSessions.value = false
}
}
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
})
onUnmounted(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
})
const showRenameModal = ref(false)
const renameValue = ref('')
const renameSessionId = ref<string | null>(null)
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
const sourceLabelKeys: Record<string, string> = {
telegram: 'Telegram',
api_server: 'API Server',
cli: 'CLI',
discord: 'Discord',
slack: 'Slack',
matrix: 'Matrix',
whatsapp: 'WhatsApp',
signal: 'Signal',
email: 'Email',
sms: 'SMS',
dingtalk: 'DingTalk',
feishu: 'Feishu',
wecom: 'WeCom',
weixin: 'WeChat',
bluebubbles: 'iMessage',
mattermost: 'Mattermost',
cron: 'Cron',
}
function getSourceLabel(source?: string): string {
if (!source) return ''
return sourceLabelKeys[source] || source
}
// Source sort order: api_server first, cron last, others alphabetical
function sourceSortKey(source: string): number {
if (source === 'api_server') return -1
if (source === 'cron') return 999
return 0
}
// Group sessions by source, with sort order
interface SessionGroup {
source: string
label: string
sessions: Session[]
}
const groupedSessions = computed<SessionGroup[]>(() => {
const all = [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
const map = new Map<string, Session[]>()
for (const s of all) {
const key = s.source || ''
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(s)
}
const keys = [...map.keys()].sort((a, b) => {
const ka = sourceSortKey(a)
const kb = sourceSortKey(b)
if (ka !== kb) return ka - kb
return a.localeCompare(b)
})
return keys.map(key => ({
source: key,
label: key ? getSourceLabel(key) : t('chat.other'),
sessions: map.get(key)!,
}))
})
function toggleGroup(source: string) {
const isExpanded = !collapsedGroups.value.has(source)
if (isExpanded) {
collapsedGroups.value = new Set([...collapsedGroups.value, source])
} else {
collapsedGroups.value = new Set(
groupedSessions.value.map(g => g.source).filter(s => s !== source)
)
// Auto-select the first session in the expanded group
const group = groupedSessions.value.find(g => g.source === source)
if (group?.sessions.length) {
chatStore.switchSession(group.sessions[0].id)
}
}
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
}
// Ensure the active session's group is expanded
watch(groupedSessions, (groups) => {
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))
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
}, { once: true })
const activeSessionTitle = computed(() =>
chatStore.activeSession?.title || t('chat.newChat'),
)
const totalTokens = computed(() => {
const input = chatStore.activeSession?.inputTokens ?? 0
const output = chatStore.activeSession?.outputTokens ?? 0
return input + output
})
const MODEL_CONTEXT: Record<string, number> = {
'claude-opus-4': 200000,
'claude-sonnet-4': 200000,
'claude-haiku-4': 200000,
'claude-3.5-sonnet': 200000,
'claude-3.5-haiku': 200000,
'claude-3-opus': 200000,
'claude-3-sonnet': 200000,
'claude-3-haiku': 200000,
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gpt-4-turbo': 128000,
'gpt-4': 8192,
'gpt-3.5-turbo': 16385,
'o1': 200000,
'o1-mini': 128000,
'o3': 200000,
'o3-mini': 200000,
'o4-mini': 200000,
'deepseek-chat': 65536,
'deepseek-reasoner': 65536,
'gemini-2.5-pro': 1000000,
'gemini-2.5-flash': 1000000,
'gemini-2.0-flash': 1000000,
'glm-4-plus': 128000,
'glm-4': 128000,
'qwen-max': 128000,
'qwen-plus': 128000,
'qwen-turbo': 128000,
}
const contextWindow = computed(() => {
const model = chatStore.activeSession?.model || ''
for (const [key, val] of Object.entries(MODEL_CONTEXT)) {
if (model.includes(key)) return val
}
return null
})
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
const activeSessionSource = computed(() =>
chatStore.activeSession?.source || '',
)
function handleNewChat() {
chatStore.newChat()
}
function copySessionId(id?: string) {
const sessionId = id || chatStore.activeSessionId
if (sessionId) {
navigator.clipboard.writeText(sessionId)
message.success(t('common.copied'))
}
}
function handleDeleteSession(id: string) {
chatStore.deleteSession(id)
message.success(t('chat.sessionDeleted'))
}
function formatTime(ts: number) {
const d = new Date(ts)
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
// Context menu
const contextMenuOptions = computed(() => [
{ label: t('chat.rename'), key: 'rename' },
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
const contextSessionId = ref<string | null>(null)
function handleContextMenu(e: MouseEvent, sessionId: string) {
e.preventDefault()
contextSessionId.value = sessionId
showContextMenu.value = true
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
}
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
function handleContextMenuSelect(key: string) {
showContextMenu.value = false
if (!contextSessionId.value) return
if (key === 'copy-id') {
copySessionId(contextSessionId.value)
} else if (key === 'rename') {
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
renameSessionId.value = contextSessionId.value
renameValue.value = session?.title || ''
showRenameModal.value = true
nextTick(() => {
renameInputRef.value?.focus()
})
}
}
function handleClickOutside() {
showContextMenu.value = false
}
async function handleRenameConfirm() {
if (!renameSessionId.value || !renameValue.value.trim()) return
const ok = await renameSession(renameSessionId.value, renameValue.value.trim())
if (ok) {
const session = chatStore.sessions.find(s => s.id === renameSessionId.value)
if (session) session.title = renameValue.value.trim()
if (chatStore.activeSession?.id === renameSessionId.value) {
chatStore.activeSession.title = renameValue.value.trim()
}
message.success(t('chat.renamed'))
} else {
message.error(t('chat.renameFailed'))
}
showRenameModal.value = false
}
</script>
<template>
<div class="chat-panel">
<!-- Session List -->
<div class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
<aside class="session-list" :class="{ collapsed: !showSessions }">
<div class="session-list-header">
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span>
<div class="session-list-actions">
<button class="session-close-btn" @click="showSessions = false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<NButton quaternary size="tiny" @click="handleNewChat" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
</NButton>
</div>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div>
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">{{ t('chat.noSessions') }}</div>
<template v-for="group in groupedSessions" :key="group.source">
<div class="session-group-header" @click="toggleGroup(group.source)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
<span class="session-group-label">{{ group.label }}</span>
<span class="session-group-count">{{ group.sessions.length }}</span>
</div>
<template v-if="!collapsedGroups.has(group.source)">
<button
v-for="s in group.sessions"
:key="s.id"
class="session-item"
:class="{ active: s.id === chatStore.activeSessionId }"
@click="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-meta">
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
</span>
</div>
<NPopconfirm
v-if="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
@positive-click="handleDeleteSession(s.id)"
>
<template #trigger>
<button class="session-item-delete" @click.stop>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
{{ t('chat.deleteSession') }}
</NPopconfirm>
</button>
</template>
</template>
</div>
</aside>
<!-- Context Menu -->
<NDropdown
placement="bottom-start"
trigger="manual"
:x="contextMenuX"
:y="contextMenuY"
:options="contextMenuOptions"
:show="showContextMenu"
@select="handleContextMenuSelect"
@clickoutside="handleClickOutside"
/>
<!-- Rename Modal -->
<NModal
v-model:show="showRenameModal"
preset="dialog"
:title="t('chat.renameSession')"
:positive-text="t('common.ok')"
:negative-text="t('common.cancel')"
@positive-click="handleRenameConfirm"
>
<NInput
ref="renameInputRef"
v-model:value="renameValue"
:placeholder="t('chat.enterNewTitle')"
@keydown.enter="handleRenameConfirm"
/>
</NModal>
<!-- Chat Area -->
<div class="chat-main">
<header class="chat-header">
<div class="header-left">
<NButton quaternary size="small" @click="showSessions = !showSessions" circle>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</template>
</NButton>
<span class="header-session-title">{{ activeSessionTitle }}</span>
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
</div>
<div class="header-actions">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="small" @click="copySessionId()" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</template>
</NButton>
</template>
{{ t('chat.copySessionId') }}
</NTooltip>
<NButton size="small" @click="handleNewChat">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
{{ t('chat.newChat') }}
</NButton>
</div>
</header>
<MessageList />
<div v-if="contextWindow !== null" class="context-info">
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
</div>
<ChatInput />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.chat-panel {
display: flex;
height: 100%;
position: relative;
}
.session-list {
width: 220px;
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: width $transition-normal, opacity $transition-normal;
overflow: hidden;
&.collapsed {
width: 0;
border-right: none;
opacity: 0;
pointer-events: none;
}
@media (max-width: $breakpoint-mobile) {
position: absolute;
left: 0;
top: 0;
height: 100%;
z-index: 10;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
width: 280px;
&.collapsed {
transform: translateX(-100%);
opacity: 0;
}
}
}
@media (max-width: $breakpoint-mobile) {
.session-close-btn {
display: flex;
}
.session-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 9;
opacity: 0;
pointer-events: none;
transition: opacity $transition-fast;
&.active {
opacity: 1;
pointer-events: auto;
}
}
}
.session-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
flex-shrink: 0;
}
.session-list-actions {
display: flex;
align-items: center;
gap: 4px;
}
.session-close-btn {
display: none;
border: none;
background: none;
cursor: pointer;
color: $text-secondary;
padding: 4px;
border-radius: $radius-sm;
&:hover {
background: rgba($accent-primary, 0.06);
}
}
.session-list-title {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.session-group-header {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px 4px;
cursor: pointer;
user-select: none;
}
.group-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
transform: rotate(90deg);
&.collapsed {
transform: rotate(0deg);
}
}
.session-group-label {
font-size: 10px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.session-group-count {
font-size: 10px;
color: $text-muted;
font-weight: 400;
}
.session-items {
flex: 1;
overflow-y: auto;
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;
justify-content: space-between;
width: 100%;
padding: 8px 10px;
border: none;
background: none;
border-radius: $radius-sm;
cursor: pointer;
text-align: left;
color: $text-secondary;
transition: all $transition-fast;
margin-bottom: 2px;
&:hover {
background: rgba($accent-primary, 0.06);
color: $text-primary;
.session-item-delete {
opacity: 1;
}
}
&.active {
background: rgba($accent-primary, 0.1);
color: $text-primary;
font-weight: 500;
}
}
.session-item-content {
flex: 1;
overflow: hidden;
}
.session-item-title {
display: block;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item-time {
font-size: 11px;
color: $text-muted;
}
.session-item-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.session-item-model {
font-size: 10px;
color: $accent-primary;
background: rgba($accent-primary, 0.08);
padding: 0 5px;
border-radius: 3px;
line-height: 16px;
flex-shrink: 0;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item-delete {
flex-shrink: 0;
opacity: 0.5;
padding: 2px;
border: none;
background: none;
color: $text-muted;
cursor: pointer;
border-radius: 3px;
transition: all $transition-fast;
&:hover {
color: $error;
background: rgba($error, 0.1);
}
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 21px 20px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
flex: 1;
min-width: 0;
}
.header-session-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-badge {
font-size: 10px;
color: $text-muted;
background: rgba($text-muted, 0.12);
padding: 1px 7px;
border-radius: 8px;
flex-shrink: 0;
white-space: nowrap;
line-height: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.context-info {
padding: 0 20px 4px;
font-size: 11px;
color: $text-muted;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.chat-header {
padding: 16px 12px 16px 52px;
}
.context-info {
padding: 0 12px 4px;
}
}
</style>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
const props = defineProps<{ content: string }>()
const { t } = useI18n()
const md: MarkdownIt = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
highlight(str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs-code-block"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
} catch {
// fall through
}
}
return `<pre class="hljs-code-block"><div class="code-header"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
},
})
const renderedHtml = computed(() => md.render(props.content))
</script>
<template>
<div class="markdown-body" v-html="renderedHtml"></div>
</template>
<style lang="scss">
@use '@/styles/variables' as *;
.markdown-body {
font-size: 14px;
line-height: 1.65;
overflow-x: auto;
p {
margin: 0 0 8px;
&:last-child {
margin-bottom: 0;
}
}
ul, ol {
padding-left: 20px;
margin: 4px 0 8px;
}
li {
margin: 2px 0;
}
strong {
color: $text-primary;
font-weight: 600;
}
em {
color: $text-secondary;
}
a {
color: $accent-primary;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $accent-hover;
}
}
blockquote {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid $border-color;
color: $text-secondary;
}
code:not(.hljs) {
background: $code-bg;
padding: 2px 6px;
border-radius: 4px;
font-family: $font-code;
font-size: 13px;
color: $accent-primary;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
display: block;
overflow-x: auto;
th, td {
padding: 6px 12px;
border: 1px solid $border-color;
text-align: left;
font-size: 13px;
}
th {
background: rgba($accent-primary, 0.08);
color: $text-primary;
font-weight: 600;
}
td {
color: $text-secondary;
}
}
hr {
border: none;
border-top: 1px solid $border-color;
margin: 12px 0;
}
}
.hljs-code-block {
margin: 8px 0;
border-radius: $radius-sm;
overflow: hidden;
background: $code-bg;
border: 1px solid $border-color;
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid $border-color;
.code-lang {
font-size: 11px;
color: $text-muted;
text-transform: uppercase;
}
.copy-btn {
font-size: 11px;
color: $text-muted;
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: all $transition-fast;
&:hover {
color: $text-primary;
background: rgba(0, 0, 0, 0.05);
}
}
}
code.hljs {
display: block;
padding: 12px;
font-family: $font-code;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
}
}
// highlight.js theme override — pure ink B&W
.hljs {
color: #2a2a2a;
background: none;
}
.hljs-keyword,
.hljs-selector-tag { color: #1a1a1a; font-weight: 600; }
.hljs-string,
.hljs-attr { color: #555555; }
.hljs-number { color: #333333; }
.hljs-comment { color: #999999; font-style: italic; }
.hljs-built_in { color: #444444; }
.hljs-type { color: #3a3a3a; }
.hljs-variable { color: #1a1a1a; }
.hljs-title,
.hljs-title\.function_ { color: #1a1a1a; }
.hljs-params { color: #2a2a2a; }
.hljs-meta { color: #999999; }
</style>
@@ -0,0 +1,484 @@
<script setup lang="ts">
import type { Message } from "@/stores/hermes/chat";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import MarkdownRenderer from "./MarkdownRenderer.vue";
const props = defineProps<{ message: Message }>();
const { t } = useI18n();
const isSystem = computed(() => props.message.role === "system");
const toolExpanded = ref(false);
const timeStr = computed(() => {
const d = new Date(props.message.timestamp);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
});
function isImage(type: string): boolean {
return type.startsWith("image/");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
const hasAttachments = computed(
() => (props.message.attachments?.length ?? 0) > 0,
);
const hasToolDetails = computed(
() => !!(props.message.toolArgs || props.message.toolResult),
);
const formattedToolArgs = computed(() => {
if (!props.message.toolArgs) return "";
try {
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2);
} catch {
return props.message.toolArgs;
}
});
const formattedToolResult = computed(() => {
if (!props.message.toolResult) return "";
try {
const parsed = JSON.parse(props.message.toolResult);
const str = JSON.stringify(parsed, null, 2);
// Truncate very long output
if (str.length > 2000)
return str.slice(0, 2000) + "\n" + t("chat.truncated");
return str;
} catch {
const raw = props.message.toolResult;
if (raw.length > 2000)
return raw.slice(0, 2000) + "\n" + t("chat.truncated");
return raw;
}
});
</script>
<template>
<div class="message" :class="[message.role]">
<template v-if="message.role === 'tool'">
<div
class="tool-line"
:class="{ expandable: hasToolDetails }"
@click="hasToolDetails && (toolExpanded = !toolExpanded)"
>
<svg
v-if="hasToolDetails"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="tool-chevron"
:class="{ rotated: toolExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<svg
v-else
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-icon"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
<span class="tool-name">{{ message.toolName }}</span>
<span
v-if="message.toolPreview && !toolExpanded"
class="tool-preview"
>{{ message.toolPreview }}</span
>
<span
v-if="message.toolStatus === 'running'"
class="tool-spinner"
></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{
t("chat.error")
}}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
<div v-if="formattedToolArgs" class="tool-detail-section">
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
</div>
<div v-if="formattedToolResult" class="tool-detail-section">
<div class="tool-detail-label">{{ t("chat.result") }}</div>
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
</div>
</div>
</template>
<template v-else>
<div class="msg-body">
<img
v-if="message.role === 'assistant'"
src="/logo.png"
alt="Hermes"
class="msg-avatar"
/>
<div class="msg-content" :class="message.role">
<div class="message-bubble" :class="{ system: isSystem }">
<div v-if="hasAttachments" class="msg-attachments">
<div
v-for="att in message.attachments"
:key="att.id"
class="msg-attachment"
:class="{ image: isImage(att.type) }"
>
<template v-if="isImage(att.type) && att.url">
<img
:src="att.url"
:alt="att.name"
class="msg-attachment-thumb"
/>
</template>
<template v-else>
<div class="msg-attachment-file">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<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" />
</svg>
<span class="att-name">{{ att.name }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
</div>
</template>
</div>
</div>
<MarkdownRenderer
v-if="message.content"
:content="message.content"
/>
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
<span></span><span></span><span></span>
</span>
</div>
<div class="message-time">{{ timeStr }}</div>
</div>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.message {
display: flex;
flex-direction: column;
&.user {
align-items: flex-end;
.msg-body {
max-width: 75%;
}
.msg-content.user {
align-items: flex-end;
}
.message-bubble {
background-color: $msg-user-bg;
border-radius: $radius-md $radius-md 4px $radius-md;
}
}
&.assistant {
flex-direction: row;
align-items: flex-start;
gap: 8px;
.msg-body {
max-width: 80%;
}
.msg-avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
margin-top: 2px;
}
.message-bubble {
background-color: $msg-assistant-bg;
border-radius: $radius-md $radius-md $radius-md 4px;
}
}
&.tool {
align-items: flex-start;
}
&.system {
align-items: flex-start;
.message-bubble.system {
border-left: 3px solid $warning;
border-radius: $radius-sm;
max-width: 80%;
background-color: rgba($warning, 0.06);
}
}
}
.msg-body {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 85%;
}
.msg-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.message-bubble {
padding: 10px 14px;
font-size: 14px;
line-height: 1.65;
word-break: break-word;
}
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.msg-attachment {
border-radius: $radius-sm;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid $border-light;
&.image {
max-width: 200px;
}
}
.msg-attachment-thumb {
display: block;
max-width: 200px;
max-height: 160px;
object-fit: contain;
}
.msg-attachment-file {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 12px;
color: $text-secondary;
.att-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.att-size {
color: $text-muted;
font-size: 11px;
flex-shrink: 0;
}
}
.message-time {
font-size: 11px;
color: $text-muted;
margin-top: 4px;
padding: 0 4px;
}
.tool-line {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-muted;
padding: 2px 4px;
border-radius: $radius-sm;
&.expandable {
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
}
.tool-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
}
.tool-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
&.rotated {
transform: rotate(90deg);
}
}
.tool-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-error-badge {
font-size: 9px;
color: $error;
background: rgba($error, 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
.tool-details {
margin-left: 16px;
margin-top: 2px;
border-left: 2px solid $border-light;
padding-left: 10px;
}
.tool-detail-section {
margin-bottom: 6px;
}
.tool-detail-label {
font-size: 10px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.tool-detail-code {
font-family: $font-code;
font-size: 11px;
line-height: 1.5;
color: $text-secondary;
background: $code-bg;
border-radius: $radius-sm;
padding: 6px 8px;
margin: 0;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.streaming-cursor {
display: inline-block;
width: 2px;
height: 1em;
background-color: $text-muted;
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 0.8s infinite;
}
.streaming-dots {
display: flex;
gap: 4px;
padding: 4px 0;
span {
width: 6px;
height: 6px;
background-color: $text-muted;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@keyframes pulse {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: $breakpoint-mobile) {
.message.user .msg-body {
max-width: 100%;
}
.message.assistant .msg-body {
max-width: 100%;
}
.message.system .msg-body {
max-width: 100%;
}
}
</style>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageItem from './MessageItem.vue'
import { useChatStore } from '@/stores/hermes/chat'
import thinkingVideo from '@/assets/thinking.mp4'
const chatStore = useChatStore()
const { t } = useI18n()
const listRef = ref<HTMLElement>()
const displayMessages = computed(() =>
chatStore.messages.filter(m => m.role !== 'tool'),
)
const currentToolCalls = computed(() => {
const msgs = chatStore.messages
// Find the last user message index
let lastUserIdx = -1
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break }
}
// Only tool calls after the last user message, newest on top
const tools = msgs.filter((m, i) => m.role === 'tool' && i > lastUserIdx)
return [...tools].reverse()
})
function scrollToBottom() {
nextTick(() => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
})
}
watch(() => chatStore.messages.length, scrollToBottom)
watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
watch(currentToolCalls, scrollToBottom)
</script>
<template>
<div ref="listRef" class="message-list">
<div v-if="chatStore.messages.length === 0" class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t('chat.emptyState') }}</p>
</div>
<MessageItem
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
/>
<Transition name="fade">
<div v-if="chatStore.isStreaming" class="streaming-indicator">
<video :src="thinkingVideo" autoplay loop muted playsinline class="thinking-video" />
<div v-if="currentToolCalls.length > 0" class="tool-calls-panel">
<div
v-for="tc in currentToolCalls"
:key="tc.id"
class="tool-call-item"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span class="tool-call-name">{{ tc.toolName }}</span>
<span v-if="tc.toolPreview" class="tool-call-preview">{{ tc.toolPreview }}</span>
<span v-if="tc.toolStatus === 'running'" class="tool-call-spinner"></span>
<span v-if="tc.toolStatus === 'error'" class="tool-call-error">{{ t('chat.error') }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background-color: #ffffff;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-muted;
gap: 12px;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.streaming-indicator {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 4px;
.thinking-video {
width: 120px;
height: 120px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
}
}
.tool-calls-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
padding-top: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.tool-call-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-secondary;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: $radius-sm;
.tool-call-icon {
flex-shrink: 0;
color: $text-muted;
}
.tool-call-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-call-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
color: $text-muted;
}
}
.tool-call-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-call-error {
font-size: 9px;
color: $error;
background: rgba($error, 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import type { Job } from '@/api/hermes/jobs'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ job: Job }>()
const emit = defineEmits<{
edit: [jobId: string]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
const message = useMessage()
const jobId = computed(() => props.job.job_id || props.job.id)
const statusLabel = computed(() => {
if (props.job.state === 'running') return t('jobs.status.running')
if (props.job.state === 'paused') return t('jobs.status.paused')
if (!props.job.enabled) return t('jobs.status.disabled')
return t('jobs.status.scheduled')
})
const statusType = computed(() => {
if (props.job.state === 'running') return 'info' as const
if (props.job.state === 'paused') return 'warning' as const
if (!props.job.enabled) return 'error' as const
return 'success' as const
})
const scheduleExpr = computed(() => {
const s = props.job.schedule
if (typeof s === 'string') return s
return s?.expr || props.job.schedule_display || '—'
})
const formatTime = (t?: string | null) => {
if (!t) return '—'
return new Date(t).toLocaleString()
}
async function handlePause() {
try {
await jobsStore.pauseJob(jobId.value)
message.success(t('jobs.jobPaused'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleResume() {
try {
await jobsStore.resumeJob(jobId.value)
message.success(t('jobs.jobResumed'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleRun() {
try {
await jobsStore.runJob(jobId.value)
message.info(t('jobs.jobTriggered'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleDelete() {
try {
await jobsStore.deleteJob(jobId.value)
message.success(t('jobs.jobDeleted'))
} catch (e: any) {
message.error(e.message)
}
}
</script>
<template>
<div class="job-card">
<div class="card-header">
<h3 class="job-name">{{ job.name }}</h3>
<span class="status-badge" :class="statusType">{{ statusLabel }}</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
<code class="info-value mono">{{ scheduleExpr }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
<span class="info-value">
{{ formatTime(job.last_run_at) }}
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
{{ job.last_status === 'ok' ? t('common.ok') : job.last_status }}
</span>
</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.nextRun') }}</span>
<span class="info-value">{{ formatTime(job.next_run_at) }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.deliver') }}</span>
<span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
</div>
<div v-if="job.repeat" class="info-row">
<span class="info-label">{{ t('jobs.info.repeat') }}</span>
<span class="info-value">
<template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
<template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '' }}</template>
</span>
</div>
</div>
<div class="card-actions">
<NTooltip v-if="job.state !== 'paused' && job.enabled">
<template #trigger>
<NButton size="tiny" quaternary @click="handlePause">{{ t('jobs.action.pause') }}</NButton>
</template>
{{ t('jobs.action.pauseJob') }}
</NTooltip>
<NTooltip v-else-if="job.state === 'paused'">
<template #trigger>
<NButton size="tiny" quaternary @click="handleResume">{{ t('jobs.action.resume') }}</NButton>
</template>
{{ t('jobs.action.resumeJob') }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="tiny" quaternary @click="handleRun">{{ t('jobs.action.runNow') }}</NButton>
</template>
{{ t('jobs.action.triggerImmediately') }}
</NTooltip>
<NButton size="tiny" quaternary @click="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.job-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba($accent-primary, 0.3);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.job-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
&.success {
background: rgba($success, 0.12);
color: $success;
}
&.info {
background: rgba($accent-primary, 0.12);
color: $accent-primary;
}
&.warning {
background: rgba($warning, 0.12);
color: $warning;
}
&.error {
background: rgba($error, 0.12);
color: $error;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.run-status {
margin-left: 6px;
font-size: 11px;
font-weight: 500;
&.ok { color: $success; }
&.err { color: $error; }
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.card-actions {
display: flex;
gap: 4px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
jobId: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const jobsStore = useJobsStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const formData = ref({
name: '',
schedule: '',
prompt: '',
deliver: 'origin',
repeat_times: null as number | null,
})
const presetValue = ref<string | null>(null)
const isEdit = computed(() => !!props.jobId)
const schedulePresets = computed(() => [
{ label: t('jobs.presetEveryMinute'), value: '* * * * *' },
{ label: t('jobs.presetEvery5Min'), value: '*/5 * * * *' },
{ label: t('jobs.presetEveryHour'), value: '0 * * * *' },
{ label: t('jobs.presetEveryDay'), value: '0 0 * * *' },
{ label: t('jobs.presetEveryDay9'), value: '0 9 * * *' },
{ label: t('jobs.presetEveryMonday'), value: '0 9 * * 1' },
{ label: t('jobs.presetEveryMonth'), value: '0 9 1 * *' },
])
const targetOptions = computed(() => [
{ label: t('jobs.origin'), value: 'origin' },
{ label: t('jobs.local'), value: 'local' },
])
onMounted(async () => {
if (props.jobId) {
try {
const { getJob } = await import('@/api/hermes/jobs')
const job = await getJob(props.jobId)
formData.value = {
name: job.name,
schedule: typeof job.schedule === 'string' ? job.schedule : (job.schedule?.expr || job.schedule_display || ''),
prompt: job.prompt,
deliver: job.deliver || 'origin',
repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
}
} catch (e: any) {
message.error(t('jobs.loadFailed') + ': ' + e.message)
}
}
})
async function handleSave() {
if (!formData.value.name.trim()) {
message.warning(t('jobs.nameRequired'))
return
}
if (!formData.value.schedule.trim()) {
message.warning(t('jobs.scheduleRequired'))
return
}
loading.value = true
try {
const payload = {
name: formData.value.name,
schedule: formData.value.schedule,
prompt: formData.value.prompt,
deliver: formData.value.deliver,
repeat: formData.value.repeat_times ?? undefined,
}
if (isEdit.value) {
await jobsStore.updateJob(props.jobId!, payload)
message.success(t('jobs.jobUpdated'))
} else {
await jobsStore.createJob(payload)
message.success(t('jobs.jobCreated'))
}
emit('saved')
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('jobs.name')" required>
<NInput
v-model:value="formData.name"
:placeholder="t('jobs.namePlaceholder')"
maxlength="200"
show-count
/>
</NFormItem>
<NFormItem :label="t('jobs.schedule')" required>
<NInput
v-model:value="formData.schedule"
:placeholder="t('jobs.schedulePlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('jobs.quickPresets')">
<NSelect
v-model:value="presetValue"
:options="schedulePresets"
:placeholder="t('jobs.selectPreset')"
@update:value="v => formData.schedule = v"
/>
</NFormItem>
<NFormItem :label="t('jobs.prompt')" required>
<NInput
v-model:value="formData.prompt"
type="textarea"
:placeholder="t('jobs.promptPlaceholder')"
:rows="4"
maxlength="5000"
show-count
/>
</NFormItem>
<NFormItem :label="t('jobs.deliverTarget')">
<NSelect
v-model:value="formData.deliver"
:options="targetOptions"
/>
</NFormItem>
<NFormItem :label="t('jobs.repeatCount')">
<NInputNumber
v-model:value="formData.repeat_times"
:min="1"
:placeholder="t('jobs.repeatPlaceholder')"
clearable
style="width: 100%"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ isEdit ? t('common.update') : t('common.create') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
edit: [jobId: string]
}>()
const jobsStore = useJobsStore()
</script>
<template>
<div v-if="jobsStore.jobs.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<p>{{ t('jobs.noJobs') }}</p>
</div>
<div v-else class="jobs-grid">
<JobCard
v-for="job in jobsStore.jobs"
:key="job.id"
:job="job"
@edit="emit('edit', job.id)"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.jobs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px;
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const message = useMessage()
const dialog = useDialog()
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
const displayName = computed(() => props.provider.label)
async function handleDelete() {
dialog.warning({
title: t('models.deleteProvider'),
content: t('models.deleteConfirm', { name: displayName.value }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await modelsStore.removeProvider(props.provider.provider)
message.success(t('models.providerDeleted'))
} catch (e: any) {
message.error(e.message)
}
},
})
}
</script>
<template>
<div class="provider-card">
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('models.provider') }}</span>
<code class="info-value mono">{{ provider.provider }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('models.baseUrl') }}</span>
<code class="info-value mono">{{ provider.base_url }}</code>
</div>
</div>
<div class="card-actions">
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.provider-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba($accent-primary, 0.3);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.provider-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
&.builtin {
background: rgba($accent-primary, 0.12);
color: $accent-primary;
}
&.custom {
background: rgba($success, 0.12);
color: $success;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.card-actions {
display: flex;
gap: 8px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
</style>
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
import { useModelsStore } from '@/stores/hermes/models'
import { PROVIDER_PRESETS } from '@/shared/providers'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const emit = defineEmits<{
close: []
saved: []
}>()
const modelsStore = useModelsStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const fetchingModels = ref(false)
const providerType = ref<'preset' | 'custom'>('preset')
const selectedPreset = ref<string | null>(null)
const formData = ref({
name: '',
base_url: '',
api_key: '',
model: '',
})
const modelOptions = ref<Array<{ label: string; value: string }>>([])
const PRESET_PROVIDERS = PROVIDER_PRESETS as any[]
function autoGenerateName(url: string): string {
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
const host = clean.split('/')[0]
if (host.includes('localhost') || host.includes('127.0.0.1')) {
return t('models.local', { host })
}
return host.charAt(0).toUpperCase() + host.slice(1)
}
watch(selectedPreset, (val) => {
formData.value.model = ''
if (val) {
const preset = PRESET_PROVIDERS.find(p => p.value === val)
if (preset) {
formData.value.name = preset.label
formData.value.base_url = preset.base_url
modelOptions.value = preset.models.map((m: string) => ({ label: m, value: m }))
if (preset.models.length > 0) {
formData.value.model = preset.models[0]
}
}
}
})
watch(() => formData.value.base_url, (url) => {
if (providerType.value === 'custom' && url.trim()) {
formData.value.name = autoGenerateName(url.trim())
}
})
watch(providerType, () => {
modelOptions.value = []
formData.value = { name: '', base_url: '', api_key: '', model: '' }
selectedPreset.value = null
})
async function fetchModels() {
const { base_url } = formData.value
if (!base_url.trim()) {
message.warning(t('models.enterBaseUrl'))
return
}
fetchingModels.value = true
try {
const url = base_url.replace(/\/+$/, '') + '/models'
const headers: Record<string, string> = {}
if (formData.value.api_key.trim()) {
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
}
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as { data?: Array<{ id: string }> }
if (!Array.isArray(data.data)) throw new Error(t('models.unexpectedFormat'))
modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id }))
if (modelOptions.value.length > 0 && !formData.value.model) {
formData.value.model = modelOptions.value[0].value
}
message.success(t('models.foundModels', { count: modelOptions.value.length }))
} catch (e: any) {
message.error(t('models.fetchFailed') + ': ' + e.message)
} finally {
fetchingModels.value = false
}
}
async function handleSave() {
if (providerType.value === 'preset' && !selectedPreset.value) {
message.warning(t('models.selectProviderRequired'))
return
}
if (!formData.value.base_url.trim()) {
message.warning(t('models.baseUrlRequired'))
return
}
if (!formData.value.api_key.trim()) {
message.warning(t('models.apiKeyRequired'))
return
}
if (!formData.value.model) {
message.warning(t('models.modelRequired'))
return
}
loading.value = true
try {
const providerKey = providerType.value === 'preset'
? (PRESET_PROVIDERS.find(p => p.value === selectedPreset.value)?.value || null)
: null
await modelsStore.addProvider({
name: formData.value.name.trim(),
base_url: formData.value.base_url.trim(),
api_key: formData.value.api_key.trim(),
model: formData.value.model,
providerKey,
})
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.addProvider')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('models.providerType')">
<div style="display: flex; gap: 12px">
<NButton
:type="providerType === 'preset' ? 'primary' : 'default'"
size="small"
@click="providerType = 'preset'"
>
{{ t('models.preset') }}
</NButton>
<NButton
:type="providerType === 'custom' ? 'primary' : 'default'"
size="small"
@click="providerType = 'custom'"
>
{{ t('models.custom') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
<NSelect
v-model:value="selectedPreset"
:options="PRESET_PROVIDERS"
:placeholder="t('models.chooseProvider')"
filterable
/>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
<NInput
v-model:value="formData.name"
:placeholder="t('models.autoGeneratedName')"
disabled
/>
</NFormItem>
<NFormItem :label="t('models.baseUrl')" required>
<NInput
v-model:value="formData.base_url"
:placeholder="t('models.baseUrlPlaceholder')"
:disabled="providerType === 'preset'"
/>
</NFormItem>
<NFormItem :label="t('models.apiKey')" required>
<NInput
v-model:value="formData.api_key"
type="password"
show-password-on="click"
:placeholder="t('models.apiKeyPlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('models.defaultModel')" required>
<div style="display: flex; gap: 8px; width: 100%">
<NSelect
v-model:value="formData.model"
:options="modelOptions"
filterable
:placeholder="t('models.selectModel')"
style="flex: 1"
/>
<NButton
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
:loading="fetchingModels"
@click="fetchModels"
>
{{ t('common.fetch') }}
</NButton>
</div>
</NFormItem>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ t('common.add') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
</script>
<template>
<div v-if="modelsStore.providers.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>{{ t('models.noProviders') }}</p>
</div>
<div v-else class="providers-grid">
<ProviderCard
v-for="g in modelsStore.providers"
:key="g.provider"
:provider="g"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('agent', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.agent.maxTurns')" :hint="t('settings.agent.maxTurnsHint')">
<NInputNumber
:value="settingsStore.agent.max_turns"
:min="1" :max="200" :step="5"
size="small" class="input-sm"
@update:value="v => v != null && save({ max_turns: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.gatewayTimeout')" :hint="t('settings.agent.gatewayTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.gateway_timeout"
:min="60" :max="7200" :step="60"
size="small" class="input-sm"
@update:value="v => v != null && save({ gateway_timeout: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.restartDrainTimeout')" :hint="t('settings.agent.restartDrainTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.restart_drain_timeout"
:min="10" :max="300" :step="10"
size="small" class="input-sm"
@update:value="v => v != null && save({ restart_drain_timeout: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.toolEnforcement')" :hint="t('settings.agent.toolEnforcementHint')">
<NSelect
:value="settingsStore.agent.tool_use_enforcement || 'auto'"
:options="[
{ label: t('settings.agent.auto'), value: 'auto' },
{ label: t('settings.agent.always'), value: 'always' },
{ label: t('settings.agent.never'), value: 'never' },
]"
size="small" class="input-sm"
@update:value="v => save({ tool_use_enforcement: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('display', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.compact')" :hint="t('settings.display.compactHint')">
<NSwitch :value="settingsStore.display.compact" @update:value="v => save({ compact: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showReasoning')" :hint="t('settings.display.showReasoningHint')">
<NSwitch :value="settingsStore.display.show_reasoning" @update:value="v => save({ show_reasoning: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showCost')" :hint="t('settings.display.showCostHint')">
<NSwitch :value="settingsStore.display.show_cost" @update:value="v => save({ show_cost: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.inlineDiffs')" :hint="t('settings.display.inlineDiffsHint')">
<NSwitch :value="settingsStore.display.inline_diffs" @update:value="v => save({ inline_diffs: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.bellOnComplete')" :hint="t('settings.display.bellOnCompleteHint')">
<NSwitch :value="settingsStore.display.bell_on_complete" @update:value="v => save({ bell_on_complete: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
<NSwitch :value="settingsStore.display.busy_input_mode === 'interrupt'" @update:value="v => save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { NSwitch, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('memory', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.memory.enabled')" :hint="t('settings.memory.enabledHint')">
<NSwitch :value="settingsStore.memory.memory_enabled" @update:value="v => save({ memory_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.userProfile')" :hint="t('settings.memory.userProfileHint')">
<NSwitch :value="settingsStore.memory.user_profile_enabled" @update:value="v => save({ user_profile_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.charLimit')" :hint="t('settings.memory.charLimitHint')">
<NInputNumber
:value="settingsStore.memory.memory_char_limit"
:min="100" :max="10000" :step="100"
size="small" class="input-sm"
@update:value="v => v != null && save({ memory_char_limit: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.memory.userCharLimit')" :hint="t('settings.memory.userCharLimitHint')">
<NInputNumber
:value="settingsStore.memory.user_char_limit"
:min="100" :max="10000" :step="100"
size="small" class="input-sm"
@update:value="v => v != null && save({ user_char_limit: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NTag } from 'naive-ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
name: string
icon: string
config: Record<string, any>
credentials?: Record<string, any>
}>()
const expanded = ref(true)
const { t } = useI18n()
const configured = computed(() => {
const creds = props.credentials
if (!creds) return false
const keys = ['token', 'api_key', 'app_id', 'client_id', 'secret', 'app_secret', 'client_secret', 'access_token', 'bot_id', 'account_id', 'enabled']
// Check top-level and nested extra.*
const targets = [creds, creds.extra].filter(Boolean)
return targets.some(obj =>
keys.some(key => {
const val = (obj as Record<string, any>)[key]
return val !== undefined && val !== null && val !== '' && val !== false
})
)
})
</script>
<template>
<div class="platform-card" :class="{ configured }">
<div class="platform-card-header" @click="expanded = !expanded">
<div class="platform-info">
<span class="platform-icon" v-html="icon" />
<span class="platform-name">{{ name }}</span>
<NTag :type="configured ? 'success' : 'default'" size="small" round>
{{ configured ? t('common.configured') : t('common.notConfigured') }}
</NTag>
</div>
<span class="expand-icon" :class="{ expanded }">&#9662;</span>
</div>
<div v-if="expanded" class="platform-card-body">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.platform-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
margin-bottom: 12px;
overflow: hidden;
&.configured {
border-color: rgba($success, 0.2);
}
}
.platform-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba($text-primary, 0.03);
}
}
.platform-info {
display: flex;
align-items: center;
gap: 10px;
}
.platform-icon {
width: 18px;
height: 18px;
color: $text-secondary;
flex-shrink: 0;
}
.platform-name {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
.expand-icon {
font-size: 12px;
color: $text-muted;
transition: transform 0.2s;
&.expanded {
transform: rotate(0deg);
}
&:not(.expanded) {
transform: rotate(-90deg);
}
}
.platform-card-body {
padding: 0 16px 12px;
border-top: 1px solid $border-light;
}
</style>
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NSwitch, NInput, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/hermes/config'
import PlatformCard from './PlatformCard.vue'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function saveChannel(platform: string, values: Record<string, any>) {
try {
await settingsStore.saveSection(platform, values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
// Save credentials to .env (matching hermes gateway setup behavior)
const savingCreds = ref(false)
async function saveCredentials(platform: string, values: Record<string, any>) {
savingCreds.value = true
try {
await saveCredsApi(platform, values)
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
} finally {
savingCreds.value = false
}
}
function getCreds(key: string) {
return (settingsStore.platforms[key] || {}) as Record<string, any>
}
// Weixin QR code login state
const wxQrUrl = ref('')
const wxQrId = ref('')
const wxQrStatus = ref<'idle' | 'loading' | 'waiting' | 'scaned' | 'confirmed' | 'error' | 'expired'>('idle')
let wxPollTimer: ReturnType<typeof setTimeout> | null = null
async function startWeixinQrLogin() {
wxQrStatus.value = 'loading'
wxQrUrl.value = ''
wxQrId.value = ''
stopWeixinPoll()
try {
const data = await fetchWeixinQrCode()
wxQrId.value = data.qrcode
wxQrUrl.value = data.qrcode_url
window.open(data.qrcode_url, '_blank')
wxQrStatus.value = 'waiting'
pollWeixinStatus()
} catch (err: any) {
wxQrStatus.value = 'error'
message.error(err.message || t('platform.qrFetching'))
}
}
function pollWeixinStatus() {
if (!wxQrId.value) return
wxPollTimer = setTimeout(async () => {
try {
const data = await pollWeixinQrStatus(wxQrId.value)
if (data.status === 'wait') {
pollWeixinStatus()
} else if (data.status === 'scaned') {
wxQrStatus.value = 'scaned'
pollWeixinStatus()
} else if (data.status === 'expired') {
wxQrStatus.value = 'expired'
} else if (data.status === 'confirmed') {
wxQrStatus.value = 'confirmed'
// Save credentials to .env
await saveWeixinCredentials({
account_id: data.account_id!,
token: data.token!,
base_url: data.base_url,
})
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
}
} catch {
// Retry poll on network error
pollWeixinStatus()
}
}, 3000)
}
function stopWeixinPoll() {
if (wxPollTimer) {
clearTimeout(wxPollTimer)
wxPollTimer = null
}
}
onUnmounted(() => {
stopWeixinPoll()
})
const platforms = [
{
key: 'telegram',
name: 'Telegram',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
},
{
key: 'discord',
name: 'Discord',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg>',
},
{
key: 'slack',
name: 'Slack',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 0a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V5.042zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1 2.523-2.52h6.313A2.528 2.528 0 0 1 24 18.956a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
},
{
key: 'whatsapp',
name: 'WhatsApp',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>',
},
{
key: 'matrix',
name: 'Matrix',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.48.324.448.217.786.619 1.017 1.205.24-.376.558-.702.956-.98.398-.277.872-.414 1.424-.414.41 0 .784.065 1.122.194.34.13.629.325.87.588.241.263.428.59.56.984.132.393.198.85.198 1.368v5.89h-2.49v-4.893c0-.268-.016-.525-.048-.77a1.627 1.627 0 00-.2-.63 1.028 1.028 0 00-.392-.426 1.294 1.294 0 00-.616-.134c-.277 0-.508.05-.693.15a1.043 1.043 0 00-.43.41 1.768 1.768 0 00-.214.616 4.15 4.15 0 00-.06.74v4.937H9.29v-4.937c0-.25-.01-.498-.032-.742a1.84 1.84 0 00-.166-.638.998.998 0 00-.363-.448 1.206 1.206 0 00-.624-.154c-.26 0-.483.048-.67.144a1.055 1.055 0 00-.436.402 1.744 1.744 0 00-.227.616 4.108 4.108 0 00-.063.74v4.937H5.21V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>',
},
{
key: 'feishu',
name: 'Feishu',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
},
// {
// key: 'dingtalk',
// name: 'DingTalk',
// icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.897 6.376a.5.5 0 0 1-.957-.016l-1.238-3.81a.5.5 0 0 0-.477-.354l-3.81-.324a.5.5 0 0 1-.074-.993l6.376-1.897a.5.5 0 0 1 .577.718z"/></svg>',
// },
{
key: 'weixin',
name: 'Weixin',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
{
key: 'wecom',
name: 'WeCom',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
]
</script>
<template>
<section class="settings-section">
<PlatformCard
v-for="p in platforms"
:key="p.key"
:name="p.name"
:icon="p.icon"
:config="settingsStore[p.key as keyof typeof settingsStore] as Record<string, any>"
:credentials="getCreds(p.key)"
>
<!-- Telegram -->
<template v-if="p.key === 'telegram'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('telegram').token || ''" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => saveCredentials('telegram', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.telegram.require_mention" @update:value="v => saveChannel('telegram', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.telegram.reactions" @update:value="v => saveChannel('telegram', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.telegram.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('telegram', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(settingsStore.telegram.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Discord -->
<template v-if="p.key === 'discord'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('discord').token || ''" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => saveCredentials('discord', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.discord.require_mention" @update:value="v => saveChannel('discord', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
<NSwitch :value="settingsStore.discord.auto_thread" @update:value="v => saveChannel('discord', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.discord.reactions" @update:value="v => saveChannel('discord', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="settingsStore.discord.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { free_response_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
<NInput :value="settingsStore.discord.allowed_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { allowed_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
<NInput :value="settingsStore.discord.ignored_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { ignored_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
<NInput :value="settingsStore.discord.no_thread_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { no_thread_channels: v })" />
</SettingRow>
</template>
<!-- Slack -->
<template v-if="p.key === 'slack'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('slack').token || ''" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => saveCredentials('slack', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.slack.require_mention" @update:value="v => saveChannel('slack', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
<NSwitch :value="settingsStore.slack.allow_bots" @update:value="v => saveChannel('slack', { allow_bots: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="settingsStore.slack.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('slack', { free_response_channels: v })" />
</SettingRow>
</template>
<!-- WhatsApp -->
<template v-if="p.key === 'whatsapp'">
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
<NSwitch :value="getCreds('whatsapp').enabled" @update:value="v => saveCredentials('whatsapp', { enabled: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.whatsapp.require_mention" @update:value="v => saveChannel('whatsapp', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.whatsapp.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('whatsapp', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Matrix -->
<template v-if="p.key === 'matrix'">
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
<NInput :value="getCreds('matrix').token || ''" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => saveCredentials('matrix', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
<NInput :value="getCreds('matrix').extra?.homeserver || ''" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => saveCredentials('matrix', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
<NSwitch :value="settingsStore.matrix.require_mention" @update:value="v => saveChannel('matrix', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
<NSwitch :value="settingsStore.matrix.auto_thread" @update:value="v => saveChannel('matrix', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
<NSwitch :value="settingsStore.matrix.dm_mention_threads" @update:value="v => saveChannel('matrix', { dm_mention_threads: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
<NInput :value="settingsStore.matrix.free_response_rooms || ''" size="small" placeholder="room_id1,room_id2" @update:value="v => saveChannel('matrix', { free_response_rooms: v })" />
</SettingRow>
</template>
<!-- Feishu -->
<template v-if="p.key === 'feishu'">
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
<NInput :value="getCreds('feishu').extra?.app_id || ''" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
<NInput :value="getCreds('feishu').extra?.app_secret || ''" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.feishu.require_mention" @update:value="v => saveChannel('feishu', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.feishu.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('feishu', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- DingTalk -->
<template v-if="p.key === 'dingtalk'">
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
<NInput :value="getCreds('dingtalk').extra?.client_id || ''" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.dingtalk.require_mention" @update:value="v => saveChannel('dingtalk', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="settingsStore.dingtalk.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('dingtalk', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- Weixin -->
<template v-if="p.key === 'weixin'">
<div class="weixin-qr-section">
<NButton
v-if="wxQrStatus === 'idle' || wxQrStatus === 'error' || wxQrStatus === 'expired' || wxQrStatus === 'confirmed'"
type="primary"
size="small"
@click="startWeixinQrLogin"
>
{{ wxQrStatus === 'confirmed' ? t('platform.qrRelogin') : t('platform.qrLogin') }}
</NButton>
<div v-if="wxQrStatus === 'loading'" class="weixin-qr-loading">
<NSpin size="small" />
<span>{{ t('platform.qrFetching') }}</span>
</div>
<div v-if="wxQrStatus === 'waiting' || wxQrStatus === 'scaned'" class="weixin-qr-hint">
{{ wxQrStatus === 'scaned' ? t('platform.qrScanedHint') : t('platform.qrScanHint') }}
</div>
</div>
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
<NInput :value="getCreds('weixin').token || ''" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => saveCredentials('weixin', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
<NInput :value="getCreds('weixin').extra?.account_id || ''" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => saveCredentials('weixin', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
</SettingRow>
</template>
<!-- WeCom -->
<template v-if="p.key === 'wecom'">
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
<NInput :value="getCreds('wecom').extra?.bot_id || ''" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
<NInput :value="getCreds('wecom').extra?.secret || ''" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, secret: v } })" />
</SettingRow>
</template>
</PlatformCard>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
.weixin-qr-section {
margin-top: 12px;
margin-bottom: 12px;
}
.weixin-qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: $text-muted;
font-size: 13px;
}
.weixin-qr-hint {
font-size: 13px;
color: $text-secondary;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('privacy', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.privacy.redactPii')" :hint="t('settings.privacy.redactPiiHint')">
<NSwitch :value="settingsStore.privacy.redact_pii" @update:value="v => save({ redact_pii: v })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('session_reset', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.session.mode')" :hint="t('settings.session.modeHint')">
<NSelect
:value="settingsStore.sessionReset.mode || 'both'"
:options="[
{ label: t('settings.session.modeBoth'), value: 'both' },
{ label: t('settings.session.modeIdle'), value: 'idle' },
{ label: t('settings.session.modeHourly'), value: 'hourly' },
]"
size="small" class="input-md"
@update:value="v => save({ mode: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.session.idleMinutes')" :hint="t('settings.session.idleMinutesHint')">
<NInputNumber
:value="settingsStore.sessionReset.idle_minutes"
:min="10" :max="10080" :step="30"
size="small" class="input-sm"
@update:value="v => v != null && save({ idle_minutes: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.session.atHour')" :hint="t('settings.session.atHourHint')">
<NInputNumber
:value="settingsStore.sessionReset.at_hour"
:min="0" :max="23" :step="1"
size="small" class="input-sm"
@update:value="v => v != null && save({ at_hour: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
label: string
hint?: string
}>()
</script>
<template>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label">{{ label }}</label>
<p v-if="hint" class="setting-hint">{{ hint }}</p>
</div>
<div class="setting-control">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.setting-info {
flex: 1;
margin-right: 16px;
}
.setting-label {
font-size: 13px;
color: $text-primary;
display: block;
}
.setting-hint {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
.setting-control {
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.setting-info {
margin-right: 0;
}
.setting-control {
width: 100%;
}
}
</style>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
category: string
skill: string
}>()
const content = ref('')
const files = ref<SkillFileEntry[]>([])
const loading = ref(false)
const fileContent = ref('')
const viewingFile = ref<string | null>(null)
const fileLoading = ref(false)
async function loadSkill() {
loading.value = true
viewingFile.value = null
fileContent.value = ''
files.value = []
content.value = ''
try {
const skillPath = `${props.category}/${props.skill}/SKILL.md`
const [skillContent, skillFiles] = await Promise.all([
fetchSkillContent(skillPath),
fetchSkillFiles(props.category, props.skill),
])
content.value = skillContent
files.value = skillFiles.filter(f => !f.isDir && f.path !== 'SKILL.md')
} catch (err: any) {
content.value = t('skills.loadFailed') + `: ${err.message}`
} finally {
loading.value = false
}
}
async function viewFile(filePath: string) {
fileLoading.value = true
viewingFile.value = filePath
try {
// filePath might be absolute or relative; normalize to relative under category/skill/
const base = `${props.category}/${props.skill}/`
let relPath = filePath
if (filePath.startsWith('/')) {
// Strip absolute prefix to get relative path
const segments = filePath.split('/.hermes/skills/')[1]
if (segments) {
const afterSkillDir = segments.split('/').slice(2).join('/')
relPath = afterSkillDir
}
}
fileContent.value = await fetchSkillContent(`${base}${relPath}`)
} catch (err: any) {
fileContent.value = t('skills.fileLoadFailed') + `: ${err.message}`
} finally {
fileLoading.value = false
}
}
function backToSkill() {
viewingFile.value = null
fileContent.value = ''
}
watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
</script>
<template>
<div class="skill-detail">
<!-- Skill title -->
<div class="detail-title">
<span class="detail-category">{{ category }}</span>
<span class="detail-separator">/</span>
<span class="detail-name">{{ skill }}</span>
</div>
<div v-if="loading && !content" class="detail-loading">{{ t('common.loading') }}</div>
<template v-else>
<!-- Breadcrumb for file view -->
<div v-if="viewingFile" class="detail-breadcrumb">
<button class="back-btn" @click="backToSkill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6" />
</svg>
{{ t('skills.backTo') }} {{ skill }}
</button>
<span class="breadcrumb-path">{{ viewingFile }}</span>
</div>
<!-- Skill content -->
<div class="detail-content">
<MarkdownRenderer v-if="viewingFile" :content="fileContent" />
<MarkdownRenderer v-else :content="content" />
</div>
<!-- Attached files -->
<div v-if="!viewingFile && files.length > 0" class="detail-files">
<div class="files-header">{{ t('skills.attachedFiles') }}</div>
<div class="files-list">
<button
v-for="f in files"
:key="f.path"
class="file-item"
@click="viewFile(f.path)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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" />
</svg>
<span>{{ f.path }}</span>
</button>
</div>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.skill-detail {
height: 100%;
display: flex;
flex-direction: column;
}
.detail-title {
flex-shrink: 0;
padding-bottom: 12px;
border-bottom: 1px solid $border-color;
margin-bottom: 12px;
font-size: 15px;
}
.detail-category {
color: $text-muted;
font-size: 13px;
}
.detail-separator {
color: $text-muted;
margin: 0 6px;
}
.detail-name {
color: $text-primary;
font-weight: 600;
}
.detail-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: $text-muted;
}
.detail-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 12px;
border-bottom: 1px solid $border-color;
margin-bottom: 12px;
flex-shrink: 0;
}
.back-btn {
display: flex;
align-items: center;
gap: 4px;
border: none;
background: none;
color: $accent-primary;
font-size: 13px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
&:hover {
background: rgba($accent-primary, 0.06);
}
}
.breadcrumb-path {
font-size: 13px;
color: $text-muted;
}
.detail-content {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: 12px;
:deep(hr) {
border: none;
margin: 12px 0;
}
}
.detail-files {
flex-shrink: 0;
border-top: 1px solid $border-color;
padding-top: 12px;
margin-top: 12px;
}
.files-header {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 6px;
}
.files-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-secondary;
color: $text-secondary;
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: $accent-primary;
color: $accent-primary;
}
}
</style>
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NSwitch, useMessage } from 'naive-ui'
import type { SkillCategory } from '@/api/hermes/skills'
import { toggleSkill } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const message = useMessage()
const props = defineProps<{
categories: SkillCategory[]
selectedSkill: string | null
searchQuery: string
}>()
const emit = defineEmits<{
select: [category: string, skill: string]
}>()
const collapsedCategories = ref<Set<string>>(new Set())
const togglingSkills = ref<Set<string>>(new Set())
const filteredCategories = computed(() => {
if (!props.searchQuery) return props.categories
const q = props.searchQuery.toLowerCase()
return props.categories
.map(cat => ({
...cat,
skills: cat.skills.filter(
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
),
}))
.filter(cat => cat.skills.length > 0 || cat.name.toLowerCase().includes(q))
})
function toggleCategory(name: string) {
if (collapsedCategories.value.has(name)) {
collapsedCategories.value.delete(name)
} else {
collapsedCategories.value.add(name)
}
}
function handleSelect(category: string, skill: string) {
emit('select', category, skill)
}
async function handleToggle(category: string, skillName: string, newEnabled: boolean) {
if (togglingSkills.value.has(skillName)) return
togglingSkills.value.add(skillName)
try {
await toggleSkill(skillName, newEnabled)
// Update local state
const cat = props.categories.find(c => c.name === category)
const skill = cat?.skills.find(s => s.name === skillName)
if (skill) skill.enabled = newEnabled
} catch (err: any) {
message.error(t('skills.toggleFailed') + `: ${err.message}`)
} finally {
togglingSkills.value.delete(skillName)
}
}
</script>
<template>
<div class="skill-list">
<div v-if="filteredCategories.length === 0" class="skill-empty">
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
</div>
<div
v-for="cat in filteredCategories"
:key="cat.name"
class="skill-category"
>
<button class="category-header" @click="toggleCategory(cat.name)">
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
class="category-arrow"
:class="{ collapsed: collapsedCategories.has(cat.name) }"
>
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="category-name">{{ cat.name }}</span>
<span class="category-count">{{ cat.skills.length }}</span>
</button>
<div v-if="!collapsedCategories.has(cat.name)" class="category-skills">
<button
v-for="skill in cat.skills"
:key="skill.name"
class="skill-item"
:class="{
active: selectedSkill === `${cat.name}/${skill.name}`,
}"
@click="handleSelect(cat.name, skill.name)"
>
<div class="skill-info">
<span class="skill-name">{{ skill.name }}</span>
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
</div>
<NSwitch
size="small"
:value="skill.enabled !== false"
:loading="togglingSkills.has(skill.name)"
@update:value="handleToggle(cat.name, skill.name, $event)"
@click.stop
/>
</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.skill-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.skill-empty {
padding: 24px 16px;
font-size: 13px;
color: $text-muted;
text-align: center;
}
.skill-category {
margin-bottom: 4px;
}
.category-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
color: $text-secondary;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
border-radius: $radius-sm;
&:hover {
background: rgba($accent-primary, 0.04);
}
}
.category-arrow {
flex-shrink: 0;
transition: transform $transition-fast;
&.collapsed {
transform: rotate(-90deg);
}
}
.category-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-count {
font-size: 11px;
color: $text-muted;
background: rgba($accent-primary, 0.06);
padding: 1px 6px;
border-radius: 8px;
}
.category-skills {
padding: 2px 0 4px;
}
.skill-item {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 6px 10px 6px 28px;
border: none;
background: none;
color: $text-secondary;
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: $radius-sm;
transition: all $transition-fast;
gap: 8px;
&:hover {
background: rgba($accent-primary, 0.06);
color: $text-primary;
}
&.active {
background: rgba($accent-primary, 0.1);
color: $text-primary;
font-weight: 500;
}
}
.skill-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.skill-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skill-desc {
font-size: 11px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
function formatCost(n: number): string {
if (n === 0) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
const maxTokens = computed(() =>
Math.max(...usageStore.dailyUsage.map(d => d.tokens), 1),
)
</script>
<script lang="ts">
import { computed } from 'vue'
</script>
<template>
<div class="daily-trend">
<h3 class="section-title">{{ t('usage.dailyTrend') }}</h3>
<div class="bar-chart">
<div
v-for="d in usageStore.dailyUsage"
:key="d.date"
class="bar-col"
>
<div class="bar-track">
<div
class="bar-fill"
:style="{ height: (d.tokens / maxTokens * 100) + '%' }"
/>
</div>
<div class="bar-tooltip">
<div class="tooltip-date">{{ d.date }}</div>
<div class="tooltip-row">{{ t('usage.tokens') }}: {{ formatTokens(d.tokens) }}</div>
<div class="tooltip-row">{{ t('usage.cache') }}: {{ formatTokens(d.cache) }}</div>
<div class="tooltip-row">{{ t('usage.sessions') }}: {{ d.sessions }}</div>
<div class="tooltip-row">{{ t('usage.cost') }}: {{ formatCost(d.cost) }}</div>
</div>
</div>
</div>
<div class="bar-dates">
<span>{{ usageStore.dailyUsage[0]?.date.slice(5) }}</span>
<span>{{ usageStore.dailyUsage[usageStore.dailyUsage.length - 1]?.date.slice(5) }}</span>
</div>
<div class="trend-table">
<table>
<thead>
<tr>
<th>{{ t('usage.date') }}</th>
<th>{{ t('usage.tokens') }}</th>
<th>{{ t('usage.cache') }}</th>
<th>{{ t('usage.sessions') }}</th>
<th>{{ t('usage.cost') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="d in [...usageStore.dailyUsage].reverse().slice(0, 30)" :key="d.date">
<td>{{ d.date }}</td>
<td>{{ formatTokens(d.tokens) }}</td>
<td>{{ formatTokens(d.cache) }}</td>
<td>{{ d.sessions }}</td>
<td>{{ formatCost(d.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.daily-trend {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
margin: 0 0 12px;
}
.bar-chart {
display: flex;
gap: 2px;
margin-bottom: 16px;
}
.bar-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.bar-track {
width: 100%;
height: 140px;
background: $bg-secondary;
border-radius: 2px 2px 0 0;
display: flex;
align-items: flex-end;
}
.bar-fill {
width: 100%;
background: $text-primary;
border-radius: 2px 2px 0 0;
min-height: 0;
transition: height 0.3s ease;
}
.bar-col {
position: relative;
}
.bar-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: $text-primary;
color: #fff;
padding: 6px 10px;
border-radius: $radius-sm;
font-size: 11px;
white-space: nowrap;
z-index: 10;
pointer-events: none;
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: $text-primary;
}
}
.bar-col:hover .bar-tooltip {
display: block;
}
.tooltip-date {
font-weight: 600;
margin-bottom: 2px;
}
.tooltip-row {
font-size: 10px;
opacity: 0.85;
line-height: 1.5;
}
.bar-label {
display: none;
}
.bar-dates {
display: flex;
justify-content: space-between;
font-size: 10px;
color: $text-muted;
margin-top: 4px;
margin-bottom: 16px;
}
.trend-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
thead {
position: sticky;
top: 0;
}
th {
text-align: left;
padding: 8px 10px;
font-weight: 600;
color: $text-muted;
border-bottom: 1px solid $border-color;
background: $bg-card;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
td {
padding: 6px 10px;
color: $text-secondary;
border-bottom: 1px solid $border-light;
font-family: $font-code;
font-size: 11px;
}
tr:last-child td {
border-bottom: none;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
</script>
<template>
<div class="model-breakdown">
<h3 class="section-title">{{ t('usage.modelBreakdown') }}</h3>
<div class="model-list">
<div v-for="m in usageStore.modelUsage" :key="m.model" class="model-row">
<span class="model-name">{{ m.model }}</span>
<div class="model-bar-wrap">
<div
class="model-bar"
:style="{ width: (m.totalTokens / usageStore.modelUsage[0].totalTokens * 100) + '%' }"
/>
</div>
<span class="model-tokens">{{ formatTokens(m.totalTokens) }}</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.model-breakdown {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
margin-bottom: 20px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
margin: 0 0 12px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-row {
display: flex;
align-items: center;
gap: 10px;
}
.model-name {
font-size: 12px;
font-family: $font-code;
color: $text-secondary;
width: 140px;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-bar-wrap {
flex: 1;
height: 16px;
background: $bg-secondary;
border-radius: 3px;
overflow: hidden;
}
.model-bar {
height: 100%;
background: $text-primary;
border-radius: 3px;
min-width: 2px;
transition: width 0.3s ease;
}
.model-tokens {
font-size: 12px;
color: $text-muted;
width: 60px;
text-align: right;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
function formatCost(n: number): string {
if (n === 0) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
</script>
<template>
<div class="stat-cards">
<div class="stat-card">
<div class="stat-label">{{ t('usage.totalTokens') }}</div>
<div class="stat-value">{{ formatTokens(usageStore.totalTokens) }}</div>
<div class="stat-sub">
{{ formatTokens(usageStore.totalInputTokens) }} {{ t('usage.inputTokens') }} /
{{ formatTokens(usageStore.totalOutputTokens) }} {{ t('usage.outputTokens') }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.totalSessions') }}</div>
<div class="stat-value">{{ usageStore.totalSessions }}</div>
<div class="stat-sub">{{ t('usage.avgPerDay', { n: usageStore.avgSessionsPerDay.toFixed(1) }) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.estimatedCost') }}</div>
<div class="stat-value">{{ formatCost(usageStore.estimatedCost) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.cacheHitRate') }}</div>
<div class="stat-value">{{ usageStore.cacheHitRate !== null ? usageStore.cacheHitRate.toFixed(1) + '%' : '--' }}</div>
<div class="stat-sub" v-if="usageStore.cacheHitRate !== null">
{{ formatTokens(usageStore.totalCacheTokens) }} {{ t('usage.tokens') }}
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
}
.stat-label {
font-size: 12px;
color: $text-muted;
margin-bottom: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
color: $text-primary;
line-height: 1.2;
}
.stat-sub {
font-size: 11px;
color: $text-muted;
margin-top: 4px;
}
@media (max-width: 768px) {
.stat-cards {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stat-cards {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useAppStore } from "@/stores/hermes/app";
import ModelSelector from "./ModelSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import danceVideo from "@/assets/dance.mp4";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const canvasRef = ref<HTMLCanvasElement>();
const selectedKey = computed(() => route.name as string);
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const video = document.createElement("video");
video.src = danceVideo;
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.addEventListener("loadeddata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
});
function draw() {
if (video.readyState >= 2 && ctx && canvas) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
if (video.currentTime >= video.duration - 0.05) {
video.currentTime = 0;
}
requestAnimationFrame(draw);
}
video.addEventListener("canplay", () => {
draw();
});
video.play();
const onVisible = () => {
if (document.visibilityState === "visible" && video.paused) {
video.play();
}
};
document.addEventListener("visibilitychange", onVisible);
onUnmounted(() => {
document.removeEventListener("visibilitychange", onVisible);
});
});
function handleNav(key: string) {
router.push({ name: key });
}
</script>
<template>
<aside class="sidebar" :class="{ open: appStore.sidebarOpen }">
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<canvas ref="canvasRef" class="logo-dance" />
</div>
<nav class="sidebar-nav">
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.chat' }"
@click="handleNav('hermes.chat')"
>
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<span>{{ t("sidebar.chat") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.jobs' }"
@click="handleNav('hermes.jobs')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.models' }"
@click="handleNav('hermes.models')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>{{ t("sidebar.models") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.channels' }"
@click="handleNav('hermes.channels')"
>
<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="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t("sidebar.channels") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.skills' }"
@click="handleNav('hermes.skills')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t("sidebar.skills") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.memory' }"
@click="handleNav('hermes.memory')"
>
<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="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>{{ t("sidebar.memory") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.logs' }"
@click="handleNav('hermes.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>{{ t("sidebar.logs") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.usage' }"
@click="handleNav('hermes.usage')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.terminal' }"
@click="handleNav('hermes.terminal')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span>{{ t("sidebar.terminal") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.settings' }"
@click="handleNav('hermes.settings')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span>{{ t("sidebar.settings") }}</span>
</button>
</nav>
<ModelSelector />
<div class="sidebar-footer">
<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
? t("sidebar.connected")
: t("sidebar.disconnected")
}}</span>
</div>
<LanguageSwitch />
</div>
<div class="version-info">
Hermes {{ appStore.serverVersion || "v0.1.0" }}
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.sidebar {
width: $sidebar-width;
height: calc(100 * var(--vh));
background-color: $bg-sidebar;
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
padding: 0 12px 20px;
flex-shrink: 0;
transition: width $transition-normal;
}
.logo-img {
width: 28px;
height: 28px;
border-radius: 0;
flex-shrink: 0;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 12px;
margin: 0 -12px;
color: $text-primary;
cursor: pointer;
background-color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
.logo-text {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.logo-dance {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
height: 100px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
width: auto;
}
}
.sidebar-nav {
flex: 1;
display: flex;
padding-top: 12px;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: none;
background: none;
color: $text-secondary;
font-size: 14px;
border-radius: $radius-sm;
cursor: pointer;
transition: all $transition-fast;
width: 100%;
text-align: left;
&:hover {
background-color: rgba($accent-primary, 0.06);
color: $text-primary;
}
&.active {
background-color: rgba($accent-primary, 0.12);
color: $accent-primary;
}
}
.sidebar-footer {
padding-top: 16px;
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;
font-size: 12px;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&.connected .status-dot {
background-color: $success;
box-shadow: 0 0 6px rgba($success, 0.5);
}
&.disconnected .status-dot {
background-color: $error;
}
.status-text {
color: $text-secondary;
}
}
.version-info {
padding: 2px 12px 8px;
font-size: 11px;
color: $text-muted;
}
@media (max-width: $breakpoint-mobile) {
.logo-dance {
display: none;
}
.status-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform $transition-normal;
&.open {
transform: translateX(0);
}
// Override global utility — sidebar is always 240px wide
.input-sm {
width: 90px;
}
}
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NSelect } from 'naive-ui'
const { locale, availableLocales, t } = useI18n()
const options = computed(() =>
availableLocales.map(loc => ({
label: loc === 'zh' ? t('language.zh') : t('language.en'),
value: loc,
})),
)
function handleChange(val: string) {
locale.value = val
localStorage.setItem('hermes_locale', val)
}
</script>
<template>
<NSelect
:value="locale"
:options="options"
size="tiny"
:consistent-menu-width="false"
class="input-sm"
@update:value="handleChange"
/>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NSelect } from 'naive-ui'
import { useAppStore } from '@/stores/hermes/app'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const appStore = useAppStore()
const options = computed(() =>
appStore.modelGroups.map(g => ({
label: g.label,
type: 'group' as const,
key: g.provider,
children: g.models.map(m => ({
label: m,
value: m,
})),
})),
)
function handleChange(value: string | number | Array<string | number>) {
if (typeof value === 'string') {
appStore.switchModel(value)
}
}
</script>
<template>
<div class="model-selector">
<div class="model-label">{{ t('models.title') }}</div>
<NSelect
:value="appStore.selectedModel"
:options="options"
size="small"
@update:value="handleChange"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.model-selector {
padding: 0 12px;
margin-bottom: 8px;
}
.model-label {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
</style>
@@ -0,0 +1,39 @@
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/stores/hermes/chat'
export function useKeyboard() {
const router = useRouter()
const chatStore = useChatStore()
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey
if (mod && e.key === 'n') {
e.preventDefault()
chatStore.newChat()
}
if (mod && e.key === 'j') {
e.preventDefault()
router.push({ name: 'hermes.jobs' })
}
if (e.key === 'Escape') {
// Close any open modals — naive-ui handles this internally
const modal = document.querySelector('.n-modal-mask')
if (modal) {
const closeBtn = modal.querySelector('.n-base-close') as HTMLElement
closeBtn?.click()
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+13
View File
@@ -0,0 +1,13 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en'
import zh from './locales/zh'
const saved = localStorage.getItem('hermes_locale')
const detected = navigator.language.slice(0, 2)
export const i18n = createI18n({
legacy: false,
locale: saved || (detected === 'zh' ? 'zh' : 'en'),
fallbackLocale: 'en',
messages: { en, zh },
})
+385
View File
@@ -0,0 +1,385 @@
export default {
// Login
login: {
title: 'Hermes Web UI',
description: 'Enter your access token to continue. Find it in the server startup logs.',
placeholder: 'Access token',
submit: 'Login',
tokenRequired: 'Please enter your access token',
invalidToken: 'Invalid token',
connectionFailed: 'Cannot connect to server',
},
// Common
common: {
loading: 'Loading...',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
save: 'Save',
saved: 'Saved',
update: 'Update',
create: 'Create',
saveFailed: 'Save failed',
ok: 'OK',
copied: 'Copied',
copy: 'Copy',
noData: 'No data',
fetch: 'Fetch',
add: 'Add',
enable: 'Enable',
disable: 'Disable',
configured: 'Configured',
notConfigured: 'Not configured',
},
// Sidebar
sidebar: {
chat: 'Chat',
jobs: 'Jobs',
models: 'Models',
skills: 'Skills',
memory: 'Memory',
logs: 'Logs',
usage: 'Usage',
channels: 'Channels',
terminal: 'Terminal',
settings: 'Settings',
connected: 'Connected',
disconnected: 'Disconnected',
},
// Chat
chat: {
emptyState: 'Start a conversation with Hermes Agent',
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
attachFiles: 'Attach files',
stop: 'Stop',
send: 'Send',
contextUsed: 'Context used:',
sessions: 'Sessions',
noSessions: 'No sessions',
newChat: 'New Chat',
deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted',
rename: 'Rename',
copySessionId: 'Copy Session ID',
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameSession: 'Rename Session',
enterNewTitle: 'Enter new title',
other: 'Other',
runFailed: 'Run failed',
error: 'Error',
tool: 'Tool',
arguments: 'Arguments',
result: 'Result',
truncated: '... (truncated)',
},
// Jobs
jobs: {
title: 'Scheduled Jobs',
createJob: 'Create Job',
editJob: 'Edit Job',
noJobs: 'No scheduled jobs yet. Create one to get started.',
name: 'Name',
namePlaceholder: 'Job name',
schedule: 'Schedule (Cron Expression)',
schedulePlaceholder: 'e.g. 0 9 * * *',
quickPresets: 'Quick Presets',
selectPreset: 'Select a preset...',
presetEveryMinute: 'Every minute',
presetEvery5Min: 'Every 5 minutes',
presetEveryHour: 'Every hour',
presetEveryDay: 'Every day at 00:00',
presetEveryDay9: 'Every day at 09:00',
presetEveryMonday: 'Every Monday at 09:00',
presetEveryMonth: 'Every month 1st at 09:00',
prompt: 'Prompt',
promptPlaceholder: 'The prompt to execute',
deliverTarget: 'Deliver Target',
origin: 'Origin',
local: 'Local',
repeatCount: 'Repeat Count (optional)',
repeatPlaceholder: 'Leave empty for infinite',
jobCreated: 'Job created',
jobUpdated: 'Job updated',
nameRequired: 'Name is required',
scheduleRequired: 'Schedule is required',
loadFailed: 'Failed to load job',
jobPaused: 'Job paused',
jobResumed: 'Job resumed',
jobTriggered: 'Job triggered',
jobDeleted: 'Job deleted',
status: {
running: 'Running',
paused: 'Paused',
disabled: 'Disabled',
scheduled: 'Scheduled',
},
info: {
schedule: 'Schedule',
lastRun: 'Last Run',
nextRun: 'Next Run',
deliver: 'Deliver',
repeat: 'Repeat',
},
action: {
pause: 'Pause',
pauseJob: 'Pause job',
resume: 'Resume',
resumeJob: 'Resume job',
runNow: 'Run Now',
triggerImmediately: 'Trigger immediately',
},
},
// Skills
skills: {
title: 'Skills',
searchPlaceholder: 'Search skills...',
noMatch: 'No skills match your search',
noSkills: 'No skills found',
backTo: 'Back to',
attachedFiles: 'Attached Files',
loadFailed: 'Failed to load skill',
fileLoadFailed: 'Failed to load file',
toggleFailed: 'Failed to toggle skill',
},
// Memory
memory: {
title: 'Memory',
refresh: 'Refresh',
loadFailed: 'Failed to load memory',
myNotes: 'My Notes',
noNotes: 'No notes yet.',
notesPlaceholder: 'Write your notes...',
userProfile: 'User Profile',
noProfile: 'No profile yet.',
profilePlaceholder: 'Write your profile...',
},
// Models
models: {
title: 'Models',
addProvider: 'Add Provider',
providerType: 'Provider Type',
preset: 'Preset',
custom: 'Custom',
selectProvider: 'Select Provider',
chooseProvider: 'Choose a provider...',
name: 'Name',
autoGeneratedName: 'Auto-generated from Base URL',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'e.g. https://api.example.com/v1',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-...',
defaultModel: 'Default Model',
selectModel: 'Select a model...',
providerAdded: 'Provider added',
providerDeleted: 'Provider deleted',
deleteProvider: 'Delete Provider',
deleteConfirm: 'Are you sure you want to delete "{name}"?',
noProviders: 'No providers found. Add a custom provider to get started.',
builtIn: 'Built-in',
customType: 'Custom',
provider: 'Provider',
local: 'Local ({host})',
selectProviderRequired: 'Please select a provider',
baseUrlRequired: 'Base URL is required',
apiKeyRequired: 'API Key is required',
modelRequired: 'Default Model is required',
enterBaseUrl: 'Please enter Base URL first',
unexpectedFormat: 'Unexpected response format',
foundModels: 'Found {count} models',
fetchFailed: 'Failed to fetch models',
},
// Logs
logs: {
title: 'Logs',
all: 'All',
searchPlaceholder: 'Search...',
refresh: 'Refresh',
noEntries: 'No log entries',
},
// Settings
settings: {
title: 'Settings',
saved: 'Saved',
saveFailed: 'Save failed',
tabs: {
display: 'Display',
agent: 'Agent',
memory: 'Memory',
session: 'Session',
privacy: 'Privacy',
apiServer: 'API Server',
},
display: {
streaming: 'Stream Responses',
streamingHint: 'Show AI replies in real-time',
compact: 'Compact Mode',
compactHint: 'Reduce message spacing',
showReasoning: 'Show Reasoning',
showReasoningHint: 'Show model thinking process',
showCost: 'Show Cost',
showCostHint: 'Show token usage in replies',
inlineDiffs: 'Inline Diffs',
inlineDiffsHint: 'Show code changes inline',
bellOnComplete: 'Completion Sound',
bellOnCompleteHint: 'Play sound when AI finishes',
busyInputMode: 'Busy Input Mode',
busyInputModeHint: 'Allow input while AI is processing',
},
agent: {
maxTurns: 'Max Turns',
maxTurnsHint: 'Maximum interaction rounds per conversation',
gatewayTimeout: 'Gateway Timeout',
gatewayTimeoutHint: 'Request timeout in seconds',
restartDrainTimeout: 'Restart Drain Timeout',
restartDrainTimeoutHint: 'Drain timeout before restart in seconds',
toolEnforcement: 'Tool Enforcement',
toolEnforcementHint: 'Control tool call execution mode',
auto: 'Auto',
always: 'Always',
never: 'Never',
},
memory: {
enabled: 'Enable Memory',
enabledHint: 'Allow AI to remember conversation context',
userProfile: 'User Profile',
userProfileHint: 'Allow AI to remember user preferences',
charLimit: 'Memory Char Limit',
charLimitHint: 'Max characters for MEMORY.md',
userCharLimit: 'User Profile Char Limit',
userCharLimitHint: 'Max characters for USER.md',
},
session: {
mode: 'Reset Mode',
modeHint: 'Trigger condition for session reset',
modeBoth: 'Idle + Scheduled',
modeIdle: 'Idle Only',
modeHourly: 'Scheduled Only',
idleMinutes: 'Idle Timeout',
idleMinutesHint: 'Wait time before auto-reset (minutes)',
atHour: 'Scheduled Reset Time',
atHourHint: 'Reset session at this hour daily',
},
privacy: {
redactPii: 'Redact PII',
redactPiiHint: 'Auto-detect and hide sensitive info (passwords, keys, etc.)',
},
apiServer: {
enable: 'Enable',
enableHint: 'Enable API server',
host: 'Host',
hostHint: 'Listen address',
port: 'Port',
portHint: 'Listen port',
key: 'Key',
keyHint: 'API access key',
cors: 'CORS Origins',
corsHint: 'Allowed cross-origin sources',
},
},
// Platform channel settings
platform: {
requireMention: "Require {'@'}Mention",
requireMentionGroup: "Require {'@'}mention in groups to respond",
requireMentionChannel: "Require {'@'}mention in channels to respond",
requireMentionRoom: "Require {'@'}mention in rooms to respond",
reactions: 'Reactions',
reactionsHint: 'React to messages with emoji',
freeResponseChats: 'Free Response Chats',
freeResponseChatsHint: "Chat IDs that respond without {'@'}mention (comma-separated)",
freeResponseChannels: 'Free Response Channels',
freeResponseChannelsHint: "Channel IDs that respond without {'@'}mention (comma-separated)",
freeResponseRooms: 'Free Response Rooms',
freeResponseRoomsHint: "Room IDs that respond without {'@'}mention (comma-separated)",
mentionPatterns: 'Custom Mention Patterns',
mentionPatternsHint: 'Additional trigger patterns',
autoThread: 'Auto Thread',
autoThreadHint: "Auto-create reply threads after {'@'}mention",
autoThreadHintRoom: 'Auto-create reply threads in rooms',
dmMentionThreads: 'DM Mention Threads',
dmMentionThreadsHint: 'Use thread replies for mentions in DMs',
allowBots: 'Allow Bot Messages',
allowBotsHint: 'Respond to messages from other bots',
allowedChannels: 'Allowed Channels',
allowedChannelsHint: 'Whitelist channel IDs (comma-separated)',
ignoredChannels: 'Ignored Channels',
ignoredChannelsHint: 'Channels where bot never responds (comma-separated)',
noThreadChannels: 'No-Thread Channels',
noThreadChannelsHint: 'Channels where bot responds without threads (comma-separated)',
botToken: 'Bot Token',
botTokenHint: 'Bot token from developer portal',
accessToken: 'Access Token',
accessTokenHint: 'Matrix access token',
homeserver: 'Homeserver URL',
homeserverHint: 'Matrix homeserver URL',
appId: 'App ID',
appIdHint: 'Feishu App ID',
appSecret: 'App Secret',
appSecretHint: 'Feishu App Secret',
clientId: 'Client ID',
clientIdHint: 'DingTalk Client ID',
clientSecret: 'Client Secret',
clientSecretHint: 'DingTalk Client Secret',
botId: 'Bot ID',
botIdHint: 'WeCom Bot ID',
wecomSecretHint: 'WeCom Bot Secret',
waEnabled: 'Enable WhatsApp',
waEnabledHint: 'Enable WhatsApp via QR code pairing',
weixinToken: 'Weixin Token',
weixinTokenHint: 'From weixin CLI QR login (hermes weixin)',
accountId: 'Account ID',
accountIdHint: 'Weixin account ID',
qrLogin: 'QR Login',
qrRelogin: 'Re-login',
qrFetching: 'Fetching QR code...',
qrScanHint: 'Scan with WeChat to login',
qrScanedHint: 'Scaned, please confirm on phone...',
},
// Language
language: {
label: 'Language',
zh: '中文',
en: 'English',
},
// Terminal
terminal: {
sessions: 'Sessions',
newTab: 'New Terminal',
closeSession: 'Close this session?',
sessionExited: 'Exited',
processExited: 'Process exited with code {code}',
},
// Usage
usage: {
title: 'Usage Statistics',
refresh: 'Refresh',
totalTokens: 'Total Tokens',
inputTokens: 'Input',
outputTokens: 'Output',
totalSessions: 'Total Sessions',
avgPerDay: '~{n}/day avg',
estimatedCost: 'Est. Cost',
cacheHitRate: 'Cache Hit Rate',
modelBreakdown: 'Model Breakdown',
dailyTrend: 'Daily Usage (Last 30 Days)',
date: 'Date',
tokens: 'Tokens',
cache: 'Cache',
sessions: 'Sessions',
cost: 'Cost',
noData: 'No usage data',
},
}
+385
View File
@@ -0,0 +1,385 @@
export default {
// 登录
login: {
title: 'Hermes Web UI',
description: '输入访问令牌以继续。令牌在服务端启动日志中查看。',
placeholder: '访问令牌',
submit: '登录',
tokenRequired: '请输入访问令牌',
invalidToken: '令牌无效',
connectionFailed: '无法连接到服务器',
},
// 通用
common: {
loading: '加载中...',
cancel: '取消',
delete: '删除',
edit: '编辑',
save: '保存',
saved: '已保存',
saveFailed: '保存失败',
ok: '确定',
copied: '已复制',
copy: '复制',
update: '更新',
create: '创建',
noData: '暂无数据',
fetch: '获取',
add: '添加',
enable: '启用',
disable: '禁用',
configured: '已配置',
notConfigured: '未配置',
},
// 侧边栏
sidebar: {
chat: '对话',
jobs: '任务',
models: '模型',
skills: '技能',
memory: '记忆',
logs: '日志',
usage: '用量',
channels: '频道',
terminal: '终端',
settings: '设置',
connected: '已连接',
disconnected: '未连接',
},
// 对话
chat: {
emptyState: '开始与 Hermes Agent 对话',
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
attachFiles: '添加附件',
stop: '停止',
send: '发送',
contextUsed: '上下文已用:',
sessions: '会话',
noSessions: '暂无会话',
newChat: '新建对话',
deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除',
rename: '重命名',
copySessionId: '复制会话 ID',
renamed: '已重命名',
renameFailed: '重命名失败',
renameSession: '重命名会话',
enterNewTitle: '输入新标题',
other: '其他',
runFailed: '运行失败',
error: '错误',
tool: '工具',
arguments: '参数',
result: '结果',
truncated: '... (已截断)',
},
// 定时任务
jobs: {
title: '定时任务',
createJob: '创建任务',
editJob: '编辑任务',
noJobs: '暂无定时任务,创建一个开始吧。',
name: '名称',
namePlaceholder: '任务名称',
schedule: '调度表达式 (Cron)',
schedulePlaceholder: '例如 0 9 * * *',
quickPresets: '快速预设',
selectPreset: '选择预设...',
presetEveryMinute: '每分钟',
presetEvery5Min: '每 5 分钟',
presetEveryHour: '每小时',
presetEveryDay: '每天 00:00',
presetEveryDay9: '每天 09:00',
presetEveryMonday: '每周一 09:00',
presetEveryMonth: '每月 1 日 09:00',
prompt: '提示词',
promptPlaceholder: '要执行的内容',
deliverTarget: '投递目标',
origin: '来源',
local: '本地',
repeatCount: '重复次数(可选)',
repeatPlaceholder: '留空表示无限重复',
jobCreated: '任务已创建',
jobUpdated: '任务已更新',
nameRequired: '名称为必填项',
scheduleRequired: '调度表达式为必填项',
loadFailed: '加载任务失败',
jobPaused: '任务已暂停',
jobResumed: '任务已恢复',
jobTriggered: '任务已触发',
jobDeleted: '任务已删除',
status: {
running: '运行中',
paused: '已暂停',
disabled: '已禁用',
scheduled: '已调度',
},
info: {
schedule: '调度',
lastRun: '上次运行',
nextRun: '下次运行',
deliver: '投递',
repeat: '重复',
},
action: {
pause: '暂停',
pauseJob: '暂停任务',
resume: '恢复',
resumeJob: '恢复任务',
runNow: '立即运行',
triggerImmediately: '立即触发',
},
},
// 技能
skills: {
title: '技能',
searchPlaceholder: '搜索技能...',
noMatch: '没有匹配的技能',
noSkills: '暂无技能',
backTo: '返回',
attachedFiles: '附件文件',
loadFailed: '加载技能失败',
fileLoadFailed: '加载文件失败',
toggleFailed: '切换技能状态失败',
},
// 记忆
memory: {
title: '记忆',
refresh: '刷新',
loadFailed: '加载记忆失败',
myNotes: '我的笔记',
noNotes: '暂无笔记。',
notesPlaceholder: '输入笔记内容...',
userProfile: '用户画像',
noProfile: '暂无画像。',
profilePlaceholder: '输入用户画像...',
},
// 模型
models: {
title: '模型',
addProvider: '添加 Provider',
providerType: 'Provider 类型',
preset: '预设',
custom: '自定义',
selectProvider: '选择 Provider',
chooseProvider: '选择一个 provider...',
name: '名称',
autoGeneratedName: '根据 Base URL 自动生成',
baseUrl: 'Base URL',
baseUrlPlaceholder: '例如 https://api.example.com/v1',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-...',
defaultModel: '默认模型',
selectModel: '选择模型...',
providerAdded: 'Provider 已添加',
providerDeleted: 'Provider 已删除',
deleteProvider: '删除 Provider',
deleteConfirm: '确定删除 "{name}" 吗?',
noProviders: '暂无 Provider,添加一个开始吧。',
builtIn: '内置',
customType: '自定义',
provider: 'Provider',
local: '本地 ({host})',
selectProviderRequired: '请选择 Provider',
baseUrlRequired: 'Base URL 为必填项',
apiKeyRequired: 'API Key 为必填项',
modelRequired: '默认模型为必填项',
enterBaseUrl: '请先输入 Base URL',
unexpectedFormat: '响应格式异常',
foundModels: '找到 {count} 个模型',
fetchFailed: '获取模型失败',
},
// 日志
logs: {
title: '日志',
all: '全部',
searchPlaceholder: '搜索...',
refresh: '刷新',
noEntries: '暂无日志',
},
// 设置
settings: {
title: '设置',
saved: '已保存',
saveFailed: '保存失败',
tabs: {
display: '显示',
agent: '代理',
memory: '记忆',
session: '会话',
privacy: '隐私',
apiServer: 'API 服务器',
},
display: {
streaming: '流式响应',
streamingHint: '实时显示 AI 回复',
compact: '紧凑模式',
compactHint: '减少消息间距',
showReasoning: '显示推理过程',
showReasoningHint: '展示模型思考过程',
showCost: '显示费用',
showCostHint: '在回复中显示 token 使用量',
inlineDiffs: '内联差异',
inlineDiffsHint: '代码变更以内联方式显示',
bellOnComplete: '完成提示音',
bellOnCompleteHint: 'AI 回复完成时播放提示音',
busyInputMode: '忙碌输入模式',
busyInputModeHint: 'AI 处理中仍可输入',
},
agent: {
maxTurns: '最大轮次',
maxTurnsHint: '单次对话最大交互轮数',
gatewayTimeout: '网关超时',
gatewayTimeoutHint: '单次请求超时时间(秒)',
restartDrainTimeout: '重启排空超时',
restartDrainTimeoutHint: '重启前排空请求的超时时间(秒)',
toolEnforcement: '工具执行策略',
toolEnforcementHint: '控制工具调用的执行模式',
auto: '自动',
always: '始终',
never: '从不',
},
memory: {
enabled: '启用记忆',
enabledHint: '允许 AI 记住对话上下文',
userProfile: '用户画像',
userProfileHint: '允许 AI 记住用户偏好信息',
charLimit: '记忆字符上限',
charLimitHint: 'MEMORY.md 最大字符数',
userCharLimit: '用户画像字符上限',
userCharLimitHint: 'USER.md 最大字符数',
},
session: {
mode: '重置模式',
modeHint: '会话重置的触发条件',
modeBoth: '空闲 + 定时',
modeIdle: '仅空闲',
modeHourly: '仅定时',
idleMinutes: '空闲超时',
idleMinutesHint: '无操作后自动重置的等待时间(分钟)',
atHour: '定时重置时间',
atHourHint: '每天在指定小时重置会话',
},
privacy: {
redactPii: '脱敏 PII',
redactPiiHint: '自动检测并隐藏敏感信息(密码、密钥等)',
},
apiServer: {
enable: '启用',
enableHint: '启用 API 服务器',
host: '主机',
hostHint: '监听地址',
port: '端口',
portHint: '监听端口',
key: '密钥',
keyHint: 'API 访问密钥',
cors: 'CORS 来源',
corsHint: '允许的跨域来源',
},
},
// 平台频道设置
platform: {
requireMention: "需要 {'@'}提及",
requireMentionGroup: "群组中需要 {'@'}机器人 才会响应",
requireMentionChannel: "频道中需要 {'@'}机器人 才会响应",
requireMentionRoom: "房间中需要 {'@'}机器人 才会响应",
reactions: '表情回应',
reactionsHint: '对消息添加表情回应',
freeResponseChats: '自由响应聊天',
freeResponseChatsHint: "不需要 {'@'}提及即响应的聊天 ID(逗号分隔)",
freeResponseChannels: '自由响应频道',
freeResponseChannelsHint: "不需要 {'@'}提及即响应的频道 ID(逗号分隔)",
freeResponseRooms: '自由响应房间',
freeResponseRoomsHint: "不需要 {'@'}提及即响应的房间 ID(逗号分隔)",
mentionPatterns: '自定义提及模式',
mentionPatternsHint: '额外的触发模式列表',
autoThread: '自动创建线程',
autoThreadHint: "{'@'}提及 后自动创建回复线程",
autoThreadHintRoom: '在房间中自动创建回复线程',
dmMentionThreads: 'DM 提及线程',
dmMentionThreadsHint: '在私聊中也使用线程回复提及',
allowBots: '允许机器人消息',
allowBotsHint: '响应其他机器人发送的消息',
allowedChannels: '允许的频道',
allowedChannelsHint: '白名单频道 ID(逗号分隔)',
ignoredChannels: '忽略的频道',
ignoredChannelsHint: '不响应的频道 ID(逗号分隔)',
noThreadChannels: '无线程频道',
noThreadChannelsHint: '不创建线程的频道 ID(逗号分隔)',
botToken: 'Bot Token',
botTokenHint: '开发者门户获取的 Bot Token',
accessToken: 'Access Token',
accessTokenHint: 'Matrix Access Token',
homeserver: 'Homeserver URL',
homeserverHint: 'Matrix 服务器地址',
appId: 'App ID',
appIdHint: '飞书 App ID',
appSecret: 'App Secret',
appSecretHint: '飞书 App Secret',
clientId: 'Client ID',
clientIdHint: '钉钉 Client ID',
clientSecret: 'Client Secret',
clientSecretHint: '钉钉 Client Secret',
botId: 'Bot ID',
botIdHint: '企业微信 Bot ID',
wecomSecretHint: '企业微信 Bot Secret',
waEnabled: '启用 WhatsApp',
waEnabledHint: '通过二维码配对启用 WhatsApp',
weixinToken: '微信 Token',
weixinTokenHint: '通过 weixin CLI 扫码登录获取 (hermes weixin)',
accountId: 'Account ID',
accountIdHint: '微信 Account ID',
qrLogin: '扫码登录',
qrRelogin: '重新登录',
qrFetching: '正在获取二维码...',
qrScanHint: '使用微信扫描二维码登录',
qrScanedHint: '已扫描,请在手机上确认...',
},
// 语言
language: {
label: '语言',
zh: '中文',
en: 'English',
},
// 终端
terminal: {
sessions: '会话',
newTab: '新建终端',
closeSession: '关闭此会话?',
sessionExited: '已退出',
processExited: '进程已退出,代码 {code}',
},
// 用量统计
usage: {
title: '用量统计',
refresh: '刷新',
totalTokens: '总 Token 数',
inputTokens: '输入',
outputTokens: '输出',
totalSessions: '总会话数',
avgPerDay: '日均 ~{n}',
estimatedCost: '预估费用',
cacheHitRate: '缓存命中率',
modelBreakdown: '模型分布',
dailyTrend: '每日用量(近 30 天)',
date: '日期',
tokens: 'Token',
cache: '缓存',
sessions: '会话',
cost: '费用',
noData: '暂无用量数据',
},
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import { i18n } from './i18n'
import App from './App.vue'
import './styles/global.scss'
// Read token from URL BEFORE router initializes (hash router strips params)
const urlParams = new URLSearchParams(window.location.search)
const hashQuery = window.location.hash.split('?')[1]
const urlToken = urlParams.get('token') || (hashQuery ? new URLSearchParams(hashQuery).get('token') : null)
if (urlToken) {
;(window as any).__LOGIN_TOKEN__ = urlToken
}
const app = createApp(App)
app.use(createPinia())
app.use(i18n)
app.use(router)
app.mount('#app')
+87
View File
@@ -0,0 +1,87 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { hasApiKey } from '@/api/client'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/hermes/chat',
name: 'hermes.chat',
component: () => import('@/views/hermes/ChatView.vue'),
},
{
path: '/hermes/jobs',
name: 'hermes.jobs',
component: () => import('@/views/hermes/JobsView.vue'),
},
{
path: '/hermes/models',
name: 'hermes.models',
component: () => import('@/views/hermes/ModelsView.vue'),
},
{
path: '/hermes/logs',
name: 'hermes.logs',
component: () => import('@/views/hermes/LogsView.vue'),
},
{
path: '/hermes/usage',
name: 'hermes.usage',
component: () => import('@/views/hermes/UsageView.vue'),
},
{
path: '/hermes/skills',
name: 'hermes.skills',
component: () => import('@/views/hermes/SkillsView.vue'),
},
{
path: '/hermes/memory',
name: 'hermes.memory',
component: () => import('@/views/hermes/MemoryView.vue'),
},
{
path: '/hermes/settings',
name: 'hermes.settings',
component: () => import('@/views/hermes/SettingsView.vue'),
},
{
path: '/hermes/channels',
name: 'hermes.channels',
component: () => import('@/views/hermes/ChannelsView.vue'),
},
{
path: '/hermes/terminal',
name: 'hermes.terminal',
component: () => import('@/views/hermes/TerminalView.vue'),
},
],
})
router.beforeEach((to, _from, next) => {
// Public pages don't need auth
if (to.meta.public) {
// Already has key, skip login
if (to.name === 'login' && hasApiKey()) {
next({ path: '/hermes/chat' })
return
}
next()
return
}
// All other pages require token
if (!hasApiKey()) {
next({ name: 'login' })
return
}
next()
})
export default router
+215
View File
@@ -0,0 +1,215 @@
/**
* Provider registry — single source of truth for both frontend and backend.
* Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
*/
export interface ProviderPreset {
label: string
value: string
base_url: string
models: string[]
}
export const PROVIDER_PRESETS: ProviderPreset[] = [
{
label: 'Anthropic',
value: 'anthropic',
base_url: 'https://api.anthropic.com',
models: [
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-opus-4-5-20251101',
'claude-sonnet-4-5-20250929',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-haiku-4-5-20251001',
],
},
{
label: 'Google AI Studio',
value: 'gemini',
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
models: [
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemma-4-31b-it',
'gemma-4-26b-it',
],
},
{
label: 'DeepSeek',
value: 'deepseek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat', 'deepseek-reasoner'],
},
{
label: 'Z.AI / GLM',
value: 'zai',
base_url: 'https://api.z.ai/api/paas/v4',
models: ['glm-5', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
},
{
label: 'Kimi Coding Plan',
value: 'kimi-coding',
base_url: 'https://api.kimi.com/coding/v1',
models: [
'kimi-for-coding',
'kimi-k2.5',
'kimi-k2-thinking',
'kimi-k2-thinking-turbo',
'kimi-k2-turbo-preview',
'kimi-k2-0905-preview',
],
},
{
label: 'Moonshot (Pay-as-you-go)',
value: 'moonshot',
base_url: 'https://api.moonshot.ai/v1',
models: ['kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview', 'kimi-k2-0905-preview'],
},
{
label: 'xAI',
value: 'xai',
base_url: 'https://api.x.ai/v1',
models: [
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4-1-fast-reasoning',
'grok-4-1-fast-non-reasoning',
'grok-4-fast-reasoning',
'grok-4-fast-non-reasoning',
'grok-4-0709',
'grok-code-fast-1',
'grok-3',
'grok-3-mini',
],
},
{
label: 'MiniMax',
value: 'minimax',
base_url: 'https://api.minimax.io/anthropic',
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
},
{
label: 'MiniMax (China)',
value: 'minimax-cn',
base_url: 'https://api.minimaxi.com/v1',
models: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2'],
},
{
label: 'Alibaba Cloud',
value: 'alibaba',
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
models: [
'qwen3.5-plus',
'qwen3-coder-plus',
'qwen3-coder-next',
'glm-5',
'glm-4.7',
'kimi-k2.5',
'MiniMax-M2.5',
],
},
{
label: 'Hugging Face',
value: 'huggingface',
base_url: 'https://router.huggingface.co/v1',
models: [
'Qwen/Qwen3.5-397B-A17B',
'Qwen/Qwen3.5-35B-A3B',
'deepseek-ai/DeepSeek-V3.2',
'moonshotai/Kimi-K2.5',
'MiniMaxAI/MiniMax-M2.5',
'zai-org/GLM-5',
'XiaomiMiMo/MiMo-V2-Flash',
'moonshotai/Kimi-K2-Thinking',
],
},
{
label: 'Xiaomi MiMo',
value: 'xiaomi',
base_url: 'https://api.xiaomimimo.com/v1',
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
},
{
label: 'Kilo Code',
value: 'kilocode',
base_url: 'https://api.kilo.ai/api/gateway',
models: [
'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6',
'openai/gpt-5.4',
'google/gemini-3-pro-preview',
'google/gemini-3-flash-preview',
],
},
{
label: 'AI Gateway',
value: 'ai-gateway',
base_url: 'https://ai-gateway.vercel.sh/v1',
models: [
'anthropic/claude-opus-4.6',
'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4.5',
'anthropic/claude-haiku-4.5',
'openai/gpt-5',
'openai/gpt-4.1',
'openai/gpt-4.1-mini',
'google/gemini-3-pro-preview',
'google/gemini-3-flash',
'google/gemini-2.5-pro',
'google/gemini-2.5-flash',
'deepseek/deepseek-v3.2',
],
},
{
label: 'OpenCode Zen',
value: 'opencode-zen',
base_url: 'https://opencode.ai/zen/v1',
models: [
'gpt-5.4-pro',
'gpt-5.4',
'gpt-5.3-codex',
'gpt-5.2',
'gpt-5.1',
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-haiku-4-5',
'gemini-3.1-pro',
'gemini-3-pro',
'gemini-3-flash',
'minimax-m2.7',
'minimax-m2.5',
'glm-5',
'glm-4.7',
'kimi-k2.5',
],
},
{
label: 'OpenCode Go',
value: 'opencode-go',
base_url: 'https://opencode.ai/zen/go/v1',
models: ['glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
},
{
label: 'OpenRouter',
value: 'openrouter',
base_url: 'https://openrouter.ai/api/v1',
models: [],
},
]
/** Build a Record<providerKey, models[]> for backend lookup */
export function buildProviderModelMap(): Record<string, string[]> {
const map: Record<string, string[]> = {}
for (const p of PROVIDER_PRESETS) {
if (p.models.length > 0) {
map[p.value] = p.models
}
}
return map
}
+89
View File
@@ -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,
}
})
+541
View File
@@ -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,
}
})
+72
View File
@@ -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,
}
})
+141
View File
@@ -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,
}
})
+140
View File
@@ -0,0 +1,140 @@
@use 'variables' as *;
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--vh: 1vh;
}
// Fix mobile viewport height (address bar)
@supports (height: 100dvh) {
:root {
--vh: 1dvh;
}
}
html, body, #app {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: $font-ui;
background-color: $bg-primary;
color: $text-primary;
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code, pre, .mono {
font-family: $font-code;
}
a {
color: $accent-primary;
text-decoration: none;
&:hover {
color: $accent-hover;
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border-color;
border-radius: 3px;
&:hover {
background: $text-muted;
}
}
::selection {
background: rgba($accent-primary, 0.3);
}
// Shared page header
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 21px 20px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
// Responsive utility classes for inline width replacement
.input-sm { width: 90px; }
.input-md { width: 200px; }
.input-lg { width: 300px; }
// Mobile drawer backdrop
.mobile-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 999;
}
// Hamburger button (mobile only)
.hamburger-btn {
display: none;
position: fixed;
top: 16px;
left: 12px;
z-index: 1001;
width: 36px;
height: 36px;
border: none;
background: $bg-card;
border-radius: $radius-sm;
cursor: pointer;
align-items: center;
justify-content: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
// Mobile responsive
@media (max-width: $breakpoint-mobile) {
.mobile-backdrop {
display: block;
}
.hamburger-btn {
display: flex;
}
.page-header {
padding: 16px 12px 16px 52px;
}
.input-sm,
.input-md,
.input-lg {
width: 100%;
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { GlobalThemeOverrides } from 'naive-ui'
export const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#333333',
primaryColorHover: '#1a1a1a',
primaryColorPressed: '#000000',
primaryColorSuppl: '#333333',
bodyColor: '#fafafa',
cardColor: '#ffffff',
modalColor: '#ffffff',
popoverColor: '#ffffff',
tableColor: '#ffffff',
inputColor: '#ffffff',
actionColor: '#f0f0f0',
textColorBase: '#1a1a1a',
textColor1: '#1a1a1a',
textColor2: '#666666',
textColor3: '#999999',
dividerColor: '#e0e0e0',
borderColor: '#e0e0e0',
hoverColor: 'rgba(0, 0, 0, 0.04)',
borderRadius: '8px',
borderRadiusSmall: '6px',
fontSize: '14px',
fontSizeMedium: '14px',
heightMedium: '36px',
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
fontFamilyMono: 'JetBrains Mono, Fira Code, Consolas, monospace',
},
Layout: {
color: '#fafafa',
siderColor: '#f5f5f5',
headerColor: '#fafafa',
},
Menu: {
itemTextColorActive: '#1a1a1a',
itemTextColorActiveHover: '#1a1a1a',
itemTextColorChildActive: '#1a1a1a',
itemIconColorActive: '#1a1a1a',
itemIconColorActiveHover: '#000000',
itemColorActive: 'rgba(0, 0, 0, 0.06)',
itemColorActiveHover: 'rgba(0, 0, 0, 0.1)',
arrowColorActive: '#1a1a1a',
},
Button: {
textColorPrimary: '#ffffff',
colorPrimary: '#333333',
colorHoverPrimary: '#1a1a1a',
colorPressedPrimary: '#000000',
},
Input: {
color: '#ffffff',
colorFocus: '#ffffff',
border: '1px solid #e0e0e0',
borderHover: '1px solid #999999',
borderFocus: '1px solid #333333',
placeholderColor: '#999999',
caretColor: '#1a1a1a',
},
Card: {
color: '#ffffff',
borderColor: '#e0e0e0',
},
Modal: {
color: '#ffffff',
},
Tag: {
borderRadius: '6px',
},
}
+57
View File
@@ -0,0 +1,57 @@
// 黑白水墨 — Pure Ink
// 纯黑白灰,无彩色
// Backgrounds
$bg-primary: #fafafa;
$bg-secondary: #f0f0f0;
$bg-sidebar: #f5f5f5;
$bg-card: #ffffff;
$bg-card-hover: #fafafa;
$bg-input: #ffffff;
// Borders
$border-color: #e0e0e0;
$border-light: #ebebeb;
// Accent
$accent-primary: #333333;
$accent-hover: #1a1a1a;
$accent-muted: #888888;
// Text
$text-primary: #1a1a1a;
$text-secondary: #666666;
$text-muted: #999999;
// Status
$success: #2e7d32;
$error: #c62828;
$warning: #f57f17;
$info: $accent-primary;
// Message bubbles
$msg-user-bg: #e8e8e8;
$msg-assistant-bg: #f5f5f5;
$msg-system-border: #bdbdbd;
// Code
$code-bg: #f4f4f4;
// Typography
$font-ui: 'Inter', system-ui, -apple-system, sans-serif;
$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
// Layout
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$header-height: 60px;
$breakpoint-mobile: 768px;
// Radius
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 14px;
// Transition
$transition-fast: 0.15s ease;
$transition-normal: 0.25s ease;
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { setApiKey, hasApiKey } from "@/api/client";
const { t } = useI18n();
const router = useRouter();
// Read token saved by main.ts (before router strips URL params)
const urlToken = (window as any).__LOGIN_TOKEN__ || "";
const token = ref(urlToken);
const loading = ref(false);
const errorMsg = ref("");
// If already has a key, try to go to main page
if (hasApiKey()) {
router.replace("/hermes/chat");
}
async function handleLogin() {
const key = token.value.trim();
if (!key) {
errorMsg.value = t("login.tokenRequired");
return;
}
loading.value = true;
errorMsg.value = "";
try {
// Validate token by calling an auth-required endpoint
const res = await fetch("/api/sessions", {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status === 401) {
errorMsg.value = t("login.invalidToken");
loading.value = false;
return;
}
setApiKey(key);
router.replace("/hermes/chat");
} catch {
errorMsg.value = t("login.connectionFailed");
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-view">
<div class="login-card">
<div class="login-logo">
<img src="/logo.png" alt="Hermes" width="80" height="80" />
</div>
<h1 class="login-title">{{ t('login.title') }}</h1>
<p class="login-desc">{{ t("login.description") }}</p>
<form class="login-form" @submit.prevent="handleLogin">
<input
v-model="token"
type="password"
class="login-input"
:placeholder="t('login.placeholder')"
autofocus
/>
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
<button type="submit" class="login-btn" :disabled="loading">
{{ loading ? "..." : t("login.submit") }}
</button>
</form>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.login-view {
height: calc(100 * var(--vh));
display: flex;
align-items: center;
justify-content: center;
background: $bg-primary;
}
.login-card {
width: 480px;
max-width: calc(100vw - 32px);
padding: 56px;
border: 1px solid $border-color;
border-radius: $radius-lg;
background: $bg-card;
text-align: center;
@media (max-width: $breakpoint-mobile) {
padding: 32px 24px;
}
}
.login-logo {
margin-bottom: 24px;
}
.login-title {
font-size: 26px;
font-weight: 600;
color: $text-primary;
margin: 0 0 10px;
}
.login-desc {
font-size: 14px;
color: $text-muted;
margin: 0 0 40px;
line-height: 1.6;
}
.login-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-input {
width: 100%;
padding: 14px 16px;
border: 1px solid $border-color;
border-radius: $radius-sm;
font-size: 15px;
color: $text-primary;
background: $bg-input;
outline: none;
transition: border-color $transition-fast;
box-sizing: border-box;
font-family: $font-code;
&::placeholder {
color: $text-muted;
}
&:focus {
border-color: $accent-primary;
}
}
.login-error {
font-size: 13px;
color: $error;
text-align: left;
}
.login-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: $radius-sm;
background: $text-primary;
color: #fff;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity $transition-fast;
&:hover {
opacity: 0.85;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import PlatformSettings from '@/components/hermes/settings/PlatformSettings.vue'
const settingsStore = useSettingsStore()
const { t } = useI18n()
onMounted(() => {
settingsStore.fetchSettings()
})
</script>
<template>
<div class="channels-view">
<header class="page-header">
<h2 class="header-title">{{ t('sidebar.channels') }}</h2>
</header>
<div class="channels-content">
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
<PlatformSettings />
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.channels-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.channels-content {
flex: 1;
overflow-y: auto;
padding: 20px;
position: relative;
}
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
const appStore = useAppStore()
const chatStore = useChatStore()
onMounted(() => {
appStore.loadModels()
chatStore.loadSessions()
})
</script>
<template>
<div class="chat-view">
<ChatPanel />
</div>
</template>
<style scoped lang="scss">
.chat-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import JobsPanel from '@/components/hermes/jobs/JobsPanel.vue'
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
import { useJobsStore } from '@/stores/hermes/jobs'
const { t } = useI18n()
const jobsStore = useJobsStore()
const showModal = ref(false)
const editingJob = ref<string | null>(null)
onMounted(() => {
jobsStore.fetchJobs()
})
function openCreateModal() {
editingJob.value = null
showModal.value = true
}
function openEditModal(jobId: string) {
editingJob.value = jobId
showModal.value = true
}
function handleModalClose() {
showModal.value = false
editingJob.value = null
}
async function handleSave() {
await jobsStore.fetchJobs()
handleModalClose()
}
</script>
<template>
<div class="jobs-view">
<header class="page-header">
<h2 class="header-title">{{ t('jobs.title') }}</h2>
<NButton type="primary" size="small" @click="openCreateModal">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
{{ t('jobs.createJob') }}
</NButton>
</header>
<div class="jobs-content">
<NSpin :show="jobsStore.loading && jobsStore.jobs.length === 0">
<JobsPanel @edit="openEditModal" />
</NSpin>
</div>
<JobFormModal
v-if="showModal"
:job-id="editingJob"
@close="handleModalClose"
@saved="handleSave"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.jobs-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.jobs-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
</style>
@@ -0,0 +1,294 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/hermes/logs'
const { t } = useI18n()
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 = computed(() => [
{ label: t('logs.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="page-header">
<h2 class="header-title">{{ t('logs.title') }}</h2>
<div class="header-actions">
<NSelect
v-model:value="selectedLog"
:options="logOptions"
size="small"
class="input-md"
@update:value="loadLogs"
/>
<NSelect
:value="levelFilter"
:options="levelOptions"
size="small"
class="input-sm"
@update:value="(v: string) => { levelFilter = v; loadLogs() }"
/>
<NSelect
:value="lineCount"
:options="lineOptions"
size="small"
class="input-sm"
@update:value="(v: number) => { lineCount = v; loadLogs() }"
/>
<input
v-model="searchQuery"
class="search-input"
:placeholder="t('logs.searchPlaceholder')"
/>
<NButton size="small" :loading="loading" @click="loadLogs">{{ t('logs.refresh') }}</NButton>
</div>
</header>
<div class="logs-body">
<NSpin :show="loading">
<div v-if="filteredEntries.length === 0 && !loading" class="logs-empty">
{{ t('logs.noEntries') }}
</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: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.page-header {
gap: 12px;
flex-wrap: wrap;
}
.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>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import { fetchMemory, saveMemory, type MemoryData } from '@/api/hermes/skills'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
const data = ref<MemoryData | null>(null)
const editingSection = ref<'memory' | 'user' | null>(null)
const editContent = ref('')
const saving = ref(false)
onMounted(loadMemory)
async function loadMemory() {
loading.value = true
try {
data.value = await fetchMemory()
} catch (err: any) {
console.error('Failed to load memory:', err)
message.error(t('memory.loadFailed'))
} finally {
loading.value = false
}
}
function startEdit(section: 'memory' | 'user') {
editingSection.value = section
editContent.value = data.value?.[section] || ''
}
function cancelEdit() {
editingSection.value = null
editContent.value = ''
}
async function handleSave() {
if (!editingSection.value) return
saving.value = true
try {
await saveMemory(editingSection.value, editContent.value)
await loadMemory()
editingSection.value = null
editContent.value = ''
message.success(t('common.saved'))
} catch (err: any) {
message.error(`${t('common.saveFailed')}: ${err.message}`)
} finally {
saving.value = false
}
}
function formatTime(ts: number | null): string {
if (!ts) return ''
return new Date(ts).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const memoryEmpty = computed(() => !data.value?.memory?.trim())
const userEmpty = computed(() => !data.value?.user?.trim())
const displayMemory = computed(() => (data.value?.memory || '').replace(/§/g, '\n\n'))
const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n'))
</script>
<template>
<div class="memory-view">
<header class="page-header">
<h2 class="header-title">{{ t('memory.title') }}</h2>
<NButton size="small" quaternary @click="loadMemory">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</template>
{{ t('memory.refresh') }}
</NButton>
</header>
<div class="memory-content">
<div v-if="loading && !data" class="memory-loading">{{ t('common.loading') }}</div>
<div v-else class="memory-sections">
<!-- My Notes -->
<div class="memory-section">
<div class="section-header">
<div class="section-title-row">
<span class="section-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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" />
</svg>
</span>
<span class="section-title">{{ t('memory.myNotes') }}</span>
<span v-if="data?.memory_mtime" class="section-mtime">{{ formatTime(data.memory_mtime) }}</span>
</div>
<NButton v-if="editingSection !== 'memory'" size="tiny" quaternary @click="startEdit('memory')">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
{{ t('common.edit') }}
</NButton>
</div>
<!-- View mode -->
<div v-if="editingSection !== 'memory'" class="section-body">
<MarkdownRenderer v-if="!memoryEmpty" :content="displayMemory" />
<p v-else class="empty-text">{{ t('memory.noNotes') }}</p>
</div>
<!-- Edit mode -->
<div v-else class="section-edit">
<textarea
v-model="editContent"
class="edit-textarea"
:placeholder="t('memory.notesPlaceholder')"
spellcheck="false"
></textarea>
<div class="edit-actions">
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
</div>
</div>
</div>
<!-- User Profile -->
<div class="memory-section">
<div class="section-header">
<div class="section-title-row">
<span class="section-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="section-title">{{ t('memory.userProfile') }}</span>
<span v-if="data?.user_mtime" class="section-mtime">{{ formatTime(data.user_mtime) }}</span>
</div>
<NButton v-if="editingSection !== 'user'" size="tiny" quaternary @click="startEdit('user')">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
{{ t('common.edit') }}
</NButton>
</div>
<!-- View mode -->
<div v-if="editingSection !== 'user'" class="section-body">
<MarkdownRenderer v-if="!userEmpty" :content="displayUser" />
<p v-else class="empty-text">{{ t('memory.noProfile') }}</p>
</div>
<!-- Edit mode -->
<div v-else class="section-edit">
<textarea
v-model="editContent"
class="edit-textarea"
:placeholder="t('memory.profilePlaceholder')"
spellcheck="false"
></textarea>
<div class="edit-actions">
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.memory-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.memory-content {
flex: 1;
overflow: hidden;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: $text-muted;
}
.memory-sections {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
}
}
.memory-section {
flex: 1;
min-height: 0;
border: 1px solid $border-color;
border-radius: $radius-md;
overflow: hidden;
display: flex;
flex-direction: column;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: $bg-secondary;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.section-icon {
color: $text-secondary;
display: flex;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.section-mtime {
font-size: 11px;
color: $text-muted;
}
.section-body {
flex: 1;
overflow-y: auto;
padding: 16px;
min-height: 0;
}
.empty-text {
color: $text-muted;
font-style: italic;
font-size: 13px;
}
.section-edit {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 16px;
min-height: 0;
}
.edit-textarea {
flex: 1;
width: 100%;
min-height: 0;
padding: 12px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-input;
color: $text-primary;
font-family: $font-code;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
&:focus {
border-color: $accent-primary;
}
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import ProvidersPanel from '@/components/hermes/models/ProvidersPanel.vue'
import ProviderFormModal from '@/components/hermes/models/ProviderFormModal.vue'
import { useModelsStore } from '@/stores/hermes/models'
const { t } = useI18n()
const modelsStore = useModelsStore()
const showModal = ref(false)
onMounted(() => {
modelsStore.fetchProviders()
})
function openCreateModal() {
showModal.value = true
}
function handleModalClose() {
showModal.value = false
}
async function handleSaved() {
await modelsStore.fetchProviders()
handleModalClose()
}
</script>
<template>
<div class="models-view">
<header class="page-header">
<h2 class="header-title">{{ t('models.title') }}</h2>
<NButton type="primary" size="small" @click="openCreateModal">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</template>
{{ t('models.addProvider') }}
</NButton>
</header>
<div class="models-content">
<NSpin :show="modelsStore.loading && modelsStore.providers.length === 0">
<ProvidersPanel />
</NSpin>
</div>
<ProviderFormModal
v-if="showModal"
@close="handleModalClose"
@saved="handleSaved"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.models-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.models-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { NTabs, NTabPane, NSpin, NSwitch, NInput, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import DisplaySettings from '@/components/hermes/settings/DisplaySettings.vue'
import AgentSettings from '@/components/hermes/settings/AgentSettings.vue'
import MemorySettings from '@/components/hermes/settings/MemorySettings.vue'
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
import PrivacySettings from '@/components/hermes/settings/PrivacySettings.vue'
import SettingRow from '@/components/hermes/settings/SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
onMounted(() => {
settingsStore.fetchSettings()
})
async function saveApiServer(values: Record<string, any>) {
try {
await settingsStore.saveSection('platforms', { api_server: values })
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<div class="settings-view">
<header class="page-header">
<h2 class="header-title">{{ t('settings.title') }}</h2>
</header>
<div class="settings-content">
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
<NTabs type="line" animated>
<NTabPane name="display" :tab="t('settings.tabs.display')">
<DisplaySettings />
</NTabPane>
<NTabPane name="agent" :tab="t('settings.tabs.agent')">
<AgentSettings />
</NTabPane>
<NTabPane name="memory" :tab="t('settings.tabs.memory')">
<MemorySettings />
</NTabPane>
<NTabPane name="session" :tab="t('settings.tabs.session')">
<SessionSettings />
</NTabPane>
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
<PrivacySettings />
</NTabPane>
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
<section class="settings-section">
<SettingRow :label="t('settings.apiServer.enable')" :hint="t('settings.apiServer.enableHint')">
<NSwitch
:value="settingsStore.platforms?.api_server?.enabled"
@update:value="v => saveApiServer({ enabled: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.host')" :hint="t('settings.apiServer.hostHint')">
<NInput
:value="settingsStore.platforms?.api_server?.host || ''"
size="small" class="input-md"
@update:value="v => saveApiServer({ host: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.port')" :hint="t('settings.apiServer.portHint')">
<NInputNumber
:value="settingsStore.platforms?.api_server?.port"
:min="1024" :max="65535"
size="small" class="input-sm"
@update:value="v => v != null && saveApiServer({ port: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.key')" :hint="t('settings.apiServer.keyHint')">
<NInput
:value="settingsStore.platforms?.api_server?.key || ''"
type="password" show-password-on="click"
size="small" class="input-md"
@update:value="v => saveApiServer({ key: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.apiServer.cors')" :hint="t('settings.apiServer.corsHint')">
<NInput
:value="settingsStore.platforms?.api_server?.cors_origins || ''"
size="small" class="input-md"
@update:value="v => saveApiServer({ cors_origins: v })"
/>
</SettingRow>
</section>
</NTabPane>
</NTabs>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
</style>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { NInput } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import SkillList from '@/components/hermes/skills/SkillList.vue'
import SkillDetail from '@/components/hermes/skills/SkillDetail.vue'
import { fetchSkills, type SkillCategory } from '@/api/hermes/skills'
const { t } = useI18n()
const categories = ref<SkillCategory[]>([])
const loading = ref(false)
const selectedCategory = ref('')
const selectedSkill = ref('')
const searchQuery = ref('')
const showSidebar = ref(true)
let mobileQuery: MediaQueryList | null = null
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
showSidebar.value = !e.matches
}
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
loadSkills()
})
onUnmounted(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
})
async function loadSkills() {
loading.value = true
try {
categories.value = await fetchSkills()
} catch (err: any) {
console.error('Failed to load skills:', err)
} finally {
loading.value = false
}
}
function handleSelect(category: string, skill: string) {
selectedCategory.value = category
selectedSkill.value = skill
if (window.innerWidth <= 768) {
showSidebar.value = false
}
}
</script>
<template>
<div class="skills-view">
<header class="page-header">
<div style="display: flex; align-items: center; gap: 8px;">
<h2 class="header-title">{{ t('skills.title') }}</h2>
<button v-if="!showSidebar" class="sidebar-toggle" @click="showSidebar = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
<NInput
v-model:value="searchQuery"
:placeholder="t('skills.searchPlaceholder')"
size="small"
clearable
style="width: 160px"
/>
</header>
<div class="skills-content">
<div v-if="loading && categories.length === 0" class="skills-loading">{{ t('common.loading') }}</div>
<div v-else class="skills-layout">
<div class="mobile-backdrop" :class="{ active: showSidebar }" @click="showSidebar = false" />
<div v-if="showSidebar" class="skills-sidebar">
<SkillList
:categories="categories"
:selected-skill="selectedCategory && selectedSkill ? `${selectedCategory}/${selectedSkill}` : null"
:search-query="searchQuery"
@select="handleSelect"
/>
</div>
<div class="skills-main">
<SkillDetail
v-if="selectedCategory && selectedSkill"
:category="selectedCategory"
:skill="selectedSkill"
/>
<div v-else class="empty-detail">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2">
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t('skills.noMatch') }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.skills-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.search-input {
width: 100px;
@media (max-width: $breakpoint-mobile) {
width: 100%;
}
}
.skills-content {
flex: 1;
overflow: hidden;
}
.skills-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: $text-muted;
}
.skills-layout {
display: flex;
height: 100%;
}
.skills-sidebar {
width: 280px;
border-right: 1px solid $border-color;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.skills-main {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
min-width: 0;
}
.sidebar-toggle {
display: none;
border: none;
background: none;
cursor: pointer;
color: $text-secondary;
padding: 4px;
border-radius: $radius-sm;
&:hover {
background: rgba($accent-primary, 0.06);
}
}
@media (max-width: $breakpoint-mobile) {
.sidebar-toggle {
display: flex;
}
.skills-sidebar {
position: absolute;
left: 0;
top: 0;
height: 100%;
z-index: 10;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.skills-layout {
position: relative;
}
.mobile-backdrop {
display: block;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 9;
opacity: 0;
pointer-events: none;
transition: opacity $transition-fast;
&.active {
opacity: 1;
pointer-events: auto;
}
}
}
.empty-detail {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: $text-muted;
font-size: 13px;
}
</style>
@@ -0,0 +1,802 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { getApiKey, getBaseUrlValue } from "@/api/client";
import { NButton, NPopconfirm, NTooltip, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const message = useMessage();
// ─── Types ──────────────────────────────────────────────────────
interface SessionInfo {
id: string;
shell: string;
pid: number;
title: string;
createdAt: number;
exited: boolean;
}
// ─── State ──────────────────────────────────────────────────────
const terminalRef = ref<HTMLDivElement | null>(null);
const showSessions = ref(true);
const sessions = ref<SessionInfo[]>([]);
const activeSessionId = ref<string | null>(null);
let ws: WebSocket | null = null;
// Keep all terminal instances alive, only dispose on close
const termMap = new Map<
string,
{ term: Terminal; fitAddon: FitAddon; opened: boolean }
>();
let activeTerm: Terminal | null = null;
let activeFitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let mobileQuery: MediaQueryList | null = null;
// ─── Computed ──────────────────────────────────────────────────
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
);
// ─── WebSocket ──────────────────────────────────────────────────
function buildWsUrl(): string {
const token = getApiKey();
const base = getBaseUrlValue();
const wsProtocol = base
? base.startsWith("https")
? "wss:"
: "ws:"
: location.protocol === "https:"
? "wss:"
: "ws:";
if (base) {
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
// Dev mode: connect directly to backend port; Production: same host
const host = import.meta.env.DEV
? `${location.hostname}:8648`
: location.host;
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
function connect() {
const url = buildWsUrl();
ws = new WebSocket(url);
ws.onopen = () => {
// Server auto-creates the first session and sends 'created'
};
ws.onmessage = (event) => {
const data = typeof event.data === "string" ? event.data : "";
if (data.charCodeAt(0) === 0x7b) {
try {
handleControl(JSON.parse(data));
} catch {}
} else {
activeTerm?.write(data);
}
};
// On reconnect, recreate all terminals for existing sessions
ws.onopen = () => {
// Server will auto-create the first session again
};
ws.onclose = () => {
// Reconnect after delay
setTimeout(connect, 3000);
};
ws.onerror = () => {
// let onclose handle reconnect
};
}
function send(data: object | string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(typeof data === "string" ? data : JSON.stringify(data));
}
// ─── Control message handlers ──────────────────────────────────
function handleControl(msg: any) {
switch (msg.type) {
case "created":
sessions.value.push({
id: msg.id,
shell: msg.shell,
pid: msg.pid,
title: `${msg.shell} #${sessions.value.length + 1}`,
createdAt: Date.now(),
exited: false,
});
switchSession(msg.id);
break;
case "switched":
// Server confirmed switch — frontend already mounted in switchSession()
break;
case "exited": {
const s = sessions.value.find((s) => s.id === msg.id);
if (s) {
s.exited = true;
if (activeSessionId.value === msg.id) {
activeTerm?.write(
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
);
}
}
break;
}
case "error":
message.error(msg.message);
break;
}
}
// ─── Session actions ────────────────────────────────────────────
function createSession() {
send({ type: "create" });
}
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
let entry = termMap.get(id);
if (!entry) {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: "#1a1a2e",
foreground: "#e0e0e0",
cursor: "#4cc9f0",
cursorAccent: "#1a1a2e",
selectionBackground: "rgba(76, 201, 240, 0.3)",
black: "#000000",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
},
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
entry = { term, fitAddon, opened: false };
termMap.set(id, entry);
}
return entry;
}
function switchSession(id: string) {
if (activeSessionId.value === id) return;
activeSessionId.value = id;
const entry = getOrCreateTerm(id);
activeTerm = entry.term;
activeFitAddon = entry.fitAddon;
mountActiveTerminal();
send({ type: "switch", sessionId: id });
if (mobileQuery?.matches) showSessions.value = false;
}
function closeSession(id: string) {
send({ type: "close", sessionId: id });
sessions.value = sessions.value.filter((s) => s.id !== id);
// Dispose terminal instance
const entry = termMap.get(id);
if (entry) {
entry.term.dispose();
termMap.delete(id);
}
if (activeSessionId.value === id) {
activeSessionId.value =
sessions.value.length > 0 ? sessions.value[0].id : null;
activeTerm = null;
activeFitAddon = null;
if (activeSessionId.value) {
switchSession(activeSessionId.value);
} else {
unmountActiveTerminal();
createSession();
}
}
}
// ─── Terminal mount/unmount ─────────────────────────────────────
function mountActiveTerminal() {
if (!terminalRef.value) return;
const container = terminalRef.value;
// Remove old terminal DOM from container
while (container.firstChild) container.removeChild(container.firstChild);
const entry = termMap.get(activeSessionId.value!);
if (!entry) return;
if (!entry.opened) {
// First time: call open()
entry.term.open(container);
entry.opened = true;
} else {
// Already opened: move the existing DOM element
const termEl = entry.term.element;
if (termEl) {
container.appendChild(termEl);
}
}
// Resize observer
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
tryFit();
sendResize();
});
resizeObserver.observe(terminalRef.value);
// Fit after DOM is ready
setTimeout(() => tryFit(), 50);
setTimeout(() => tryFit(), 200);
}
function unmountActiveTerminal() {
if (!terminalRef.value) return;
const container = terminalRef.value;
while (container.firstChild) container.removeChild(container.firstChild);
}
function tryFit() {
if (!activeFitAddon) return;
try {
activeFitAddon.fit();
} catch {}
}
function sendResize() {
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
try {
send({
type: "resize",
cols: activeTerm.cols,
rows: activeTerm.rows,
});
} catch {}
}
// ─── Helpers ────────────────────────────────────────────────────
function formatTime(ts: number) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && showSessions.value) showSessions.value = false;
}
// ─── Lifecycle ──────────────────────────────────────────────────
onMounted(() => {
mobileQuery = window.matchMedia("(max-width: 768px)");
handleMobileChange(mobileQuery);
mobileQuery.addEventListener("change", handleMobileChange);
connect();
});
onUnmounted(() => {
mobileQuery?.removeEventListener("change", handleMobileChange);
unmountActiveTerminal();
// Dispose all terminal instances
for (const entry of termMap.values()) {
entry.term.dispose();
}
termMap.clear();
activeTerm = null;
activeFitAddon = null;
ws?.close();
ws = null;
});
</script>
<template>
<div class="terminal-panel">
<!-- Session backdrop (mobile) -->
<div
class="session-backdrop"
:class="{ active: showSessions }"
@click="showSessions = false"
/>
<!-- Session list sidebar -->
<aside class="session-list" :class="{ collapsed: !showSessions }">
<div class="session-list-header">
<span v-if="showSessions" class="session-list-title">{{
t("terminal.sessions")
}}</span>
<div class="session-list-actions">
<!-- <button class="session-close-btn" @click="showSessions = false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> -->
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" @click="createSession" circle>
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
</NButton>
</template>
{{ t("terminal.newTab") }}
</NTooltip>
</div>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="sessions.length === 0" class="session-empty">
{{ t("common.loading") }}
</div>
<button
v-for="s in sessions"
:key="s.id"
class="session-item"
:class="{ active: s.id === activeSessionId, exited: s.exited }"
@click="switchSession(s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-meta">
<span class="session-item-shell">{{ s.shell }}</span>
<span v-if="s.exited" class="session-item-status">{{
t("terminal.sessionExited")
}}</span>
<span v-else class="session-item-time">{{
formatTime(s.createdAt)
}}</span>
</span>
</div>
<NPopconfirm
v-if="sessions.length > 1"
@positive-click="closeSession(s.id)"
>
<template #trigger>
<button class="session-item-delete" @click.stop>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</template>
{{ t("terminal.closeSession") }}
</NPopconfirm>
</button>
</div>
</aside>
<!-- Main terminal area -->
<div class="terminal-main">
<header class="terminal-header">
<div class="header-left">
<NButton
quaternary
size="small"
@click="showSessions = !showSessions"
circle
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
</template>
</NButton>
<span v-if="activeSession" class="header-session-title">{{
activeSession.title
}}</span>
</div>
<div class="header-actions">
<NButton size="small" @click="createSession">
<template #icon>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
{{ t("terminal.newTab") }}
</NButton>
</div>
</header>
<div class="terminal-container">
<div ref="terminalRef" class="terminal-xterm" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.terminal-panel {
display: flex;
height: 100%;
position: relative;
}
// ─── Session list ──────────────────────────────────────────────
.session-list {
width: 220px;
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
flex-shrink: 0;
transition:
width $transition-normal,
opacity $transition-normal;
overflow: hidden;
&.collapsed {
width: 0;
border-right: none;
opacity: 0;
pointer-events: none;
}
@media (max-width: $breakpoint-mobile) {
position: absolute;
left: 0;
top: 0;
height: 100%;
z-index: 10;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
width: 280px;
&.collapsed {
transform: translateX(-100%);
opacity: 0;
}
}
}
.session-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
flex-shrink: 0;
}
.session-list-actions {
display: flex;
align-items: center;
gap: 4px;
}
.session-list-title {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.session-items {
flex: 1;
overflow-y: auto;
padding: 0 6px 12px;
}
.session-empty {
padding: 16px 10px;
font-size: 12px;
color: $text-muted;
text-align: center;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 10px;
border: none;
background: none;
border-radius: $radius-sm;
cursor: pointer;
text-align: left;
color: $text-secondary;
transition: all $transition-fast;
margin-bottom: 2px;
&:hover {
background: rgba($accent-primary, 0.06);
color: $text-primary;
.session-item-delete {
opacity: 1;
}
}
&.active {
background: rgba($accent-primary, 0.1);
color: $text-primary;
font-weight: 500;
}
&.exited {
opacity: 0.5;
}
}
.session-item-content {
flex: 1;
overflow: hidden;
}
.session-item-title {
display: block;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.session-item-shell {
font-size: 10px;
color: $accent-primary;
background: rgba($accent-primary, 0.08);
padding: 0 5px;
border-radius: 3px;
line-height: 16px;
}
.session-item-time {
font-size: 11px;
color: $text-muted;
}
.session-item-status {
font-size: 11px;
color: $text-muted;
font-style: italic;
}
.session-item-delete {
flex-shrink: 0;
opacity: 0.5;
padding: 2px;
border: none;
background: none;
color: $text-muted;
cursor: pointer;
border-radius: 3px;
transition: all $transition-fast;
&:hover {
color: $error;
background: rgba($error, 0.1);
}
}
.session-close-btn {
display: none;
border: none;
background: none;
cursor: pointer;
color: $text-secondary;
padding: 4px;
border-radius: $radius-sm;
&:hover {
background: rgba($accent-primary, 0.06);
}
}
// ─── Main area ──────────────────────────────────────────────────
.terminal-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 21px 20px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
flex: 1;
min-width: 0;
}
.header-session-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
// ─── Terminal container ─────────────────────────────────────────
.terminal-container {
flex: 1;
margin: 10px;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.terminal-xterm {
flex: 1;
border-radius: $radius-md;
overflow: hidden;
background-color: #1a1a2e;
border: 1px solid $border-color;
:deep(.xterm) {
height: 100%;
padding: 8px;
}
:deep(.xterm-viewport) {
overflow-y: scroll !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
background-color: transparent !important;
}
:deep(.xterm-viewport::-webkit-scrollbar) {
display: none !important;
}
:deep(.xterm-screen) {
background-color: transparent !important;
}
:deep(.xterm-scrollable-element) {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
display: none !important;
}
}
// ─── Mobile ─────────────────────────────────────────────────────
@media (max-width: $breakpoint-mobile) {
.session-close-btn {
display: flex;
}
.session-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 9;
opacity: 0;
pointer-events: none;
transition: opacity $transition-fast;
&.active {
opacity: 1;
pointer-events: auto;
}
}
.terminal-header {
padding: 16px 12px 16px 52px;
}
.terminal-container {
padding: 8px;
}
.terminal-xterm {
left: 0;
right: 0;
bottom: 0;
}
}
</style>
<style lang="scss">
/* Global: xterm scrollbar (scoped :deep can't reach dynamically created elements) */
.xterm .scrollbar {
width: 6px !important;
border-radius: 3px !important;
background: rgba(255, 255, 255, 0.08) !important;
}
.xterm .scrollbar .slider {
border-radius: 3px !important;
background: rgba(255, 255, 255, 0.2) !important;
transition: background 0.15s ease !important;
}
.xterm .scrollbar:hover .slider {
background: rgba(255, 255, 255, 0.35) !important;
}
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
import StatCards from '@/components/hermes/usage/StatCards.vue'
import ModelBreakdown from '@/components/hermes/usage/ModelBreakdown.vue'
import DailyTrend from '@/components/hermes/usage/DailyTrend.vue'
const { t } = useI18n()
const usageStore = useUsageStore()
onMounted(() => {
usageStore.loadSessions()
})
</script>
<template>
<div class="usage-view">
<header class="page-header">
<h2 class="header-title">{{ t('usage.title') }}</h2>
<NButton size="small" quaternary :loading="usageStore.isLoading" @click="usageStore.loadSessions()">
{{ t('usage.refresh') }}
</NButton>
</header>
<div class="usage-content">
<div v-if="usageStore.isLoading && usageStore.sessions.length === 0" class="usage-loading">
{{ t('common.loading') }}
</div>
<template v-else-if="usageStore.sessions.length > 0">
<StatCards />
<ModelBreakdown />
<DailyTrend />
</template>
<div v-else class="usage-empty">
{{ t('usage.noData') }}
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.usage-view {
height: 100%;
display: flex;
flex-direction: column;
}
.usage-content {
flex: 1;
overflow-y: auto;
padding: 20px;
max-width: 960px;
margin: 0 auto;
width: 100%;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.usage-loading,
.usage-empty {
text-align: center;
padding: 60px 0;
color: $text-muted;
font-size: 14px;
}
</style>