add website downloads and deploy workflow (#1165)

This commit is contained in:
ekko
2026-05-30 21:35:38 +08:00
committed by GitHub
parent 4c3f025b8f
commit e45b3a881e
14 changed files with 425 additions and 41 deletions
@@ -150,7 +150,7 @@ onMounted(() => {
<div class="install-box animate-fade-in animate-delay-3">
<code>{{ installCmd }}</code>
<button class="copy-btn" @click="copyCmd">
{{ copied ? 'Copied!' : 'Copy' }}
{{ copied ? t('ui.copied') : t('ui.copy') }}
</button>
</div>
</div>
@@ -1,11 +1,29 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useScrollReveal } from '@/composables/useScrollReveal'
const { t } = useI18n()
interface DesktopDownload {
title: string
desc: string
assetSuffix: string
}
const { t, tm } = useI18n()
useScrollReveal()
const activeTab = ref<'npm' | 'docker' | 'source'>('npm')
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 releaseDownloadUrl = `${releaseBaseUrl}/download/${releaseTag}`
const desktopDownloads = computed(() =>
(tm('install.desktop.downloads') as DesktopDownload[]).map((item) => ({
...item,
href: `${releaseDownloadUrl}/Hermes.Studio-${releaseVersion}-${item.assetSuffix}`,
})),
)
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
@@ -19,7 +37,7 @@ function copyText(text: string) {
<div class="install-tabs reveal">
<button
v-for="tab in (['npm', 'docker', 'source'] as const)"
v-for="tab in (['desktop', 'npm', 'docker', 'source'] as const)"
:key="tab"
class="tab-btn"
:class="{ active: activeTab === tab }"
@@ -30,7 +48,33 @@ function copyText(text: string) {
</div>
<div class="install-content reveal reveal-delay-1">
<template v-if="activeTab === 'npm'">
<template v-if="activeTab === 'desktop'">
<div class="download-list">
<a
v-for="item in desktopDownloads"
:key="item.href"
class="download-row"
:href="item.href"
target="_blank"
rel="noopener"
>
<span>
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
<span class="download-action">{{ t('install.desktop.download') }}</span>
</a>
</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>
@@ -51,7 +95,7 @@ function copyText(text: string) {
<code>{{ t('install.source.cmd2') }}</code>
</div>
</template>
<p class="prereq">{{ t('install.prereq') }}</p>
<p class="prereq">{{ activeTab === 'desktop' ? t('install.desktop.prereq') : t('install.prereq') }}</p>
</div>
</div>
</template>
@@ -88,6 +132,8 @@ function copyText(text: string) {
background: var(--bg-secondary);
border-radius: $radius-md;
padding: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
@@ -114,6 +160,65 @@ function copyText(text: string) {
// 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;
&:first-child {
padding-top: 0;
}
&:hover .download-action {
border-color: var(--text-muted);
}
strong,
small {
display: block;
}
strong {
font-size: 15px;
font-weight: 650;
}
small {
color: var(--text-muted);
font-size: 12px;
margin-top: 3px;
}
}
.download-action {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
padding: 7px 12px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
transition: border-color $transition-fast;
}
.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);
@@ -1,25 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
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 = [
{ 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' },
]
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.length
activeIndex.value = (activeIndex.value + 1) % images.value.length
}
function prev() {
activeIndex.value = (activeIndex.value - 1 + images.length) % images.length
activeIndex.value = (activeIndex.value - 1 + images.value.length) % images.value.length
}
function setActive(i: number) {
@@ -53,7 +54,7 @@ onUnmounted(() => {
<span class="dot green" />
</div>
<div class="browser-url">
<span>http://localhost:8648</span>
<span>{{ t('screenshots.localUrl') }}</span>
</div>
<div class="browser-spacer" />
</div>
@@ -71,7 +72,7 @@ onUnmounted(() => {
<!-- Navigation -->
<div class="screenshot-nav">
<button class="nav-arrow" @click="prev(); resetTimer()">
<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>
@@ -80,12 +81,13 @@ onUnmounted(() => {
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" @click="next(); resetTimer()">
<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>
@@ -39,19 +39,19 @@ onMounted(async () => {
<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>Star</span>
<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="License"
:alt="t('starHistory.licenseAlt')"
/>
<img
class="github-badge"
src="https://img.shields.io/github/v/release/EKKOLearnAI/hermes-web-ui?style=flat-square"
alt="Version"
:alt="t('starHistory.versionAlt')"
/>
</div>
@@ -64,7 +64,7 @@ onMounted(async () => {
>
<img
:src="chartSrc"
alt="Star History"
:alt="t('starHistory.chartAlt')"
class="chart-img"
/>
</a>
@@ -9,8 +9,8 @@ const { t } = useI18n()
<div class="footer-inner">
<div class="footer-left">
<div class="footer-brand">
<img src="/logo.png" alt="Hermes" class="footer-logo" />
<span>Hermes Web UI</span>
<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>
@@ -30,8 +30,8 @@ function goHome() {
<header class="site-header">
<div class="header-inner">
<div class="header-left" @click="goHome">
<img src="/logo.png" alt="Hermes" class="logo-icon" />
<span class="logo-text">Hermes Web UI</span>
<img src="/logo.png" :alt="t('brand.logoAlt')" class="logo-icon" />
<span class="logo-text">{{ t('brand.name') }}</span>
</div>
<nav class="header-nav">
@@ -50,10 +50,10 @@ function goHome() {
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<button class="icon-btn" @click="switchLocale" :title="locale === 'en' ? '中文' : 'English'">
<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 ? 'Light' : 'Dark'">
<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" />
@@ -71,7 +71,7 @@ function goHome() {
</button>
</nav>
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen">
<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" />
@@ -92,7 +92,7 @@ function goHome() {
<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' ? '中文' : 'English' }}
{{ 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">
@@ -109,7 +109,7 @@ function goHome() {
<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 ? 'Light Mode' : 'Dark Mode' }}
{{ isDark ? t('ui.lightMode') : t('ui.darkMode') }}
</button>
</div>
</div>
+71 -1
View File
@@ -1,4 +1,19 @@
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',
@@ -75,9 +90,59 @@ export default {
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: 'Get Hermes Web UI running in under a minute.',
desc: 'Download the desktop app or run Hermes Web UI yourself.',
desktop: {
title: 'Desktop',
download: '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',
@@ -97,6 +162,10 @@ export default {
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.',
@@ -104,6 +173,7 @@ export default {
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',
+71 -1
View File
@@ -1,4 +1,19 @@
export default {
brand: {
name: 'Hermes Web UI',
logoAlt: 'Hermes',
},
ui: {
copy: '复制',
copied: '已复制',
darkTheme: '深色',
lightTheme: '浅色',
darkMode: '深色模式',
lightMode: '浅色模式',
menu: '菜单',
switchToChinese: '中文',
switchToEnglish: 'English',
},
nav: {
home: '首页',
docs: '文档',
@@ -75,9 +90,59 @@ export default {
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。',
desc: '下载桌面应用,或自行运行 Hermes Web UI。',
desktop: {
title: '桌面版',
download: '下载',
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',
@@ -97,6 +162,10 @@ export default {
starHistory: {
title: '社区成长',
desc: '在 GitHub 上给我们加星,加入社区。',
star: '加星',
licenseAlt: '许可证',
versionAlt: '版本',
chartAlt: 'Star 历史',
},
footer: {
description: 'Hermes Agent 的自托管 AI 聊天仪表板。',
@@ -104,6 +173,7 @@ export default {
madeWith: '使用 Vue 3、Naive UI 和 TypeScript 构建。',
},
docs: {
placeholder: '从侧边栏选择一个章节开始阅读。',
sidebar: {
gettingStarted: '快速开始',
configuration: '配置说明',
+1 -1
View File
@@ -39,7 +39,7 @@ function navigate(name: string) {
<router-view />
<DocContent v-if="route.meta.page" />
<div v-else class="docs-placeholder">
<p>Select a section from the sidebar to get started.</p>
<p>{{ t('docs.placeholder') }}</p>
</div>
</div>
</div>
+13 -1
View File
@@ -9,16 +9,28 @@ import StarHistorySection from '@/components/landing/StarHistorySection.vue'
<template>
<div class="landing">
<HeroSection />
<section class="download-section">
<InstallSection />
</section>
<ScreenshotsSection />
<FeaturesGrid />
<div class="cta-row">
<InstallSection class="cta-col" />
<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;