Files
Hermes-ui/packages/client/src/components/layout/AppSidebar.vue
T
ekko da7910f934 Merge pull request #44 from 0xnuu/pr/chat-resilience-sidebar
feat(chat): improve resilience and collapsible sidebar
2026-04-18 14:32:36 +08:00

692 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { NButton, useMessage } from "naive-ui";
import { useAppStore } from "@/stores/hermes/app";
import ModelSelector from "./ModelSelector.vue";
import ProfileSelector from "./ProfileSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import ThemeSwitch from "./ThemeSwitch.vue";
import danceVideoLight from "@/assets/dance-light.mp4";
import danceVideoDark from "@/assets/dance-dark.mp4";
import { useTheme } from "@/composables/useTheme";
const { t } = useI18n();
const { isDark } = useTheme();
const message = useMessage();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const selectedKey = computed(() => route.name as string);
function handleNav(key: string) {
router.push({ name: key });
}
async function handleUpdate() {
const ok = await appStore.doUpdate();
if (ok) {
message.success(t('sidebar.updateSuccess'), { duration: 5000 });
} else {
message.error(t('sidebar.updateFailed'));
}
}
</script>
<template>
<aside class="sidebar" :class="{ open: appStore.sidebarOpen, collapsed: appStore.sidebarCollapsed }">
<div class="sidebar-logo">
<button
type="button"
class="logo-main"
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : 'Hermes'"
@click="router.push('/hermes/chat')"
>
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
</button>
<video
class="logo-dance"
:src="isDark ? danceVideoDark : danceVideoLight"
autoplay
loop
muted
playsinline
/>
<button
type="button"
class="sidebar-collapse-btn"
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')"
@click="appStore.toggleSidebarCollapsed"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline v-if="appStore.sidebarCollapsed" points="9 18 15 12 9 6" />
<polyline v-else points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.chat' }"
:title="t('sidebar.chat')"
@click="handleNav('hermes.chat')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<span>{{ t("sidebar.chat") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.jobs' }"
:title="t('sidebar.jobs')"
@click="handleNav('hermes.jobs')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>{{ t("sidebar.jobs") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.models' }"
:title="t('sidebar.models')"
@click="handleNav('hermes.models')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
<span>{{ t("sidebar.models") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.channels' }"
:title="t('sidebar.channels')"
@click="handleNav('hermes.channels')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
<span>{{ t("sidebar.channels") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.skills' }"
:title="t('sidebar.skills')"
@click="handleNav('hermes.skills')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
<span>{{ t("sidebar.skills") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.memory' }"
:title="t('sidebar.memory')"
@click="handleNav('hermes.memory')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
<span>{{ t("sidebar.memory") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.logs' }"
:title="t('sidebar.logs')"
@click="handleNav('hermes.logs')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>{{ t("sidebar.logs") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.usage' }"
:title="t('sidebar.usage')"
@click="handleNav('hermes.usage')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
<span>{{ t("sidebar.usage") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.profiles' }"
@click="handleNav('hermes.profiles')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>{{ t("sidebar.profiles") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.terminal' }"
:title="t('sidebar.terminal')"
@click="handleNav('hermes.terminal')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span>{{ t("sidebar.terminal") }}</span>
</button>
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.settings' }"
:title="t('sidebar.settings')"
@click="handleNav('hermes.settings')"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
<span>{{ t("sidebar.settings") }}</span>
</button>
</nav>
<ProfileSelector />
<ModelSelector />
<div class="sidebar-footer">
<div class="status-row">
<div
class="status-indicator"
:class="{
connected: appStore.connected,
disconnected: !appStore.connected,
}"
>
<span class="status-dot"></span>
<span class="status-text">{{
appStore.connected
? t("sidebar.connected")
: t("sidebar.disconnected")
}}</span>
</div>
<LanguageSwitch />
</div>
<div class="version-info">
<a class="github-link" href="https://github.com/EKKOLearnAI/hermes-web-ui" target="_blank" rel="noopener noreferrer" title="GitHub">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
</a>
<span>Hermes Web UI v{{ appStore.serverVersion || "0.1.0" }}</span>
<ThemeSwitch />
</div>
<NButton v-if="appStore.updateAvailable" type="primary" size="tiny" block class="update-btn" :loading="appStore.updating" @click="handleUpdate">
{{ appStore.updating ? t('sidebar.updating') : t('sidebar.updateVersion', { version: appStore.latestVersion }) }}
</NButton>
</div>
</aside>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.sidebar {
width: $sidebar-width;
height: calc(100 * var(--vh));
background-color: $bg-sidebar;
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
padding: 0 12px 20px;
flex-shrink: 0;
transition: width $transition-normal;
}
.logo-img {
width: 28px;
height: 28px;
border-radius: 0;
flex-shrink: 0;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 6px;
padding: 20px 12px;
margin: 0 -12px;
color: $text-primary;
background-color: $bg-card;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
.dark & {
background-color: #393939;
}
position: relative;
overflow: hidden;
.logo-main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
border: none;
background: none;
padding: 0;
cursor: pointer;
color: inherit;
text-align: left;
}
.logo-text {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.5px;
}
.logo-dance {
position: absolute;
// Give the 36-wide collapse button + 12px sidebar padding breathing room.
right: 54px;
top: 50%;
transform: translateY(-50%);
height: 100px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
width: auto;
pointer-events: none;
}
}
.sidebar-collapse-btn {
flex-shrink: 0;
// 36×36 meets the 44dp Material / 44pt Apple touch-target floor after
// typical zoom-out (e.g. Chrome mobile at 80% → ~29 physical px, still
// finger-friendly). The 14×14 SVG inside keeps the chevron visually small.
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-color;
background: $bg-card;
border-radius: $radius-sm;
color: $text-secondary;
cursor: pointer;
padding: 0;
position: relative;
z-index: 2;
transition: all $transition-fast;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.08);
// Invisible padding via ::before extends the hit box a further 6px on each
// side without affecting layout — makes the button easy to tap even with
// imprecise touch input.
&::before {
content: '';
position: absolute;
inset: -6px;
}
svg {
width: 14px;
height: 14px;
}
&:hover {
color: $text-primary;
border-color: $accent-muted;
background: $bg-card-hover;
}
&:active {
background: rgba(0, 0, 0, 0.06);
transform: scale(0.94);
}
}
.sidebar-nav {
flex: 1;
display: flex;
padding-top: 12px;
flex-direction: column;
gap: 4px;
overflow-y: auto;
min-height: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: none;
background: none;
color: $text-secondary;
font-size: 14px;
border-radius: $radius-sm;
cursor: pointer;
transition: all $transition-fast;
width: 100%;
text-align: left;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
}
&.active {
background-color: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
}
.sidebar-footer {
padding-top: 16px;
border-top: 1px solid $border-color;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
&.connected .status-dot {
background-color: $success;
box-shadow: 0 0 6px rgba(var(--success-rgb), 0.5);
}
&.disconnected .status-dot {
background-color: $error;
}
.status-text {
color: $text-secondary;
}
}
.version-info {
padding: 2px 12px 8px;
font-size: 11px;
color: $text-muted;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.github-link {
color: $text-muted;
display: flex;
align-items: center;
transition: color 0.2s;
&:hover {
color: $text-primary;
}
}
.update-btn {
margin: 4px 0 0;
border-radius: 4px;
}
// Desktop-only collapsed ("icon rail") state. Mobile continues to use the
// slide-in open/close behaviour below.
@media (min-width: #{$breakpoint-mobile + 1px}) {
.sidebar.collapsed {
width: $sidebar-collapsed-width;
padding: 0 6px 16px;
.sidebar-logo {
padding: 16px 6px;
margin: 0 -6px;
gap: 4px;
justify-content: center;
.logo-main {
flex: 0 0 auto;
justify-content: center;
}
.logo-text,
.logo-dance {
display: none;
}
}
.nav-item {
justify-content: center;
padding: 12px 0;
gap: 0;
span {
display: none;
}
}
:deep(.model-selector) {
display: none;
}
.sidebar-footer {
padding-top: 12px;
}
.status-row {
padding: 8px 0;
justify-content: center;
:deep(.input-sm),
.status-text {
display: none;
}
.status-indicator {
gap: 0;
}
}
.version-info {
display: none;
}
}
}
@media (max-width: $breakpoint-mobile) {
.logo-dance {
display: none;
}
// Desktop-only collapse toggle — mobile relies on the slide-in open state.
.sidebar-collapse-btn {
display: none;
}
.status-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform $transition-normal;
&.open {
transform: translateX(0);
}
// Override global utility — sidebar is always 240px wide
.input-sm {
width: 90px;
}
}
}
</style>