refactor: restructure project for multi-agent extensibility
- Migrate source to packages/client and packages/server directories - Namespace all Hermes-specific code under hermes/ subdirectories (api/hermes/, components/hermes/, views/hermes/, stores/hermes/) - Add hermes.* route names and /hermes/* path prefixes - Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax - Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/* - Fix frontend API paths to match backend /api/hermes/* routes - Fix WebSocket terminal path to /api/hermes/terminal - Add proxyMiddleware for reliable unmatched route proxying - Add profiles route module and hermes-cli profile commands - Update CLAUDE.md development guide with new architecture - Add Chinese README (README_zh.md) - Add Web Terminal feature to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { setApiKey, hasApiKey } from "@/api/client";
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// Read token saved by main.ts (before router strips URL params)
|
||||
const urlToken = (window as any).__LOGIN_TOKEN__ || "";
|
||||
|
||||
const token = ref(urlToken);
|
||||
const loading = ref(false);
|
||||
const errorMsg = ref("");
|
||||
// If already has a key, try to go to main page
|
||||
if (hasApiKey()) {
|
||||
router.replace("/hermes/chat");
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const key = token.value.trim();
|
||||
if (!key) {
|
||||
errorMsg.value = t("login.tokenRequired");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMsg.value = "";
|
||||
|
||||
try {
|
||||
// Validate token by calling an auth-required endpoint
|
||||
const res = await fetch("/api/sessions", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
errorMsg.value = t("login.invalidToken");
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setApiKey(key);
|
||||
router.replace("/hermes/chat");
|
||||
} catch {
|
||||
errorMsg.value = t("login.connectionFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-view">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<img src="/logo.png" alt="Hermes" width="80" height="80" />
|
||||
</div>
|
||||
<h1 class="login-title">{{ t('login.title') }}</h1>
|
||||
<p class="login-desc">{{ t("login.description") }}</p>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<input
|
||||
v-model="token"
|
||||
type="password"
|
||||
class="login-input"
|
||||
:placeholder="t('login.placeholder')"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="errorMsg" class="login-error">{{ errorMsg }}</div>
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
{{ loading ? "..." : t("login.submit") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.login-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-primary;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: 56px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-card;
|
||||
text-align: center;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
font-size: 14px;
|
||||
color: $text-muted;
|
||||
margin: 0 0 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 15px;
|
||||
color: $text-primary;
|
||||
background: $bg-input;
|
||||
outline: none;
|
||||
transition: border-color $transition-fast;
|
||||
box-sizing: border-box;
|
||||
font-family: $font-code;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.login-error {
|
||||
font-size: 13px;
|
||||
color: $error;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: $radius-sm;
|
||||
background: $text-primary;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import PlatformSettings from '@/components/hermes/settings/PlatformSettings.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.fetchSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channels-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('sidebar.channels') }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="channels-content">
|
||||
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
|
||||
<PlatformSettings />
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.channels-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.channels-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
|
||||
onMounted(() => {
|
||||
appStore.loadModels()
|
||||
chatStore.loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import JobsPanel from '@/components/hermes/jobs/JobsPanel.vue'
|
||||
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
|
||||
const { t } = useI18n()
|
||||
const jobsStore = useJobsStore()
|
||||
const showModal = ref(false)
|
||||
const editingJob = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
jobsStore.fetchJobs()
|
||||
})
|
||||
|
||||
function openCreateModal() {
|
||||
editingJob.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(jobId: string) {
|
||||
editingJob.value = jobId
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showModal.value = false
|
||||
editingJob.value = null
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await jobsStore.fetchJobs()
|
||||
handleModalClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="jobs-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('jobs.title') }}</h2>
|
||||
<NButton type="primary" size="small" @click="openCreateModal">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
{{ t('jobs.createJob') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="jobs-content">
|
||||
<NSpin :show="jobsStore.loading && jobsStore.jobs.length === 0">
|
||||
<JobsPanel @edit="openEditModal" />
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<JobFormModal
|
||||
v-if="showModal"
|
||||
:job-id="editingJob"
|
||||
@close="handleModalClose"
|
||||
@saved="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.jobs-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.jobs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/hermes/logs'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const logFiles = ref<{ name: string; size: string; modified: string }[]>([])
|
||||
const selectedLog = ref('agent')
|
||||
const entries = ref<LogEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const lineCount = ref(100)
|
||||
const levelFilter = ref<string>('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const logOptions = computed(() =>
|
||||
logFiles.value.map(f => ({ label: `${f.name} (${f.size})`, value: f.name })),
|
||||
)
|
||||
|
||||
const levelOptions = computed(() => [
|
||||
{ label: t('logs.all'), value: '' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
{ label: 'WARNING', value: 'WARNING' },
|
||||
{ label: 'INFO', value: 'INFO' },
|
||||
{ label: 'DEBUG', value: 'DEBUG' },
|
||||
])
|
||||
|
||||
const lineOptions = [
|
||||
{ label: '50', value: 50 },
|
||||
{ label: '100', value: 100 },
|
||||
{ label: '200', value: 200 },
|
||||
{ label: '500', value: 500 },
|
||||
]
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
if (!searchQuery.value) return entries.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return entries.value.filter(e =>
|
||||
e.message.toLowerCase().includes(q) ||
|
||||
e.logger.toLowerCase().includes(q) ||
|
||||
e.raw.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function levelClass(level: string): string {
|
||||
switch (level) {
|
||||
case 'ERROR': return 'level-error'
|
||||
case 'WARNING': return 'level-warning'
|
||||
case 'DEBUG': return 'level-debug'
|
||||
default: return 'level-info'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const match = ts.match(/\d{2}:\d{2}:\d{2}/)
|
||||
return match ? match[0] : ts
|
||||
}
|
||||
|
||||
function parseAccessLog(msg: string) {
|
||||
const match = msg.match(/"(\w+)\s+(\S+)\s+HTTP\/[^"]+"\s+(\d+)/)
|
||||
if (match) return { method: match[1], path: match[2], status: match[3] }
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await fetchLogs(selectedLog.value, {
|
||||
lines: lineCount.value,
|
||||
level: levelFilter.value || undefined,
|
||||
})
|
||||
entries.value = data.filter((e): e is LogEntry => e !== null)
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
logFiles.value = await fetchLogFiles()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="logs-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('logs.title') }}</h2>
|
||||
<div class="header-actions">
|
||||
<NSelect
|
||||
v-model:value="selectedLog"
|
||||
:options="logOptions"
|
||||
size="small"
|
||||
class="input-md"
|
||||
@update:value="loadLogs"
|
||||
/>
|
||||
<NSelect
|
||||
:value="levelFilter"
|
||||
:options="levelOptions"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="(v: string) => { levelFilter = v; loadLogs() }"
|
||||
/>
|
||||
<NSelect
|
||||
:value="lineCount"
|
||||
:options="lineOptions"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="(v: number) => { lineCount = v; loadLogs() }"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
:placeholder="t('logs.searchPlaceholder')"
|
||||
/>
|
||||
<NButton size="small" :loading="loading" @click="loadLogs">{{ t('logs.refresh') }}</NButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="logs-body">
|
||||
<NSpin :show="loading">
|
||||
<div v-if="filteredEntries.length === 0 && !loading" class="logs-empty">
|
||||
{{ t('logs.noEntries') }}
|
||||
</div>
|
||||
<div class="log-list">
|
||||
<div
|
||||
v-for="(entry, idx) in filteredEntries"
|
||||
:key="idx"
|
||||
class="log-entry"
|
||||
:class="levelClass(entry.level)"
|
||||
>
|
||||
<span class="log-time">{{ formatTime(entry.timestamp) }}</span>
|
||||
<span class="log-level" :class="levelClass(entry.level)">{{ entry.level }}</span>
|
||||
<span class="log-logger">{{ entry.logger }}</span>
|
||||
<template v-if="parseAccessLog(entry.message)">
|
||||
<span class="access-method">{{ parseAccessLog(entry.message)!.method }}</span>
|
||||
<span class="access-path">{{ parseAccessLog(entry.message)!.path }}</span>
|
||||
<span class="access-status" :class="'status-' + (parseAccessLog(entry.message)!.status?.[0] || 'x')">
|
||||
{{ parseAccessLog(entry.message)!.status }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="log-message">{{ entry.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.logs-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-input;
|
||||
color: $text-primary;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 160px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:focus { border-color: $accent-primary; }
|
||||
&::placeholder { color: $text-muted; }
|
||||
}
|
||||
|
||||
.logs-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 20px;
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
border-left: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($accent-primary, 0.03);
|
||||
}
|
||||
|
||||
&.level-error {
|
||||
border-left-color: $error;
|
||||
.log-message { color: $error; }
|
||||
}
|
||||
|
||||
&.level-warning {
|
||||
border-left-color: $warning;
|
||||
.log-message { color: #d9720f; }
|
||||
}
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
|
||||
&.level-error { background: rgba($error, 0.12); color: $error; }
|
||||
&.level-warning { background: rgba($warning, 0.12); color: #d9720f; }
|
||||
&.level-debug { background: rgba($accent-primary, 0.06); color: $text-muted; }
|
||||
&.level-info { background: rgba($accent-primary, 0.06); color: $text-muted; }
|
||||
}
|
||||
|
||||
.log-logger {
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: $text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-method {
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.access-path {
|
||||
color: $accent-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.access-status {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
|
||||
&.status-2 { color: $success; }
|
||||
&.status-3 { color: $warning; }
|
||||
&.status-4 { color: $error; }
|
||||
&.status-5 { color: $error; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
import { fetchMemory, saveMemory, type MemoryData } from '@/api/hermes/skills'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const data = ref<MemoryData | null>(null)
|
||||
const editingSection = ref<'memory' | 'user' | null>(null)
|
||||
const editContent = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(loadMemory)
|
||||
|
||||
async function loadMemory() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await fetchMemory()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load memory:', err)
|
||||
message.error(t('memory.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(section: 'memory' | 'user') {
|
||||
editingSection.value = section
|
||||
editContent.value = data.value?.[section] || ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editingSection.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await saveMemory(editingSection.value, editContent.value)
|
||||
await loadMemory()
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
message.success(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(`${t('common.saveFailed')}: ${err.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const memoryEmpty = computed(() => !data.value?.memory?.trim())
|
||||
const userEmpty = computed(() => !data.value?.user?.trim())
|
||||
|
||||
const displayMemory = computed(() => (data.value?.memory || '').replace(/§/g, '\n\n'))
|
||||
const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="memory-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('memory.title') }}</h2>
|
||||
<NButton size="small" quaternary @click="loadMemory">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t('memory.refresh') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="memory-content">
|
||||
<div v-if="loading && !data" class="memory-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else class="memory-sections">
|
||||
<!-- My Notes -->
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<span class="section-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">{{ t('memory.myNotes') }}</span>
|
||||
<span v-if="data?.memory_mtime" class="section-mtime">{{ formatTime(data.memory_mtime) }}</span>
|
||||
</div>
|
||||
<NButton v-if="editingSection !== 'memory'" size="tiny" quaternary @click="startEdit('memory')">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t('common.edit') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- View mode -->
|
||||
<div v-if="editingSection !== 'memory'" class="section-body">
|
||||
<MarkdownRenderer v-if="!memoryEmpty" :content="displayMemory" />
|
||||
<p v-else class="empty-text">{{ t('memory.noNotes') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="section-edit">
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
:placeholder="t('memory.notesPlaceholder')"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<span class="section-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">{{ t('memory.userProfile') }}</span>
|
||||
<span v-if="data?.user_mtime" class="section-mtime">{{ formatTime(data.user_mtime) }}</span>
|
||||
</div>
|
||||
<NButton v-if="editingSection !== 'user'" size="tiny" quaternary @click="startEdit('user')">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t('common.edit') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- View mode -->
|
||||
<div v-if="editingSection !== 'user'" class="section-body">
|
||||
<MarkdownRenderer v-if="!userEmpty" :content="displayUser" />
|
||||
<p v-else class="empty-text">{{ t('memory.noProfile') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="section-edit">
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
:placeholder="t('memory.profilePlaceholder')"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.memory-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.memory-sections {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.memory-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: $bg-secondary;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: $text-secondary;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.section-mtime {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-edit {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-input;
|
||||
color: $text-primary;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProvidersPanel from '@/components/hermes/models/ProvidersPanel.vue'
|
||||
import ProviderFormModal from '@/components/hermes/models/ProviderFormModal.vue'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const showModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
modelsStore.fetchProviders()
|
||||
})
|
||||
|
||||
function openCreateModal() {
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function handleSaved() {
|
||||
await modelsStore.fetchProviders()
|
||||
handleModalClose()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="models-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('models.title') }}</h2>
|
||||
<NButton type="primary" size="small" @click="openCreateModal">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
{{ t('models.addProvider') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="models-content">
|
||||
<NSpin :show="modelsStore.loading && modelsStore.providers.length === 0">
|
||||
<ProvidersPanel />
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<ProviderFormModal
|
||||
v-if="showModal"
|
||||
@close="handleModalClose"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.models-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.models-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NTabs, NTabPane, NSpin, NSwitch, NInput, NInputNumber, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import DisplaySettings from '@/components/hermes/settings/DisplaySettings.vue'
|
||||
import AgentSettings from '@/components/hermes/settings/AgentSettings.vue'
|
||||
import MemorySettings from '@/components/hermes/settings/MemorySettings.vue'
|
||||
import SessionSettings from '@/components/hermes/settings/SessionSettings.vue'
|
||||
import PrivacySettings from '@/components/hermes/settings/PrivacySettings.vue'
|
||||
import SettingRow from '@/components/hermes/settings/SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.fetchSettings()
|
||||
})
|
||||
|
||||
async function saveApiServer(values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection('platforms', { api_server: values })
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('settings.title') }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="settings-content">
|
||||
<NSpin :show="settingsStore.loading || settingsStore.saving" size="large" :description="t('common.loading')">
|
||||
<NTabs type="line" animated>
|
||||
<NTabPane name="display" :tab="t('settings.tabs.display')">
|
||||
<DisplaySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="agent" :tab="t('settings.tabs.agent')">
|
||||
<AgentSettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="memory" :tab="t('settings.tabs.memory')">
|
||||
<MemorySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="session" :tab="t('settings.tabs.session')">
|
||||
<SessionSettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
|
||||
<PrivacySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.apiServer.enable')" :hint="t('settings.apiServer.enableHint')">
|
||||
<NSwitch
|
||||
:value="settingsStore.platforms?.api_server?.enabled"
|
||||
@update:value="v => saveApiServer({ enabled: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.host')" :hint="t('settings.apiServer.hostHint')">
|
||||
<NInput
|
||||
:value="settingsStore.platforms?.api_server?.host || ''"
|
||||
size="small" class="input-md"
|
||||
@update:value="v => saveApiServer({ host: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.port')" :hint="t('settings.apiServer.portHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.platforms?.api_server?.port"
|
||||
:min="1024" :max="65535"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => v != null && saveApiServer({ port: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.key')" :hint="t('settings.apiServer.keyHint')">
|
||||
<NInput
|
||||
:value="settingsStore.platforms?.api_server?.key || ''"
|
||||
type="password" show-password-on="click"
|
||||
size="small" class="input-md"
|
||||
@update:value="v => saveApiServer({ key: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.cors')" :hint="t('settings.apiServer.corsHint')">
|
||||
<NInput
|
||||
:value="settingsStore.platforms?.api_server?.cors_origins || ''"
|
||||
size="small" class="input-md"
|
||||
@update:value="v => saveApiServer({ cors_origins: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { NInput } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SkillList from '@/components/hermes/skills/SkillList.vue'
|
||||
import SkillDetail from '@/components/hermes/skills/SkillDetail.vue'
|
||||
import { fetchSkills, type SkillCategory } from '@/api/hermes/skills'
|
||||
|
||||
const { t } = useI18n()
|
||||
const categories = ref<SkillCategory[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedCategory = ref('')
|
||||
const selectedSkill = ref('')
|
||||
const searchQuery = ref('')
|
||||
const showSidebar = ref(true)
|
||||
let mobileQuery: MediaQueryList | null = null
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
showSidebar.value = !e.matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||
handleMobileChange(mobileQuery)
|
||||
mobileQuery.addEventListener('change', handleMobileChange)
|
||||
loadSkills()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||
})
|
||||
|
||||
async function loadSkills() {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = await fetchSkills()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load skills:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(category: string, skill: string) {
|
||||
selectedCategory.value = category
|
||||
selectedSkill.value = skill
|
||||
if (window.innerWidth <= 768) {
|
||||
showSidebar.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skills-view">
|
||||
<header class="page-header">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<h2 class="header-title">{{ t('skills.title') }}</h2>
|
||||
<button v-if="!showSidebar" class="sidebar-toggle" @click="showSidebar = true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="searchQuery"
|
||||
:placeholder="t('skills.searchPlaceholder')"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="skills-content">
|
||||
<div v-if="loading && categories.length === 0" class="skills-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else class="skills-layout">
|
||||
<div class="mobile-backdrop" :class="{ active: showSidebar }" @click="showSidebar = false" />
|
||||
<div v-if="showSidebar" class="skills-sidebar">
|
||||
<SkillList
|
||||
:categories="categories"
|
||||
:selected-skill="selectedCategory && selectedSkill ? `${selectedCategory}/${selectedSkill}` : null"
|
||||
:search-query="searchQuery"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
<div class="skills-main">
|
||||
<SkillDetail
|
||||
v-if="selectedCategory && selectedSkill"
|
||||
:category="selectedCategory"
|
||||
:skill="selectedSkill"
|
||||
/>
|
||||
<div v-else class="empty-detail">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>{{ t('skills.noMatch') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skills-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100px;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.skills-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.skills-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skills-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skills-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.skills-sidebar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.skills-layout {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,802 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { getApiKey, getBaseUrlValue } from "@/api/client";
|
||||
import { NButton, NPopconfirm, NTooltip, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
shell: string;
|
||||
pid: number;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
exited: boolean;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
const showSessions = ref(true);
|
||||
const sessions = ref<SessionInfo[]>([]);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
// Keep all terminal instances alive, only dispose on close
|
||||
const termMap = new Map<
|
||||
string,
|
||||
{ term: Terminal; fitAddon: FitAddon; opened: boolean }
|
||||
>();
|
||||
let activeTerm: Terminal | null = null;
|
||||
let activeFitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let mobileQuery: MediaQueryList | null = null;
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
);
|
||||
|
||||
// ─── WebSocket ──────────────────────────────────────────────────
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const token = getApiKey();
|
||||
const base = getBaseUrlValue();
|
||||
const wsProtocol = base
|
||||
? base.startsWith("https")
|
||||
? "wss:"
|
||||
: "ws:"
|
||||
: location.protocol === "https:"
|
||||
? "wss:"
|
||||
: "ws:";
|
||||
|
||||
if (base) {
|
||||
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
// Dev mode: connect directly to backend port; Production: same host
|
||||
const host = import.meta.env.DEV
|
||||
? `${location.hostname}:8648`
|
||||
: location.host;
|
||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const url = buildWsUrl();
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Server auto-creates the first session and sends 'created'
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = typeof event.data === "string" ? event.data : "";
|
||||
if (data.charCodeAt(0) === 0x7b) {
|
||||
try {
|
||||
handleControl(JSON.parse(data));
|
||||
} catch {}
|
||||
} else {
|
||||
activeTerm?.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
// On reconnect, recreate all terminals for existing sessions
|
||||
ws.onopen = () => {
|
||||
// Server will auto-create the first session again
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Reconnect after delay
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// let onclose handle reconnect
|
||||
};
|
||||
}
|
||||
|
||||
function send(data: object | string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── Control message handlers ──────────────────────────────────
|
||||
|
||||
function handleControl(msg: any) {
|
||||
switch (msg.type) {
|
||||
case "created":
|
||||
sessions.value.push({
|
||||
id: msg.id,
|
||||
shell: msg.shell,
|
||||
pid: msg.pid,
|
||||
title: `${msg.shell} #${sessions.value.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
exited: false,
|
||||
});
|
||||
switchSession(msg.id);
|
||||
break;
|
||||
|
||||
case "switched":
|
||||
// Server confirmed switch — frontend already mounted in switchSession()
|
||||
break;
|
||||
|
||||
case "exited": {
|
||||
const s = sessions.value.find((s) => s.id === msg.id);
|
||||
if (s) {
|
||||
s.exited = true;
|
||||
if (activeSessionId.value === msg.id) {
|
||||
activeTerm?.write(
|
||||
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
message.error(msg.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session actions ────────────────────────────────────────────
|
||||
|
||||
function createSession() {
|
||||
send({ type: "create" });
|
||||
}
|
||||
|
||||
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
|
||||
let entry = termMap.get(id);
|
||||
if (!entry) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#1a1a2e",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#4cc9f0",
|
||||
cursorAccent: "#1a1a2e",
|
||||
selectionBackground: "rgba(76, 201, 240, 0.3)",
|
||||
black: "#000000",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
},
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
entry = { term, fitAddon, opened: false };
|
||||
termMap.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function switchSession(id: string) {
|
||||
if (activeSessionId.value === id) return;
|
||||
activeSessionId.value = id;
|
||||
const entry = getOrCreateTerm(id);
|
||||
activeTerm = entry.term;
|
||||
activeFitAddon = entry.fitAddon;
|
||||
mountActiveTerminal();
|
||||
send({ type: "switch", sessionId: id });
|
||||
if (mobileQuery?.matches) showSessions.value = false;
|
||||
}
|
||||
|
||||
function closeSession(id: string) {
|
||||
send({ type: "close", sessionId: id });
|
||||
sessions.value = sessions.value.filter((s) => s.id !== id);
|
||||
// Dispose terminal instance
|
||||
const entry = termMap.get(id);
|
||||
if (entry) {
|
||||
entry.term.dispose();
|
||||
termMap.delete(id);
|
||||
}
|
||||
if (activeSessionId.value === id) {
|
||||
activeSessionId.value =
|
||||
sessions.value.length > 0 ? sessions.value[0].id : null;
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
if (activeSessionId.value) {
|
||||
switchSession(activeSessionId.value);
|
||||
} else {
|
||||
unmountActiveTerminal();
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Terminal mount/unmount ─────────────────────────────────────
|
||||
|
||||
function mountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
// Remove old terminal DOM from container
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
const entry = termMap.get(activeSessionId.value!);
|
||||
if (!entry) return;
|
||||
|
||||
if (!entry.opened) {
|
||||
// First time: call open()
|
||||
entry.term.open(container);
|
||||
entry.opened = true;
|
||||
} else {
|
||||
// Already opened: move the existing DOM element
|
||||
const termEl = entry.term.element;
|
||||
if (termEl) {
|
||||
container.appendChild(termEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Resize observer
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
tryFit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.value);
|
||||
|
||||
// Fit after DOM is ready
|
||||
setTimeout(() => tryFit(), 50);
|
||||
setTimeout(() => tryFit(), 200);
|
||||
}
|
||||
|
||||
function unmountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
function tryFit() {
|
||||
if (!activeFitAddon) return;
|
||||
try {
|
||||
activeFitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
send({
|
||||
type: "resize",
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches && showSessions.value) showSessions.value = false;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia("(max-width: 768px)");
|
||||
handleMobileChange(mobileQuery);
|
||||
mobileQuery.addEventListener("change", handleMobileChange);
|
||||
connect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mobileQuery?.removeEventListener("change", handleMobileChange);
|
||||
unmountActiveTerminal();
|
||||
// Dispose all terminal instances
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.dispose();
|
||||
}
|
||||
termMap.clear();
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
ws?.close();
|
||||
ws = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-panel">
|
||||
<!-- Session backdrop (mobile) -->
|
||||
<div
|
||||
class="session-backdrop"
|
||||
:class="{ active: showSessions }"
|
||||
@click="showSessions = false"
|
||||
/>
|
||||
|
||||
<!-- Session list sidebar -->
|
||||
<aside class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{
|
||||
t("terminal.sessions")
|
||||
}}</span>
|
||||
<div class="session-list-actions">
|
||||
<!-- <button class="session-close-btn" @click="showSessions = false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button> -->
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" @click="createSession" circle>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="sessions.length === 0" class="session-empty">
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<button
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === activeSessionId, exited: s.exited }"
|
||||
@click="switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span class="session-item-shell">{{ s.shell }}</span>
|
||||
<span v-if="s.exited" class="session-item-status">{{
|
||||
t("terminal.sessionExited")
|
||||
}}</span>
|
||||
<span v-else class="session-item-time">{{
|
||||
formatTime(s.createdAt)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
v-if="sessions.length > 1"
|
||||
@positive-click="closeSession(s.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
{{ t("terminal.closeSession") }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main terminal area -->
|
||||
<div class="terminal-main">
|
||||
<header class="terminal-header">
|
||||
<div class="header-left">
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showSessions = !showSessions"
|
||||
circle
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<span v-if="activeSession" class="header-session-title">{{
|
||||
activeSession.title
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NButton size="small" @click="createSession">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="terminal-container">
|
||||
<div ref="terminalRef" class="terminal-xterm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// ─── Session list ──────────────────────────────────────────────
|
||||
|
||||
.session-list {
|
||||
width: 220px;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
width $transition-normal,
|
||||
opacity $transition-normal;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
width: 0;
|
||||
border-right: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
width: 280px;
|
||||
|
||||
&.collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-list-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 6px 12px;
|
||||
}
|
||||
|
||||
.session-empty {
|
||||
padding: 16px 10px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
transition: all $transition-fast;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
color: $text-primary;
|
||||
|
||||
.session-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($accent-primary, 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.exited {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-shell {
|
||||
font-size: 10px;
|
||||
color: $accent-primary;
|
||||
background: rgba($accent-primary, 0.08);
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.session-item-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-status {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba($error, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.session-close-btn {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main area ──────────────────────────────────────────────────
|
||||
|
||||
.terminal-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 21px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ─── Terminal container ─────────────────────────────────────────
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
margin: 10px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
flex: 1;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
background-color: #1a1a2e;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
:deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport) {
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-screen) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element) {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mobile ─────────────────────────────────────────────────────
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.session-close-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.session-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
padding: 16px 12px 16px 52px;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Global: xterm scrollbar (scoped :deep can't reach dynamically created elements) */
|
||||
.xterm .scrollbar {
|
||||
width: 6px !important;
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.xterm .scrollbar .slider {
|
||||
border-radius: 3px !important;
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
transition: background 0.15s ease !important;
|
||||
}
|
||||
|
||||
.xterm .scrollbar:hover .slider {
|
||||
background: rgba(255, 255, 255, 0.35) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUsageStore } from '@/stores/hermes/usage'
|
||||
import StatCards from '@/components/hermes/usage/StatCards.vue'
|
||||
import ModelBreakdown from '@/components/hermes/usage/ModelBreakdown.vue'
|
||||
import DailyTrend from '@/components/hermes/usage/DailyTrend.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const usageStore = useUsageStore()
|
||||
|
||||
onMounted(() => {
|
||||
usageStore.loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usage-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('usage.title') }}</h2>
|
||||
<NButton size="small" quaternary :loading="usageStore.isLoading" @click="usageStore.loadSessions()">
|
||||
{{ t('usage.refresh') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="usage-content">
|
||||
<div v-if="usageStore.isLoading && usageStore.sessions.length === 0" class="usage-loading">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="usageStore.sessions.length > 0">
|
||||
<StatCards />
|
||||
<ModelBreakdown />
|
||||
<DailyTrend />
|
||||
</template>
|
||||
|
||||
<div v-else class="usage-empty">
|
||||
{{ t('usage.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.usage-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-loading,
|
||||
.usage-empty {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: $text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user