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
@@ -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>