feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Studio - Self-Hosted AI Chat Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { darkTheme } from 'naive-ui'
import { NConfigProvider, NMessageProvider } from 'naive-ui'
import { useTheme } from '@/composables/useTheme'
import { getThemeOverrides } from '@client/styles/theme'
import SiteHeader from '@/components/layout/SiteHeader.vue'
import SiteFooter from '@/components/layout/SiteFooter.vue'
const { isDark } = useTheme()
</script>
<template>
<NConfigProvider :theme="isDark ? darkTheme : undefined" :theme-overrides="getThemeOverrides(isDark)">
<NMessageProvider>
<div class="website-app">
<SiteHeader />
<main class="website-main">
<router-view />
</main>
<SiteFooter />
</div>
</NMessageProvider>
</NConfigProvider>
</template>
<style scoped lang="scss">
.website-app {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
color: var(--text-primary);
}
.website-main {
flex: 1;
}
</style>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t, tm, rt } = useI18n()
const route = useRoute()
const pageKey = computed(() => (route.meta.page as string) || 'gettingStarted')
const pageTitle = computed(() => t(`docs.${pageKey.value}.title`))
const pageIntro = computed(() => t(`docs.${pageKey.value}.intro`))
interface DocSection {
title: string
content: string
rows?: string[][]
}
const sections = computed<DocSection[]>(() => {
const key = pageKey.value
const meta = tm(`docs.${key}`) as Record<string, any>
if (!meta) return []
const result: DocSection[] = []
const sectionKeys = Object.keys(meta).filter(
(k) => k !== 'title' && k !== 'intro' && typeof meta[k] === 'object' && meta[k] !== null,
)
for (const sk of sectionKeys) {
const section = meta[sk] as Record<string, any>
result.push({
title: rt(section.title || ''),
content: rt(section.content || ''),
rows: Array.isArray(section.rows) ? section.rows : undefined,
})
}
return result
})
</script>
<template>
<div class="doc-content">
<h1 class="doc-title">{{ pageTitle }}</h1>
<p v-if="pageIntro" class="doc-intro">{{ pageIntro }}</p>
<div v-for="(section, i) in sections" :key="i" class="doc-section">
<h2 class="doc-section-title">{{ section.title }}</h2>
<p v-if="section.content" class="doc-section-text">{{ section.content }}</p>
<table v-if="section.rows?.length" class="doc-table">
<tbody>
<tr v-for="(row, ri) in section.rows" :key="ri">
<td class="doc-table-key"><code>{{ row[0] }}</code></td>
<td>{{ row[1] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped lang="scss">
.doc-content {
max-width: 720px;
width: 100%;
padding: 40px 32px 80px;
@media (max-width: $breakpoint-mobile) {
padding: 24px 16px 60px;
}
}
.doc-title {
font-size: 32px;
font-weight: 700;
margin-bottom: 16px;
color: var(--text-primary);
}
.doc-intro {
font-size: 16px;
line-height: 1.7;
color: var(--text-secondary);
margin-bottom: 40px;
}
.doc-section {
margin-bottom: 36px;
}
.doc-section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.doc-section-text {
font-size: 15px;
line-height: 1.7;
color: var(--text-secondary);
}
.doc-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
tr {
border-bottom: 1px solid var(--border-light);
}
td {
padding: 10px 12px;
font-size: 14px;
color: var(--text-secondary);
vertical-align: top;
}
@media (max-width: $breakpoint-mobile) {
display: block;
tr {
display: block;
padding: 10px 0;
}
td {
display: block;
padding: 2px 0;
}
}
}
.doc-table-key {
white-space: nowrap;
width: 1%;
font-weight: 500;
code {
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
}
@media (max-width: $breakpoint-mobile) {
white-space: normal;
width: auto;
margin-bottom: 4px;
}
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const pages = [
{ key: 'gettingStarted', name: 'docs.getting-started' },
{ key: 'configuration', name: 'docs.configuration' },
{ key: 'features', name: 'docs.features' },
{ key: 'platforms', name: 'docs.platforms' },
{ key: 'api', name: 'docs.api' },
]
function isActive(name: string) {
return route.name === name
}
function navigate(name: string) {
router.push({ name })
}
</script>
<template>
<aside class="doc-sidebar">
<nav class="sidebar-nav">
<a
v-for="p in pages"
:key="p.key"
class="sidebar-link"
:class="{ active: isActive(p.name) }"
@click.prevent="navigate(p.name)"
>
{{ t(`docs.sidebar.${p.key}`) }}
</a>
</nav>
</aside>
</template>
<style scoped lang="scss">
.doc-sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
padding: 24px 0;
position: sticky;
top: 60px;
height: calc(100vh - 60px);
overflow-y: auto;
@media (max-width: $breakpoint-mobile) {
display: none;
}
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 12px;
}
.sidebar-link {
display: block;
padding: 8px 12px;
border-radius: $radius-sm;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
&.active {
color: var(--text-primary);
background: var(--bg-secondary);
font-weight: 500;
}
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useScrollReveal } from '@/composables/useScrollReveal'
const { t } = useI18n()
useScrollReveal()
const features = [
{ key: 'streaming', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' },
{ key: 'platforms', icon: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z' },
{ key: 'multiModel', icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 14l-4-4h8l-4 4z' },
{ key: 'groupChat', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75' },
{ key: 'kanban', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z' },
{ key: 'analytics', icon: 'M18 20V10M12 20V4M6 20v-6' },
{ key: 'profiles', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z' },
{ key: 'files', icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z' },
{ key: 'terminal', icon: 'M4 17l6-6-6-6M12 19h8' },
{ key: 'quickInstall', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' },
{ key: 'i18n', icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1 17.93a8 8 0 0 1 0-15.86M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10M2 12h20' },
{ key: 'theme', icon: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' },
]
</script>
<template>
<section class="section">
<h2 class="section-title reveal">{{ t('features.title') }}</h2>
<p class="section-desc reveal">{{ t('features.desc') }}</p>
<div class="features-grid">
<div
v-for="(f, i) in features"
:key="f.key"
class="feature-card reveal"
:class="[`reveal-delay-${(i % 4) + 1}`]"
>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="f.icon" />
</svg>
</div>
<h3 class="feature-title">{{ t(`features.${f.key}.title`) }}</h3>
<p class="feature-desc">{{ t(`features.${f.key}.desc`) }}</p>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: $breakpoint-mobile) {
grid-template-columns: 1fr;
}
}
.feature-card {
padding: 28px 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-md;
transition: all $transition-normal;
&:hover {
border-color: var(--text-muted);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
}
.feature-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border-radius: $radius-sm;
margin-bottom: 16px;
color: var(--accent-primary);
transition: background $transition-normal;
.feature-card:hover & {
background: var(--border-color);
}
svg {
width: 20px;
height: 20px;
}
}
.feature-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.feature-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
const { t } = useI18n()
const router = useRouter()
const copied = ref(false)
const canvasRef = ref<HTMLCanvasElement>()
const installCmd = 'npm install -g hermes-web-ui'
async function copyCmd() {
try {
await navigator.clipboard.writeText(installCmd)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {}
}
// ─── Particle network animation ──────────────────────────
interface Particle {
x: number
y: number
vx: number
vy: number
r: number
}
let animId = 0
let particles: Particle[] = []
function initCanvas() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')!
const dpr = window.devicePixelRatio || 1
function resize() {
const el = canvasRef.value
if (!el || !el.parentElement) return
const rect = el.parentElement.getBoundingClientRect()
el.width = rect.width * dpr
el.height = rect.height * dpr
el.style.width = rect.width + 'px'
el.style.height = rect.height + 'px'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
const count = Math.min(60, Math.floor((canvas.width / dpr) / 18))
const w = canvas.width / dpr
const h = canvas.height / dpr
particles = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
r: Math.random() * 1.5 + 0.5,
}))
const maxDist = 120
function draw() {
const dark = document.documentElement.classList.contains('dark')
const dotColor = dark ? 'rgba(224,224,224,' : 'rgba(51,51,51,'
const lineColor = dark ? 'rgba(224,224,224,' : 'rgba(51,51,51,'
ctx.clearRect(0, 0, w, h)
// Update & draw particles
for (const p of particles) {
p.x += p.vx
p.y += p.vy
if (p.x < 0 || p.x > w) p.vx *= -1
if (p.y < 0 || p.y > h) p.vy *= -1
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fillStyle = dotColor + '0.6)'
ctx.fill()
}
// Draw connections
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x
const dy = particles[i].y - particles[j].y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < maxDist) {
const alpha = (1 - dist / maxDist) * 0.15
ctx.beginPath()
ctx.moveTo(particles[i].x, particles[i].y)
ctx.lineTo(particles[j].x, particles[j].y)
ctx.strokeStyle = lineColor + alpha + ')'
ctx.lineWidth = 0.5
ctx.stroke()
}
}
}
animId = requestAnimationFrame(draw)
}
draw()
const onResize = () => {
cancelAnimationFrame(animId)
initCanvas()
}
window.addEventListener('resize', onResize)
onUnmounted(() => {
cancelAnimationFrame(animId)
window.removeEventListener('resize', onResize)
})
}
onMounted(() => {
initCanvas()
})
</script>
<template>
<section class="hero">
<canvas ref="canvasRef" class="hero-canvas" />
<div class="hero-inner">
<h1 class="hero-title animate-fade-in-up">{{ t('hero.title') }}</h1>
<p class="hero-subtitle animate-fade-in-up animate-delay-1">{{ t('hero.subtitle') }}</p>
<div class="hero-actions animate-fade-in-up animate-delay-2">
<button class="btn-primary" @click="router.push({ name: 'docs.getting-started' })">
{{ t('hero.cta') }}
</button>
<a
class="btn-outline"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="btn-icon">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ t('hero.viewGithub') }}
</a>
</div>
<div class="install-box animate-fade-in animate-delay-3">
<code>{{ installCmd }}</code>
<button class="copy-btn" @click="copyCmd">
{{ copied ? t('ui.copied') : t('ui.copy') }}
</button>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.hero {
position: relative;
overflow: hidden;
padding: 120px 24px 80px;
text-align: center;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
@media (max-width: $breakpoint-mobile) {
padding: 80px 16px 48px;
}
}
.hero-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.hero-inner {
position: relative;
z-index: 1;
max-width: 720px;
margin: 0 auto;
}
.hero-title {
font-size: 48px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 20px;
color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
font-size: 32px;
}
}
.hero-subtitle {
font-size: 18px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 36px;
@media (max-width: $breakpoint-mobile) {
font-size: 15px;
}
}
.hero-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 36px;
flex-wrap: wrap;
}
.btn-primary {
padding: 12px 28px;
background: var(--accent-primary);
color: var(--text-on-accent);
border: none;
border-radius: $radius-md;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
&:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
}
.btn-outline {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: all $transition-fast;
&:hover {
border-color: var(--text-muted);
transform: translateY(-1px);
}
}
.btn-icon {
width: 18px;
height: 18px;
}
.install-box {
display: inline-flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 12px 20px;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
code {
font-size: 14px;
background: transparent;
padding: 0;
white-space: nowrap;
}
@media (max-width: $breakpoint-mobile) {
padding: 10px 14px;
gap: 8px;
code {
font-size: 12px;
}
}
}
.copy-btn {
padding: 4px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
}
</style>
@@ -0,0 +1,294 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { useScrollReveal } from '@/composables/useScrollReveal'
interface DesktopDownload {
title: string
desc: string
assetSuffix: string
}
const { t, tm } = useI18n()
useScrollReveal()
const activeTab = ref<'desktop' | 'npm' | 'docker' | 'source'>('desktop')
const releaseVersion = __APP_VERSION__.replace(/^v/, '')
const releaseTag = `v${releaseVersion}`
const releaseBaseUrl = 'https://github.com/EKKOLearnAI/hermes-web-ui/releases'
const releaseUrl = `${releaseBaseUrl}/tag/${releaseTag}`
const githubDownloadUrl = `${releaseBaseUrl}/download/${releaseTag}`
const cloudflareDownloadUrl = `https://download.ekkolearnai.com/${releaseTag}`
const desktopDownloads = computed(() =>
(tm('install.desktop.downloads') as DesktopDownload[]).map((item) => {
const assetName = `Hermes.Studio-${releaseVersion}-${item.assetSuffix}`
return {
...item,
githubHref: `${githubDownloadUrl}/${assetName}`,
cloudflareHref: `${cloudflareDownloadUrl}/${assetName}`,
}
}),
)
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
</script>
<template>
<div class="install-panel">
<h2 class="panel-title reveal">{{ t('install.title') }}</h2>
<p class="panel-desc reveal">{{ t('install.desc') }}</p>
<div class="install-tabs reveal">
<button
v-for="tab in (['desktop', 'npm', 'docker', 'source'] as const)"
:key="tab"
class="tab-btn"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ t(`install.${tab}.title`) }}
</button>
</div>
<div class="install-content reveal reveal-delay-1">
<template v-if="activeTab === 'desktop'">
<div class="download-list">
<div
v-for="item in desktopDownloads"
:key="item.githubHref"
class="download-row"
>
<span>
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
<span class="download-actions">
<a
class="download-action"
:href="item.githubHref"
target="_blank"
rel="noopener"
>
{{ t('install.desktop.githubDownload') }}
</a>
<a
class="download-action"
:href="item.cloudflareHref"
target="_blank"
rel="noopener"
>
{{ t('install.desktop.cloudflareDownload') }}
</a>
</span>
</div>
</div>
<a
class="all-downloads"
:href="releaseUrl"
target="_blank"
rel="noopener"
>
{{ t('install.desktop.allDownloads') }}
</a>
</template>
<template v-else-if="activeTab === 'npm'">
<div class="code-block" @click="copyText(t('install.npm.cmd1'))">
<code>{{ t('install.npm.cmd1') }}</code>
</div>
<div class="code-block" @click="copyText(t('install.npm.cmd2'))">
<code>{{ t('install.npm.cmd2') }}</code>
</div>
</template>
<template v-else-if="activeTab === 'docker'">
<div class="code-block" @click="copyText(t('install.docker.cmd'))">
<code>{{ t('install.docker.cmd') }}</code>
</div>
</template>
<template v-else>
<div class="code-block" @click="copyText(t('install.source.cmd1'))">
<code>{{ t('install.source.cmd1') }}</code>
</div>
<div class="code-block" @click="copyText(t('install.source.cmd2'))">
<code>{{ t('install.source.cmd2') }}</code>
</div>
</template>
<p class="prereq">{{ activeTab === 'desktop' ? t('install.desktop.prereq') : t('install.prereq') }}</p>
</div>
</div>
</template>
<style scoped lang="scss">
.install-panel {
padding: 40px 32px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
@media (max-width: $breakpoint-mobile) {
padding: 24px 16px;
}
}
.panel-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.panel-desc {
color: var(--text-secondary);
font-size: 15px;
margin-bottom: 24px;
}
.install-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: var(--bg-secondary);
border-radius: $radius-md;
padding: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all $transition-fast;
white-space: nowrap;
&.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
}
.install-content {
// full width within panel
}
.download-list {
display: grid;
gap: 8px;
}
.download-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
text-decoration: none;
@media (max-width: $breakpoint-mobile) {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
&:first-child {
padding-top: 0;
}
strong,
small {
display: block;
}
strong {
font-size: 15px;
font-weight: 650;
}
small {
color: var(--text-muted);
font-size: 12px;
margin-top: 3px;
}
}
.download-actions {
flex: 0 0 auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
@media (max-width: $breakpoint-mobile) {
width: 100%;
justify-content: stretch;
}
}
.download-action {
display: inline-flex;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
padding: 7px 12px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: border-color $transition-fast;
&:hover {
border-color: var(--text-muted);
}
@media (max-width: $breakpoint-mobile) {
flex: 1 1 0;
}
}
.all-downloads {
display: inline-flex;
margin-top: 14px;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
}
.code-block {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-sm;
padding: 14px 18px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color $transition-fast;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
&:hover {
border-color: var(--text-muted);
}
code {
font-size: 14px;
background: transparent;
padding: 0;
white-space: nowrap;
}
}
.prereq {
color: var(--text-muted);
font-size: 13px;
margin-top: 16px;
}
</style>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const platforms = [
{ key: 'telegram', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.02-1.96 1.25-5.54 3.67-.52.36-1 .53-1.42.52-.47-.01-1.37-.26-2.03-.48-.82-.27-1.47-.42-1.42-.88.03-.24.37-.49 1.02-.75 3.99-1.74 6.65-2.89 7.99-3.44 3.81-1.58 4.6-1.86 5.12-1.87.11 0 .37.03.54.17.14.12.18.28.2.45-.01.06.01.24 0 .38z' },
{ key: 'discord', icon: 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z' },
{ key: 'slack', icon: '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.313z' },
{ key: 'whatsapp', icon: '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-.347z' },
{ key: 'matrix', icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 3a7 7 0 0 1 7 7h-2a5 5 0 0 0-5-5V5z' },
{ key: 'feishu', icon: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5' },
{ key: 'wechat', icon: '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 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.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 0 1-1.162 1.178A1.17 1.17 0 0 1 4.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 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18z' },
{ key: 'wecom', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z' },
]
</script>
<template>
<section class="platforms-section">
<div class="section-inner">
<h2 class="section-title">{{ t('platforms.title') }}</h2>
<p class="section-desc">{{ t('platforms.desc') }}</p>
<div class="platforms-grid">
<div v-for="p in platforms" :key="p.key" class="platform-item">
<div class="platform-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path :d="p.icon" />
</svg>
</div>
<span class="platform-name">{{ t(`platforms.${p.key}`) }}</span>
</div>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.platforms-section {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.section-inner {
max-width: 1120px;
margin: 0 auto;
padding: 80px 24px;
@media (max-width: $breakpoint-mobile) {
padding: 48px 16px;
}
}
.platforms-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 24px;
@media (max-width: $breakpoint-mobile) {
gap: 16px;
}
}
.platform-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 120px;
@media (max-width: $breakpoint-mobile) {
width: 80px;
gap: 6px;
}
}
.platform-icon {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-md;
color: var(--text-primary);
transition: all $transition-fast;
&:hover {
border-color: var(--text-muted);
}
svg {
width: 24px;
height: 24px;
}
@media (max-width: $breakpoint-mobile) {
width: 44px;
height: 44px;
svg {
width: 20px;
height: 20px;
}
}
}
.platform-name {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
@media (max-width: $breakpoint-mobile) {
font-size: 12px;
}
}
</style>
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useScrollReveal } from '@/composables/useScrollReveal'
interface ScreenshotItem {
src: string
alt: string
}
const { t, tm } = useI18n()
useScrollReveal()
const images = computed(() => tm('screenshots.items') as ScreenshotItem[])
const activeIndex = ref(0)
let timer: ReturnType<typeof setInterval>
function next() {
activeIndex.value = (activeIndex.value + 1) % images.value.length
}
function prev() {
activeIndex.value = (activeIndex.value - 1 + images.value.length) % images.value.length
}
function setActive(i: number) {
activeIndex.value = i
resetTimer()
}
function resetTimer() {
clearInterval(timer)
timer = setInterval(next, 5000)
}
onMounted(() => {
timer = setInterval(next, 5000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
<template>
<section class="screenshots-section">
<div class="screenshots-inner reveal">
<!-- Browser frame mockup -->
<div class="browser-frame">
<div class="browser-bar">
<div class="browser-dots">
<span class="dot red" />
<span class="dot yellow" />
<span class="dot green" />
</div>
<div class="browser-url">
<span>{{ t('screenshots.localUrl') }}</span>
</div>
<div class="browser-spacer" />
</div>
<div class="browser-viewport">
<transition name="slide" mode="out-in">
<img
:key="activeIndex"
:src="images[activeIndex].src"
:alt="images[activeIndex].alt"
class="screenshot-img"
/>
</transition>
</div>
</div>
<!-- Navigation -->
<div class="screenshot-nav">
<button class="nav-arrow" :aria-label="t('screenshots.previous')" @click="prev(); resetTimer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<div class="screenshot-dots">
<button
v-for="(_img, i) in images"
:key="i"
class="dot-btn"
:aria-label="t('screenshots.goTo', { number: i + 1 })"
:class="{ active: activeIndex === i }"
@click="setActive(i)"
/>
</div>
<button class="nav-arrow" :aria-label="t('screenshots.next')" @click="next(); resetTimer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.screenshots-section {
padding: 0 24px;
margin-top: 48px;
margin-bottom: 24px;
@media (max-width: $breakpoint-mobile) {
padding: 0 12px;
margin-top: 32px;
margin-bottom: 16px;
}
}
.screenshots-inner {
max-width: 920px;
margin: 0 auto;
}
// ─── Browser Frame ──────────────────────────
.browser-frame {
border-radius: $radius-lg;
border: 1px solid var(--border-color);
overflow: hidden;
background: var(--bg-secondary);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
0 20px 60px rgba(0, 0, 0, 0.08);
transition: transform 0.4s ease, box-shadow 0.4s ease;
&:hover {
transform: translateY(-4px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.08),
0 32px 80px rgba(0, 0, 0, 0.12);
}
}
.browser-bar {
display: flex;
align-items: center;
padding: 10px 14px;
gap: 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
}
.browser-dots {
display: flex;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
&.red { background: #ff5f57; }
&.yellow { background: #febc2e; }
&.green { background: #28c840; }
}
.browser-url {
flex: 1;
background: var(--bg-secondary);
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
color: var(--text-muted);
font-family: $font-code;
text-align: center;
}
.browser-spacer {
width: 52px;
}
.browser-viewport {
position: relative;
width: 100%;
background: var(--bg-secondary);
}
.screenshot-img {
width: 100%;
display: block;
}
// ─── Slide Transition ───────────────────────
.slide-enter-active,
.slide-leave-active {
transition: all 0.4s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(24px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-24px);
}
// ─── Navigation ─────────────────────────────
.screenshot-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.nav-arrow {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
svg {
width: 16px;
height: 16px;
}
}
.screenshot-dots {
display: flex;
gap: 8px;
}
.dot-btn {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: var(--border-color);
cursor: pointer;
transition: all $transition-fast;
&.active {
background: var(--accent-primary);
transform: scale(1.3);
}
&:hover:not(.active) {
background: var(--text-muted);
}
}
</style>
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useScrollReveal } from '@/composables/useScrollReveal'
import { useTheme } from '@/composables/useTheme'
const { t } = useI18n()
const { isDark } = useTheme()
useScrollReveal()
const stars = ref<number | null>(null)
const chartSrc = computed(() => {
const base = 'https://api.star-history.com/svg?repos=EKKOLearnAI%2Fhermes-web-ui&type=Date'
return isDark.value ? `${base}&theme=dark` : base
})
onMounted(async () => {
try {
const res = await fetch('https://api.github.com/repos/EKKOLearnAI/hermes-web-ui')
const data = await res.json()
stars.value = data.stargazers_count
} catch {}
})
</script>
<template>
<div class="star-panel">
<h2 class="panel-title reveal">{{ t('starHistory.title') }}</h2>
<p class="panel-desc reveal">{{ t('starHistory.desc') }}</p>
<div class="star-badges reveal reveal-delay-1">
<a
class="star-btn"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span>{{ t('starHistory.star') }}</span>
<span v-if="stars !== null" class="star-count">{{ stars.toLocaleString() }}</span>
</a>
<img
class="github-badge"
src="https://img.shields.io/github/license/EKKOLearnAI/hermes-web-ui?style=flat-square"
:alt="t('starHistory.licenseAlt')"
/>
<img
class="github-badge"
src="https://img.shields.io/github/v/release/EKKOLearnAI/hermes-web-ui?style=flat-square"
:alt="t('starHistory.versionAlt')"
/>
</div>
<div class="star-chart reveal reveal-delay-2">
<a
href="https://www.star-history.com/?type=date&repos=EKKOLearnAI%2Fhermes-web-ui"
target="_blank"
rel="noopener noreferrer"
class="chart-link"
>
<img
:src="chartSrc"
:alt="t('starHistory.chartAlt')"
class="chart-img"
/>
</a>
</div>
</div>
</template>
<style scoped lang="scss">
.star-panel {
padding: 40px 32px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
display: flex;
flex-direction: column;
@media (max-width: $breakpoint-mobile) {
padding: 24px 16px;
}
}
.panel-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.panel-desc {
color: var(--text-secondary);
font-size: 15px;
margin-bottom: 24px;
}
.star-badges {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.star-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
text-decoration: none;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
transition: all $transition-fast;
&:hover {
border-color: var(--text-muted);
}
}
.star-icon {
width: 16px;
height: 16px;
fill: var(--text-muted);
}
.star-count {
padding: 1px 8px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
border-radius: 0 $radius-sm $radius-sm 0;
margin-left: 2px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.github-badge {
height: 22px;
border-radius: 2px;
}
.star-chart {
flex: 1;
display: flex;
align-items: center;
}
.chart-link {
display: block;
width: 100%;
}
.chart-img {
width: 100%;
border-radius: $radius-sm;
transition: opacity $transition-fast;
&:hover {
opacity: 0.85;
}
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-left">
<div class="footer-brand">
<img src="/logo.png" :alt="t('brand.logoAlt')" class="footer-logo" />
<span>{{ t('brand.name') }}</span>
</div>
<p class="footer-desc">{{ t('footer.description') }}</p>
</div>
<div class="footer-right">
<p class="footer-meta">{{ t('footer.madeWith') }}</p>
<p class="footer-meta">{{ t('footer.license') }}</p>
<a
class="footer-github"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
</div>
</div>
</footer>
</template>
<style scoped lang="scss">
.site-footer {
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.footer-inner {
max-width: 1120px;
margin: 0 auto;
padding: 40px 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
gap: 24px;
}
}
.footer-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.footer-brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--text-primary);
}
.footer-logo {
width: 24px;
height: 24px;
border-radius: $radius-sm;
}
.footer-desc {
color: var(--text-muted);
font-size: 13px;
}
.footer-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
@media (max-width: $breakpoint-mobile) {
align-items: flex-start;
}
}
.footer-meta {
color: var(--text-muted);
font-size: 13px;
}
.footer-github {
color: var(--text-muted);
transition: color $transition-fast;
&:hover {
color: var(--text-primary);
}
svg {
width: 20px;
height: 20px;
}
}
</style>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
const { t, locale } = useI18n()
const router = useRouter()
const { isDark, toggleTheme } = useTheme()
const mobileMenuOpen = ref(false)
function switchLocale() {
const next = locale.value === 'en' ? 'zh' : 'en'
locale.value = next
localStorage.setItem('hermes_website_locale', next)
}
function navigateTo(name: string) {
router.push({ name })
mobileMenuOpen.value = false
}
function goHome() {
router.push({ name: 'landing' })
mobileMenuOpen.value = false
}
</script>
<template>
<header class="site-header">
<div class="header-inner">
<div class="header-left" @click="goHome">
<img src="/logo.png" :alt="t('brand.logoAlt')" class="logo-icon" />
<span class="logo-text">{{ t('brand.name') }}</span>
</div>
<nav class="header-nav">
<a class="nav-link" @click.prevent="navigateTo('landing')">{{ t('nav.home') }}</a>
<a class="nav-link" @click.prevent="navigateTo('docs.getting-started')">{{ t('nav.docs') }}</a>
<a
class="nav-link"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
{{ t('nav.github') }}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="external-icon">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<button class="icon-btn" @click="switchLocale" :title="locale === 'en' ? t('ui.switchToChinese') : t('ui.switchToEnglish')">
{{ locale === 'en' ? '中' : 'EN' }}
</button>
<button class="icon-btn" @click="toggleTheme" :title="isDark ? t('ui.lightTheme') : t('ui.darkTheme')">
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</nav>
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" :title="t('ui.menu')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
<div v-if="mobileMenuOpen" class="mobile-menu" @click="mobileMenuOpen = false">
<div class="mobile-menu-inner" @click.stop>
<a class="mobile-link" @click.prevent="navigateTo('landing')">{{ t('nav.home') }}</a>
<a class="mobile-link" @click.prevent="navigateTo('docs.getting-started')">{{ t('nav.docs') }}</a>
<a class="mobile-link" href="https://github.com/EKKOLearnAI/hermes-web-ui" target="_blank" rel="noopener">{{ t('nav.github') }}</a>
<div class="mobile-actions">
<button class="mobile-action-btn" @click="switchLocale">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="action-icon">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
{{ locale === 'en' ? t('ui.switchToChinese') : t('ui.switchToEnglish') }}
</button>
<button class="mobile-action-btn" @click="toggleTheme">
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="action-icon">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="action-icon">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
{{ isDark ? t('ui.lightMode') : t('ui.darkMode') }}
</button>
</div>
</div>
</div>
</header>
</template>
<style scoped lang="scss">
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
&:hover {
opacity: 0.8;
}
}
.logo-icon {
width: 28px;
height: 28px;
border-radius: $radius-sm;
}
.logo-text {
white-space: nowrap;
}
.header-nav {
display: flex;
align-items: center;
gap: 8px;
}
.nav-link {
padding: 6px 14px;
border-radius: $radius-sm;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all $transition-fast;
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
}
.external-icon {
width: 12px;
height: 12px;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted);
background: var(--bg-secondary);
}
svg {
width: 16px;
height: 16px;
}
}
.mobile-toggle {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
svg {
width: 20px;
height: 20px;
}
}
.mobile-menu {
position: fixed;
inset: 0;
top: 60px;
background: rgba(0, 0, 0, 0.3);
z-index: 99;
}
.mobile-menu-inner {
background: var(--bg-card);
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 4px;
border-bottom: 1px solid var(--border-color);
}
.mobile-link {
padding: 12px 0;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
&:hover {
color: var(--text-primary);
}
}
.mobile-actions {
display: flex;
gap: 8px;
padding-top: 12px;
}
.mobile-action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all $transition-fast;
&:active {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
.action-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.header-nav {
display: none;
}
.mobile-toggle {
display: flex;
}
}
</style>
@@ -0,0 +1,27 @@
import { onMounted, onUnmounted } from 'vue'
export function useScrollReveal() {
let observer: IntersectionObserver | null = null
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('revealed')
observer!.unobserve(entry.target)
}
}
},
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' },
)
document.querySelectorAll('.reveal').forEach((el) => {
observer!.observe(el)
})
})
onUnmounted(() => {
observer?.disconnect()
})
}
@@ -0,0 +1,54 @@
import { ref, watch } from 'vue'
export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'hermes_website_theme'
const mode = ref<ThemeMode>(
(localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system',
)
const isDark = ref(false)
function applyTheme(dark: boolean) {
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
}
function resolveDark(m: ThemeMode): boolean {
if (m === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return m === 'dark'
}
applyTheme(resolveDark(mode.value))
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', () => {
if (mode.value === 'system') {
applyTheme(resolveDark('system'))
}
})
watch(mode, (newMode) => {
localStorage.setItem(STORAGE_KEY, newMode)
applyTheme(resolveDark(newMode))
})
export function useTheme() {
function setMode(m: ThemeMode) {
mode.value = m
}
function toggleTheme() {
mode.value = isDark.value ? 'light' : 'dark'
}
return {
mode,
isDark,
setMode,
toggleTheme,
}
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+357
View File
@@ -0,0 +1,357 @@
export default {
brand: {
name: 'Hermes Web UI',
logoAlt: 'Hermes',
},
ui: {
copy: 'Copy',
copied: 'Copied!',
darkTheme: 'Dark',
lightTheme: 'Light',
darkMode: 'Dark Mode',
lightMode: 'Light Mode',
menu: 'Menu',
switchToChinese: 'Chinese',
switchToEnglish: 'English',
},
nav: {
home: 'Home',
docs: 'Documentation',
github: 'GitHub',
},
hero: {
title: 'Self-Hosted AI Chat Dashboard',
subtitle: 'Open-source AI agent dashboard — streaming chat, multi-model routing, Kanban boards, usage analytics, web terminal, all in one self-hosted interface.',
cta: 'Get Started',
viewGithub: 'View on GitHub',
install: 'npm install -g hermes-web-ui',
},
features: {
title: 'Everything You Need',
desc: 'A complete AI agent management dashboard with rich features out of the box.',
streaming: {
title: 'Streaming Chat',
desc: 'Real-time Socket.IO-powered AI conversations with multi-session management, Markdown rendering, and code syntax highlighting.',
},
platforms: {
title: '8 Platforms',
desc: 'Unified management for Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, WeChat, and WeCom channels.',
},
multiModel: {
title: 'Multi-Model',
desc: 'Support for Claude, GPT, Gemini, DeepSeek, and any OpenAI-compatible provider with auto-discovery.',
},
groupChat: {
title: 'Group Chat',
desc: 'Multi-agent chat rooms with mention routing, context compression, and real-time collaboration.',
},
kanban: {
title: 'Kanban Board',
desc: 'Visual task management with 7 status columns, assignee tracking, and filtering for AI-driven workflows.',
},
analytics: {
title: 'Usage Analytics',
desc: 'Token usage breakdown, cost tracking, cache hit rates, model distribution, and 30-day trends.',
},
profiles: {
title: 'Multi-Profile',
desc: 'Account-authorized Hermes profiles with isolated config, models, uploads, jobs, usage, memory, skills, plugins, and providers.',
},
files: {
title: 'File Browser',
desc: 'Manage files across local, Docker, SSH, and Singularity backends with profile-scoped upload plus path-based download, preview, and edit.',
},
terminal: {
title: 'Web Terminal',
desc: 'Full PTY terminal in the browser with multi-session support via WebSocket and xterm.js.',
},
quickInstall: {
title: 'One Command',
desc: 'Install and start with a single command. Initializes Web UI data, starts the bridge, and opens the browser.',
},
i18n: {
title: '8 Languages',
desc: 'Built-in support for English, Chinese, German, Spanish, French, Japanese, Korean, and Portuguese.',
},
theme: {
title: 'Dark / Light',
desc: 'Pure Ink monochrome design with smooth theme switching. Responsive layout for mobile and desktop.',
},
},
platforms: {
title: 'Unified Platform Management',
desc: 'Configure credentials and behavior for 8 messaging platforms from a single settings page.',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
whatsapp: 'WhatsApp',
matrix: 'Matrix',
feishu: 'Feishu',
wechat: 'WeChat',
wecom: 'WeCom',
},
screenshots: {
localUrl: 'http://localhost:8648',
previous: 'Previous screenshot',
next: 'Next screenshot',
goTo: 'View screenshot {number}',
items: [
{ src: '/image1.png', alt: 'AI chat with image generation' },
{ src: '/image2.png', alt: 'Chat and file browser' },
{ src: '/image3.png', alt: 'Multi-panel workspace' },
{ src: '/image4.png', alt: 'Kanban board' },
],
},
install: {
title: 'Quick Start',
desc: 'Download the desktop app or run Hermes Web UI yourself.',
desktop: {
title: 'Desktop',
download: 'Download',
githubDownload: 'GitHub Download',
cloudflareDownload: 'Cloudflare Download',
allDownloads: 'View all release assets',
prereq: 'Desktop builds bundle the Web UI runtime.',
downloads: [
{
title: 'macOS Apple Silicon',
desc: 'Apple Silicon DMG',
assetSuffix: 'arm64.dmg',
},
{
title: 'macOS Intel',
desc: 'x64 DMG',
assetSuffix: 'x64.dmg',
},
{
title: 'Windows',
desc: 'x64 installer',
assetSuffix: 'x64.exe',
},
{
title: 'Linux x64 AppImage',
desc: 'x64 AppImage',
assetSuffix: 'x86_64.AppImage',
},
{
title: 'Linux x64 Debian',
desc: 'amd64 .deb package',
assetSuffix: 'amd64.deb',
},
{
title: 'Linux arm64',
desc: 'arm64 AppImage',
assetSuffix: 'arm64.AppImage',
},
],
},
npm: {
title: 'npm',
cmd1: 'npm install -g hermes-web-ui',
cmd2: 'hermes-web-ui start',
},
docker: {
title: 'Docker',
cmd: 'docker compose up -d',
},
source: {
title: 'From Source',
cmd1: 'git clone https://github.com/EKKOLearnAI/hermes-web-ui.git',
cmd2: 'cd hermes-web-ui && npm install && npm run dev',
},
prereq: 'Requires Node.js >= 23',
},
starHistory: {
title: 'Growing Community',
desc: 'Star us on GitHub and join the community.',
star: 'Star',
licenseAlt: 'License',
versionAlt: 'Version',
chartAlt: 'Star History',
},
footer: {
description: 'Self-hosted AI chat dashboard for Hermes Agent.',
license: 'BSL-1.1 License',
madeWith: 'Built with Vue 3, Naive UI, and TypeScript.',
},
docs: {
placeholder: 'Select a section from the sidebar to get started.',
sidebar: {
gettingStarted: 'Getting Started',
configuration: 'Configuration',
features: 'Features',
platforms: 'Platform Guides',
api: 'API Reference',
},
gettingStarted: {
title: 'Getting Started',
intro: 'Hermes Web UI is a self-hosted web dashboard for managing AI conversations, platform channels, scheduled jobs, and more. It wraps the Hermes Agent CLI and provides a beautiful web interface.',
install: {
title: 'Installation',
content: 'Install globally via npm. Node.js 23 or higher is required.',
},
firstRun: {
title: 'First Run',
content: 'On first start, Hermes Web UI will automatically generate an auth token, initialize local data, start the Hermes agent bridge, and open the dashboard in your browser.',
},
login: {
title: 'Login',
content: 'The auto-generated token is stored in ~/.hermes-web-ui/.token. Username/password login is available with bootstrap credentials admin / 123456 on first use, and the app prompts users to change default credentials after login.',
},
},
configuration: {
title: 'Configuration',
intro: 'Hermes Web UI can be configured via environment variables.',
envVars: {
title: 'Environment Variables',
rows: [
['PORT', 'Server listen port (default: 8648)'],
['BIND_HOST', 'Server bind host (default: 0.0.0.0). Set :: explicitly to enable IPv6 listening.'],
['HERMES_WEB_UI_HOME', 'Web UI data home for auth token, credentials, logs, DB, and default uploads'],
['HERMES_WEBUI_STATE_DIR', 'Compatibility alias for HERMES_WEB_UI_HOME'],
['UPLOAD_DIR', 'Custom upload root. Uploaded files are stored below profile-scoped subdirectories.'],
['CORS_ORIGINS', 'CORS origin config (default: *)'],
['AUTH_TOKEN', 'Custom bearer token; overrides the auto-generated token'],
['AUTH_JWT_SECRET', 'JWT signing secret override for username/password sessions'],
['PROFILE', 'Startup/default Hermes profile'],
['LOG_LEVEL', 'Server log level'],
['BRIDGE_LOG_LEVEL', 'Bridge log level'],
['MAX_DOWNLOAD_SIZE', 'Maximum file download size'],
['MAX_EDIT_SIZE', 'Maximum editable file size'],
['WORKSPACE_BASE', 'Base directory for workspace browsing'],
['HERMES_HOME', 'Hermes data home'],
['HERMES_BIN', 'Custom Hermes CLI binary path'],
['HERMES_AGENT_ROOT', 'Hermes Agent source checkout containing run_agent.py'],
['HERMES_AGENT_BRIDGE_PYTHON', 'Python interpreter used to launch the agent bridge'],
['HERMES_AGENT_BRIDGE_UV', 'uv executable used to launch the agent bridge when available'],
['UV', 'Fallback uv executable path'],
['PYTHON', 'Fallback Python executable for the agent bridge'],
['HERMES_AGENT_BRIDGE_ENDPOINT', 'Agent bridge broker endpoint. Windows defaults to tcp://127.0.0.1:18765; macOS/Linux defaults to ipc:///tmp/hermes-agent-bridge.sock'],
['HERMES_AGENT_BRIDGE_TIMEOUT_MS', 'Timeout for Node requests to the bridge broker'],
['HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS', 'Short retry window for connecting to the bridge socket'],
['HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS', 'Timeout while waiting for the Python bridge to become ready'],
['HERMES_AGENT_BRIDGE_AUTO_RESTART', 'Auto-restart the bridge broker after unexpected exit; set 0/false/no/off to disable'],
['HERMES_AGENT_BRIDGE_RESTART_DELAY_MS', 'Base delay for bridge auto-restart backoff'],
['HERMES_AGENT_BRIDGE_PLATFORM', 'Platform identity passed to Hermes Agent'],
['HERMES_AGENT_BRIDGE_WORKER_TRANSPORT', 'Profile worker endpoint transport. Set tcp for loopback TCP, or ipc/unix for Unix domain sockets; defaults to Windows TCP and macOS/Linux IPC'],
['HERMES_AGENT_BRIDGE_WORKER_PORT_BASE', 'Base port for TCP worker endpoints (default: 18780). Version Preview uses an isolated 19650 port range'],
['HERMES_BRIDGE_PROVIDER', 'Provider override for bridge runs'],
['HERMES_BRIDGE_TOOLSETS', 'Toolset override for bridge runs'],
['HERMES_BRIDGE_MAX_TURNS', 'Maximum turn override for bridge runs'],
['HERMES_BRIDGE_SUPPRESS_PLATFORM_HINT', 'Controls bridge platform hint suppression passed to Hermes Agent'],
['HERMES_OPENROUTER_APP_REFERER', 'OpenRouter attribution referer sent by bridge runs'],
['HERMES_OPENROUTER_APP_TITLE', 'OpenRouter attribution title sent by bridge runs'],
['HERMES_OPENROUTER_APP_CATEGORIES', 'OpenRouter attribution categories sent by bridge runs'],
['HERMES_WEB_UI_MANAGED_GATEWAY', 'Force managed legacy gateway process handling'],
['HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN', 'Controls whether Web UI shutdown also stops managed gateway processes'],
['GATEWAY_HOST', 'Default gateway host written into profile config for legacy gateway compatibility'],
['HERMES_WEB_UI_PREVIEW_REPO', 'GitHub repository used by Version Preview'],
['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT', 'Version Preview broker endpoint transport. Set tcp to use loopback TCP for Preview on macOS/Linux; when unset, Preview follows HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp'],
['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT', 'Directly overrides the Version Preview broker endpoint for deployments that need a fully custom Preview bridge address'],
['HERMES_WEB_UI_BACKEND_PORT', 'Backend port used by the Vite dev proxy'],
['HERMES_WEB_UI_FRONTEND_PORT', 'Frontend Vite dev server port'],
],
},
gateway: {
title: 'Agent Bridge Runtime',
content: 'Chat runs are handled through the Hermes agent bridge, which runs alongside the Web UI server and talks directly to the Hermes Agent runtime. HERMES_AGENT_BRIDGE_ENDPOINT controls the Node-to-broker address, while HERMES_AGENT_BRIDGE_WORKER_TRANSPORT controls the broker-to-profile-worker transport. Switching the frontend Hermes Profile changes later request context only; it does not restart the bridge or clear other running tasks.',
},
profiles: {
title: 'Profiles',
content: 'Profiles provide isolated configurations for different use cases. Super administrators can manage every profile, while regular administrators only see and use profiles assigned to their account. Create, clone, import, export, or switch Hermes profiles from the Profiles page.',
},
},
features: {
title: 'Features',
intro: 'Explore the core features of Hermes Web UI.',
chat: {
title: 'AI Chat',
content: 'Real-time chat streaming over Socket.IO /chat-run. Supports multi-session management, Markdown rendering with syntax highlighting, tool call inspection, profile-scoped upload, path-based download, and Ctrl+K search across the Web UI local session database.',
},
kanban: {
title: 'Kanban Board',
content: 'A visual task management board with 7 status columns: triage, todo, ready, running, blocked, done, and archived. Supports assignee management, filtering, and detailed task editing via a side drawer.',
},
groupChat: {
title: 'Group Chat',
content: 'Multi-agent chat rooms where multiple AI agents collaborate. Features mention routing to trigger specific agents, automatic context compression when history exceeds limits, typing indicators, and SQLite-based message persistence.',
},
jobs: {
title: 'Scheduled Jobs',
content: 'Create and manage cron-based scheduled jobs that run AI tasks automatically. Configure schedule, prompt, and model for each job.',
},
skills: {
title: 'Skills',
content: 'Browse and manage installed AI skills. Skills extend the agent\'s capabilities with specialized knowledge and tool integrations.',
},
memory: {
title: 'Memory',
content: 'Manage agent memory and user notes. The agent uses memory to maintain context across conversations and personalize responses.',
},
terminal: {
title: 'Terminal',
content: 'Full pseudo-terminal in the browser powered by node-pty and xterm.js. Supports multiple terminal sessions, real-time keyboard input, and window resizing via WebSocket.',
},
files: {
title: 'File Browser',
content: 'Browse and manage files on remote backends including local, Docker, SSH, and Singularity. Uploads are stored under the selected/requested profile, while downloads resolve real paths so agent-generated artifacts outside the upload directory still work.',
},
analytics: {
title: 'Usage Analytics',
content: 'Track token usage (input/output), estimated costs, cache hit rates, session counts, and model distribution. View 30-day daily trends with interactive charts.',
},
},
platforms: {
title: 'Platform Guides',
intro: 'Configure messaging platform integrations from the Channels settings page.',
telegram: {
title: 'Telegram',
content: 'Create a Telegram Bot via BotFather, then enter the bot token. Configure mention requirements, free-response chats, and reaction handling.',
},
discord: {
title: 'Discord',
content: 'Create a Discord Bot in the Developer Portal. Supports auto-thread creation, allowed/ignored channels, reaction handling, and free-response channels.',
},
slack: {
title: 'Slack',
content: 'Create a Slack App with bot token scope. Configure mention requirements, bot allowlisting, and free-response channels.',
},
whatsapp: {
title: 'WhatsApp',
content: 'Enable WhatsApp integration and configure mention patterns and free-response chats.',
},
matrix: {
title: 'Matrix',
content: 'Provide access token and homeserver URL. Supports auto-thread, DM mention threads, and free-response rooms.',
},
feishu: {
title: 'Feishu (Lark)',
content: 'Register a Feishu app and configure App ID and Secret.',
},
wechat: {
title: 'WeChat',
content: 'Scan the QR code from the settings page to log in. Credentials are auto-saved for subsequent sessions.',
},
wecom: {
title: 'WeCom',
content: 'Configure Bot ID and Secret from the WeCom admin console.',
},
},
api: {
title: 'API Reference',
intro: 'Hermes Web UI provides a local BFF API for the dashboard and Socket.IO endpoints for streaming chat.',
local: {
title: 'Local BFF Endpoints',
content: 'The Koa server handles session management, profile CRUD, account- and profile-scoped management, config read/write, log access, skill listing, memory operations, and static assets.',
},
proxy: {
title: 'Chat Streaming',
content: 'Chat runs use the /chat-run Socket.IO namespace and the Hermes agent bridge. Legacy gateway proxy routes are kept only for compatibility where applicable.',
},
auth: {
title: 'Authentication',
content: 'API endpoints require authenticated access. The token is auto-generated on first run and stored in ~/.hermes-web-ui/.token. Username/password login uses account records; super administrators manage users and profile bindings, while regular administrators manage their own account details.',
},
},
},
}
+13
View File
@@ -0,0 +1,13 @@
import { createI18n } from 'vue-i18n'
import en from './en'
import zh from './zh'
const detected = navigator.language.startsWith('zh') ? 'zh' : 'en'
const saved = localStorage.getItem('hermes_website_locale')
export const i18n = createI18n({
legacy: false,
locale: saved || detected,
fallbackLocale: 'en',
messages: { en, zh },
})
+357
View File
@@ -0,0 +1,357 @@
export default {
brand: {
name: 'Hermes Web UI',
logoAlt: 'Hermes',
},
ui: {
copy: '复制',
copied: '已复制',
darkTheme: '深色',
lightTheme: '浅色',
darkMode: '深色模式',
lightMode: '浅色模式',
menu: '菜单',
switchToChinese: '中文',
switchToEnglish: 'English',
},
nav: {
home: '首页',
docs: '文档',
github: 'GitHub',
},
hero: {
title: '自托管 AI 聊天仪表板',
subtitle: '开源 AI Agent 仪表板 — 流式对话、多模型调度、看板管理、用量分析、Web 终端,一个界面掌控一切。',
cta: '快速开始',
viewGithub: '查看 GitHub',
install: 'npm install -g hermes-web-ui',
},
features: {
title: '功能齐全',
desc: '开箱即用的完整 AI Agent 管理仪表板。',
streaming: {
title: '流式聊天',
desc: '基于 Socket.IO 的实时 AI 对话,支持多会话管理、Markdown 渲染和代码语法高亮。',
},
platforms: {
title: '8 大平台',
desc: '统一管理 Telegram、Discord、Slack、WhatsApp、Matrix、飞书、微信、企业微信。',
},
multiModel: {
title: '多模型支持',
desc: '支持 Claude、GPT、Gemini、DeepSeek 及任何 OpenAI 兼容模型,自动发现。',
},
groupChat: {
title: '群聊协作',
desc: '多 Agent 聊天室,支持提及路由、上下文压缩和实时协作。',
},
kanban: {
title: '看板管理',
desc: '可视化任务看板,7 个状态列,支持任务分配和筛选。',
},
analytics: {
title: '用量分析',
desc: 'Token 用量、费用追踪、缓存命中率、模型分布和 30 天趋势。',
},
profiles: {
title: '多配置',
desc: '按账号授权的 Hermes Profile,隔离配置、模型、上传、任务、用量、记忆、技能、插件和 Provider。',
},
files: {
title: '文件管理',
desc: '跨本地、Docker、SSH 和 Singularity 管理文件,支持按 Profile 上传、按路径下载、预览和编辑。',
},
terminal: {
title: 'Web 终端',
desc: '浏览器内完整 PTY 终端,基于 WebSocket 和 xterm.js 的多会话支持。',
},
quickInstall: {
title: '一键安装',
desc: '一条命令安装启动。初始化 Web UI 数据、启动 bridge 并打开浏览器。',
},
i18n: {
title: '8 种语言',
desc: '内置英语、中文、德语、西班牙语、法语、日语、韩语和葡萄牙语。',
},
theme: {
title: '暗色 / 亮色',
desc: '水墨单色设计,平滑主题切换,响应式布局适配移动端和桌面端。',
},
},
platforms: {
title: '统一平台管理',
desc: '在一个页面配置 8 大消息平台的凭证和行为。',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
whatsapp: 'WhatsApp',
matrix: 'Matrix',
feishu: '飞书',
wechat: '微信',
wecom: '企业微信',
},
screenshots: {
localUrl: 'http://localhost:8648',
previous: '上一张截图',
next: '下一张截图',
goTo: '查看第 {number} 张截图',
items: [
{ src: '/image1.png', alt: '带图片生成的 AI 聊天界面' },
{ src: '/image2.png', alt: '聊天和文件浏览器界面' },
{ src: '/image3.png', alt: '多面板工作区界面' },
{ src: '/image4.png', alt: '看板管理界面' },
],
},
install: {
title: '快速开始',
desc: '下载桌面应用,或自行运行 Hermes Web UI。',
desktop: {
title: '桌面版',
download: '下载',
githubDownload: 'GitHub 下载',
cloudflareDownload: 'Cloudflare 下载',
allDownloads: '查看全部发布文件',
prereq: '桌面版已内置 Web UI 运行时。',
downloads: [
{
title: 'macOS Apple Silicon',
desc: 'Apple Silicon DMG',
assetSuffix: 'arm64.dmg',
},
{
title: 'macOS Intel',
desc: 'x64 DMG',
assetSuffix: 'x64.dmg',
},
{
title: 'Windows',
desc: 'x64 安装包',
assetSuffix: 'x64.exe',
},
{
title: 'Linux x64 AppImage',
desc: 'x64 AppImage',
assetSuffix: 'x86_64.AppImage',
},
{
title: 'Linux x64 Debian',
desc: 'amd64 .deb 安装包',
assetSuffix: 'amd64.deb',
},
{
title: 'Linux arm64',
desc: 'arm64 AppImage',
assetSuffix: 'arm64.AppImage',
},
],
},
npm: {
title: 'npm',
cmd1: 'npm install -g hermes-web-ui',
cmd2: 'hermes-web-ui start',
},
docker: {
title: 'Docker',
cmd: 'docker compose up -d',
},
source: {
title: '源码安装',
cmd1: 'git clone https://github.com/EKKOLearnAI/hermes-web-ui.git',
cmd2: 'cd hermes-web-ui && npm install && npm run dev',
},
prereq: '需要 Node.js >= 23',
},
starHistory: {
title: '社区成长',
desc: '在 GitHub 上给我们加星,加入社区。',
star: '加星',
licenseAlt: '许可证',
versionAlt: '版本',
chartAlt: 'Star 历史',
},
footer: {
description: 'Hermes Agent 的自托管 AI 聊天仪表板。',
license: 'BSL-1.1 开源协议',
madeWith: '使用 Vue 3、Naive UI 和 TypeScript 构建。',
},
docs: {
placeholder: '从侧边栏选择一个章节开始阅读。',
sidebar: {
gettingStarted: '快速开始',
configuration: '配置说明',
features: '功能详解',
platforms: '平台接入',
api: 'API 参考',
},
gettingStarted: {
title: '快速开始',
intro: 'Hermes Web UI 是一个自托管的 Web 仪表板,用于管理 AI 对话、平台通道、定时任务等。它封装了 Hermes Agent CLI 并提供美观的 Web 界面。',
install: {
title: '安装',
content: '通过 npm 全局安装。需要 Node.js 23 或更高版本。',
},
firstRun: {
title: '首次运行',
content: '首次启动时,Hermes Web UI 会自动生成认证令牌、初始化本地数据、启动 Hermes agent bridge 并在浏览器中打开仪表板。',
},
login: {
title: '登录',
content: '自动生成的令牌存储在 ~/.hermes-web-ui/.token。首次使用可通过默认登录名 admin / 默认密码 123456 登录;登录后系统会提示尽快修改默认账户和密码。',
},
},
configuration: {
title: '配置说明',
intro: 'Hermes Web UI 可通过环境变量进行配置。',
envVars: {
title: '环境变量',
rows: [
['PORT', '服务器监听端口(默认:8648'],
['BIND_HOST', '服务器绑定地址(默认:0.0.0.0)。如需 IPv6,请显式设置为 ::。'],
['HERMES_WEB_UI_HOME', 'Web UI 数据目录,用于认证 token、登录凭据、日志、数据库和默认上传目录'],
['HERMES_WEBUI_STATE_DIR', 'HERMES_WEB_UI_HOME 的兼容别名'],
['UPLOAD_DIR', '自定义上传根目录。文件会保存在按 Profile 隔离的子目录下'],
['CORS_ORIGINS', 'CORS 来源配置(默认:*'],
['AUTH_TOKEN', '自定义 bearer token,会覆盖自动生成的 token'],
['AUTH_JWT_SECRET', '用户名/密码会话的 JWT 签名密钥覆盖'],
['PROFILE', '启动/默认 Hermes profile'],
['LOG_LEVEL', 'Server 日志级别'],
['BRIDGE_LOG_LEVEL', 'Bridge 日志级别'],
['MAX_DOWNLOAD_SIZE', '最大文件下载大小'],
['MAX_EDIT_SIZE', '最大可编辑文件大小'],
['WORKSPACE_BASE', 'Workspace 浏览根目录'],
['HERMES_HOME', 'Hermes 数据目录'],
['HERMES_BIN', '自定义 Hermes CLI 二进制路径'],
['HERMES_AGENT_ROOT', '包含 run_agent.py 的 Hermes Agent 源码目录'],
['HERMES_AGENT_BRIDGE_PYTHON', '用于启动 agent bridge 的 Python 解释器'],
['HERMES_AGENT_BRIDGE_UV', '可用时用于启动 agent bridge 的 uv 可执行文件'],
['UV', 'uv 可执行文件 fallback'],
['PYTHON', 'agent bridge 的 Python 可执行文件 fallback'],
['HERMES_AGENT_BRIDGE_ENDPOINT', 'Agent bridge broker endpoint。Windows 默认 tcp://127.0.0.1:18765macOS/Linux 默认 ipc:///tmp/hermes-agent-bridge.sock'],
['HERMES_AGENT_BRIDGE_TIMEOUT_MS', 'Node 请求 bridge broker 的响应超时'],
['HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS', '连接 bridge socket 失败时的短重试窗口'],
['HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS', '等待 Python bridge ready 的超时'],
['HERMES_AGENT_BRIDGE_AUTO_RESTART', 'bridge broker 意外退出后是否自动重启;设为 0/false/no/off 可关闭'],
['HERMES_AGENT_BRIDGE_RESTART_DELAY_MS', 'bridge 自动重启退避的基础延迟'],
['HERMES_AGENT_BRIDGE_PLATFORM', '传给 Hermes Agent 的 platform 标识'],
['HERMES_AGENT_BRIDGE_WORKER_TRANSPORT', 'profile worker endpoint transport。设为 tcp 使用 loopback TCP;设为 ipc/unix 使用 Unix domain socket;默认 Windows TCP、macOS/Linux IPC'],
['HERMES_AGENT_BRIDGE_WORKER_PORT_BASE', 'TCP worker endpoint 起始端口(默认:18780)。Version Preview 会使用独立端口段 19650'],
['HERMES_BRIDGE_PROVIDER', 'bridge 运行时的 provider 覆盖'],
['HERMES_BRIDGE_TOOLSETS', 'bridge 运行时的 toolset 覆盖'],
['HERMES_BRIDGE_MAX_TURNS', 'bridge 运行时的最大轮数覆盖'],
['HERMES_BRIDGE_SUPPRESS_PLATFORM_HINT', '控制传给 Hermes Agent 的 bridge platform hint suppression'],
['HERMES_OPENROUTER_APP_REFERER', 'bridge 运行发送给 OpenRouter 的 attribution referer'],
['HERMES_OPENROUTER_APP_TITLE', 'bridge 运行发送给 OpenRouter 的 attribution title'],
['HERMES_OPENROUTER_APP_CATEGORIES', 'bridge 运行发送给 OpenRouter 的 attribution categories'],
['HERMES_WEB_UI_MANAGED_GATEWAY', '强制启用旧 gateway 进程托管'],
['HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN', 'Web UI 关闭时是否同时停止托管的 gateway 进程'],
['GATEWAY_HOST', '旧 gateway 兼容配置中写入 profile 的默认 gateway host'],
['HERMES_WEB_UI_PREVIEW_REPO', 'Version Preview 使用的 GitHub 仓库'],
['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT', 'Version Preview 的 broker endpoint transport。设为 tcp 可让预览环境在 macOS/Linux 上也使用 loopback TCP;未设置时会跟随 HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp'],
['HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT', '直接覆盖 Version Preview 的 broker endpoint;用于需要完全自定义预览 bridge 地址的部署'],
['HERMES_WEB_UI_BACKEND_PORT', 'Vite dev proxy 使用的后端端口'],
['HERMES_WEB_UI_FRONTEND_PORT', '前端 Vite dev server 端口'],
],
},
gateway: {
title: 'Agent Bridge 运行时',
content: '聊天运行通过 Hermes agent bridge 处理。它随 Web UI 服务一起运行,并直接连接 Hermes Agent runtime。HERMES_AGENT_BRIDGE_ENDPOINT 控制 Node 与 bridge broker 的连接地址;HERMES_AGENT_BRIDGE_WORKER_TRANSPORT 控制 broker 与各 Profile worker 的连接方式。前端切换 Hermes Profile 只影响后续请求上下文,不会重启 bridge 或清理其他正在运行的任务。',
},
profiles: {
title: '配置文件',
content: 'Profile 为不同场景提供隔离配置。超级管理员可以管理全部 Profile;普通管理员只能查看和使用分配给自己的 Profile。可在 Profile 页面创建、克隆、导入、导出或切换 Hermes Profile。',
},
},
features: {
title: '功能详解',
intro: '探索 Hermes Web UI 的核心功能。',
chat: {
title: 'AI 聊天',
content: '通过 Socket.IO /chat-run 实时流式聊天。支持多会话管理、Markdown 渲染与语法高亮、工具调用检查、按 Profile 上传、按路径下载,以及 Ctrl+K 搜索 Web UI 本地会话库。',
},
kanban: {
title: '看板管理',
content: '可视化任务看板,包含 7 个状态列:分流、待办、就绪、运行中、阻塞、完成和已归档。支持任务分配、筛选和通过侧边抽屉进行详细编辑。',
},
groupChat: {
title: '群聊协作',
content: '多 Agent 聊天室,多个 AI Agent 协同工作。支持提及路由触发特定 Agent、历史记录超限时自动压缩上下文、输入状态指示和基于 SQLite 的消息持久化。',
},
jobs: {
title: '定时任务',
content: '创建和管理基于 cron 的定时任务,自动运行 AI 任务。可配置计划、提示词和模型。',
},
skills: {
title: '技能',
content: '浏览和管理已安装的 AI 技能。技能通过专业知识和工具集成扩展 Agent 能力。',
},
memory: {
title: '记忆',
content: '管理 Agent 记忆和用户笔记。Agent 使用记忆在对话间保持上下文并提供个性化回复。',
},
terminal: {
title: '终端',
content: '基于 node-pty 和 xterm.js 的浏览器内完整伪终端。支持多个终端会话、实时键盘输入和通过 WebSocket 的窗口大小调整。',
},
files: {
title: '文件管理',
content: '浏览和管理本地、Docker、SSH 和 Singularity 等远程后端上的文件。上传保存到当前选择/请求的 Profile;下载按真实路径解析,因此上传目录外的 Agent 产物也可以下载。',
},
analytics: {
title: '用量分析',
content: '追踪 Token 用量(输入/输出)、预估费用、缓存命中率、会话数和模型分布。查看 30 天日趋势交互图表。',
},
},
platforms: {
title: '平台接入',
intro: '从通道设置页面配置消息平台集成。',
telegram: {
title: 'Telegram',
content: '通过 BotFather 创建 Telegram Bot,输入 Bot Token。可配置提及要求、自由回复聊天和反应处理。',
},
discord: {
title: 'Discord',
content: '在开发者门户创建 Discord Bot。支持自动创建线程、允许/忽略频道、反应处理和自由回复频道。',
},
slack: {
title: 'Slack',
content: '创建带有 bot token 权限的 Slack App。配置提及要求、Bot 白名单和自由回复频道。',
},
whatsapp: {
title: 'WhatsApp',
content: '启用 WhatsApp 集成,配置提及模式和自由回复聊天。',
},
matrix: {
title: 'Matrix',
content: '提供访问令牌和服务器 URL。支持自动线程、私聊提及线程和自由回复房间。',
},
feishu: {
title: '飞书',
content: '注册飞书应用并配置 App ID 和 Secret。',
},
wechat: {
title: '微信',
content: '从设置页面扫描二维码登录。凭据会自动保存供后续使用。',
},
wecom: {
title: '企业微信',
content: '从企业微信管理后台配置 Bot ID 和 Secret。',
},
},
api: {
title: 'API 参考',
intro: 'Hermes Web UI 提供本地 BFF API,并通过 Socket.IO 端点进行聊天流式通信。',
local: {
title: '本地 BFF 端点',
content: 'Koa 服务器处理会话管理、Profile CRUD、分账户分 Profile 管理、配置读写、日志访问、技能列表、记忆操作和静态资源。',
},
proxy: {
title: '聊天流式通信',
content: '聊天运行使用 /chat-run Socket.IO 命名空间和 Hermes agent bridge。旧 gateway proxy 路由仅在兼容场景下保留。',
},
auth: {
title: '认证',
content: 'API 端点需要经过认证访问。令牌在首次运行时自动生成并存储在 ~/.hermes-web-ui/.token。用户名/密码登录使用账户记录;超级管理员管理用户和 Profile 绑定,普通管理员管理自己的账户信息。',
},
},
},
}
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import router from './router'
import { i18n } from './i18n'
import App from './App.vue'
// Import CSS custom properties (theme variables) from client
import '@client/styles/variables.scss'
import './styles/global.scss'
const savedTheme = localStorage.getItem('hermes_website_theme') || 'system'
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (savedTheme === 'system' && prefersDark)) {
document.documentElement.classList.add('dark')
}
const app = createApp(App)
app.use(i18n)
app.use(router)
app.mount('#app')
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const EmptyView = { render: () => null }
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'landing',
component: () => import('@/views/LandingView.vue'),
},
{
path: '/docs',
name: 'docs',
component: () => import('@/views/DocsView.vue'),
redirect: { name: 'docs.getting-started' },
children: [
{
path: 'getting-started',
name: 'docs.getting-started',
component: EmptyView,
meta: { page: 'gettingStarted' },
},
{
path: 'configuration',
name: 'docs.configuration',
component: EmptyView,
meta: { page: 'configuration' },
},
{
path: 'features',
name: 'docs.features',
component: EmptyView,
meta: { page: 'features' },
},
{
path: 'platforms',
name: 'docs.platforms',
component: EmptyView,
meta: { page: 'platforms' },
},
{
path: 'api',
name: 'docs.api',
component: EmptyView,
meta: { page: 'api' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
],
scrollBehavior() {
return { top: 0 }
},
})
export default router
@@ -0,0 +1,14 @@
// Website SCSS variables — pure constants (no CSS custom properties)
// CSS custom properties are defined in global.scss (imported from client)
$font-ui: 'Inter', system-ui, -apple-system, sans-serif;
$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
$breakpoint-mobile: 768px;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 14px;
$transition-fast: 0.15s ease;
$transition-normal: 0.25s ease;
+132
View File
@@ -0,0 +1,132 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 14px;
line-height: 1.6;
scroll-behavior: smooth;
}
body {
font-family: $font-ui;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--accent-primary);
text-decoration: none;
&:hover {
color: var(--accent-hover);
}
}
code {
font-family: $font-code;
background: var(--code-bg);
padding: 2px 6px;
border-radius: $radius-sm;
font-size: 0.9em;
}
pre {
code {
display: block;
padding: 16px;
border-radius: $radius-md;
overflow-x: auto;
}
}
.section {
max-width: 1120px;
margin: 0 auto;
padding: 80px 24px;
@media (max-width: $breakpoint-mobile) {
padding: 48px 16px;
}
}
.section-title {
font-size: 32px;
font-weight: 700;
text-align: center;
margin-bottom: 16px;
color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
font-size: 24px;
}
}
.section-desc {
text-align: center;
color: var(--text-secondary);
font-size: 16px;
max-width: 640px;
margin: 0 auto 48px;
}
// ─── Scroll reveal animations ────────────────────────────
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.6s ease;
&.revealed {
opacity: 1;
transform: translateY(0);
}
}
.reveal-delay-1 { transition-delay: 0.08s; }
.reveal-delay-2 { transition-delay: 0.16s; }
.reveal-delay-3 { transition-delay: 0.24s; }
.reveal-delay-4 { transition-delay: 0.32s; }
// ─── Keyframes ────────────────────────────────────────────
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse-border {
0%, 100% { border-color: var(--border-color); }
50% { border-color: var(--text-muted); }
}
.animate-fade-in-up {
animation: fade-in-up 0.7s ease both;
}
.animate-fade-in {
animation: fade-in 0.5s ease both;
}
.animate-delay-1 { animation-delay: 0.1s; }
.animate-delay-2 { animation-delay: 0.2s; }
.animate-delay-3 { animation-delay: 0.3s; }
.animate-delay-4 { animation-delay: 0.4s; }
.animate-delay-5 { animation-delay: 0.5s; }
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import DocSidebar from '@/components/docs/DocSidebar.vue'
import DocContent from '@/components/docs/DocContent.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const pages = [
{ key: 'gettingStarted', name: 'docs.getting-started' },
{ key: 'configuration', name: 'docs.configuration' },
{ key: 'features', name: 'docs.features' },
{ key: 'platforms', name: 'docs.platforms' },
{ key: 'api', name: 'docs.api' },
]
function navigate(name: string) {
router.push({ name })
}
</script>
<template>
<div class="docs-layout">
<DocSidebar v-if="route.meta.page" />
<div class="docs-main">
<nav v-if="route.meta.page" class="mobile-doc-tabs">
<button
v-for="p in pages"
:key="p.key"
class="mobile-tab"
:class="{ active: route.name === p.name }"
@click="navigate(p.name)"
>
{{ t(`docs.sidebar.${p.key}`) }}
</button>
</nav>
<router-view />
<DocContent v-if="route.meta.page" />
<div v-else class="docs-placeholder">
<p>{{ t('docs.placeholder') }}</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.docs-layout {
display: flex;
max-width: 1120px;
margin: 0 auto;
min-height: calc(100vh - 60px - 200px);
}
.docs-main {
flex: 1;
min-width: 0;
}
.docs-placeholder {
padding: 60px 32px;
color: var(--text-muted);
font-size: 16px;
text-align: center;
}
// ─── Mobile doc tabs ────────────────────────────
.mobile-doc-tabs {
display: none;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 4px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
position: sticky;
top: 60px;
z-index: 10;
&::-webkit-scrollbar {
display: none;
}
}
.mobile-tab {
flex-shrink: 0;
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all $transition-fast;
white-space: nowrap;
&.active {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-muted);
}
}
@media (max-width: $breakpoint-mobile) {
.mobile-doc-tabs {
display: flex;
}
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import HeroSection from '@/components/landing/HeroSection.vue'
import ScreenshotsSection from '@/components/landing/ScreenshotsSection.vue'
import FeaturesGrid from '@/components/landing/FeaturesGrid.vue'
import InstallSection from '@/components/landing/InstallSection.vue'
import StarHistorySection from '@/components/landing/StarHistorySection.vue'
</script>
<template>
<div class="landing">
<HeroSection />
<section class="download-section">
<InstallSection />
</section>
<ScreenshotsSection />
<FeaturesGrid />
<div class="cta-row">
<StarHistorySection class="cta-col" />
</div>
</div>
</template>
<style scoped lang="scss">
.download-section {
max-width: 1120px;
margin: 0 auto;
padding: 56px 24px 16px;
@media (max-width: $breakpoint-mobile) {
padding: 32px 16px 8px;
}
}
.cta-row {
max-width: 1120px;
margin: 0 auto;
padding: 80px 24px;
display: flex;
gap: 40px;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
padding: 48px 16px;
gap: 0;
}
}
.cta-col {
flex: 1;
min-width: 0;
}
</style>