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
co-authored by Cursor
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { fetchConversationDetail, fetchConversationSummaries, type ConversationDetail, type ConversationSummary } from '@/api/hermes/conversations'
import { formatTimestampSeconds, getSourceLabel } from '@/shared/session-display'
import { useAppStore } from '@/stores/hermes/app'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ humanOnly: boolean }>()
const { t } = useI18n()
const appStore = useAppStore()
const POLL_INTERVAL_MS = 15000
const sessions = ref<ConversationSummary[]>([])
const selectedSessionId = ref<string | null>(null)
const detail = ref<ConversationDetail | null>(null)
const sessionsLoading = ref(false)
const detailLoading = ref(false)
const error = ref('')
let refreshTimer: ReturnType<typeof setInterval> | null = null
let sessionsRequestId = 0
let detailRequestId = 0
const selectedSession = computed(() => sessions.value.find(session => session.id === selectedSessionId.value) || null)
const selectedSessionModelName = computed(() =>
selectedSession.value?.model
? appStore.displayModelName(selectedSession.value.model, selectedSession.value.provider)
: '',
)
function roleLabel(role: string): string {
return role === 'user' ? t('chat.monitorRoleUser') : t('chat.monitorRoleAssistant')
}
function linkedSessionsLabel(count: number): string {
return t('chat.linkedSessions', { count })
}
function invalidateRequests() {
sessionsRequestId += 1
detailRequestId += 1
}
async function loadSessions(silent = false) {
const requestId = ++sessionsRequestId
if (!silent) {
sessionsLoading.value = true
error.value = ''
}
try {
const loaded = await fetchConversationSummaries({ humanOnly: props.humanOnly })
if (requestId !== sessionsRequestId) return
sessions.value = loaded
if (!loaded.length) {
selectedSessionId.value = null
detail.value = null
return
}
if (!selectedSessionId.value || !loaded.some(session => session.id === selectedSessionId.value)) {
selectedSessionId.value = loaded[0].id
}
} catch (err: any) {
if (requestId !== sessionsRequestId || silent) return
error.value = err?.message || String(err)
sessions.value = []
selectedSessionId.value = null
detail.value = null
} finally {
if (!silent && requestId === sessionsRequestId) sessionsLoading.value = false
}
}
async function loadDetail(sessionId: string | null, silent = false) {
const requestId = ++detailRequestId
if (!sessionId) {
detail.value = null
return
}
const requestedHumanOnly = props.humanOnly
if (!silent) {
detailLoading.value = true
error.value = ''
}
try {
const loaded = await fetchConversationDetail(sessionId, { humanOnly: requestedHumanOnly })
if (
requestId !== detailRequestId
|| sessionId !== selectedSessionId.value
|| requestedHumanOnly !== props.humanOnly
) {
return
}
detail.value = loaded
} catch (err: any) {
if (requestId !== detailRequestId || silent) return
error.value = err?.message || String(err)
detail.value = null
} finally {
if (!silent && requestId === detailRequestId) detailLoading.value = false
}
}
watch(selectedSessionId, async sessionId => {
await loadDetail(sessionId, false)
})
watch(() => props.humanOnly, async () => {
invalidateRequests()
selectedSessionId.value = null
detail.value = null
await loadSessions(false)
})
onMounted(async () => {
await loadSessions(false)
refreshTimer = setInterval(async () => {
await loadSessions(true)
if (selectedSessionId.value) {
await loadDetail(selectedSessionId.value, true)
}
}, POLL_INTERVAL_MS)
})
onUnmounted(() => {
invalidateRequests()
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<template>
<div class="conversation-monitor">
<aside class="conversation-monitor__sidebar">
<div v-if="sessionsLoading && sessions.length === 0" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
<div v-else-if="sessions.length === 0" class="conversation-monitor__empty">{{ t('chat.noSessions') }}</div>
<button
v-for="session in sessions"
:key="session.id"
class="conversation-monitor__session"
:class="{ active: session.id === selectedSessionId }"
:aria-pressed="session.id === selectedSessionId"
@click="selectedSessionId = session.id"
>
<div class="conversation-monitor__session-title-row">
<span class="conversation-monitor__session-title">{{ session.title || session.preview || session.id }}</span>
<span v-if="session.is_active" class="conversation-monitor__session-live">{{ t('chat.recentBadge') }}</span>
</div>
<div class="conversation-monitor__session-meta">{{ getSourceLabel(session.source) }} · {{ formatTimestampSeconds(session.last_active) }}</div>
<div v-if="session.preview" class="conversation-monitor__session-preview">{{ session.preview }}</div>
</button>
</aside>
<section class="conversation-monitor__detail">
<header v-if="selectedSession" class="conversation-monitor__detail-header">
<div class="conversation-monitor__detail-title">{{ selectedSession.title || selectedSession.preview || selectedSession.id }}</div>
<div class="conversation-monitor__detail-meta">
<span>{{ getSourceLabel(selectedSession.source) }}</span>
<span>·</span>
<span :title="selectedSession.model">{{ selectedSessionModelName }}</span>
<span>·</span>
<span>{{ linkedSessionsLabel(selectedSession.thread_session_count) }}</span>
</div>
</header>
<div v-if="error" class="conversation-monitor__empty conversation-monitor__empty--error">{{ error }}</div>
<div v-else-if="detailLoading && !detail" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
<div v-else-if="!detail || detail.messages.length === 0" class="conversation-monitor__empty">{{ t('chat.noVisibleMessages') }}</div>
<div v-else class="conversation-monitor__messages">
<article
v-for="message in detail.messages"
:key="`${message.session_id}-${message.id}`"
class="conversation-monitor__message"
:class="`role-${message.role}`"
>
<div class="conversation-monitor__message-meta">{{ roleLabel(message.role) }} · {{ formatTimestampSeconds(message.timestamp) }}</div>
<div class="conversation-monitor__message-content">{{ message.content }}</div>
</article>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.conversation-monitor {
display: flex;
min-height: 0;
flex: 1;
}
.conversation-monitor__sidebar {
width: 260px;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
scrollbar-gutter: stable;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba($text-muted, 0.3);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba($text-muted, 0.5);
}
}
.conversation-monitor__session {
width: 100%;
border: 0;
border-bottom: 1px solid rgba($border-color, 0.6);
background: transparent;
color: inherit;
text-align: left;
padding: 12px 14px;
cursor: pointer;
&.active {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $text-primary;
font-weight: 500;
}
&.active .conversation-monitor__session-title {
color: $accent-primary;
}
}
.conversation-monitor__session-title-row,
.conversation-monitor__detail-meta,
.conversation-monitor__message-meta {
display: flex;
align-items: center;
gap: 6px;
}
.conversation-monitor__session-title,
.conversation-monitor__detail-title {
font-weight: 600;
}
.conversation-monitor__session-live {
font-size: 11px;
color: $accent-primary;
}
.conversation-monitor__session-meta,
.conversation-monitor__session-preview,
.conversation-monitor__detail-meta,
.conversation-monitor__message-meta {
font-size: 12px;
color: $text-muted;
}
.conversation-monitor__session-preview,
.conversation-monitor__message-content {
margin-top: 6px;
white-space: pre-wrap;
}
.conversation-monitor__detail {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.conversation-monitor__detail-header {
padding: 16px 20px;
border-bottom: 1px solid $border-color;
}
.conversation-monitor__messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.conversation-monitor__message {
padding: 12px 14px;
border-radius: 10px;
background: rgba($bg-secondary, 0.8);
&.role-user {
border: 1px solid rgba($accent-primary, 0.18);
}
&.role-assistant {
border: 1px solid rgba($border-color, 0.9);
}
}
.conversation-monitor__empty {
padding: 24px;
color: $text-muted;
}
.conversation-monitor__empty--error {
color: $error;
}
@media (max-width: $breakpoint-mobile) {
.conversation-monitor {
flex-direction: column;
}
.conversation-monitor__sidebar {
width: 100%;
max-height: 220px;
border-right: 0;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.conversation-monitor__detail {
min-height: 0;
overflow: hidden;
}
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TerminalPanel from './TerminalPanel.vue'
import FilesPanel from './FilesPanel.vue'
interface Props {
show: boolean
activeTab?: 'terminal' | 'files'
}
interface Emits {
(e: 'update:show', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
activeTab: 'files'
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const activeTab = ref<'terminal' | 'files'>(props.activeTab)
watch(() => props.activeTab, (newVal) => {
if (newVal) activeTab.value = newVal
})
function handleClose() {
emit('update:show', false)
}
</script>
<template>
<Teleport to="body">
<div v-if="show" class="drawer-overlay" @click="handleClose"></div>
<div :class="['drawer-panel', { show }]">
<div class="drawer-header">
<div class="drawer-tabs">
<button
:class="['tab-button', { active: activeTab === 'files' }]"
@click="activeTab = 'files'"
>
{{ t('drawer.files') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'terminal' }]"
@click="activeTab = 'terminal'"
>
{{ t('drawer.terminal') }}
</button>
</div>
<button class="close-button" @click="handleClose">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="drawer-content">
<div v-show="activeTab === 'files'" class="drawer-pane">
<FilesPanel />
</div>
<div v-show="activeTab === 'terminal'" class="drawer-pane">
<TerminalPanel :visible="activeTab === 'terminal' && show" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.drawer-panel {
position: fixed;
top: 0;
right: min(-1180px, -88vw);
width: min(1180px, 88vw);
height: calc(100 * var(--vh));
max-height: calc(100 * var(--vh));
background: $bg-card;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 1000;
transition: right 0.3s ease;
&.show {
right: 0;
}
@media (max-width: $breakpoint-mobile) {
width: 100%;
right: -100%;
&.show {
right: 0;
}
}
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.drawer-tabs {
display: flex;
gap: 8px;
}
.tab-button {
padding: 8px 16px;
border: none;
background: transparent;
color: $text-secondary;
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
flex-shrink: 0;
white-space: nowrap;
border-radius: $radius-sm;
&:hover {
color: $text-primary;
background: rgba(var(--accent-primary-rgb), 0.05);
}
&.active {
color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.1);
}
}
.close-button {
padding: 8px;
border: none;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
cursor: pointer;
border-radius: $radius-sm;
transition: all 0.2s;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $text-primary;
background: rgba(var(--accent-primary-rgb), 0.15);
}
}
.drawer-content {
flex: 1;
overflow: hidden;
position: relative;
min-height: 0;
}
.drawer-pane {
height: 100%;
overflow: auto;
}
</style>
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useFilesStore } from '@/stores/hermes/files'
import { useI18n } from 'vue-i18n'
import { NButton } from 'naive-ui'
import FileTree from '@/components/hermes/files/FileTree.vue'
import FileBreadcrumb from '@/components/hermes/files/FileBreadcrumb.vue'
import FileToolbar from '@/components/hermes/files/FileToolbar.vue'
import FileList from '@/components/hermes/files/FileList.vue'
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
import FileEditor from '@/components/hermes/files/FileEditor.vue'
import FilePreview from '@/components/hermes/files/FilePreview.vue'
import FileUploadModal from '@/components/hermes/files/FileUploadModal.vue'
import FileRenameModal from '@/components/hermes/files/FileRenameModal.vue'
import type { FileEntry } from '@/api/hermes/files'
const filesStore = useFilesStore()
const { t } = useI18n()
const contextMenuRef = ref<InstanceType<typeof FileContextMenu> | null>(null)
const showUpload = ref(false)
const showRenameModal = ref(false)
const renameMode = ref<'newFile' | 'newFolder' | 'rename'>('newFile')
const renameEntry = ref<FileEntry | null>(null)
const showSidebar = ref(false)
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
contextMenuRef.value?.show(e, entry)
}
function handleShowNewFile() {
renameMode.value = 'newFile'
renameEntry.value = null
showRenameModal.value = true
}
function handleShowNewFolder() {
renameMode.value = 'newFolder'
renameEntry.value = null
showRenameModal.value = true
}
function handleRename(entry: FileEntry) {
renameMode.value = 'rename'
renameEntry.value = entry
showRenameModal.value = true
}
onMounted(() => {
filesStore.fetchEntries('')
})
</script>
<template>
<div class="files-panel-drawer">
<div
v-if="showSidebar"
class="sidebar-overlay"
@click="showSidebar = false"
></div>
<div
class="files-tree-panel"
:class="{ 'mobile-visible': showSidebar }"
>
<FileTree />
</div>
<div class="files-main-panel">
<div class="main-toolbar">
<NButton
size="small"
@click="showSidebar = !showSidebar"
class="sidebar-toggle"
>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</template>
{{ t('files.fileTree') }}
</NButton>
<FileToolbar
@show-new-file="handleShowNewFile"
@show-new-folder="handleShowNewFolder"
@show-upload="showUpload = true"
/>
</div>
<FileBreadcrumb />
<div class="files-content">
<FileEditor v-if="filesStore.editingFile" />
<FilePreview v-else-if="filesStore.previewFile" />
<FileList v-else @contextmenu-entry="handleContextMenu" />
</div>
</div>
<FileContextMenu ref="contextMenuRef" @rename="handleRename" />
<FileUploadModal v-model:show="showUpload" />
<FileRenameModal v-model:show="showRenameModal" :mode="renameMode" :entry="renameEntry" />
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.files-panel-drawer {
display: flex;
height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
}
.files-tree-panel {
width: 200px;
min-width: 150px;
max-width: 300px;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
@media (max-width: $breakpoint-mobile) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 80%;
max-width: 300px;
z-index: 51;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
transform: translateX(-100%);
transition: transform 0.3s ease;
&.mobile-visible {
transform: translateX(0);
}
}
}
.files-main-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.main-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
gap: 4px;
padding: 8px 8px;
flex-wrap: wrap;
}
}
.sidebar-toggle {
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
@media (max-width: $breakpoint-mobile) {
font-size: 12px;
padding: 0 8px;
height: 32px;
}
}
.files-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { NSpin } from 'naive-ui'
import { request } from '@/api/client'
interface FolderEntry {
name: string
path: string
fullPath: string
}
interface FolderListResponse {
base: string
current: string
folders: FolderEntry[]
}
/** Flat display node for rendering tree without recursion */
interface FlatNode {
folder: FolderEntry
depth: number
isExpanded: boolean
isLoading: boolean
hasChildren: boolean | null // null = unknown
}
const props = defineProps<{
modelValue: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const loading = ref(false)
const basePath = ref('')
const folders = ref<FolderEntry[]>([])
const expandedPaths = ref<Set<string>>(new Set())
const childrenCache = ref<Map<string, FolderEntry[]>>(new Map())
const loadingPaths = ref<Set<string>>(new Set())
const selectedPath = ref(props.modelValue || '')
watch(() => props.modelValue, (v) => { selectedPath.value = v || '' })
async function loadFolders(subPath = ''): Promise<FolderListResponse | null> {
try {
const query = subPath ? `?path=${encodeURIComponent(subPath)}` : ''
return await request<FolderListResponse>(`/api/hermes/workspace/folders${query}`)
} catch {
return null
}
}
onMounted(async () => {
loading.value = true
const res = await loadFolders()
if (res) {
basePath.value = res.base
folders.value = res.folders
}
loading.value = false
})
async function toggleExpand(folder: FolderEntry) {
if (expandedPaths.value.has(folder.path)) {
expandedPaths.value.delete(folder.path)
expandedPaths.value = new Set(expandedPaths.value)
return
}
expandedPaths.value.add(folder.path)
expandedPaths.value = new Set(expandedPaths.value)
if (!childrenCache.value.has(folder.path)) {
loadingPaths.value.add(folder.path)
loadingPaths.value = new Set(loadingPaths.value)
const res = await loadFolders(folder.path)
childrenCache.value.set(folder.path, res?.folders || [])
childrenCache.value = new Map(childrenCache.value)
loadingPaths.value.delete(folder.path)
loadingPaths.value = new Set(loadingPaths.value)
}
}
function selectFolder(folder: FolderEntry) {
const fullPath = `${basePath.value}/${folder.path}`
selectedPath.value = fullPath
emit('update:modelValue', fullPath)
}
function selectBase() {
selectedPath.value = basePath.value
emit('update:modelValue', basePath.value)
}
/** Build a flat list by DFS traversal of expanded nodes */
const flatNodes = computed<FlatNode[]>(() => {
const result: FlatNode[] = []
function traverse(entries: FolderEntry[], depth: number) {
for (const folder of entries) {
const isExpanded = expandedPaths.value.has(folder.path)
const isLoading = loadingPaths.value.has(folder.path)
const children = childrenCache.value.get(folder.path)
result.push({
folder,
depth,
isExpanded,
isLoading,
hasChildren: children ? children.length > 0 : null,
})
if (isExpanded && children && children.length > 0) {
traverse(children, depth + 1)
}
}
}
traverse(folders.value, 0)
return result
})
</script>
<template>
<div class="folder-picker">
<div v-if="loading" class="folder-picker-loading">
<NSpin size="small" />
</div>
<div v-else class="folder-tree">
<!-- Base path as root -->
<div
class="folder-item root"
:class="{ selected: selectedPath === basePath }"
@click="selectBase"
>
<span class="folder-icon">📂</span>
<span class="folder-name">{{ basePath || '/' }}</span>
</div>
<!-- Flat rendered tree -->
<div
v-for="node in flatNodes"
:key="node.folder.path"
class="folder-item"
:class="{ selected: selectedPath === `${basePath}/${node.folder.path}` }"
:style="{ paddingLeft: `${12 + node.depth * 16}px` }"
>
<span class="folder-expand" @click.stop="toggleExpand(node.folder)">
<template v-if="node.isLoading"></template>
<template v-else>{{ node.isExpanded ? '' : '' }}</template>
</span>
<span class="folder-icon" @click="selectFolder(node.folder)">📁</span>
<span class="folder-name" @click="selectFolder(node.folder)">{{ node.folder.name }}</span>
</div>
<!-- Empty children indicator for expanded folders with no children -->
<template v-for="node in flatNodes" :key="'empty-' + node.folder.path">
<div
v-if="node.isExpanded && !node.isLoading && node.hasChildren === false"
class="folder-item empty"
:style="{ paddingLeft: `${28 + node.depth * 16}px` }"
>
<span class="folder-empty-text"></span>
</div>
</template>
<div v-if="folders.length === 0 && !loading" class="folder-empty">
暂无工作区文件夹
</div>
</div>
<!-- Selected path display -->
<div v-if="selectedPath" class="folder-selected">
<span class="folder-selected-label">已选择</span>
<span class="folder-selected-path">{{ selectedPath }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.folder-picker {
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
}
.folder-picker-loading {
display: flex;
justify-content: center;
padding: 24px;
}
.folder-tree {
font-size: 13px;
}
.folder-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
&.selected {
background: rgba(64, 158, 255, 0.15);
outline: 1px solid rgba(64, 158, 255, 0.4);
}
&.root {
font-weight: 600;
margin-bottom: 4px;
}
&.empty {
opacity: 0.5;
cursor: default;
}
}
.folder-expand {
width: 14px;
font-size: 10px;
text-align: center;
flex-shrink: 0;
user-select: none;
opacity: 0.6;
}
.folder-icon {
flex-shrink: 0;
}
.folder-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-empty-text {
font-size: 11px;
opacity: 0.5;
font-style: italic;
}
.folder-empty {
text-align: center;
padding: 16px;
opacity: 0.5;
}
.folder-selected {
margin-top: 8px;
padding: 6px 8px;
background: rgba(64, 158, 255, 0.08);
border-radius: 4px;
font-size: 12px;
display: flex;
gap: 4px;
align-items: center;
}
.folder-selected-label {
opacity: 0.6;
flex-shrink: 0;
}
.folder-selected-path {
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,245 @@
<script lang="ts">
type HistorySessionScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const historySessionScrollPositions = new Map<string, HistorySessionScrollSnapshot>();
</script>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
import type { Session } from "@/stores/hermes/chat";
const props = defineProps<{
session?: Session | null; // Optional: use this session instead of chatStore.activeSession
loadOlder?: (sessionId: string) => Promise<boolean>;
}>();
const chatStore = useChatStore();
const { toolTraceVisible } = useToolTraceVisibility();
const { t } = useI18n();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
const pendingInitialScrollSessionId = ref<string | null>(null);
const activeSession = computed(() => props.session || null);
const listInstanceKey = computed(() => activeSession.value?.id ? `history-${activeSession.value.id}` : "history-empty");
const displayMessages = computed(() =>
(activeSession.value?.messages || []).filter((m) => {
// Tool messages without a name are internal use only and remain hidden.
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
// Filter out messages with empty content.
if (!m.content?.trim()) return false
return true
}),
);
function isNearBottom(threshold = 200): boolean {
return listRef.value?.isNearBottom(threshold) ?? true;
}
function scrollToBottom() {
listRef.value?.scrollToBottom();
}
function scrollToMessage(messageId: string) {
listRef.value?.scrollToMessage(messageId);
}
function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
function saveSessionScrollPosition(sessionId: string | null | undefined) {
if (!sessionId) return;
const snapshot = listRef.value?.captureViewportPosition() ?? null;
if (snapshot) historySessionScrollPositions.set(sessionId, snapshot);
}
function applyInitialSessionScroll(sessionId: string) {
if (activeSession.value?.id !== sessionId) return;
if (chatStore.focusMessageId) {
pendingInitialScrollSessionId.value = null;
scrollToMessage(chatStore.focusMessageId);
return;
}
const snapshot = historySessionScrollPositions.get(sessionId);
if (snapshot) {
pendingInitialScrollSessionId.value = null;
if (snapshot.wasNearBottom) {
scrollToBottom();
} else {
listRef.value?.restoreViewportPosition(snapshot);
}
return;
}
scrollToBottom();
if ((activeSession.value?.messages.length || 0) > 0) pendingInitialScrollSessionId.value = null;
}
async function handleTopReach() {
const session = activeSession.value;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages || !props.loadOlder) return;
const snapshot = listRef.value?.captureScrollPosition() ?? null;
const loaded = await props.loadOlder(session.id);
if (!loaded) return;
await nextTick();
listRef.value?.restoreScrollPosition(snapshot);
}
watch(
() => activeSession.value?.id,
async (id, previousId) => {
saveSessionScrollPosition(previousId);
if (!id) return;
pendingInitialScrollSessionId.value = id;
await nextTick();
applyInitialSessionScroll(id);
},
{ immediate: true },
);
watch(
() => chatStore.focusMessageId,
(messageId) => {
if (!messageId) return;
scrollToMessage(messageId);
},
);
// During streaming, only auto-scroll if the user is already near the bottom
watch(
() => (activeSession.value?.messages || [])[((activeSession.value?.messages || []).length - 1)]?.content,
(content) => {
if (pendingInitialScrollSessionId.value === activeSession.value?.id) return;
if (!content) return
if (!isNearBottom()) return;
scrollToBottom();
},
);
watch(
() => (activeSession.value?.messages || []).length,
(length) => {
if (length === 0) return
const id = activeSession.value?.id
if (id && pendingInitialScrollSessionId.value === id) {
applyInitialSessionScroll(id);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
},
{ flush: "post" },
);
onBeforeUnmount(() => {
saveSessionScrollPosition(activeSession.value?.id);
});
defineExpose({
scrollToBottom,
scrollToMessage,
scrollToAnchor,
});
</script>
<template>
<VirtualMessageList
:key="listInstanceKey"
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="activeSession?.hasMoreBefore || activeSession?.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<MessageItem
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-muted;
gap: 12px;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,780 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui'
import type MarkdownIt from 'markdown-it'
import MarkdownItConstructor from 'markdown-it'
import katex from 'katex'
import markdownItKatex from '@vscode/markdown-it-katex'
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
import { repairNestedMarkdownFences } from './markdownFenceRepair'
import {
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
MERMAID_MAX_SOURCE_LENGTH,
MERMAID_RENDER_TIMEOUT_MS,
decodeMermaidSource,
isMermaidFence,
renderMermaidPlaceholder,
SUPPORT_PREVIEW_FILE_TYPES,
} from './mermaidRenderer'
import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download'
const LATEX_FENCE_LANGS = new Set(['latex', 'tex', 'math', 'katex'])
const PREVIEW_AREA_WIDTH = 'min(800px, 100vw)'
function getFenceLanguage(info: string): string {
return info.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
}
function isLatexFence(info: string): boolean {
return LATEX_FENCE_LANGS.has(getFenceLanguage(info))
}
function normalizeLatexFenceContent(content: string): string {
const trimmed = content.trim()
if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) {
return trimmed.slice(2, -2).trim()
}
if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) {
return trimmed.slice(2, -2).trim()
}
if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) {
return trimmed.slice(2, -2).trim()
}
return trimmed
}
function renderLatexFence(content: string): string {
const latex = normalizeLatexFenceContent(content)
return `<div class="latex-block">${katex.renderToString(latex, {
displayMode: true,
output: 'htmlAndMathml',
throwOnError: false,
strict: 'ignore',
})}</div>`
}
const props = withDefaults(defineProps<{
content: string
mentionNames?: string[]
headingIdPrefix?: string
}>(), {
mentionNames: () => [],
headingIdPrefix: '',
})
const { t } = useI18n()
const message = useMessage()
const md: MarkdownIt = new MarkdownItConstructor({
html: false,
breaks: true,
linkify: true,
typographer: true,
highlight(str: string, lang: string): string {
return renderHighlightedCodeBlock(str, lang, t('common.copy'))
},
})
md.use(markdownItKatex, {
katex,
throwOnError: false,
strict: 'ignore',
})
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
if (isLatexFence(token.info)) {
return renderLatexFence(token.content)
}
if (isMermaidFence(token.info)) {
return renderMermaidPlaceholder(token.content)
}
if (defaultFenceRenderer) {
return defaultFenceRenderer(tokens, idx, options, env, self)
}
return self.renderToken(tokens, idx, options)
}
const markdownBody = ref<HTMLElement | null>(null)
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
const previewUrl = ref<string | null>(null)
// Preview config variable
const textPreviewContent = ref<string | null>(null)
const textPreviewFileName = ref('')
const textPreviewLoading = ref(false)
const textPreviewVisible = ref(false)
const textPreviewIsMarkdown = computed(() => /\.(md|markdown)$/i.test(textPreviewFileName.value))
let renderGeneration = 0
let unmounted = false
function isLocalFilePath(path: string): boolean {
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path)
}
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
const renderedHtml = computed(() => {
let html = md.render(repairNestedMarkdownFences(props.content))
// Add IDs to headings for anchor links
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
let headingCounter = 0
// Match any h1-h6 tags, with or without attributes
html = html.replace(/<(h[1-6])([^>]*)>/g, (match, tag, attrs) => {
headingCounter++
const id = `${prefix}heading-${headingCounter}`
// Check if id attribute already exists
if (attrs.includes('id=')) {
// Replace existing id
return match.replace(/id="[^"]*"/, `id="${id}"`).replace(/id='[^']*'/, `id="${id}"`)
}
// Add new id
if (attrs.trim() === '') {
return `<${tag} id="${id}">`
}
return `<${tag} ${attrs.trim()} id="${id}">`
})
// Replace image src paths with download URLs
html = html.replace(/\bsrc=(["'])([^"']+)\1/g, (match, quote, path) => {
if (!isLocalFilePath(path)) return match
const downloadUrl = getDownloadUrl(normalizeLocalFilePath(path))
return `src=${quote}${downloadUrl}${quote}`
})
// Replace local file links with file card UI or video player
// Match <a href="/tmp/file.pdf">filename</a> or <a href="C:/tmp/file.pdf">filename</a>
html = html.replace(/<a href="([^"]+)">([^<]+)<\/a>/g, (match, rawPath, filename) => {
if (!isLocalFilePath(rawPath)) return match
const path = normalizeLocalFilePath(rawPath)
const fileName = filename.trim()
const ext = path.split('.').pop()?.toLowerCase()
// Video files: render as video player
if (ext === 'mp4' || ext === 'webm' || ext === 'mov') {
const downloadUrl = getDownloadUrl(path)
return `<div class="markdown-video-container">
<video class="markdown-video" controls preload="metadata" src="${downloadUrl}"></video>
<div class="markdown-video-footer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span class="att-name">${fileName}</span>
</div>
</div>`
}
// Other files: render as file card
return `<div class="markdown-file-card" data-path="${path}" data-filename="${fileName}" title="${t('download.downloadFile')}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="att-name">${fileName}</span>
<button class="att-download-btn" type="button" title="${t('download.downloadFile')}" aria-label="${t('download.downloadFile')}">
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>`
})
if (props.mentionNames && props.mentionNames.length > 0) {
const escaped = [...props.mentionNames]
.sort((a, b) => b.length - a.length)
.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const re = new RegExp(`(?<=[\\s>({\\[<]|^)@(${escaped.join('|')})(?=[\\s.,!?;:,。!?;:)\\]}>]|<|$)`, 'gi')
html = html.replace(re, '<span class="mention-highlight">@$1</span>')
}
return html
})
function renderMermaidFallback(element: HTMLElement, source: string): void {
element.outerHTML = renderHighlightedCodeBlock(source, 'mermaid', t('common.copy'))
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
}, timeoutMs)
})
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
})
}
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
if (!el) return null
let current: HTMLElement | null = el.parentElement
while (current) {
const { overflow, overflowY } = getComputedStyle(current)
if (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return null
}
function isNearScrollBottom(el: HTMLElement, threshold = 200): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
}
function cleanupMermaidRenderArtifacts(id: string): void {
document.getElementById(id)?.remove()
document.getElementById(`d${id}`)?.remove()
}
async function renderMermaidDiagrams(): Promise<void> {
const generation = ++renderGeneration
await nextTick()
const root = markdownBody.value
if (unmounted || generation !== renderGeneration || !root) return
const pendingDiagrams = Array.from(root.querySelectorAll<HTMLElement>('[data-mermaid-pending="true"]'))
if (pendingDiagrams.length === 0) return
const diagramsToRender = pendingDiagrams.slice(0, MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
const diagramsToFallback = pendingDiagrams.slice(MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
for (const element of diagramsToFallback) {
renderMermaidFallback(element, decodeMermaidSource(element.getAttribute('data-mermaid-source')))
}
const renderCandidates = diagramsToRender
.map(element => ({
element,
source: decodeMermaidSource(element.getAttribute('data-mermaid-source')),
}))
const validDiagrams = [] as typeof renderCandidates
for (const candidate of renderCandidates) {
if (unmounted || generation !== renderGeneration || !root.contains(candidate.element)) return
if (!candidate.source || candidate.source.length > MERMAID_MAX_SOURCE_LENGTH) {
renderMermaidFallback(candidate.element, candidate.source)
continue
}
validDiagrams.push(candidate)
}
if (validDiagrams.length === 0) return
let mermaid: typeof import('mermaid').default
try {
mermaid = (await withTimeout(import('mermaid'), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid import')).default
if (unmounted || generation !== renderGeneration) return
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
})
} catch {
if (unmounted || generation !== renderGeneration) return
for (const { element, source } of validDiagrams) {
if (root.contains(element)) {
renderMermaidFallback(element, source)
}
}
return
}
for (const [index, { element, source }] of validDiagrams.entries()) {
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
try {
const id = `${componentId}-${generation}-${index}`
const result = await withTimeout(mermaid.render(id, source), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid render')
cleanupMermaidRenderArtifacts(id)
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
const scrollParent = getScrollParent(markdownBody.value)
const shouldKeepBottom = scrollParent ? isNearScrollBottom(scrollParent) : false
element.removeAttribute('data-mermaid-pending')
element.removeAttribute('data-mermaid-source')
element.innerHTML = result.svg
if (scrollParent && shouldKeepBottom) {
nextTick(() => {
scrollParent.scrollTop = scrollParent.scrollHeight
})
}
} catch {
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
renderMermaidFallback(element, source)
}
}
}
onMounted(() => {
void renderMermaidDiagrams()
})
watch(renderedHtml, () => {
void renderMermaidDiagrams()
}, { flush: 'post' })
onBeforeUnmount(() => {
unmounted = true
renderGeneration += 1
})
async function handleMarkdownClick(event: MouseEvent): Promise<void> {
const copyResult = await handleCodeBlockCopyClick(event)
if (copyResult !== null) {
if (copyResult) {
message.success(t('common.copied'))
} else {
message.error(t('chat.copyFailed'))
}
return
}
const target = event.target as HTMLElement
// Handle image clicks for preview
const img = target.closest('img') as HTMLImageElement | null
if (img) {
event.preventDefault()
previewUrl.value = img.src
return
}
// Handle file card clicks for download
const fileCard = target.closest('.markdown-file-card') as HTMLElement | null
if (fileCard) {
event.preventDefault()
event.stopPropagation()
const path = fileCard.getAttribute('data-path')
const fileName = fileCard.getAttribute('data-filename') || undefined
const isDownloadBtn = target.closest('.att-download-btn')
if (isDownloadBtn && path) { // Only download file with download icon clicked.
message.info(t('download.downloading'))
downloadFile(path, fileName).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
return
}
if (path) {
const ext = fileName?.split('.').pop()?.toLowerCase()
if (SUPPORT_PREVIEW_FILE_TYPES.includes(ext || '')) {
previewTextFile(path, fileName || '')
} else { // Download file immediately
downloadFile(path, fileName).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
}
}
return
}
// Handle file path link clicks for download
const link = target.closest('a') as HTMLAnchorElement | null
if (!link) return
const href = link.getAttribute('href')
if (!href) return
// Let http(s) links behave normally — use window.open to prevent
// the hash-based router from intercepting the click
if (href.startsWith('http://') || href.startsWith('https://')) {
event.preventDefault()
window.open(href, '_blank', 'noopener,noreferrer')
return
}
// Full download URL: open directly (already has /api/hermes/download?path=...)
if (href.startsWith('/api/hermes/download?')) {
event.preventDefault()
event.stopPropagation()
const linkText = link.textContent || ''
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
message.info(t('download.downloading'))
// Parse the real file path from the existing query param
const url = new URL(href, window.location.origin)
const realPath = url.searchParams.get('path') || href
downloadFile(realPath, fileName || undefined).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
return
}
// File path links: intercept and download
if (isLocalFilePath(href)) {
event.preventDefault()
event.stopPropagation()
const linkText = link.textContent || ''
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
message.info(t('download.downloading'))
downloadFile(normalizeLocalFilePath(href), fileName || undefined).catch((err: Error) => {
message.error(err.message || t('download.downloadFailed'))
})
}
}
// Get file content and show preview area.
async function previewTextFile(path: string, fileName: string): Promise<void> {
textPreviewLoading.value = true
textPreviewVisible.value = true
textPreviewFileName.value = fileName
textPreviewContent.value = null
try {
textPreviewContent.value = await fetchFileText(path, fileName)
} catch (err: any) {
message.error(err.message || t('download.downloadFailed'))
} finally {
textPreviewLoading.value = false
}
}
function closeTextPreview(): void {
textPreviewVisible.value = false
}
</script>
<template>
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
<!-- File preview area -->
<NDrawer
v-model:show="textPreviewVisible"
:width="PREVIEW_AREA_WIDTH"
placement="right"
:show-mask="false"
:trap-focus="false"
class="markdown-text-preview-drawer"
>
<NDrawerContent
:title="t('download.contentDisplay')"
closable
:body-content-style="{ padding: 0 }"
@close="closeTextPreview"
>
<NSpin :show="textPreviewLoading">
<div v-if="textPreviewContent !== null && textPreviewIsMarkdown" class="text-preview-markdown">
<MarkdownRenderer :content="textPreviewContent" />
</div>
<pre v-else-if="textPreviewContent !== null" class="text-preview-body">{{ textPreviewContent }}</pre>
</NSpin>
</NDrawerContent>
</NDrawer>
<Teleport to="body">
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
</div>
</Teleport>
</template>
<style lang="scss">
@use '@/styles/variables' as *;
.markdown-body {
font-size: 14px;
line-height: 1.65;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
overflow-x: auto;
p {
margin: 0 0 8px;
&:last-child {
margin-bottom: 0;
}
}
ul, ol {
padding-left: 20px;
margin: 4px 0 8px;
}
li {
margin: 2px 0;
}
strong {
color: $text-primary;
font-weight: 600;
}
em {
color: $text-secondary;
}
a {
color: $accent-primary;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: $accent-hover;
}
}
img {
display: block;
max-width: 200px;
max-height: 160px;
object-fit: contain;
cursor: pointer;
border-radius: 4px;
margin: 8px 0;
}
.markdown-video-container {
margin: 12px 0;
border-radius: $radius-sm;
overflow: hidden;
background: #000;
border: 1px solid $border-color;
}
.markdown-video {
display: block;
width: 100%;
max-width: 640px;
max-height: 480px;
object-fit: contain;
}
.markdown-video-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 12px;
.att-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.markdown-file-card {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 12px;
color: $text-secondary;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid $border-light;
border-radius: $radius-sm;
margin: 8px 0;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.08);
border-color: $border-color;
}
.att-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.att-download-icon {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s ease;
}
.att-download-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
cursor: pointer;
}
&:hover .att-download-icon,
.att-download-btn:hover .att-download-icon {
opacity: 1;
}
}
blockquote {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid $border-color;
color: $text-secondary;
}
code:not(.hljs) {
background: $code-bg;
padding: 2px 6px;
border-radius: 4px;
font-family: $font-code;
font-size: 13px;
color: $accent-primary;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
display: block;
overflow-x: auto;
th, td {
padding: 6px 12px;
border: 1px solid $border-color;
text-align: left;
font-size: 13px;
}
th {
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-primary;
font-weight: 600;
}
td {
color: $text-secondary;
}
}
hr {
border: none;
border-top: 1px solid $border-color;
margin: 12px 0;
}
.mermaid-diagram {
margin: 10px 0;
padding: 14px;
border: 1px solid $border-color;
border-radius: 8px;
background: rgba(var(--accent-primary-rgb), 0.04);
overflow-x: auto;
svg {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
}
.mermaid-loading {
color: $text-secondary;
font-size: 13px;
font-family: $font-code;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
}
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.image-preview-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: 4px;
cursor: pointer;
}
.text-preview-body {
flex: 1;
overflow: auto;
padding: 16px;
margin: 0;
font-family: $font-code;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
color: $text-primary;
}
.text-preview-markdown {
padding: 16px;
overflow: auto;
}
.markdown-text-preview-drawer {
max-width: 100vw;
.n-drawer-content,
.n-drawer-body-content-wrapper {
max-width: 100vw;
}
}
@media (max-width: $breakpoint-mobile) {
.markdown-text-preview-drawer {
max-width: 100vw;
.n-drawer-content,
.n-drawer-body-content-wrapper {
max-width: 100vw;
}
}
.text-preview-body {
padding: 12px;
max-width: 100vw;
}
.text-preview-markdown {
padding: 12px;
max-width: 100vw;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,798 @@
<script lang="ts">
type SessionScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const sessionScrollPositions = new Map<string, SessionScrollSnapshot>();
</script>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useI18n } from "vue-i18n";
import VirtualMessageList from "./VirtualMessageList.vue";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import ThinkingIndicator from "./ThinkingIndicator.vue";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
const chatStore = useChatStore();
const { t } = useI18n();
const { toolTraceVisible } = useToolTraceVisibility();
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
const pendingInitialScrollSessionId = ref<string | null>(null);
function formatTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return String(n)
}
function formatToolDuration(seconds: number): string {
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`
const mins = Math.floor(seconds / 60)
const secs = Math.round(seconds % 60)
return `${mins}m ${secs}s`
}
const currentToolCalls = computed(() => {
const msgs = chatStore.messages;
// Find the last user message index
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
// Only tool calls after the last user message, newest on top
const tools = msgs.filter((m, i) => m.role === "tool" && i > lastUserIdx);
return [...tools].reverse();
});
const visibleToolCalls = computed(() =>
currentToolCalls.value.filter((tool) => !!tool.toolName),
);
const displayMessages = computed(() => {
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
return chatStore.messages.filter((m) => {
if (m.role === "tool") {
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
}
if (
m.role === "assistant" &&
m.isStreaming &&
!m.content?.trim() &&
!!m.reasoning?.trim() &&
currentToolCalls.value.length === 0
) {
return false;
}
return true;
});
});
const queuedMessages = computed(() => {
const sid = chatStore.activeSessionId;
if (!sid) return [];
return chatStore.queuedUserMessages.get(sid) || [];
});
function removeQueuedMessage(messageId: string) {
const sid = chatStore.activeSessionId;
if (!sid) return;
chatStore.removeQueuedMessage(sid, messageId);
}
function queuedPreview(content: string): string {
const normalized = content.replace(/\s+/g, " ").trim();
return normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
}
function isNearBottom(threshold = 200): boolean {
return listRef.value?.isNearBottom(threshold) ?? true;
}
function scrollToBottom() {
listRef.value?.scrollToBottom();
}
function scrollToMessage(messageId: string) {
listRef.value?.scrollToMessage(messageId);
}
function scrollToAnchor(messageId: string, anchorId: string) {
listRef.value?.scrollToAnchor(messageId, anchorId);
}
function saveSessionScrollPosition(sessionId: string | null | undefined) {
if (!sessionId) return;
const snapshot = listRef.value?.captureViewportPosition() ?? null;
if (snapshot) sessionScrollPositions.set(sessionId, snapshot);
}
function applyInitialSessionScroll(sessionId: string) {
if (chatStore.activeSessionId !== sessionId) return;
if (chatStore.focusMessageId) {
pendingInitialScrollSessionId.value = null;
scrollToMessage(chatStore.focusMessageId);
return;
}
const snapshot = sessionScrollPositions.get(sessionId);
if (snapshot) {
pendingInitialScrollSessionId.value = null;
if (snapshot.wasNearBottom) {
scrollToBottom();
} else {
listRef.value?.restoreViewportPosition(snapshot);
}
return;
}
scrollToBottom();
if (chatStore.messages.length > 0) pendingInitialScrollSessionId.value = null;
}
async function handleTopReach() {
const session = chatStore.activeSession;
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
const snapshot = listRef.value?.captureScrollPosition() ?? null;
const loaded = await chatStore.loadOlderMessages(session.id);
if (!loaded) return;
await nextTick();
listRef.value?.restoreScrollPosition(snapshot);
}
watch(
() => chatStore.activeSessionId,
async (id, previousId) => {
saveSessionScrollPosition(previousId);
if (!id) return;
pendingInitialScrollSessionId.value = id;
await nextTick();
applyInitialSessionScroll(id);
},
{ immediate: true },
);
watch(
() => [chatStore.activeSessionId, chatStore.messages.length] as const,
([id, length]) => {
if (!id || pendingInitialScrollSessionId.value !== id || length === 0) return;
applyInitialSessionScroll(id);
},
{ flush: "post" },
);
watch(
() => chatStore.focusMessageId,
(messageId) => {
if (!messageId) return;
scrollToMessage(messageId);
},
);
// When a run starts (user just sent a message), always scroll to bottom once
watch(
() => chatStore.isRunActive,
(v) => {
if (v) scrollToBottom();
},
);
// During streaming, only auto-scroll if the user is already near the bottom
watch(
() => chatStore.messages[chatStore.messages.length - 1]?.content,
() => {
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
},
);
watch(currentToolCalls, () => {
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
if (chatStore.focusMessageId) {
scrollToMessage(chatStore.focusMessageId);
return;
}
if (!isNearBottom()) return;
scrollToBottom();
});
onBeforeUnmount(() => {
saveSessionScrollPosition(chatStore.activeSessionId);
});
defineExpose({
scrollToBottom,
scrollToMessage,
scrollToAnchor,
});
</script>
<template>
<VirtualMessageList
:key="chatStore.activeSessionId || 'chat-empty'"
ref="listRef"
:messages="displayMessages"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="chatStore.activeSession?.hasMoreBefore || chatStore.activeSession?.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="chatStore.activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<MessageItem
:message="msg"
:highlight="chatStore.focusMessageId === msg.id"
/>
</template>
<template #after>
<Transition name="fade">
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
<ThinkingIndicator />
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
<!-- Abort indicator -->
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
<svg
v-if="chatStore.abortState.aborting"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M10 9v6m4-6v6M5 5h14v14H5z" />
</svg>
<svg
v-else
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span class="tool-call-name">
{{
chatStore.abortState.aborting
? 'Pausing... waiting for the run to stop and sync'
: chatStore.abortState.synced
? 'Paused and synced'
: 'Paused'
}}
</span>
<span
v-if="chatStore.abortState.aborting"
class="tool-call-spinner"
></span>
</div>
<!-- Compression indicator -->
<div v-if="chatStore.compressionState" class="tool-call-item compression-item">
<svg
v-if="chatStore.compressionState.compressing"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg
v-else-if="chatStore.compressionState.compressed"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M5 13l4 4L19 7" />
</svg>
<span class="tool-call-name">
{{
chatStore.compressionState.compressing
? `Compressing... (${chatStore.compressionState.messageCount} msgs, ~${formatTokens(chatStore.compressionState.beforeTokens)} tokens)`
: chatStore.compressionState.compressed
? `Compressed ${chatStore.compressionState.messageCount} msgs: ~${formatTokens(chatStore.compressionState.beforeTokens)} → ~${formatTokens(chatStore.compressionState.afterTokens)} tokens`
: `Compression skipped`
}}
</span>
<span
v-if="chatStore.compressionState.compressing"
class="tool-call-spinner"
></span>
</div>
<!-- Tool calls -->
<div
v-for="tc in visibleToolCalls"
:key="tc.id"
class="tool-call-item"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
<span class="tool-call-name">{{ tc.toolName }}</span>
<span v-if="tc.toolPreview" class="tool-call-preview">{{
tc.toolPreview
}}</span>
<span
v-if="tc.toolDuration && tc.toolStatus !== 'running'"
class="tool-call-duration"
:title="$t('chat.executionDuration')"
>{{ formatToolDuration(tc.toolDuration) }}</span
>
<svg
v-if="tc.toolStatus === 'done'"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
class="tool-call-success-icon"
>
<circle cx="12" cy="12" r="10" fill="currentColor" fill-opacity="0.15"/>
<path
d="M8 12L11 15L16 9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span
v-if="tc.toolStatus === 'running'"
class="tool-call-spinner"
></span>
<svg
v-if="tc.toolStatus === 'error'"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
class="tool-call-error-icon"
>
<circle cx="12" cy="12" r="10" fill="currentColor" fill-opacity="0.15"/>
<path
d="M15 9L9 15M9 9L15 15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
</div>
</div>
</div>
</Transition>
<Transition name="queue-float">
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
<div class="queue-float-header">
<span class="queue-orbit" aria-hidden="true">
<span></span>
</span>
<span>{{ t('chat.messageQueue') }}</span>
<strong>{{ queuedMessages.length }}</strong>
</div>
<div class="queue-float-list">
<div
v-for="(message, index) in queuedMessages"
:key="message.id"
class="queue-float-item"
>
<span class="queue-index">{{ index + 1 }}</span>
<span class="queue-text">{{ queuedPreview(message.content) }}</span>
<button
type="button"
class="queue-remove"
:title="t('chat.removeQueuedMessage')"
@click="removeQueuedMessage(message.id)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.queue-float-panel {
position: sticky;
right: 16px;
bottom: 16px;
z-index: 4;
width: min(340px, calc(100% - 16px));
margin-top: 16px;
margin-left: auto;
padding: 10px;
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.14);
backdrop-filter: blur(14px);
.dark & {
background: #262626;
}
}
.queue-float-header {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px 8px;
color: $text-secondary;
font-size: 12px;
font-weight: 600;
strong {
margin-left: auto;
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(var(--accent-info-rgb), 0.16);
color: var(--accent-info);
}
}
.queue-orbit {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid rgba(var(--accent-info-rgb), 0.28);
position: relative;
animation: queue-spin 1.6s linear infinite;
span {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
right: -2px;
top: 5px;
background: var(--accent-info);
box-shadow: 0 0 12px rgba(var(--accent-info-rgb), 0.65);
}
}
.queue-float-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 172px;
overflow-y: auto;
}
.queue-float-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 7px 8px;
border-radius: 11px;
background: rgba(255, 255, 255, 0.68);
color: $text-primary;
.dark & {
background: rgba(255, 255, 255, 0.08);
}
}
.queue-index {
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--accent-info);
background: rgba(var(--accent-info-rgb), 0.12);
}
.queue-text {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.queue-remove {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: none;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: $text-muted;
background: transparent;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: $error;
background: rgba($error, 0.1);
}
}
@media (max-width: 640px) {
.queue-float-panel {
right: 8px;
bottom: 8px;
width: min(260px, calc(100% - 8px));
padding: 7px;
border-radius: 14px;
}
.queue-float-header {
padding: 0 2px;
font-size: 11px;
span:nth-child(2) {
display: none;
}
}
.queue-orbit {
width: 16px;
height: 16px;
span {
width: 5px;
height: 5px;
top: 5px;
}
}
.queue-float-list {
margin-top: 6px;
max-height: min(220px, 34dvh);
overflow-y: auto;
}
.queue-float-item {
min-height: 30px;
padding: 5px 6px;
}
.queue-index {
width: 18px;
height: 18px;
border-radius: 6px;
font-size: 10px;
}
.queue-text {
font-size: 11px;
}
.queue-remove {
width: 22px;
height: 22px;
}
}
@keyframes queue-spin {
to {
transform: rotate(360deg);
}
}
.queue-float-enter-active,
.queue-float-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.queue-float-enter-from,
.queue-float-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-muted;
gap: 12px;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.streaming-indicator {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 4px 4px 4px 0;
}
.tool-calls-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
padding-top: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.tool-call-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-secondary;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: $radius-sm;
.dark & {
background: rgba(255, 255, 255, 0.06);
}
&.compression-item {
color: $text-muted;
font-size: 10px;
}
.tool-call-icon {
flex-shrink: 0;
color: $text-muted;
}
.tool-call-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-call-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
color: $text-muted;
}
}
.tool-call-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-call-error-icon {
color: #ff4d4f;
flex-shrink: 0;
margin-left: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.tool-call-duration {
font-size: 10px;
color: $text-muted;
font-family: $font-code;
margin-left: 4px;
flex-shrink: 0;
}
.tool-call-success-icon {
color: #52c41a;
flex-shrink: 0;
margin-left: 6px;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Message } from '@/stores/hermes/chat'
interface OutlineItem {
id: string
type: 'user' | 'outline'
content: string
messageId: string
level: number
anchorId: string
}
const props = defineProps<{
messages: Message[]
}>()
const emit = defineEmits<{
navigate: [target: { messageId: string; anchorId: string }]
}>()
const { t } = useI18n()
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
const items: OutlineItem[] = []
let cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
const lines = cleanedText.split('\n')
let headingIndex = 0
for (const line of lines) {
const trimmed = line.trim()
const h1Match = trimmed.match(/^#\s+(.+)/)
const h2Match = trimmed.match(/^##\s+(.+)/)
const h3Match = trimmed.match(/^###\s+(.+)/)
if (h1Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h1Match[1].trim(),
messageId,
level: 1,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
} else if (h2Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h2Match[1].trim(),
messageId,
level: 2,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
} else if (h3Match) {
headingIndex++
items.push({
id: `outline-${messageId}-h${headingIndex}`,
type: 'outline',
content: h3Match[1].trim(),
messageId,
level: 3,
anchorId: `msg-${messageId}-heading-${headingIndex}`
})
}
}
return items
}
function extractUserQuestion(text: string): string {
const cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
const firstLine = cleanedText.split('\n')[0] || ''
if (firstLine.length > 50) {
return firstLine.slice(0, 50) + '...'
}
return firstLine || t('chat.outlineUserQuestion')
}
const outlineItems = computed<OutlineItem[]>(() => {
const items: OutlineItem[] = []
let i = 0
const filteredMessages = props.messages.filter(m => m.role === 'user' || m.role === 'assistant')
while (i < filteredMessages.length) {
const msg = filteredMessages[i]
if (msg.role === 'user') {
items.push({
id: `user-${msg.id}`,
type: 'user',
content: extractUserQuestion(msg.content || ''),
messageId: msg.id,
level: 0,
anchorId: `message-${msg.id}`
})
i++
while (i < filteredMessages.length && filteredMessages[i].role !== 'assistant') {
i++
}
if (i < filteredMessages.length) {
const assistantMsg = filteredMessages[i]
const headings = extractAllHeadings(assistantMsg.content || '', assistantMsg.id)
items.push(...headings)
}
}
i++
}
return items
})
function scrollToTarget(item: OutlineItem) {
emit('navigate', {
messageId: item.messageId,
anchorId: item.anchorId,
})
}
</script>
<template>
<div class="outline-panel">
<div class="outline-header">
<span class="outline-title">{{ t('chat.outlineTitle') }}</span>
</div>
<div class="outline-content">
<template v-if="outlineItems.length > 0">
<template v-for="item in outlineItems" :key="item.id">
<div
v-if="item.type === 'user'"
class="outline-item user-item"
@click="scrollToTarget(item)"
>
<div class="user-question">
<span class="q-label">Q:</span>
<span class="q-text">{{ item.content }}</span>
</div>
</div>
<div
v-else
class="outline-item outline-heading-item"
:class="`level-${item.level}`"
@click="scrollToTarget(item)"
>
<div class="heading-item">
<span class="heading-text">{{ item.content }}</span>
</div>
</div>
</template>
</template>
<div v-else class="outline-empty">{{ t('chat.outlineEmpty') }}</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.outline-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: $bg-card;
border-left: 1px solid $border-color;
width: 280px;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(280px, 86vw);
z-index: 8;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
}
.outline-header {
padding: 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.outline-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.outline-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.outline-item {
margin-bottom: 4px;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.user-item {
margin-bottom: 6px;
}
.user-question {
background-color: $bg-secondary;
color: $text-primary;
padding: 8px 12px;
border-radius: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
.dark & {
background-color: $bg-input;
}
.q-label {
font-weight: 600;
flex-shrink: 0;
font-size: 13px;
line-height: 1.4;
}
.q-text {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
}
.outline-heading-item {
&.level-1 {
padding-left: 0;
}
&.level-2 {
padding-left: 12px;
}
&.level-3 {
padding-left: 24px;
}
}
.heading-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
.dark & {
background-color: rgba(255, 255, 255, 0.06);
}
}
.level-1 & {
.heading-marker {
color: $text-primary;
font-weight: 600;
}
.heading-text {
color: $text-primary;
font-weight: 500;
}
}
.level-2 & {
.heading-marker {
color: $text-secondary;
}
.heading-text {
color: $text-secondary;
}
}
.level-3 & {
.heading-marker {
color: $text-muted;
}
.heading-text {
color: $text-muted;
font-size: 12px;
}
}
}
.heading-text {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
.outline-empty {
text-align: center;
color: $text-muted;
font-size: 13px;
padding: 20px 0;
}
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, ref, onUnmounted } from 'vue'
import { NPopconfirm, NCheckbox, NTooltip } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { Session } from '@/stores/hermes/chat'
import { useAppStore } from '@/stores/hermes/app'
import { useProfilesStore } from '@/stores/hermes/profiles'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import { formatTimestampMs } from '@/shared/session-display'
const props = withDefaults(defineProps<{
session: Session
active: boolean
pinned: boolean
canDelete: boolean
streaming?: boolean
selectable?: boolean
selected?: boolean
showProfile?: boolean
to?: string
}>(), {
showProfile: true,
})
const emit = defineEmits<{
select: []
contextmenu: [event: MouseEvent]
delete: []
'toggle-select': []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const profilesStore = useProfilesStore()
const sessionModelName = computed(() =>
props.session.model
? appStore.displayModelName(props.session.model, props.session.provider)
: '',
)
const profileName = computed(() => props.session.profile || 'default')
const profileAvatar = computed(() => profilesStore.profiles.find(profile => profile.name === profileName.value)?.avatar)
const profileHasModels = computed(() => {
const profileModels = appStore.profileModelGroups.find(profile => profile.profile === profileName.value)
return !!profileModels?.groups?.some(group => group.models.length > 0)
})
const profileModelsMissing = computed(() =>
appStore.profileModelGroups.length > 0 && !profileHasModels.value,
)
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const longPressTriggered = ref(false)
function onTouchStart(e: TouchEvent) {
longPressTriggered.value = false
longPressTimer = setTimeout(() => {
longPressTriggered.value = true
const touch = e.touches[0]
const syntheticEvent = new MouseEvent('contextmenu', {
clientX: touch.clientX,
clientY: touch.clientY,
bubbles: true,
})
emit('contextmenu', syntheticEvent)
}, 500)
}
function onTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function onTouchMove() {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
function isModifiedNavigation(event?: MouseEvent) {
return !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
}
function onClick(event?: MouseEvent) {
if (longPressTriggered.value) {
longPressTriggered.value = false
event?.preventDefault()
return
}
if (isModifiedNavigation(event)) return
if (props.to && !props.selectable) event?.preventDefault()
emit('select')
}
onUnmounted(() => {
if (longPressTimer) clearTimeout(longPressTimer)
})
</script>
<template>
<component
:is="selectable || !to ? 'button' : 'a'"
class="session-item"
:class="{ active, 'batch-mode': selectable, 'missing-models': profileModelsMissing }"
:aria-current="active ? 'page' : undefined"
:href="!selectable ? to : undefined"
:type="selectable || !to ? 'button' : undefined"
@click="onClick"
@contextmenu="emit('contextmenu', $event)"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchmove="onTouchMove"
>
<div v-if="selectable" class="session-item-checkbox">
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
</div>
<div class="session-item-content">
<span class="session-item-title-row">
<span v-if="pinned" class="session-item-pin" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5" />
<path d="M5 8l14 0" />
<path d="M8 3l8 0 0 5 3 5-14 0 3-5z" />
</svg>
</span>
<span class="session-item-title">
<svg v-if="streaming" class="session-item-streaming" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
{{ session.title }}
</span>
<NTooltip v-if="profileModelsMissing" trigger="click" placement="top">
<template #trigger>
<button class="session-item-warning" type="button" @click.stop.prevent>
!
</button>
</template>
{{ t('chat.profileMissingModelsTip', { profile: profileName }) }}
</NTooltip>
</span>
<span class="session-item-meta">
<span v-if="sessionModelName" class="session-item-model" :title="session.model">{{ sessionModelName }}</span>
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
</span>
<span v-if="props.showProfile" class="session-item-profile">
<ProfileAvatar class="session-item-profile-avatar" :name="profileName" :avatar="profileAvatar" :size="16" />
<span class="session-item-profile-name">{{ profileName }}</span>
</span>
</div>
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
<template #trigger>
<button class="session-item-delete" @click.stop.prevent>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
{{ t('chat.deleteSession') }}
</NPopconfirm>
</component>
</template>
<style scoped>
.session-item-profile {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
margin-top: 4px;
}
.session-item-profile-avatar {
background: var(--bg-secondary);
}
.session-item-profile-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
line-height: 16px;
color: var(--text-muted);
}
.session-item-warning {
flex-shrink: 0;
width: 16px;
height: 16px;
border: 1px solid rgba(180, 35, 24, 0.35);
border-radius: 50%;
background: rgba(220, 38, 38, 0.1);
color: #b42318;
font-size: 11px;
font-weight: 700;
line-height: 14px;
cursor: pointer;
}
</style>
@@ -0,0 +1,464 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NInput, NModal, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { fetchSessions, searchSessions, type SessionSearchResult, type SessionSummary } from '@/api/hermes/sessions'
import { useChatStore } from '@/stores/hermes/chat'
import { useSessionSearch } from '@/composables/useSessionSearch'
const { t } = useI18n()
const message = useMessage()
const router = useRouter()
const chatStore = useChatStore()
const { sessionSearchOpen } = useSessionSearch()
const query = ref('')
const loading = ref(false)
const recentSessions = ref<SessionSummary[]>([])
const searchResults = ref<SessionSearchResult[]>([])
const activeIndex = ref(0)
const inputRef = ref<InstanceType<typeof NInput> | null>(null)
const profileFilter = computed(() => chatStore.sessionProfileFilter || undefined)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let requestSeq = 0
type SearchItem = SessionSearchResult | (SessionSummary & {
snippet?: string
matched_message_id: number | null
rank: number
})
const hasQuery = computed(() => query.value.trim().length > 0)
const items = computed<SearchItem[]>(() => {
if (hasQuery.value) return searchResults.value
return recentSessions.value.map(session => ({
...session,
matched_message_id: null,
snippet: session.preview || '',
rank: 0,
}))
})
function formatSource(source: string): string {
const map: Record<string, string> = {
api_server: 'API Server',
cli: 'CLI',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
matrix: 'Matrix',
whatsapp: 'WhatsApp',
signal: 'Signal',
cron: 'Cron',
weixin: 'WeChat',
}
return map[source] || source
}
function formatTime(ts?: number): string {
if (!ts) return ''
const date = new Date(ts * 1000)
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function getItemTitle(item: SearchItem): string {
const title = item.title?.trim()
if (title) return title
if (item.preview?.trim()) return item.preview.trim()
return item.id
}
async function loadRecentSessions() {
const seq = ++requestSeq
loading.value = true
try {
const sessions = profileFilter.value
? await fetchSessions(undefined, 8, profileFilter.value)
: await fetchSessions(undefined, 8)
if (seq !== requestSeq) return
recentSessions.value = sessions
searchResults.value = []
activeIndex.value = 0
} catch (err) {
if (seq !== requestSeq) return
message.error(err instanceof Error ? err.message : t('chat.searchFailed'))
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
async function runSearch(text: string) {
const seq = ++requestSeq
loading.value = true
try {
const results = text.trim()
? profileFilter.value
? await searchSessions(text.trim(), undefined, 10, profileFilter.value)
: await searchSessions(text.trim(), undefined, 10)
: []
if (seq !== requestSeq) return
searchResults.value = results
activeIndex.value = 0
} catch (err) {
if (seq !== requestSeq) return
message.error(err instanceof Error ? err.message : t('chat.searchFailed'))
} finally {
if (seq === requestSeq) {
loading.value = false
}
}
}
async function ensureChatSessionsLoaded() {
if (chatStore.sessions.length === 0) {
await chatStore.loadSessions(chatStore.sessionProfileFilter)
}
}
async function openItem(item: SearchItem) {
const messageId = item.matched_message_id != null ? String(item.matched_message_id) : null
sessionSearchOpen.value = false
await ensureChatSessionsLoaded()
if (!chatStore.sessions.some(session => session.id === item.id) && typeof chatStore.addOrUpdateSession === 'function') {
chatStore.addOrUpdateSession({
id: item.id,
profile: item.profile || 'default',
title: item.title || '',
source: item.source,
messages: [],
createdAt: Math.round(item.started_at * 1000),
updatedAt: Math.round((item.last_active || item.ended_at || item.started_at) * 1000),
model: item.model,
provider: item.provider || item.billing_provider || '',
messageCount: item.message_count,
endedAt: item.ended_at != null ? Math.round(item.ended_at * 1000) : null,
lastActiveAt: item.last_active != null ? Math.round(item.last_active * 1000) : undefined,
workspace: item.workspace || null,
})
}
await chatStore.switchSession(item.id, messageId)
if (router.currentRoute.value.name !== 'hermes.chat') {
await router.push({ name: 'hermes.chat' })
}
}
function closeModal() {
sessionSearchOpen.value = false
}
function moveSelection(delta: number) {
const list = items.value
if (list.length === 0) return
const next = activeIndex.value + delta
activeIndex.value = (next + list.length) % list.length
}
async function handleKeydown(e: KeyboardEvent) {
if (!sessionSearchOpen.value) return
if (e.key === 'ArrowDown') {
e.preventDefault()
moveSelection(1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
moveSelection(-1)
return
}
if (e.key === 'Enter') {
e.preventDefault()
const item = items.value[activeIndex.value]
if (item) {
await openItem(item)
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
closeModal()
}
}
watch(
() => sessionSearchOpen.value,
async (open) => {
if (!open) {
query.value = ''
searchResults.value = []
recentSessions.value = []
activeIndex.value = 0
return
}
query.value = ''
searchResults.value = []
activeIndex.value = 0
await loadRecentSessions()
await nextTick()
inputRef.value?.focus?.()
},
)
watch(query, (value) => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
debounceTimer = setTimeout(() => {
if (!sessionSearchOpen.value) return
void runSearch(value)
}, 160)
})
watch(items, () => {
if (activeIndex.value >= items.value.length) {
activeIndex.value = 0
}
})
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
if (debounceTimer) {
clearTimeout(debounceTimer)
}
})
</script>
<template>
<NModal
v-model:show="sessionSearchOpen"
preset="card"
:title="t('chat.searchTitle')"
:style="{ width: 'min(760px, calc(100vw - 24px))' }"
:mask-closable="true"
:auto-focus="false"
>
<div class="session-search-modal">
<div class="search-header">
<div class="search-title">{{ t('chat.searchSubtitle') }}</div>
<div class="search-hint">{{ t('chat.searchHint') }}</div>
</div>
<div class="search-scope">{{ t('chat.searchScope') }}</div>
<NInput
ref="inputRef"
v-model:value="query"
:placeholder="t('chat.searchPlaceholder')"
clearable
size="large"
/>
<div class="search-body">
<NSpin :show="loading">
<div v-if="items.length === 0" class="search-empty">
{{ hasQuery ? t('chat.searchNoResults') : t('chat.searchEmpty') }}
</div>
<div v-else class="result-list">
<button
v-for="(item, idx) in items"
:key="item.id"
class="result-item"
:class="{ active: idx === activeIndex }"
@click="openItem(item)"
@mouseenter="activeIndex = idx"
>
<div class="result-main">
<div class="result-title-row">
<span class="result-title">{{ getItemTitle(item) }}</span>
<span class="result-source">{{ formatSource(item.source) }}</span>
</div>
<div class="result-snippet">
{{ hasQuery ? item.snippet || t('chat.searchNoSnippet') : item.preview || t('chat.searchRecent') }}
</div>
</div>
<div class="result-meta">
<span class="result-time">{{ formatTime(item.last_active || item.started_at) }}</span>
<span v-if="hasQuery && item.matched_message_id != null" class="result-match">
#{{ item.matched_message_id }}
</span>
</div>
</button>
</div>
</NSpin>
</div>
<div class="search-footer">
<span>{{ t('chat.searchEnterHint') }}</span>
<NButton quaternary size="small" @click="closeModal">{{ t('common.cancel') }}</NButton>
</div>
</div>
</NModal>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.session-search-modal {
display: flex;
flex-direction: column;
gap: 14px;
}
.search-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.search-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.search-hint {
font-size: 12px;
color: $text-muted;
}
.search-scope {
font-size: 12px;
color: $text-muted;
line-height: 1.5;
}
.search-body {
max-height: min(60vh, 540px);
overflow: hidden;
}
.search-empty {
padding: 28px 0;
text-align: center;
color: $text-muted;
font-size: 13px;
}
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(60vh, 540px);
overflow-y: auto;
padding-right: 2px;
}
.result-item {
width: 100%;
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid $border-color;
border-radius: $radius-md;
background: $bg-card;
color: $text-primary;
text-align: left;
cursor: pointer;
transition: border-color $transition-fast, background-color $transition-fast, transform $transition-fast;
&:hover,
&.active {
border-color: $accent-muted;
background: rgba(var(--accent-primary-rgb), 0.04);
}
}
.result-main {
flex: 1;
min-width: 0;
}
.result-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.result-title {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-source {
flex-shrink: 0;
font-size: 11px;
color: $text-muted;
}
.result-snippet {
margin-top: 4px;
font-size: 12px;
color: $text-secondary;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.result-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 11px;
color: $text-muted;
flex-shrink: 0;
}
.result-match {
font-family: $font-code;
}
.search-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: $text-muted;
}
@media (max-width: $breakpoint-mobile) {
:deep(.n-modal-body-wrapper) {
width: calc(100vw - 24px);
}
.search-header {
flex-direction: column;
align-items: flex-start;
}
.result-item {
flex-direction: column;
align-items: flex-start;
}
.result-meta {
align-items: flex-start;
flex-direction: row;
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,920 @@
<script setup lang="ts">
import { ref, onUnmounted, computed, watch } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { getApiKey, getBaseUrlValue } from "@/api/client";
import { NButton, NPopconfirm, NTooltip, NSelect, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
import type { ITheme } from "@xterm/xterm";
const { t } = useI18n();
const message = useMessage();
const props = defineProps<{ visible?: boolean; initialCommand?: string }>();
// ─── Terminal themes ────────────────────────────────────────────
const TERMINAL_THEMES: Record<string, { label: string; theme: ITheme }> = {
default: {
label: "Default",
theme: {
background: "#1a1a2e",
foreground: "#e0e0e0",
cursor: "#4cc9f0",
cursorAccent: "#1a1a2e",
selectionBackground: "rgba(76, 201, 240, 0.3)",
black: "#000000", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#ffffff",
},
},
"solarized-dark": {
label: "Solarized Dark",
theme: {
background: "#002b36", foreground: "#839496",
cursor: "#93a1a1", cursorAccent: "#002b36",
selectionBackground: "rgba(147, 161, 161, 0.3)",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
"tokyo-night": {
label: "Tokyo Night",
theme: {
background: "#1a1b26", foreground: "#a9b1d6",
cursor: "#c0caf5", cursorAccent: "#1a1b26",
selectionBackground: "rgba(192, 202, 245, 0.2)",
black: "#15161e", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68",
blue: "#7aa2f7", magenta: "#bb9af7", cyan: "#7dcfff", white: "#a9b1d6",
brightBlack: "#414868", brightRed: "#f7768e", brightGreen: "#9ece6a",
brightYellow: "#e0af68", brightBlue: "#7aa2f7", brightMagenta: "#bb9af7",
brightCyan: "#7dcfff", brightWhite: "#c0caf5",
},
},
"github-dark": {
label: "GitHub Dark",
theme: {
background: "#0d1117", foreground: "#c9d1d9",
cursor: "#58a6ff", cursorAccent: "#0d1117",
selectionBackground: "rgba(88, 166, 255, 0.25)",
black: "#484f58", red: "#ff7b72", green: "#7ee787", yellow: "#ffa657",
blue: "#79c0ff", magenta: "#d2a8ff", cyan: "#a5d6ff", white: "#c9d1d9",
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#58a6ff", brightMagenta: "#bc8cff",
brightCyan: "#79c0ff", brightWhite: "#f0f6fc",
},
},
};
const STORAGE_KEY_THEME = "hermes_terminal_theme";
// ─── Types ──────────────────────────────────────────────────────
interface SessionInfo {
id: string;
shell: string;
pid: number;
title: string;
createdAt: number;
exited: boolean;
}
// ─── State ──────────────────────────────────────────────────────
const terminalRef = ref<HTMLDivElement | null>(null);
const sessions = ref<SessionInfo[]>([]);
const activeSessionId = ref<string | null>(null);
const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default");
const connectionError = ref<string | null>(null);
const isConnecting = ref(false);
const showSidebar = ref(false);
let ws: WebSocket | null = null;
const termMap = new Map<string, { term: Terminal; fitAddon: FitAddon; opened: boolean }>();
let activeTerm: Terminal | null = null;
let activeFitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 3;
let touchScrollLastY: number | null = null;
let touchScrollRemainder = 0;
const TOUCH_SCROLL_LINE_PX = 18;
const initialCommandSent = ref(false);
// ─── Computed ──────────────────────────────────────────────────
const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
);
const themeOptions = computed(() =>
Object.entries(TERMINAL_THEMES).map(([key, val]) => ({
label: val.label,
value: key,
})),
);
const terminalBg = computed(
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
);
// ─── WebSocket ──────────────────────────────────────────────────
function formatHostForPort(hostname: string, port: number): string {
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return `${hostname}:${port}`;
}
return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`;
}
function buildWsUrl(): string {
const token = getApiKey();
const base = getBaseUrlValue();
const wsProtocol = base
? base.startsWith("https")
? "wss:"
: "ws:"
: location.protocol === "https:"
? "wss:"
: "ws:";
if (base) {
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT;
const host = import.meta.env.DEV && directDevPort
? formatHostForPort(location.hostname, Number(directDevPort))
: location.host;
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
}
function connect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
connectionError.value = t('terminal.connectionFailed');
isConnecting.value = false;
return;
}
const url = buildWsUrl();
connectionError.value = null;
isConnecting.value = true;
reconnectAttempts++;
ws = new WebSocket(url);
ws.onopen = () => {
isConnecting.value = false;
connectionError.value = null;
};
ws.onmessage = (event) => {
const data = typeof event.data === "string" ? event.data : "";
if (data.charCodeAt(0) === 0x7b) {
try {
handleControl(JSON.parse(data));
} catch {}
} else {
activeTerm?.write(data);
}
};
ws.onclose = (event) => {
isConnecting.value = false;
// 如果是正常关闭(code 1000)或认证失败,不重连
if (event.code === 1000 || event.code === 1003 || event.code === 1008) {
connectionError.value = t('terminal.connectionClosed');
return;
}
// 其他情况尝试重连
setTimeout(connect, 3000);
};
ws.onerror = (error) => {
console.error('[Terminal] WebSocket error:', error);
connectionError.value = t('terminal.connectionError');
};
}
function send(data: object | string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(typeof data === "string" ? data : JSON.stringify(data));
}
// ─── Control message handlers ──────────────────────────────────
function handleControl(msg: any) {
switch (msg.type) {
case "created":
reconnectAttempts = 0;
sessions.value.push({
id: msg.id,
shell: msg.shell,
pid: msg.pid,
title: `${msg.shell} #${sessions.value.length + 1}`,
createdAt: Date.now(),
exited: false,
});
switchSession(msg.id);
runInitialCommand();
break;
case "exited": {
const s = sessions.value.find((s) => s.id === msg.id);
if (s) {
s.exited = true;
if (activeSessionId.value === msg.id) {
activeTerm?.write(
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
);
}
}
break;
}
case "error":
message.error(msg.message);
break;
}
}
// ─── Session actions ────────────────────────────────────────────
function createSession() {
send({ type: "create" });
}
function runInitialCommand() {
const command = props.initialCommand?.trim();
if (!command || initialCommandSent.value) return;
initialCommandSent.value = true;
setTimeout(() => {
send(`${command}\r`);
}, 100);
}
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
let entry = termMap.get(id);
if (!entry) {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: { ...TERMINAL_THEMES[selectedTheme.value].theme },
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
entry = { term, fitAddon, opened: false };
termMap.set(id, entry);
}
return entry;
}
function switchSession(id: string) {
if (activeSessionId.value === id) return;
activeSessionId.value = id;
const entry = getOrCreateTerm(id);
activeTerm = entry.term;
activeFitAddon = entry.fitAddon;
mountActiveTerminal();
send({ type: "switch", sessionId: id });
}
function closeSession(id: string) {
send({ type: "close", sessionId: id });
sessions.value = sessions.value.filter((s) => s.id !== id);
const entry = termMap.get(id);
if (entry) {
entry.term.dispose();
termMap.delete(id);
}
if (activeSessionId.value === id) {
activeSessionId.value = sessions.value.length > 0 ? sessions.value[0].id : null;
activeTerm = null;
activeFitAddon = null;
if (activeSessionId.value) {
switchSession(activeSessionId.value);
} else {
unmountActiveTerminal();
createSession();
}
}
}
// ─── Terminal mount/unmount ─────────────────────────────────────
function mountActiveTerminal() {
if (!terminalRef.value) return;
const container = terminalRef.value;
while (container.firstChild) container.removeChild(container.firstChild);
const entry = termMap.get(activeSessionId.value!);
if (!entry) return;
if (!entry.opened) {
entry.term.open(container);
entry.opened = true;
} else {
const termEl = entry.term.element;
if (termEl) {
container.appendChild(termEl);
}
}
resizeObserver?.disconnect();
resizeObserver = new ResizeObserver(() => {
tryFit();
sendResize();
});
resizeObserver.observe(terminalRef.value);
setTimeout(() => tryFit(), 50);
setTimeout(() => tryFit(), 200);
}
function unmountActiveTerminal() {
if (!terminalRef.value) return;
const container = terminalRef.value;
while (container.firstChild) container.removeChild(container.firstChild);
}
function tryFit() {
if (!activeFitAddon) return;
try {
activeFitAddon.fit();
} catch {}
}
function sendResize() {
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
try {
send({
type: "resize",
cols: activeTerm.cols,
rows: activeTerm.rows,
});
} catch {}
}
function handleTerminalTouchStart(event: TouchEvent) {
if (event.touches.length !== 1) {
touchScrollLastY = null;
touchScrollRemainder = 0;
return;
}
touchScrollLastY = event.touches[0].clientY;
touchScrollRemainder = 0;
}
function handleTerminalTouchMove(event: TouchEvent) {
if (!activeTerm || event.touches.length !== 1 || touchScrollLastY === null) return;
const nextY = event.touches[0].clientY;
touchScrollRemainder += touchScrollLastY - nextY;
touchScrollLastY = nextY;
const lines = Math.trunc(touchScrollRemainder / TOUCH_SCROLL_LINE_PX);
if (lines === 0) return;
activeTerm.scrollLines(lines);
touchScrollRemainder -= lines * TOUCH_SCROLL_LINE_PX;
event.preventDefault();
}
function handleTerminalTouchEnd() {
touchScrollLastY = null;
touchScrollRemainder = 0;
}
// ─── Theme ───────────────────────────────────────────────────────
function applyTheme(themeName: string) {
selectedTheme.value = themeName;
localStorage.setItem(STORAGE_KEY_THEME, themeName);
const themeObj = TERMINAL_THEMES[themeName]?.theme;
if (!themeObj) return;
for (const entry of termMap.values()) {
entry.term.options.theme = { ...themeObj };
}
}
// ─── Helpers ────────────────────────────────────────────────────
function formatTime(ts: number) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ─── Lifecycle ──────────────────────────────────────────────────
let hasConnected = false;
watch(() => props.visible, (visible) => {
if (visible && !hasConnected && !ws) {
hasConnected = true;
connect();
}
}, { immediate: true });
onUnmounted(() => {
unmountActiveTerminal();
for (const entry of termMap.values()) {
entry.term.dispose();
}
termMap.clear();
activeTerm = null;
activeFitAddon = null;
ws?.close();
ws = null;
});
</script>
<template>
<div class="terminal-panel-drawer">
<div
v-if="showSidebar"
class="sidebar-overlay"
@click="showSidebar = false"
></div>
<div
class="terminal-sidebar"
:class="{ 'mobile-visible': showSidebar }"
>
<div class="sidebar-header">
<span class="sidebar-title">{{ t("terminal.sessions") }}</span>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" @click="createSession" circle>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
</NButton>
</template>
{{ t("terminal.newTab") }}
</NTooltip>
</div>
<div class="session-list">
<div v-if="connectionError" class="session-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{{ connectionError }}</span>
<NButton size="tiny" @click="connect">{{ t("common.retry") }}</NButton>
</div>
<div v-else-if="sessions.length === 0" class="session-empty">
<template v-if="isConnecting">
{{ t("common.loading") }}
</template>
<template v-else>
{{ t("terminal.noSessions") }}
</template>
</div>
<button
v-for="s in sessions"
:key="s.id"
class="session-item"
:class="{ active: s.id === activeSessionId, exited: s.exited }"
@click="switchSession(s.id)"
>
<div class="session-item-content">
<span class="session-item-title">{{ s.title }}</span>
<span class="session-item-meta">
<span class="session-item-shell">{{ s.shell }}</span>
<span v-if="s.exited" class="session-item-status">{{
t("terminal.sessionExited")
}}</span>
<span v-else class="session-item-time">{{
formatTime(s.createdAt)
}}</span>
</span>
</div>
<NPopconfirm v-if="sessions.length > 1" @positive-click="closeSession(s.id)">
<template #trigger>
<button class="session-item-delete" @click.stop>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</template>
{{ t("terminal.closeSession") }}
</NPopconfirm>
</button>
</div>
</div>
<div class="terminal-main">
<header class="terminal-header">
<span v-if="activeSession" class="header-session-title">{{
activeSession.title
}}</span>
<div class="header-actions">
<NButton
size="small"
@click="showSidebar = !showSidebar"
class="sidebar-toggle"
>
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
</template>
{{ t("terminal.sessions") }}
</NButton>
<NSelect
:value="selectedTheme"
:options="themeOptions"
size="small"
:consistent-menu-width="false"
class="theme-select"
@update:value="applyTheme"
/>
<NButton size="small" @click="createSession">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</template>
{{ t("terminal.newTab") }}
</NButton>
</div>
</header>
<div class="terminal-container">
<div
ref="terminalRef"
class="terminal-xterm"
:style="{ backgroundColor: terminalBg }"
@touchstart="handleTerminalTouchStart"
@touchmove="handleTerminalTouchMove"
@touchend="handleTerminalTouchEnd"
@touchcancel="handleTerminalTouchEnd"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.terminal-panel-drawer {
display: flex;
height: 100%;
width: 100%;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
}
.terminal-sidebar {
width: 180px;
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
flex-shrink: 0;
@media (max-width: $breakpoint-mobile) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 80%;
max-width: 300px;
z-index: 51;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
transform: translateX(-100%);
transition: transform 0.3s ease;
&.mobile-visible {
transform: translateX(0);
}
}
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
flex-shrink: 0;
border-bottom: 1px solid $border-color;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.session-empty {
padding: 16px 8px;
font-size: 12px;
color: $text-muted;
text-align: center;
}
.session-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 12px;
font-size: 12px;
color: $error;
text-align: center;
svg {
width: 32px;
height: 32px;
opacity: 0.8;
}
span {
flex: 1;
}
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 6px 8px;
border: none;
background: none;
border-radius: $radius-sm;
cursor: pointer;
text-align: left;
color: $text-secondary;
transition: all $transition-fast;
margin-bottom: 2px;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
.session-item-delete {
opacity: 1;
}
}
&.active {
background: rgba(var(--accent-primary-rgb), 0.1);
color: $text-primary;
font-weight: 500;
}
&.exited {
opacity: 0.5;
}
}
.session-item-content {
flex: 1;
overflow: hidden;
}
.session-item-title {
display: block;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
.session-item-shell {
font-size: 9px;
color: $accent-primary;
background: rgba(var(--accent-primary-rgb), 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
.session-item-time,
.session-item-status {
font-size: 10px;
color: $text-muted;
}
.session-item-delete {
flex-shrink: 0;
opacity: 0.5;
padding: 2px;
border: none;
background: none;
color: $text-muted;
cursor: pointer;
border-radius: 3px;
transition: all $transition-fast;
&:hover {
color: $error;
background: rgba(var(--error-rgb), 0.1);
}
}
.terminal-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
min-width: 0;
}
.header-session-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-width: 0;
}
.theme-select {
width: 120px;
}
.sidebar-toggle {
@media (min-width: $breakpoint-mobile + 1) {
display: none;
}
}
.terminal-container {
flex: 1;
margin: 8px;
overflow: hidden;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.terminal-xterm {
flex: 1;
min-height: 0;
min-width: 0;
border-radius: $radius-md;
overflow: hidden;
border: 1px solid $border-color;
:deep(.xterm) {
height: 100%;
padding: 8px;
}
:deep(.xterm-viewport) {
overflow-y: scroll !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
background-color: transparent !important;
}
:deep(.xterm-viewport::-webkit-scrollbar) {
display: none !important;
}
:deep(.xterm-screen) {
background-color: transparent !important;
}
:deep(.xterm-scrollable-element) {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
display: none !important;
}
}
@media (max-width: $breakpoint-mobile) {
.terminal-panel-drawer {
height: 100%;
max-height: 100%;
}
.terminal-main {
min-height: 0;
min-width: 0;
}
.terminal-header {
padding: 8px;
gap: 6px;
}
.header-session-title {
display: none;
}
.header-actions {
width: 100%;
justify-content: flex-end;
gap: 6px;
}
.theme-select {
width: 96px;
}
.terminal-container {
margin: 6px;
margin-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
}
.terminal-xterm {
border-radius: $radius-sm;
:deep(.xterm) {
padding: 6px;
}
:deep(.xterm-viewport),
:deep(.xterm-scrollable-element) {
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-width: thin !important;
}
:deep(.xterm-viewport::-webkit-scrollbar),
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
display: block !important;
width: 6px !important;
}
}
}
</style>
@@ -0,0 +1,118 @@
<template>
<div class="thinking-indicator" role="status" :aria-label="label">
<div class="thinking-indicator__mark">
<svg viewBox="0 0 48 48" fill="none" aria-hidden="true">
<defs>
<linearGradient id="thinking-grad" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563eb" />
<stop offset="1" stop-color="#0891b2" />
</linearGradient>
</defs>
<rect x="6" y="6" width="36" height="36" rx="10" fill="url(#thinking-grad)" class="thinking-indicator__bg" />
<path d="M24 14 L30 24 L24 34 L18 24 Z" fill="rgba(255,255,255,0.9)" />
<circle cx="24" cy="22" r="3" fill="#ffffff" />
</svg>
<span class="thinking-indicator__ring" aria-hidden="true" />
</div>
<div class="thinking-indicator__dots" aria-hidden="true">
<span />
<span />
<span />
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
label?: string
}>(), {
label: '思考中',
})
</script>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.thinking-indicator {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
flex-shrink: 0;
}
.thinking-indicator__mark {
position: relative;
width: 40px;
height: 40px;
svg {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 2px 8px rgba(var(--accent-primary-rgb), 0.25));
}
}
.thinking-indicator__bg {
transform-origin: center;
animation: thinking-breathe 2s ease-in-out infinite;
}
.thinking-indicator__ring {
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid rgba(var(--accent-primary-rgb), 0.35);
animation: thinking-ring 1.6s ease-out infinite;
}
.thinking-indicator__dots {
display: flex;
align-items: center;
gap: 5px;
span {
width: 6px;
height: 6px;
border-radius: 50%;
background: $accent-primary;
animation: thinking-dot 1.2s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.15s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
}
}
@keyframes thinking-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
@keyframes thinking-ring {
0% {
transform: scale(0.85);
opacity: 0.8;
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
@keyframes thinking-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
</style>
@@ -0,0 +1,482 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
DynamicScroller,
DynamicScrollerItem,
type DynamicScrollerExposed,
type ScrollToOptions,
} from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
type VirtualItem = {
id: string | number;
}
type AnchorAlign = "start" | "center";
type AnchorTarget = {
token: number;
index: number;
messageId: string;
anchorId: string;
align: AnchorAlign;
}
type BottomScrollOptions = number | {
frames?: number;
keepAliveMs?: number;
}
type ViewportScrollSnapshot = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
wasNearBottom: boolean;
}
const props = withDefaults(defineProps<{
messages: VirtualItem[];
estimatedItemHeight?: number;
overscan?: number;
rowGap?: number;
padding?: string;
topThreshold?: number;
}>(), {
estimatedItemHeight: 180,
overscan: 8,
rowGap: 16,
padding: "20px",
topThreshold: 120,
});
const emit = defineEmits<{
scroll: [];
topReach: [];
}>();
defineSlots<{
empty?: () => any;
before?: () => any;
item?: (props: { message: any }) => any;
after?: () => any;
}>();
const hostRef = ref<HTMLElement | null>(null);
const scrollerRef = ref<DynamicScrollerExposed<VirtualItem> | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
let keepBottomUntil = 0;
let bottomFrame: number | null = null;
let bottomFrameRemaining = 0;
let bottomFrameAttempts = 0;
let anchorFrame: number | null = null;
let anchorToken = 0;
let activeAnchorTarget: AnchorTarget | null = null;
let viewportRestoreFrame: number | null = null;
const messageKeys = computed(() => props.messages.map(messageKey));
const bufferPx = computed(() => Math.max(props.estimatedItemHeight, props.estimatedItemHeight * props.overscan));
function messageKey(message: VirtualItem): string {
return String(message.id);
}
function getScrollerElement(): HTMLElement | null {
return hostRef.value?.querySelector<HTMLElement>(".virtual-message-list") ?? null;
}
function syncViewport() {
const el = getScrollerElement();
if (!el) return;
scrollTop.value = el.scrollTop;
viewportHeight.value = el.clientHeight;
}
function handleScroll() {
syncViewport();
emit("scroll");
if (scrollTop.value <= props.topThreshold) emit("topReach");
}
function handleResize() {
syncViewport();
if (Date.now() < keepBottomUntil || isNearBottom(64)) scheduleScrollToBottom(2);
if (activeAnchorTarget) scheduleAnchorAlignment(activeAnchorTarget.token, 4);
}
function isNearBottom(threshold = 200): boolean {
const el = getScrollerElement();
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
}
function scrollToBottom(options: BottomScrollOptions = {}) {
const frames = typeof options === "number" ? options : options.frames ?? 5;
const keepAliveMs = typeof options === "number" ? 700 : options.keepAliveMs ?? 700;
keepBottomUntil = Date.now() + keepAliveMs;
nextTick(() => {
scheduleScrollToBottom(frames);
});
}
function setScrollToBottomNow(): boolean {
const el = getScrollerElement();
scrollerRef.value?.scrollToBottom();
if (el) {
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
syncViewport();
return true;
}
return false;
}
function scheduleScrollToBottom(frames = 1) {
bottomFrameRemaining = Math.max(bottomFrameRemaining, frames);
if (bottomFrame != null) return;
const step = () => {
const scrolled = setScrollToBottomNow();
if (scrolled) {
bottomFrameAttempts = 0;
bottomFrameRemaining -= 1;
} else {
bottomFrameAttempts += 1;
}
if (bottomFrameRemaining <= 0) {
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
return;
}
if (bottomFrameAttempts > 30) {
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
return;
}
bottomFrame = requestAnimationFrame(step);
};
bottomFrame = requestAnimationFrame(step);
}
function findTargetElement(messageId: string, anchorId: string): HTMLElement | null {
const el = getScrollerElement();
if (!el) return null;
const anchor = document.getElementById(anchorId);
if (anchor instanceof HTMLElement && el.contains(anchor)) return anchor;
const message = document.getElementById(`message-${messageId}`);
if (message instanceof HTMLElement && el.contains(message)) return message;
return null;
}
function alignElement(targetEl: HTMLElement, align: AnchorAlign) {
const el = getScrollerElement();
if (!el) return;
const scrollerRect = el.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const delta = align === "center"
? targetRect.top + targetRect.height / 2 - (scrollerRect.top + scrollerRect.height / 2)
: targetRect.top - scrollerRect.top - 24;
if (Math.abs(delta) > 1) {
el.scrollTop = Math.max(0, el.scrollTop + delta);
}
syncViewport();
}
function scrollToItem(index: number, options?: ScrollToOptions) {
scrollerRef.value?.scrollToItem(index, options);
syncViewport();
}
function scheduleAnchorAlignment(token: number, frames = 1) {
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
const step = (remaining: number) => {
const target = activeAnchorTarget;
if (!target || target.token !== token) {
anchorFrame = null;
return;
}
const targetEl = findTargetElement(target.messageId, target.anchorId);
if (targetEl) {
alignElement(targetEl, target.align);
} else {
scrollToItem(target.index, {
align: target.align,
offset: target.align === "start" ? -24 : 0,
});
}
if (remaining <= 1) {
anchorFrame = null;
activeAnchorTarget = null;
return;
}
anchorFrame = requestAnimationFrame(() => step(remaining - 1));
};
anchorFrame = requestAnimationFrame(() => step(frames));
}
function cancelAnchorAlignment() {
anchorToken += 1;
activeAnchorTarget = null;
if (anchorFrame != null) {
cancelAnimationFrame(anchorFrame);
anchorFrame = null;
}
}
function scrollToMessage(messageId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
cancelAnchorAlignment();
const token = anchorToken;
activeAnchorTarget = {
token,
index,
messageId,
anchorId: `message-${messageId}`,
align: "center",
};
nextTick(() => {
scrollToItem(index, { align: "center" });
scheduleAnchorAlignment(token, 8);
});
}
function scrollToAnchor(messageId: string, anchorId: string) {
const index = props.messages.findIndex(message => String(message.id) === messageId);
if (index < 0) return;
cancelAnchorAlignment();
const token = anchorToken;
activeAnchorTarget = {
token,
index,
messageId,
anchorId,
align: "start",
};
nextTick(() => {
scrollToItem(index, { align: "start", offset: -24 });
scheduleAnchorAlignment(token, 10);
});
}
function captureScrollPosition() {
const el = getScrollerElement();
if (!el) return null;
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
};
}
function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: number } | null) {
if (!snapshot) return;
nextTick(() => {
const el = getScrollerElement();
if (!el) return;
const nextScrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
scrollerRef.value?.scrollToPosition(nextScrollTop);
el.scrollTop = nextScrollTop;
syncViewport();
});
}
function captureViewportPosition(): ViewportScrollSnapshot | null {
const el = getScrollerElement();
if (!el) return null;
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
wasNearBottom: isNearBottom(64),
};
}
function restoreViewportPosition(snapshot: ViewportScrollSnapshot | null, frames = 4) {
if (!snapshot) return;
keepBottomUntil = 0;
if (bottomFrame != null) {
cancelAnimationFrame(bottomFrame);
bottomFrame = null;
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
}
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
nextTick(() => {
let remaining = frames;
const step = () => {
const el = getScrollerElement();
if (!el) {
viewportRestoreFrame = null;
return;
}
const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, snapshot.scrollTop));
scrollerRef.value?.scrollToPosition(nextScrollTop);
el.scrollTop = nextScrollTop;
syncViewport();
remaining -= 1;
if (remaining <= 0) {
viewportRestoreFrame = null;
return;
}
viewportRestoreFrame = requestAnimationFrame(step);
};
viewportRestoreFrame = requestAnimationFrame(step);
});
}
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
nextTick(() => {
syncViewport();
const el = getScrollerElement();
if (el && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(el);
}
});
});
onBeforeUnmount(() => {
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
bottomFrameRemaining = 0;
bottomFrameAttempts = 0;
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
resizeObserver?.disconnect();
});
watch(messageKeys, () => {
cancelAnchorAlignment();
nextTick(syncViewport);
});
defineExpose({
isNearBottom,
scrollToBottom,
scrollToMessage,
scrollToAnchor,
captureScrollPosition,
restoreScrollPosition,
captureViewportPosition,
restoreViewportPosition,
});
</script>
<template>
<div
ref="hostRef"
class="virtual-message-list-host"
:style="{ '--virtual-row-gap': `${rowGap}px`, '--virtual-list-padding': padding }"
>
<DynamicScroller
ref="scrollerRef"
class="virtual-message-list"
:items="messages"
key-field="id"
:min-item-size="estimatedItemHeight"
:buffer="bufferPx"
:flow-mode="true"
:prerender="overscan"
@scroll.passive="handleScroll"
@resize="handleResize"
@visible="syncViewport"
>
<template #before>
<slot v-if="messages.length > 0" name="before" />
</template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:index="index"
:active="active"
class="virtual-row"
>
<slot v-if="active" name="item" :message="item" />
</DynamicScrollerItem>
</template>
<template #after>
<slot v-if="messages.length > 0" name="after" />
</template>
</DynamicScroller>
<div v-if="messages.length === 0 && $slots.empty" class="virtual-message-list-empty">
<slot name="empty" />
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.virtual-message-list-host {
flex: 1;
min-height: 0;
display: flex;
position: relative;
animation: message-list-fade-in 1.5s ease both;
}
.virtual-message-list {
flex: 1;
min-height: 0;
padding: var(--virtual-list-padding);
box-sizing: border-box;
background-color: $bg-card;
.dark & {
background-color: #333333;
}
}
.virtual-row {
box-sizing: border-box;
padding-bottom: var(--virtual-row-gap);
}
.virtual-message-list-empty {
position: absolute;
inset: var(--virtual-list-padding);
display: grid;
place-items: center;
min-width: 0;
min-height: 0;
pointer-events: auto;
}
.virtual-message-list-empty :deep(.empty-state) {
width: 100%;
height: 100%;
min-height: 0;
}
@keyframes message-list-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.virtual-message-list-host {
animation: none;
}
}
</style>
@@ -0,0 +1,103 @@
import hljs from 'highlight.js'
import { copyToClipboard } from '@/utils/clipboard'
const LANGUAGE_ALIASES: Record<string, string> = {
shellscript: 'bash',
sh: 'bash',
zsh: 'bash',
yml: 'yaml',
vue: 'xml',
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function sanitizeLanguageClass(value: string): string {
return value.replace(/[^a-z0-9_-]/gi, '-') || 'plain'
}
export function normalizeHighlightLanguage(lang?: string): string {
const normalized = lang?.trim().toLowerCase() || ''
return LANGUAGE_ALIASES[normalized] || normalized
}
export function inferStructuredLanguage(content: string): string | undefined {
try {
JSON.parse(content)
return 'json'
} catch {
return undefined
}
}
type RenderHighlightedCodeBlockOptions = {
maxHighlightLength?: number
}
export function renderHighlightedCodeBlock(
content: string,
lang: string | undefined,
copyLabel: string,
options: RenderHighlightedCodeBlockOptions = {},
): string {
const requestedLanguage = lang?.trim().toLowerCase() || ''
const normalizedLanguage = normalizeHighlightLanguage(requestedLanguage)
const highlightLimit = options.maxHighlightLength ?? Number.POSITIVE_INFINITY
let highlighted = ''
let codeClassLanguage = normalizedLanguage || requestedLanguage || 'plain'
let labelLanguage = requestedLanguage
try {
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage) && content.length <= highlightLimit) {
highlighted = hljs.highlight(content, {
language: normalizedLanguage,
ignoreIllegals: true,
}).value
codeClassLanguage = normalizedLanguage
} else {
highlighted = escapeHtml(content)
if (!labelLanguage) {
labelLanguage = 'text'
}
}
} catch {
highlighted = escapeHtml(content)
if (!labelLanguage) {
labelLanguage = 'text'
}
}
const languageLabelHtml = labelLanguage
? `<span class="code-lang">${escapeHtml(labelLanguage)}</span>`
: ''
return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>`
}
export async function copyTextToClipboard(text: string): Promise<boolean> {
return copyToClipboard(text)
}
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<boolean | null> {
const target = event.target
if (!(target instanceof HTMLElement)) return null
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
if (!button) return null
event.preventDefault()
const block = button.closest('.hljs-code-block')
const code = block?.querySelector('code')
const text = code?.textContent ?? ''
if (!text) return false
return copyTextToClipboard(text)
}
@@ -0,0 +1,216 @@
const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd'])
type FenceInfo = {
indent: string
marker: string
fence: string
length: number
info: string
}
function parseFence(line: string): FenceInfo | null {
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
if (!match) return null
const [, indent, fence, rawInfo = ''] = match
const marker = fence[0]
const info = rawInfo.trim()
// CommonMark permits backticks in tilde-fence info strings, but not in
// backtick-fence info strings. Keeping this distinction prevents inline-ish
// malformed backtick text from being promoted into a fence opener.
if (marker === '`' && info.includes('`')) return null
return {
indent,
marker,
fence,
length: fence.length,
info,
}
}
function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string {
return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}`
}
function isMarkdownFence(fence: FenceInfo): boolean {
const language = fence.info.split(/\s+/)[0]?.toLowerCase()
return MARKDOWN_FENCE_LANGUAGES.has(language)
}
function isClosingFence(line: string, opener: FenceInfo): boolean {
const fence = parseFence(line)
return Boolean(
fence
&& fence.marker === opener.marker
&& fence.length >= opener.length
&& fence.info === '',
)
}
function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number {
let index = start
while (index >= 0 && lines[index].trim() === '') {
index -= 1
}
return index
}
function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number {
for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) {
if (isClosingFence(lines[i], opener)) {
return i
}
}
return -1
}
type OpenFence = {
marker: string
length: number
}
function canBalanceNestedFences(lines: string[], marker: string): boolean {
const stack: OpenFence[] = []
let sawFence = false
for (const line of lines) {
const fence = parseFence(line)
if (!fence || fence.marker !== marker) continue
sawFence = true
const current = stack[stack.length - 1]
if (fence.info === '' && current && fence.length >= current.length) {
stack.pop()
continue
}
// Inside a Markdown example, an unlabeled fence can be either a closing
// fence or a literal nested unlabeled example opener. If there is no nested
// opener waiting to close, treat it as the latter while evaluating a later
// candidate closing fence for the outer example.
stack.push({ marker: fence.marker, length: fence.length })
}
return sawFence && stack.length === 0
}
function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number {
const candidates: number[] = []
for (let i = start; i < lines.length; i += 1) {
const fence = parseFence(lines[i])
if (
fence
&& fence.marker === opener.marker
&& fence.info === ''
&& fence.length >= opener.length
) {
candidates.push(i)
}
}
for (let i = candidates.length - 1; i >= 0; i -= 1) {
const candidate = candidates[i]
if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) {
return candidate
}
}
return candidates[0] ?? -1
}
function maxFenceLength(lines: string[], marker: string): number {
let maxLength = 0
for (const line of lines) {
const fence = parseFence(line)
if (fence?.marker === marker) {
maxLength = Math.max(maxLength, fence.length)
}
}
return maxLength
}
function promoteMarkdownExampleFences(lines: string[]): string[] {
const output: string[] = []
for (let i = 0; i < lines.length; i += 1) {
const opener = parseFence(lines[i])
if (!opener || !isMarkdownFence(opener)) {
output.push(lines[i])
continue
}
const balancedClose = findBalancedClosingFence(lines, opener, i + 1)
if (balancedClose === -1) {
output.push(lines[i])
continue
}
const body = lines.slice(i + 1, balancedClose)
const innerMaxLength = maxFenceLength(body, opener.marker)
if (innerMaxLength >= opener.length) {
const promotedLength = innerMaxLength + 1
output.push(serializeFence(opener, promotedLength))
output.push(...body)
output.push(serializeFence(opener, promotedLength, ''))
} else {
output.push(lines[i])
output.push(...body)
output.push(lines[balancedClose])
}
i = balancedClose
}
return output
}
/**
* LLMs often wrap a complete PR draft or Markdown answer in an outer
* ```md fence. Showing that outer wrapper as a code block makes the UI look
* like Markdown rendering is broken: headings, lists, and inline code remain
* literal text. Strip only that outer draft wrapper before handing content to
* markdown-it.
*
* The unwrapped draft can still contain Markdown examples that themselves
* contain fenced examples. CommonMark closes fences at the first same-marker
* line with at least the opener length, so a malformed example like
* ```md ... ```md ... ``` ... ``` must be normalized by making the example's
* outer fence longer than the literal fences inside it.
*/
export function repairNestedMarkdownFences(content: string): string {
if (!content.includes('```') && !content.includes('~~~')) return content
const lines = content.split('\n')
const output: string[] = []
let changed = false
for (let i = 0; i < lines.length; i += 1) {
const opener = parseFence(lines[i])
if (!opener || !isMarkdownFence(opener)) {
output.push(lines[i])
continue
}
const finalClose = findFinalClosingFence(lines, opener, i + 1)
if (finalClose === -1) {
output.push(lines[i])
continue
}
const lastNonEmpty = findLastNonEmptyLine(lines)
if (finalClose !== lastNonEmpty) {
output.push(lines[i])
continue
}
output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose)))
output.push(...lines.slice(finalClose + 1))
changed = true
break
}
return changed ? output.join('\n') : content
}
@@ -0,0 +1,46 @@
const MERMAID_LANGUAGE = 'mermaid'
export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
export const MERMAID_MAX_SOURCE_LENGTH = 20_000
export const MERMAID_RENDER_TIMEOUT_MS = 5_000
export const SUPPORT_PREVIEW_FILE_TYPES = ['txt', 'md', 'json', 'csv', 'log', 'py', 'yaml', 'yml', 'toml', 'sh', 'xml', 'html', 'css', 'js', 'ts', 'rs', 'go', 'java', 'c', 'cpp', 'h']
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
export function getFenceLanguage(info: string | undefined): string {
return info?.trim().split(/\s+/)[0]?.toLowerCase() || ''
}
export function isMermaidFence(info: string | undefined): boolean {
return getFenceLanguage(info) === MERMAID_LANGUAGE
}
export function encodeMermaidSource(source: string): string {
return encodeURIComponent(source)
}
export function decodeMermaidSource(encoded: string | null | undefined): string {
if (!encoded) return ''
try {
return decodeURIComponent(encoded)
} catch {
return ''
}
}
export function renderMermaidPlaceholder(source: string): string {
return [
'<div class="mermaid-diagram" data-mermaid-pending="true"',
` data-mermaid-source="${escapeHtml(encodeMermaidSource(source))}">`,
'<div class="mermaid-loading">Rendering Mermaid diagram…</div>',
'</div>',
].join('')
}
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const filesStore = useFilesStore()
function handleClick(index: number) {
if (index < 0) {
filesStore.navigateTo('')
} else {
const path = filesStore.pathSegments.slice(0, index + 1).join('/')
filesStore.navigateTo(path)
}
}
</script>
<template>
<div class="file-breadcrumb">
<NBreadcrumb>
<NBreadcrumbItem @click="handleClick(-1)">
{{ t('files.breadcrumbRoot') }}
</NBreadcrumbItem>
<NBreadcrumbItem
v-for="(segment, index) in filesStore.pathSegments"
:key="index"
@click="handleClick(index)"
>
{{ segment }}
</NBreadcrumbItem>
</NBreadcrumb>
</div>
</template>
<style scoped lang="scss">
.file-breadcrumb {
padding: 0 16px;
}
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { NDropdown, useMessage, useDialog } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
import { copyToClipboard } from '@/utils/clipboard'
import { getClipboardPathForEntry } from '@/utils/file-path'
const { t } = useI18n()
const message = useMessage()
const dialog = useDialog()
const filesStore = useFilesStore()
const showMenu = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const targetEntry = ref<FileEntry | null>(null)
const emit = defineEmits<{
(e: 'rename', entry: FileEntry): void
}>()
function show(e: MouseEvent, entry: FileEntry) {
targetEntry.value = entry
menuX.value = e.clientX
menuY.value = e.clientY
showMenu.value = false
nextTick(() => {
showMenu.value = true
})
}
function getOptions() {
const entry = targetEntry.value
if (!entry) return []
const options: any[] = []
if (entry.isDir) {
options.push({ label: t('files.open'), key: 'open' })
} else {
if (isTextFile(entry.name)) {
options.push({ label: t('files.edit'), key: 'edit' })
}
if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
options.push({ label: t('files.preview'), key: 'preview' })
}
options.push({ label: t('files.download'), key: 'download' })
}
options.push({ type: 'divider', key: 'd1' })
options.push({ label: t('files.copyPath'), key: 'copyPath' })
options.push({ label: t('files.rename'), key: 'rename' })
options.push({ type: 'divider', key: 'd2' })
options.push({ label: t('files.delete'), key: 'delete' })
return options
}
async function handleSelect(key: string) {
showMenu.value = false
const entry = targetEntry.value
if (!entry) return
switch (key) {
case 'open':
filesStore.navigateTo(entry.path)
break
case 'edit':
try { await filesStore.openEditor(entry.path) } catch { message.error(t('files.backendError')) }
break
case 'preview':
try { await filesStore.openPreview(entry) } catch { message.error(t('files.backendError')) }
break
case 'download':
try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) }
break
case 'copyPath': {
const ok = await copyToClipboard(getClipboardPathForEntry(entry))
if (ok) {
message.success(t('files.pathCopied'))
} else {
message.error(t('files.pathCopied') + ' ✗')
}
break
}
case 'rename':
emit('rename', entry)
break
case 'delete':
dialog.warning({
title: t('files.delete'),
content: entry.isDir ? t('files.confirmDeleteDir', { name: entry.name }) : t('files.confirmDelete', { name: entry.name }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await filesStore.deleteEntry(entry)
message.success(t('files.deleted'))
} catch {
message.error(t('files.deleteFailed'))
}
},
})
break
}
}
function handleClickOutside() {
showMenu.value = false
}
defineExpose({ show })
</script>
<template>
<NDropdown
:show="showMenu"
:x="menuX"
:y="menuY"
:options="getOptions()"
placement="bottom-start"
trigger="manual"
@select="handleSelect"
@clickoutside="handleClickOutside"
/>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { NButton, NSpace, useMessage, useDialog } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import * as monaco from 'monaco-editor'
// Configure Monaco workers using import.meta.url
;(self as any).MonacoEnvironment = {
getWorker(_: any, _label: string) {
return new Worker(
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
{ type: 'module' }
)
},
}
const { t } = useI18n()
const message = useMessage()
const dialogApi = useDialog()
const filesStore = useFilesStore()
const editorContainer = ref<HTMLElement | null>(null)
let editor: monaco.editor.IStandaloneCodeEditor | null = null
const saving = ref(false)
onMounted(() => {
if (!editorContainer.value || !filesStore.editingFile) return
editor = monaco.editor.create(editorContainer.value, {
value: filesStore.editingFile.content,
language: filesStore.editingFile.language,
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
})
editor.onDidChangeModelContent(() => {
if (filesStore.editingFile) {
filesStore.editingFile.content = editor!.getValue()
}
})
// Ctrl/Cmd + S to save
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave()
})
})
onBeforeUnmount(() => {
editor?.dispose()
editor = null
})
async function handleSave() {
saving.value = true
try {
await filesStore.saveEditor()
message.success(t('files.saved'))
} catch {
message.error(t('files.saveFailed'))
} finally {
saving.value = false
}
}
function handleClose() {
if (filesStore.hasUnsavedChanges) {
dialogApi.warning({
title: t('files.unsavedChanges'),
positiveText: t('common.ok'),
negativeText: t('common.cancel'),
onPositiveClick: () => {
filesStore.closeEditor()
},
})
} else {
filesStore.closeEditor()
}
}
</script>
<template>
<div class="file-editor">
<div class="editor-header">
<span class="editor-filename">{{ filesStore.editingFile?.path }}</span>
<NSpace>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">
{{ t('files.saveFile') }}
</NButton>
<NButton size="small" @click="handleClose">
{{ t('files.closeEditor') }}
</NButton>
</NSpace>
</div>
<div ref="editorContainer" class="editor-container" />
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-editor {
display: flex;
flex-direction: column;
height: 100%;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid $border-color;
background-color: $bg-card;
}
.editor-filename {
font-size: 13px;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
@media (max-width: $breakpoint-mobile) {
max-width: 120px;
font-size: 12px;
}
}
.editor-container {
flex: 1;
min-height: 0;
}
</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore, isImageFile, isMarkdownFile, isTextFile } from '@/stores/hermes/files'
import { downloadFile } from '@/api/hermes/download'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'contextmenu-entry', event: MouseEvent, entry: FileEntry): void
}>()
function formatSize(bytes: number): string {
if (bytes === 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatDate(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString()
}
function getFileIcon(entry: FileEntry): string {
if (entry.isDir) return '📁'
const ext = entry.name.split('.').pop()?.toLowerCase() || ''
const iconMap: Record<string, string> = {
yaml: '⚙️', yml: '⚙️', json: '📋', toml: '⚙️',
md: '📝', txt: '📄', log: '📄',
py: '🐍', js: '📜', ts: '📜', vue: '💚',
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
zip: '📦', gz: '📦', tar: '📦',
sh: '⚡', bash: '⚡',
}
return iconMap[ext] || '📄'
}
function handleDoubleClick(entry: FileEntry) {
if (entry.isDir) {
filesStore.navigateTo(entry.path)
} else if (isTextFile(entry.name)) {
filesStore.openEditor(entry.path)
} else if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
filesStore.openPreview(entry)
}
}
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
e.preventDefault()
emit('contextmenu-entry', e, entry)
}
async function handleDownload(entry: FileEntry) {
try {
await downloadFile(entry.path, entry.name)
} catch (err: any) {
message.error(err.message || t('files.backendError'))
}
}
</script>
<template>
<div class="file-list">
<NSpin :show="filesStore.loading">
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
<div v-else class="file-list-items">
<div class="file-list-header">
<div class="file-name sort-header" @click="filesStore.setSort('name')">
{{ t('files.name') }}
<span v-if="filesStore.sortBy === 'name'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-size sort-header" @click="filesStore.setSort('size')">
{{ t('files.size') }}
<span v-if="filesStore.sortBy === 'size'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-date sort-header" @click="filesStore.setSort('modTime')">
{{ t('files.modified') }}
<span v-if="filesStore.sortBy === 'modTime'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '' : '' }}</span>
</div>
<div class="file-actions-placeholder" />
</div>
<div
v-for="entry in filesStore.sortedEntries"
:key="entry.path"
class="file-list-row"
@dblclick="handleDoubleClick(entry)"
@contextmenu="handleContextMenu($event, entry)"
>
<div class="file-name">
<span class="file-icon">{{ getFileIcon(entry) }}</span>
<span>{{ entry.name }}</span>
</div>
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
<div class="file-actions">
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')"></NButton>
<NButton v-if="!entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')"></NButton>
</div>
</div>
</div>
</NSpin>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-list {
padding: 8px 16px;
}
.file-list-header {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 16px;
font-size: 12px;
font-weight: 500;
color: $text-muted;
border-bottom: 1px solid $border-light;
margin-bottom: 4px;
user-select: none;
}
.sort-header {
cursor: pointer;
&:hover {
color: $text-primary;
}
}
.sort-indicator {
margin-left: 2px;
font-size: 11px;
}
.file-actions-placeholder {
width: 60px;
flex-shrink: 0;
}
.file-list-row {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: $radius-sm;
cursor: pointer;
gap: 16px;
font-size: 13px;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
.file-actions {
opacity: 1;
}
}
}
.file-name {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-icon {
flex-shrink: 0;
}
.file-size {
width: 80px;
text-align: right;
color: $text-secondary;
flex-shrink: 0;
}
.file-date {
width: 160px;
color: $text-secondary;
flex-shrink: 0;
}
.file-actions {
opacity: 0;
transition: opacity $transition-fast;
display: flex;
gap: 4px;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.file-size, .file-date {
display: none;
}
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { h } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import { getFileDownloadUrl } from '@/api/hermes/files'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
const { t } = useI18n()
const filesStore = useFilesStore()
function getImageUrl(): string {
if (!filesStore.previewFile) return ''
return getFileDownloadUrl(filesStore.previewFile.path)
}
const CloseIcon = () =>
h(
'svg',
{ viewBox: '0 0 24 24', width: '14', height: '14', fill: 'currentColor' },
[h('path', { d: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z' })],
)
</script>
<template>
<div class="file-preview" v-if="filesStore.previewFile">
<div class="preview-header">
<span class="preview-filename">{{ filesStore.previewFile.path }}</span>
<NButton size="small" quaternary @click="filesStore.closePreview()">
<template #icon>
<NIcon><CloseIcon /></NIcon>
</template>
{{ t('files.closePreview') }}
</NButton>
</div>
<div class="preview-content">
<img
v-if="filesStore.previewFile.type === 'image'"
:src="getImageUrl()"
class="preview-image"
:alt="filesStore.previewFile.path"
/>
<div v-else-if="filesStore.previewFile.type === 'markdown'" class="preview-markdown">
<MarkdownRenderer :content="filesStore.previewFile.content || ''" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-preview {
display: flex;
flex-direction: column;
height: 100%;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid $border-color;
}
.preview-filename {
font-size: 13px;
color: $text-secondary;
}
.preview-content {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
justify-content: center;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-markdown {
max-width: 800px;
width: 100%;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NModal, NInput, NButton, NSpace, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import type { FileEntry } from '@/api/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const props = defineProps<{
show: boolean
mode: 'newFile' | 'newFolder' | 'rename'
entry?: FileEntry | null
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()
const inputValue = ref('')
const submitting = ref(false)
watch(() => props.show, (val) => {
if (val) {
if (props.mode === 'rename' && props.entry) {
inputValue.value = props.entry.name
} else {
inputValue.value = ''
}
}
})
const title = computed(() => {
switch (props.mode) {
case 'newFile': return t('files.newFile')
case 'newFolder': return t('files.newFolder')
case 'rename': return t('files.rename')
}
})
const placeholder = computed(() => {
switch (props.mode) {
case 'newFile': return t('files.newFileName')
case 'newFolder': return t('files.newFolderName')
case 'rename': return t('files.renameTo')
}
})
async function handleSubmit() {
if (!inputValue.value.trim()) return
submitting.value = true
try {
switch (props.mode) {
case 'newFile':
await filesStore.createFile(inputValue.value.trim())
message.success(t('files.created'))
break
case 'newFolder':
await filesStore.createDir(inputValue.value.trim())
message.success(t('files.created'))
break
case 'rename':
if (props.entry) {
await filesStore.renameEntry(props.entry, inputValue.value.trim())
message.success(t('files.renamed'))
}
break
}
emit('update:show', false)
} catch (err: any) {
const msg = props.mode === 'rename' ? t('files.renameFailed') : t('files.createFailed')
message.error(err.message || msg)
} finally {
submitting.value = false
}
}
</script>
<template>
<NModal :show="props.show" preset="dialog" :title="title" @update:show="emit('update:show', false)" style="width: 400px;">
<NInput
v-model:value="inputValue"
:placeholder="placeholder"
@keydown.enter="handleSubmit"
autofocus
/>
<template #action>
<NSpace>
<NButton @click="emit('update:show', false)">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
{{ t('common.ok') }}
</NButton>
</NSpace>
</template>
</NModal>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { NButton, NSpace, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const emit = defineEmits<{
(e: 'showNewFile'): void
(e: 'showNewFolder'): void
(e: 'showUpload'): void
}>()
async function handleRefresh() {
try {
await filesStore.fetchEntries()
} catch {
message.error(t('files.backendError'))
}
}
</script>
<template>
<div class="file-toolbar">
<NSpace :size="8" :wrap="true" class="toolbar-space">
<NButton size="small" @click="emit('showNewFile')" class="toolbar-btn">
{{ t('files.newFile') }}
</NButton>
<NButton size="small" @click="emit('showNewFolder')" class="toolbar-btn">
{{ t('files.newFolder') }}
</NButton>
<NButton size="small" @click="emit('showUpload')" class="toolbar-btn">
{{ t('files.upload') }}
</NButton>
<NButton size="small" @click="handleRefresh" class="toolbar-btn">
{{ t('files.refresh') }}
</NButton>
</NSpace>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-toolbar {
padding: 12px 16px;
@media (max-width: $breakpoint-mobile) {
padding: 8px 4px;
}
}
.toolbar-space {
@media (max-width: $breakpoint-mobile) {
:deep(.n-space) {
gap: 4px !important;
}
}
}
.toolbar-btn {
@media (max-width: $breakpoint-mobile) {
font-size: 12px;
padding: 0 8px;
height: 32px;
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NTree } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
import * as filesApi from '@/api/hermes/files'
import type { TreeOption } from 'naive-ui'
const { t } = useI18n()
const filesStore = useFilesStore()
const treeData = ref<TreeOption[]>([])
const selectedKeys = ref<string[]>([])
async function loadChildren(path: string): Promise<TreeOption[]> {
try {
const result = await filesApi.listFiles(path)
return result.entries
.filter(e => e.isDir)
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => ({
key: e.path,
label: e.name,
isLeaf: false,
}))
} catch {
return []
}
}
async function handleLoad(node: TreeOption): Promise<void> {
node.children = await loadChildren(node.key as string)
}
function handleSelect(keys: string[]) {
if (keys.length > 0) {
selectedKeys.value = keys
filesStore.navigateTo(keys[0])
}
}
function handleRootClick() {
selectedKeys.value = []
filesStore.navigateTo('')
}
onMounted(async () => {
treeData.value = await loadChildren('')
})
</script>
<template>
<div class="file-tree">
<div class="tree-header" @click="handleRootClick">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>{{ t('files.breadcrumbRoot') }}</span>
</div>
<NTree
:data="treeData"
:selected-keys="selectedKeys"
:on-load="handleLoad"
expand-on-click
block-line
@update:selected-keys="handleSelect"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.file-tree {
padding: 8px;
}
.tree-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: $radius-sm;
font-size: 13px;
font-weight: 500;
color: $text-primary;
&:hover {
background-color: rgba(var(--accent-primary-rgb), 0.06);
}
}
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NButton, NUpload, NSpace, useMessage } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useFilesStore } from '@/stores/hermes/files'
const { t } = useI18n()
const message = useMessage()
const filesStore = useFilesStore()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ (e: 'update:show', value: boolean): void }>()
const uploading = ref(false)
const fileList = ref<File[]>([])
function handleFileChange(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
fileList.value = data.fileList
.map((f: UploadFileInfo) => f.file)
.filter((f): f is File => f != null)
}
async function handleUpload() {
if (fileList.value.length === 0) return
uploading.value = true
try {
await filesStore.uploadFiles(fileList.value)
message.success(t('files.uploadSuccess', { count: fileList.value.length }))
fileList.value = []
emit('update:show', false)
} catch (err: any) {
message.error(err.message || t('files.uploadFailed'))
} finally {
uploading.value = false
}
}
function handleClose() {
fileList.value = []
emit('update:show', false)
}
</script>
<template>
<NModal :show="props.show" preset="dialog" :title="t('files.upload')" @update:show="handleClose" style="width: 500px;">
<NUpload
multiple
directory-dnd
:default-upload="false"
@change="handleFileChange"
>
<div class="upload-dragger">
<p>{{ t('files.dragDropHint') }}</p>
</div>
</NUpload>
<template #action>
<NSpace>
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
{{ t('files.upload') }} ({{ fileList.length }})
</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped>
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
cursor: pointer;
}
.upload-dragger p {
margin-top: 12px;
opacity: 0.6;
font-size: 14px;
}
</style>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui'
type InputLikeInstance = {
focus: () => void
}
const { t } = useI18n()
const emit = defineEmits<{
submit: [name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }]
cancel: []
}>()
const roomName = ref('')
const inviteCode = ref('')
const userName = ref(localStorage.getItem('gc_user_name') || '')
const description = ref(localStorage.getItem('gc_user_description') || '')
const roomInput = ref<InputLikeInstance | null>(null)
const compression = ref({
triggerTokens: 100000,
maxHistoryTokens: 32000,
tailMessageCount: 10,
})
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
function handleCreate() {
const name = roomName.value.trim()
const code = inviteCode.value.trim() || generateCode()
const user = userName.value.trim()
if (!name || !user) return
emit('submit', name, code, user, description.value.trim(), { ...compression.value })
}
function focusRoomInput() {
nextTick(() => roomInput.value?.focus())
}
</script>
<template>
<div class="create-form">
<div class="form-group">
<label class="form-label">{{ t('groupChat.yourName') }}</label>
<NInput
v-model:value="userName"
:placeholder="t('groupChat.yourNamePlaceholder')"
@keyup.enter="focusRoomInput"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.yourDescription') }}</label>
<NInput
v-model:value="description"
type="textarea"
:rows="2"
:placeholder="t('groupChat.yourDescriptionPlaceholder')"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.roomName') }}</label>
<NInput
ref="roomInput"
v-model:value="roomName"
:placeholder="t('groupChat.roomNamePlaceholder')"
@keyup.enter="handleCreate"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.inviteCode') }}</label>
<div class="code-row">
<NInput
v-model:value="inviteCode"
:placeholder="t('groupChat.autoGenerate')"
/>
<NButton size="small" @click="inviteCode = generateCode()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</NButton>
</div>
</div>
<NCollapse class="compression-collapse">
<NCollapseItem :title="t('groupChat.compressionSettings')" name="compression">
<div class="compression-fields">
<div class="form-group">
<label class="form-label">{{ t('groupChat.triggerTokens') }}</label>
<NInputNumber v-model:value="compression.triggerTokens" :min="1000" :step="1000" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.triggerTokensDesc') }}</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.maxHistoryTokens') }}</label>
<NInputNumber v-model:value="compression.maxHistoryTokens" :min="1000" :step="1000" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.maxHistoryTokensDesc') }}</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.tailMessageCount') }}</label>
<NInputNumber v-model:value="compression.tailMessageCount" :min="1" :step="5" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.tailMessageCountDesc') }}</p>
</div>
</div>
</NCollapseItem>
</NCollapse>
<div class="modal-actions">
<NSpace justify="end">
<NButton @click="emit('cancel')">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="!roomName.trim() || !userName.trim()" @click="handleCreate">{{ t('common.create') }}</NButton>
</NSpace>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.create-form {
.form-group {
margin-bottom: 16px;
}
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: $text-secondary;
margin-bottom: 6px;
}
.form-hint {
font-size: 11px;
color: $text-muted;
margin: 4px 0 0;
}
.code-row {
display: flex;
gap: 8px;
align-items: center;
}
.compression-collapse {
margin-bottom: 16px;
}
.compression-fields {
padding-top: 8px;
}
</style>
@@ -0,0 +1,774 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NSwitch, NTooltip } from 'naive-ui'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import { buildMentionOptions, type MentionOption } from './mention-options'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
const store = useGroupChatStore()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const dropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const autoPlaySpeech = ref(false)
onMounted(() => {
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
store.setAutoPlaySpeech(autoPlaySpeech.value)
}
})
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
store.setAutoPlaySpeech(value)
})
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null)
function startResize(e: MouseEvent) {
e.preventDefault()
const el = textareaRef.value
if (!el) return
const startHeight = el.clientHeight
const startY = e.clientY
function onMouseMove(e: MouseEvent) {
const deltaY = e.clientY - startY
const newHeight = startHeight - deltaY
textareaHeight.value = Math.max(20, Math.min(400, Math.round(newHeight)))
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// ─── Mention State ───────────────────────────────────────
const mentionActive = ref(false)
const mentionQuery = ref('')
const mentionStartIndex = ref(-1)
const dropdownX = ref(0)
const dropdownY = ref(0)
const dropdownBottom = ref(0)
const placement = ref<'bottom' | 'top'>('bottom')
const activeIndex = ref(0)
const filteredMentionOptions = computed(() => buildMentionOptions(store.agents, mentionQuery.value))
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
// ─── Scroll active item into view ──────────────────────
function scrollToActive() {
nextTick(() => {
if (!dropdownRef.value) return
const active = dropdownRef.value.querySelector('.active') as HTMLElement | null
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'instant' })
})
}
// ─── Mention Logic ───────────────────────────────────────
function updateMentionState() {
const el = textareaRef.value
if (!el) { mentionActive.value = false; return }
const text = inputText.value
const cursorPos = el.selectionStart
// Find the last @ before the cursor
let atPos = -1
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '@') { atPos = i; break }
if (text[i] === ' ' || text[i] === '\n') break
}
if (atPos === -1) {
mentionActive.value = false
return
}
// Make sure the @ is not part of a word (preceded by space or start of line)
if (atPos > 0 && text[atPos - 1] !== ' ' && text[atPos - 1] !== '\n') {
mentionActive.value = false
return
}
const query = text.slice(atPos + 1, cursorPos)
if (query.includes(' ')) {
mentionActive.value = false
return
}
mentionQuery.value = query
mentionStartIndex.value = atPos
activeIndex.value = 0
// Calculate dropdown position using mirror span
const mirror = document.createElement('span')
const style = getComputedStyle(el)
const props = ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent', 'border', 'padding', 'boxSizing', 'lineHeight']
props.forEach(p => { (mirror.style as any)[p] = style[p as any] })
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'nowrap'
mirror.textContent = text.slice(0, atPos + 1)
const rect = el.getBoundingClientRect()
document.body.appendChild(mirror)
const mirrorRect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft
// Decide placement: if dropdown would go below viewport, flip upward
const estimatedHeight = Math.min(filteredMentionOptions.value.length * 36 + 8, 240)
const spaceBelow = window.innerHeight - rect.top + el.scrollTop - 8
if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) {
placement.value = 'top'
dropdownY.value = rect.top - el.scrollTop - 8
} else {
placement.value = 'bottom'
dropdownY.value = rect.top - el.scrollTop - 8
}
dropdownBottom.value = window.innerHeight - dropdownY.value
mentionActive.value = filteredMentionOptions.value.length > 0
}
function selectMention(name: string) {
const el = textareaRef.value
if (!el || mentionStartIndex.value === -1) return
const before = inputText.value.slice(0, mentionStartIndex.value)
const after = inputText.value.slice(el.selectionStart)
inputText.value = `${before}@${name} ${after}`
mentionActive.value = false
nextTick(() => {
if (el) {
const newPos = before.length + name.length + 2
el.setSelectionRange(newPos, newPos)
el.focus()
if (textareaHeight.value === null) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
}
}
})
}
// ─── Event Handlers ──────────────────────────────────────
function handleKeydown(e: KeyboardEvent) {
// Mention navigation — fully custom, no NDropdown interference
if (mentionActive.value && filteredMentionOptions.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % filteredMentionOptions.value.length
scrollToActive()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + filteredMentionOptions.value.length) % filteredMentionOptions.value.length
scrollToActive()
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectMention(filteredMentionOptions.value[activeIndex.value].name)
return
}
if (e.key === 'Escape') {
e.preventDefault()
mentionActive.value = false
return
}
}
if (e.key !== 'Enter' || e.shiftKey) return
if (isComposing.value || e.isComposing || e.keyCode === 229) return
e.preventDefault()
handleSend()
}
function handleSend() {
const content = inputText.value.trim()
if (!content && attachments.value.length === 0) return
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
attachments.value = []
mentionActive.value = false
// 发送后重置到自定义高度(不清除拖拽状态)
}
function handleInput(e: Event) {
// 用户手动拖拽自定义高度时,不覆盖
if (textareaHeight.value !== null) return
store.emitTyping()
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
if (!isComposing.value) {
updateMentionState()
}
}
function handleMentionClick(option: MentionOption) {
selectMention(option.name)
}
function handleMentionHover(index: number) {
activeIndex.value = index
}
// ─── Click outside to close dropdown ─────────────────
function onDocumentMousedown(e: MouseEvent) {
if (!mentionActive.value) return
const target = e.target as HTMLElement
if (!target.closest('.mention-dropdown')) {
mentionActive.value = false
}
}
onMounted(() => {
document.addEventListener('mousedown', onDocumentMousedown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMousedown)
})
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
requestAnimationFrame(() => {
isComposing.value = false
updateMentionState()
})
}
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
textareaRef.value?.focus()
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
</script>
<template>
<div class="chat-input-area">
<div class="input-top-bar">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" circle @click="handleAttachClick">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<div class="auto-play-speech-switch">
<NTooltip trigger="hover">
<template #trigger>
<div class="switch-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
</template>
{{ t('chat.autoPlaySpeech') }}
</NTooltip>
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
</div>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
</svg>
</NButton>
</template>
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
</NTooltip>
</div>
<div v-if="attachments.length > 0" class="attachment-previews">
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
<div v-else class="attachment-file">
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
<div class="resize-handle" @mousedown="startResize"></div>
<textarea
ref="textareaRef"
v-model="inputText"
class="input-textarea"
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
:placeholder="t('groupChat.inputPlaceholder')"
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
/>
<div class="input-actions">
<NButton
size="small"
type="primary"
:disabled="!canSend"
@click="handleSend"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</template>
{{ t('chat.send') }}
</NButton>
</div>
</div>
<Transition name="dropdown-fade">
<div
v-if="mentionActive && filteredMentionOptions.length > 0"
ref="dropdownRef"
class="mention-dropdown"
:class="{ 'placement-top': placement === 'top' }"
:style="{
left: dropdownX + 'px',
top: placement === 'bottom' ? dropdownY + 'px' : 'auto',
bottom: placement === 'top' ? dropdownBottom + 'px' : 'auto',
}"
>
<div
v-for="(option, i) in filteredMentionOptions"
:key="option.key"
class="mention-dropdown-item"
:class="{ active: i === activeIndex, 'mention-all-option': option.type === 'all' }"
@mousedown.prevent="handleMentionClick(option)"
@mouseenter="handleMentionHover(i)"
>
<span class="mention-name">{{ option.label }}</span>
<span class="mention-profile">{{ option.description }}</span>
</div>
</div>
</Transition>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.chat-input-area {
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.input-top-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 6px;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 6px;
padding-left: 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: -4px;
padding: 0;
background: transparent !important;
:deep(.n-button__state-border),
:deep(.n-button__border),
:deep(.n-button__ripple) {
display: none;
}
.tool-trace-icon {
display: block;
width: 16px;
height: 16px;
}
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
.typing-dots {
display: inline-flex;
align-items: center;
gap: 2px;
span {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: $text-muted;
animation: typing-bounce 1.2s infinite;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-3px); opacity: 1; }
}
.input-wrapper {
display: flex;
align-items: center;
gap: 10px;
background-color: $bg-input;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 10px 12px;
position: relative;
transition: border-color $transition-fast, background-color $transition-fast;
&:focus-within {
border-color: $accent-primary;
}
&.drag-over {
border-color: $accent-primary;
background-color: rgba($accent-primary, 0.08);
}
.dark & {
background-color: #333333;
}
}
.resize-handle {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 8px;
cursor: row-resize;
z-index: 2;
&:hover {
background: rgba($accent-primary, 0.15);
border-radius: 4px;
}
}
.input-textarea {
flex: 1;
background: none;
border: none;
outline: none;
color: $text-primary;
font-family: $font-ui;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 400px;
min-height: 20px;
overflow-y: auto;
@media (max-width: 768px) {
font-size: 16px;
}
&::placeholder {
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.input-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
}
/* ── Custom mention dropdown (replaces NDropdown) ── */
.mention-dropdown {
position: fixed;
background: $bg-card;
border: 1px solid $border-color;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
min-width: 200px;
max-height: 240px;
overflow-y: auto;
z-index: 9999;
padding: 4px;
}
.mention-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
&:hover,
&.active {
background: rgba(var(--text-primary-rgb), 0.08);
}
.mention-name {
color: $text-primary;
font-size: 14px;
font-weight: 500;
}
.mention-profile {
color: $text-muted;
font-size: 12px;
}
&.mention-all-option .mention-name {
color: $accent-primary;
font-weight: 600;
}
}
/* ── Dropdown fade/scale animation (matching NDropdown) ── */
.dropdown-fade-enter-active {
transition: opacity 0.2s cubic-bezier(0, 0, .2, 1), transform 0.2s cubic-bezier(0, 0, .2, 1);
transform-origin: top;
}
.dropdown-fade-leave-active {
transition: opacity 0.2s cubic-bezier(.4, 0, 1, 1), transform 0.2s cubic-bezier(.4, 0, 1, 1);
transform-origin: top;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: scale(0.9);
}
.placement-top.dropdown-fade-enter-active,
.placement-top.dropdown-fade-leave-active {
transform-origin: bottom;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import GroupMessageItem from './GroupMessageItem.vue'
import VirtualMessageList from '../chat/VirtualMessageList.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
let pendingInitialBottomRoomId: string | null = store.currentRoomId
type BottomScrollOptions = number | {
frames?: number
keepAliveMs?: number
}
function scrollToBottom(options?: BottomScrollOptions): void {
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
scrollToBottom: (options?: BottomScrollOptions) => void
}) | null
list?.scrollToBottom(options)
}
async function handleTopReach(): Promise<void> {
if (!store.hasMoreBefore || store.isLoadingOlderMessages) return
const snapshot = listRef.value?.captureScrollPosition() ?? null
const loaded = await store.loadOlderMessages()
if (!loaded) return
await nextTick()
listRef.value?.restoreScrollPosition(snapshot)
}
watch(() => store.currentRoomId, (roomId) => {
pendingInitialBottomRoomId = roomId
})
watch(() => displayMessages.value.map(msg => [
msg.id,
msg.content?.length ?? 0,
msg.reasoning?.length ?? 0,
msg.reasoning_content?.length ?? 0,
msg.toolStatus ?? '',
].join(':')).join('|'), async () => {
const shouldForceInitialBottom = !!store.currentRoomId &&
pendingInitialBottomRoomId === store.currentRoomId &&
displayMessages.value.length > 0
const shouldScroll = shouldForceInitialBottom || (listRef.value?.isNearBottom(200) ?? true)
await nextTick()
if (shouldScroll) {
scrollToBottom(shouldForceInitialBottom ? { frames: 5, keepAliveMs: 700 } : { frames: 1, keepAliveMs: 120 })
if (shouldForceInitialBottom) pendingInitialBottomRoomId = null
}
})
onMounted(async () => {
if (!store.currentRoomId || displayMessages.value.length === 0) return
pendingInitialBottomRoomId = null
await nextTick()
scrollToBottom({ frames: 5, keepAliveMs: 700 })
})
defineExpose({ scrollToBottom })
</script>
<template>
<VirtualMessageList
ref="listRef"
:messages="displayMessages"
:estimated-item-height="170"
:row-gap="12"
padding="16px 20px"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="store.hasMoreBefore || store.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<GroupMessageItem
:message="msg"
:agents="store.agents"
:current-user-id="store.userId"
/>
</template>
</VirtualMessageList>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: $text-muted;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,46 @@
export type MentionOption = {
key: string
type: 'all' | 'agent'
name: string
label: string
description: string
}
type MentionAgent = {
name: string
profile?: string
}
function isReservedMentionName(name: string): boolean {
return name.trim().toLowerCase() === 'all'
}
export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] {
const normalizedQuery = query.trim().toLowerCase()
const options: MentionOption[] = []
if (!normalizedQuery || 'all'.includes(normalizedQuery)) {
options.push({
key: 'special:all',
type: 'all',
name: 'all',
label: '@all',
description: 'All agents',
})
}
for (const agent of agents) {
const agentName = agent.name || ''
if (isReservedMentionName(agentName)) continue
if (!agentName.toLowerCase().includes(normalizedQuery)) continue
options.push({
key: `agent:${agentName}`,
type: 'agent',
name: agentName,
label: `@${agentName}`,
description: agent.profile || '',
})
}
return options
}
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NTooltip, useMessage } from 'naive-ui'
import type { Job } from '@/api/hermes/jobs'
import { scheduleToDisplayText } from '@/api/hermes/jobs'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
job: Job
selected?: boolean
}>()
const emit = defineEmits<{
edit: [jobId: string]
select: [jobId: string]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
const message = useMessage()
const jobId = computed(() => props.job.job_id || props.job.id)
const statusLabel = computed(() => {
if (props.job.state === 'running') return t('jobs.status.running')
if (props.job.state === 'paused') return t('jobs.status.paused')
if (!props.job.enabled) return t('jobs.status.disabled')
return t('jobs.status.scheduled')
})
const statusType = computed(() => {
if (props.job.state === 'running') return 'info' as const
if (props.job.state === 'paused') return 'warning' as const
if (!props.job.enabled) return 'error' as const
return 'success' as const
})
const scheduleExpr = computed(() => scheduleToDisplayText(props.job.schedule, props.job.schedule_display || '—'))
const formatTime = (t?: string | null) => {
if (!t) return '—'
return new Date(t).toLocaleString()
}
async function handlePause() {
try {
await jobsStore.pauseJob(jobId.value)
message.success(t('jobs.jobPaused'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleResume() {
try {
await jobsStore.resumeJob(jobId.value)
message.success(t('jobs.jobResumed'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleRun() {
try {
await jobsStore.runJob(jobId.value)
message.info(t('jobs.jobTriggered'))
} catch (e: any) {
message.error(e.message)
}
}
async function handleDelete() {
try {
await jobsStore.deleteJob(jobId.value)
message.success(t('jobs.jobDeleted'))
} catch (e: any) {
message.error(e.message)
}
}
function handleCardClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.card-actions')) return
emit('select', jobId.value)
}
</script>
<template>
<div class="job-card" :class="{ selected }" @click="handleCardClick">
<div class="card-header">
<h3 class="job-name">{{ job.name }}</h3>
<span class="status-badge" :class="statusType">{{ statusLabel }}</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
<code class="info-value mono">{{ scheduleExpr }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.model') }}</span>
<span class="info-value mono">{{ job.model || '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
<span class="info-value">
{{ formatTime(job.last_run_at) }}
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
{{ job.last_status === 'ok' ? t('common.ok') : job.last_status }}
</span>
</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.nextRun') }}</span>
<span class="info-value">{{ formatTime(job.next_run_at) }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.deliver') }}</span>
<span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
</div>
<div v-if="job.repeat" class="info-row">
<span class="info-label">{{ t('jobs.info.repeat') }}</span>
<span class="info-value">
<template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
<template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '' }}</template>
</span>
</div>
</div>
<div class="card-actions">
<NTooltip v-if="job.state !== 'paused' && job.enabled">
<template #trigger>
<NButton size="tiny" quaternary @click.stop="handlePause">{{ t('jobs.action.pause') }}</NButton>
</template>
{{ t('jobs.action.pauseJob') }}
</NTooltip>
<NTooltip v-else-if="job.state === 'paused'">
<template #trigger>
<NButton size="tiny" quaternary @click.stop="handleResume">{{ t('jobs.action.resume') }}</NButton>
</template>
{{ t('jobs.action.resumeJob') }}
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="tiny" quaternary @click.stop="handleRun">{{ t('jobs.action.runNow') }}</NButton>
</template>
{{ t('jobs.action.triggerImmediately') }}
</NTooltip>
<NButton size="tiny" quaternary @click.stop="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
<NButton size="tiny" quaternary type="error" @click.stop="handleDelete">{{ t('common.delete') }}</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.job-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
cursor: pointer;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
&.selected {
border-color: rgba(var(--accent-primary-rgb), 0.6);
background-color: rgba(var(--accent-primary-rgb), 0.04);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.job-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
&.success {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.info {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.warning {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.error {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.run-status {
margin-left: 6px;
font-size: 11px;
font-weight: 500;
&.ok { color: $success; }
&.err { color: $error; }
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.card-actions {
display: flex;
gap: 4px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
</style>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useSettingsStore } from '@/stores/hermes/settings'
import {
buildJobUpdateRequest,
getJob,
jobRepeatToEditValue,
scheduleToEditableInput,
} from '@/api/hermes/jobs'
import type { CreateJobRequest, Job } from '@/api/hermes/jobs'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
jobId: string | null
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const jobsStore = useJobsStore()
const settingsStore = useSettingsStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const formData = ref({
name: '',
schedule: '',
prompt: '',
deliver: 'origin',
repeat_times: null as number | null,
})
const presetValue = ref<string | null>(null)
const isEdit = computed(() => !!props.jobId)
const schedulePresets = computed(() => [
{ label: t('jobs.presetEveryMinute'), value: '* * * * *' },
{ label: t('jobs.presetEvery5Min'), value: '*/5 * * * *' },
{ label: t('jobs.presetEveryHour'), value: '0 * * * *' },
{ label: t('jobs.presetEveryDay'), value: '0 0 * * *' },
{ label: t('jobs.presetEveryDay9'), value: '0 9 * * *' },
{ label: t('jobs.presetEveryMonday'), value: '0 9 * * 1' },
{ label: t('jobs.presetEveryMonth'), value: '0 9 1 * *' },
])
function hasText(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0
}
function isDeliverTargetConfigured(key: string): boolean {
const config = settingsStore.platforms[key] || {}
switch (key) {
case 'telegram':
case 'discord':
case 'slack':
return hasText(config.token)
case 'whatsapp':
return config.enabled === true || config.enabled === 'true'
case 'matrix':
return hasText(config.token) && hasText(config.extra?.homeserver)
case 'weixin':
return hasText(config.token) && hasText(config.extra?.account_id)
case 'wecom':
return hasText(config.extra?.bot_id) && hasText(config.extra?.secret)
case 'feishu':
return hasText(config.extra?.app_id) && hasText(config.extra?.app_secret)
case 'dingtalk':
return (hasText(config.extra?.client_id) && hasText(config.extra?.client_secret))
|| (hasText(config.extra?.app_key) && hasText(config.extra?.client_secret))
case 'qqbot':
return hasText(config.extra?.app_id) && hasText(config.extra?.client_secret)
default:
return false
}
}
const targetOptions = computed(() => {
const options: Array<{ label: string; value: string; disabled?: boolean }> = [
{ label: t('jobs.origin'), value: 'origin' },
{ label: t('jobs.local'), value: 'local' },
]
const channels = [
{ key: 'telegram', label: 'Telegram' },
{ key: 'discord', label: 'Discord' },
{ key: 'slack', label: 'Slack' },
{ key: 'whatsapp', label: 'WhatsApp' },
{ key: 'matrix', label: 'Matrix' },
{ key: 'weixin', label: 'WeChat' },
{ key: 'wecom', label: 'WeCom' },
{ key: 'feishu', label: 'Feishu' },
{ key: 'dingtalk', label: 'DingTalk' },
{ key: 'qqbot', label: 'QQBot' },
]
for (const ch of channels) {
options.push({
label: ch.label,
value: ch.key,
disabled: !isDeliverTargetConfigured(ch.key),
})
}
return options
})
const originalJob = ref<Job | null>(null)
onMounted(async () => {
if (Object.keys(settingsStore.platforms || {}).length === 0) {
await settingsStore.fetchSettings()
}
if (props.jobId) {
try {
const job = await getJob(props.jobId)
originalJob.value = job
formData.value = {
name: job.name,
schedule: scheduleToEditableInput(job.schedule, job.schedule_display || ''),
prompt: job.prompt,
deliver: job.deliver || 'origin',
repeat_times: jobRepeatToEditValue(job.repeat),
}
} catch (e: any) {
message.error(t('jobs.loadFailed') + ': ' + e.message)
}
}
})
async function handleSave() {
if (!formData.value.name.trim()) {
message.warning(t('jobs.nameRequired'))
return
}
if (!formData.value.schedule.trim()) {
message.warning(t('jobs.scheduleRequired'))
return
}
loading.value = true
try {
if (isEdit.value) {
if (!originalJob.value) {
message.error(t('jobs.loadFailed'))
return
}
const payload = buildJobUpdateRequest(originalJob.value, formData.value)
if (Object.keys(payload).length === 0) {
message.success(t('jobs.jobUpdated'))
emit('saved')
return
}
await jobsStore.updateJob(props.jobId!, payload)
message.success(t('jobs.jobUpdated'))
} else {
const payload: CreateJobRequest = {
name: formData.value.name,
schedule: formData.value.schedule,
prompt: formData.value.prompt,
deliver: formData.value.deliver,
repeat: formData.value.repeat_times ?? undefined,
}
await jobsStore.createJob(payload)
message.success(t('jobs.jobCreated'))
}
emit('saved')
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('jobs.name')" required>
<NInput
v-model:value="formData.name"
:placeholder="t('jobs.namePlaceholder')"
maxlength="200"
show-count
/>
</NFormItem>
<NFormItem :label="t('jobs.schedule')" required>
<NInput
v-model:value="formData.schedule"
:placeholder="t('jobs.schedulePlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('jobs.quickPresets')">
<NSelect
v-model:value="presetValue"
:options="schedulePresets"
:placeholder="t('jobs.selectPreset')"
@update:value="v => formData.schedule = v"
/>
</NFormItem>
<NFormItem :label="t('jobs.prompt')" required>
<NInput
v-model:value="formData.prompt"
type="textarea"
:placeholder="t('jobs.promptPlaceholder')"
:rows="4"
maxlength="5000"
show-count
/>
</NFormItem>
<NFormItem :label="t('jobs.deliverTarget')">
<NSelect
v-model:value="formData.deliver"
:options="targetOptions"
/>
</NFormItem>
<NFormItem :label="t('jobs.repeatCount')">
<NInputNumber
v-model:value="formData.repeat_times"
:min="1"
:placeholder="t('jobs.repeatPlaceholder')"
clearable
style="width: 100%"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ isEdit ? t('common.update') : t('common.create') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NSpin, NEmpty, NCollapse, NCollapseItem } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { listCronRuns, readCronRun } from '@/api/hermes/cron-history'
import type { RunEntry, RunDetail } from '@/api/hermes/cron-history'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
const props = defineProps<{
selectedJobId: string | null
jobNameMap: Record<string, string>
profileKey: string
}>()
const { t } = useI18n()
const loading = ref(false)
const runs = ref<RunEntry[]>([])
const expandedContent = ref<Record<string, string>>({})
const loadingContent = ref<Record<string, boolean>>({})
const filteredRuns = computed(() => {
if (!props.selectedJobId) return runs.value
return runs.value.filter(r => r.jobId === props.selectedJobId)
})
async function fetchRuns() {
loading.value = true
try {
runs.value = await listCronRuns(props.selectedJobId ?? undefined)
} catch (err) {
console.error('Failed to fetch cron runs:', err)
runs.value = []
} finally {
loading.value = false
}
}
async function handleExpand(key: string | number | Array<string | number>) {
// accordion mode emits a single value; non-accordion emits an array
const keys = Array.isArray(key) ? key : key != null ? [key] : []
for (const raw of keys) {
const k = String(raw)
if (expandedContent.value[k] || loadingContent.value[k]) continue
const run = filteredRuns.value.find(r => `${r.jobId}/${r.fileName}` === k)
if (!run) continue
loadingContent.value[k] = true
try {
const detail: RunDetail = await readCronRun(run.jobId, run.fileName)
expandedContent.value[k] = detail.content
} catch (err) {
expandedContent.value[k] = `[Error loading content]`
} finally {
loadingContent.value[k] = false
}
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
function getJobName(jobId: string): string {
return props.jobNameMap[jobId] || jobId
}
watch(() => [props.selectedJobId, props.profileKey], () => {
expandedContent.value = {}
fetchRuns()
}, { immediate: true })
</script>
<template>
<div class="run-history">
<div class="history-header">
<span class="history-title">{{ t('jobs.runHistory.title') }}</span>
<span class="history-count">{{ filteredRuns.length }} {{ t('jobs.runHistory.runs') }}</span>
</div>
<div class="history-body">
<NSpin :show="loading">
<NEmpty v-if="!loading && filteredRuns.length === 0" :description="t('jobs.runHistory.noRuns')" />
<NCollapse
v-else
accordion
@update:expanded-names="handleExpand"
>
<NCollapseItem
v-for="run in filteredRuns"
:key="`${run.jobId}/${run.fileName}`"
:title="`${getJobName(run.jobId)} — ${run.runTime}`"
:name="`${run.jobId}/${run.fileName}`"
>
<template #header-extra>
<span class="run-meta">{{ formatSize(run.size) }}</span>
</template>
<NSpin v-if="loadingContent[`${run.jobId}/${run.fileName}`]" size="small" />
<MarkdownRenderer v-else-if="expandedContent[`${run.jobId}/${run.fileName}`]" :content="expandedContent[`${run.jobId}/${run.fileName}`]" />
</NCollapseItem>
</NCollapse>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.run-history {
height: 100%;
display: flex;
flex-direction: column;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid $border-light;
flex-shrink: 0;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.history-count {
font-size: 12px;
color: $text-muted;
}
.history-body {
flex: 1;
overflow-y: auto;
padding: 8px 20px 20px;
}
.run-meta {
font-size: 11px;
color: $text-muted;
font-family: $font-code;
}
</style>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
selectedJobId: string | null
}>()
const emit = defineEmits<{
edit: [jobId: string]
select: [jobId: string | null]
}>()
const { t } = useI18n()
const jobsStore = useJobsStore()
function handleSelect(jobId: string) {
emit('select', props.selectedJobId === jobId ? null : jobId)
}
function handleDeselect() {
if (props.selectedJobId) {
emit('select', null)
}
}
</script>
<template>
<div v-if="jobsStore.jobs.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<p>{{ t('jobs.noJobs') }}</p>
</div>
<div v-else class="jobs-grid">
<JobCard
v-for="job in jobsStore.jobs"
:key="job.id"
:job="job"
:selected="selectedJobId === (job.job_id || job.id)"
@edit="emit('edit', job.id)"
@select="handleSelect"
/>
</div>
<!-- Click outside cards to deselect -->
<div
v-if="selectedJobId"
class="deselect-overlay"
@click="handleDeselect"
/>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.jobs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px;
}
.deselect-overlay {
display: none;
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCollapse, NCollapseItem } from 'naive-ui'
import KanbanTaskCard from './KanbanTaskCard.vue'
import type { KanbanTask, KanbanTaskStatus } from '@/api/hermes/kanban'
const props = defineProps<{
status: KanbanTaskStatus
tasks: KanbanTask[]
}>()
const emit = defineEmits<{
taskClick: [taskId: string]
}>()
const { t } = useI18n()
const statusLabel = computed(() => t(`kanban.columns.${props.status}`, props.status))
const statusCount = computed(() => props.tasks.length)
const statusIcon = computed(() => {
switch (props.status) {
case 'todo': return '○'
case 'ready': return '◎'
case 'running': return '●'
case 'blocked': return '⊘'
case 'done': return '✓'
default: return '○'
}
})
const headerTitle = computed(() => `${statusIcon.value} ${statusLabel.value} (${statusCount.value})`)
</script>
<template>
<div class="kanban-column">
<NCollapse :default-expanded-names="[status]" display-directive="show">
<NCollapseItem :title="headerTitle" :name="status">
<KanbanTaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
@click="emit('taskClick', task.id)"
/>
<div v-if="tasks.length === 0" class="column-empty">
{{ t('kanban.noTasks') }}
</div>
</NCollapseItem>
</NCollapse>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-column {
flex: 1 1 calc(20% - 12px);
min-width: 200px;
background-color: rgba(var(--accent-primary-rgb), 0.02);
border-radius: $radius-md;
border: 1px solid $border-light;
:deep(.n-collapse) {
--n-title-font-size: 13px;
--n-title-font-weight: 600;
}
:deep(.n-collapse-item__header-main) {
color: $text-primary;
}
:deep(.n-collapse-item__content-wrapper) {
padding: 0 10px 10px;
}
:deep(.n-collapse-item) {
display: flex;
flex-direction: column;
}
}
.column-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 60px;
font-size: 12px;
color: $text-muted;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useKanbanStore } from '@/stores/hermes/kanban'
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
const emit = defineEmits<{
close: []
created: []
}>()
const { t } = useI18n()
const message = useMessage()
const kanbanStore = useKanbanStore()
const title = ref('')
const body = ref('')
const assignee = ref<string | null>(null)
const priority = ref<number | null>(null)
const saving = ref(false)
const priorityOptions = computed(() => [
{ label: t('kanban.card.priority.low'), value: 1 },
{ label: t('kanban.card.priority.medium'), value: 2 },
{ label: t('kanban.card.priority.high'), value: 3 },
])
const assigneeOptions = computed(() => {
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
.map(a => ({ label: a.name, value: a.name }))
})
async function handleSubmit() {
if (!title.value.trim()) {
message.warning(t('kanban.form.titleRequired'))
return
}
saving.value = true
try {
await kanbanStore.createTask({
title: title.value.trim(),
body: body.value.trim() || undefined,
assignee: assignee.value || undefined,
priority: priority.value ?? undefined,
})
message.success(t('kanban.message.taskCreated'))
emit('created')
emit('close')
} catch (err: any) {
message.error(err.message)
} finally {
saving.value = false
}
}
</script>
<template>
<NModal :show="true" preset="dialog" :title="t('kanban.createTask')" style="width: 480px;" @close="emit('close')">
<NForm label-placement="top">
<NFormItem :label="t('kanban.form.title')">
<NInput v-model:value="title" :placeholder="t('kanban.form.titlePlaceholder')" />
</NFormItem>
<NFormItem :label="t('kanban.form.body')">
<NInput v-model:value="body" type="textarea" :rows="3" :placeholder="t('kanban.form.bodyPlaceholder')" />
</NFormItem>
<NFormItem :label="t('kanban.form.assignee')">
<NSelect v-model:value="assignee" :options="assigneeOptions" :placeholder="t('kanban.form.selectAssignee')" clearable />
</NFormItem>
<NFormItem :label="t('kanban.form.priority')">
<NSelect v-model:value="priority" :options="priorityOptions" :placeholder="t('kanban.form.selectPriority')" clearable />
</NFormItem>
</NForm>
<template #action>
<NButton @click="emit('close')">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="saving" @click="handleSubmit">{{ t('common.create') }}</NButton>
</template>
</NModal>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NTooltip } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
import type { KanbanTask } from '@/api/hermes/kanban'
import type { ProfileAvatar as ProfileAvatarData } from '@/api/hermes/profiles'
const props = defineProps<{
task: KanbanTask
assigneeAvatar?: ProfileAvatarData | null
}>()
const emit = defineEmits<{
click: [taskId: string]
}>()
const { t } = useI18n()
const timeAgo = computed(() => {
const diff = Date.now() / 1000 - props.task.created_at
if (diff < 60) return t('kanban.card.timeAgo.justNow')
if (diff < 3600) return t('kanban.card.timeAgo.minutes', { count: Math.floor(diff / 60) })
if (diff < 86400) return t('kanban.card.timeAgo.hours', { count: Math.floor(diff / 3600) })
return t('kanban.card.timeAgo.days', { count: Math.floor(diff / 86400) })
})
const priorityLabel = computed(() => {
if (props.task.priority >= 3) return 'high'
if (props.task.priority === 2) return 'medium'
return 'low'
})
const priorityText = computed(() => {
return t(`kanban.card.priority.${priorityLabel.value}`)
})
</script>
<template>
<div class="kanban-task-card" :class="`status-${task.status}`" @click="emit('click', task.id)">
<div class="card-title">{{ task.title }}</div>
<div class="card-meta">
<NTooltip v-if="task.assignee" trigger="hover">
<template #trigger>
<span class="meta-tag assignee-tag">
<ProfileAvatar
class="assignee-profile-avatar"
:name="task.assignee"
:avatar="assigneeAvatar"
:size="18"
aria-hidden="true"
/>
<span>{{ task.assignee }}</span>
</span>
</template>
{{ t('kanban.card.assigneeTooltip') }}
</NTooltip>
<span v-if="task.priority >= 2" class="meta-tag priority-tag" :class="priorityLabel">{{ priorityText }}</span>
<span class="meta-time">{{ timeAgo }}</span>
</div>
<div v-if="task.body" class="card-body-preview">{{ task.body.slice(0, 80) }}{{ task.body.length > 80 ? '...' : '' }}</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.kanban-task-card {
--kanban-card-status-color: #64748b;
background-color: $bg-card;
border: 1px solid $border-color;
border-left: 3px solid var(--kanban-card-status-color);
border-radius: $radius-md;
padding: 12px;
cursor: pointer;
transition: border-color $transition-fast, box-shadow $transition-fast;
&.status-triage { --kanban-card-status-color: #94a3b8; }
&.status-todo { --kanban-card-status-color: #38bdf8; }
&.status-ready { --kanban-card-status-color: #f59e0b; }
&.status-running { --kanban-card-status-color: #2563eb; }
&.status-blocked { --kanban-card-status-color: #ef4444; }
&.status-done { --kanban-card-status-color: #22c55e; }
&.status-archived { --kanban-card-status-color: #64748b; }
&:hover {
border-color: var(--kanban-card-status-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
}
.card-title {
font-size: 13px;
font-weight: 600;
color: $text-primary;
line-height: 1.4;
word-break: break-word;
}
.card-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
.assignee-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: rgba(var(--accent-primary-rgb), 0.1);
color: $accent-primary;
padding-left: 2px;
}
.assignee-profile-avatar {
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.28);
}
.priority-tag {
&.high {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.medium {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.low {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
}
.meta-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.card-body-preview {
font-size: 12px;
color: $text-muted;
margin-top: 6px;
line-height: 1.4;
}
</style>
@@ -0,0 +1,704 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { NDrawer, NDrawerContent, NButton, NSelect, NInput, NSpin, NModal, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { request } from '@/api/client'
import { getTask } from '@/api/hermes/kanban'
import { useKanbanStore } from '@/stores/hermes/kanban'
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import type { Session, Message } from '@/stores/hermes/chat'
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
const props = defineProps<{
taskId: string | null
}>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { t } = useI18n()
const router = useRouter()
const message = useMessage()
const kanbanStore = useKanbanStore()
const detail = ref<KanbanTaskDetail | null>(null)
const loading = ref(false)
const assignProfile = ref<string | null>(null)
const blockReason = ref('')
const showBlockInput = ref(false)
const completeSummary = ref('')
const showCompleteInput = ref(false)
const showMessagesModal = ref(false)
const completionSummary = computed(() => {
if (!detail.value) return ''
return detail.value.task.result || detail.value.latest_summary || ''
})
const localizedTaskStatus = computed(() => {
if (!detail.value) return ''
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
})
const canMutateTask = computed(() => {
const status = detail.value?.task.status
return status !== 'done' && status !== 'archived'
})
const sessionResults = ref<any[]>([])
const sessionLoading = ref(false)
const showSessions = ref(false)
const latestRunProfile = computed(() => {
if (!detail.value) return null
return [...detail.value.runs].reverse().find(run => run.profile)?.profile || null
})
async function searchTaskSessions() {
if (!detail.value) return
const profile = latestRunProfile.value
if (!profile) return
showSessions.value = !showSessions.value
if (!showSessions.value) return
sessionLoading.value = true
try {
const res = await request<{ results: any[] }>(
`/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}&board=${encodeURIComponent(kanbanStore.selectedBoard)}`
)
sessionResults.value = res.results
} catch {
sessionResults.value = []
} finally {
sessionLoading.value = false
}
}
function openResultDetail() {
if (detail.value?.session) {
showMessagesModal.value = true
}
}
const historySession = computed<Session | null>(() => {
const s = detail.value?.session
if (!s) return null
return {
id: s.id,
title: s.title || '',
source: s.source,
messages: s.messages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({
id: String(m.id),
role: m.role as Message['role'],
content: m.content,
timestamp: m.timestamp,
})),
createdAt: s.started_at,
updatedAt: s.ended_at || s.started_at,
model: s.model,
messageCount: s.messages.length,
endedAt: s.ended_at,
}
})
const assigneeOptions = computed(() => {
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
.map(a => ({ label: a.name, value: a.name }))
})
watch(() => [props.taskId, kanbanStore.selectedBoard] as const, async ([id, board]) => {
if (!id) {
detail.value = null
return
}
loading.value = true
try {
const nextDetail = await getTask(id, { board })
if (props.taskId === id && kanbanStore.selectedBoard === board) {
detail.value = nextDetail
}
} catch (err: any) {
if (props.taskId === id && kanbanStore.selectedBoard === board) {
message.error(t('kanban.message.loadFailed'))
}
} finally {
if (props.taskId === id && kanbanStore.selectedBoard === board) {
loading.value = false
}
}
}, { immediate: true })
function formatTime(ts: number | null) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
async function handleComplete() {
if (!props.taskId) return
if (!showCompleteInput.value) {
showCompleteInput.value = true
return
}
try {
await kanbanStore.completeTasks([props.taskId], completeSummary.value.trim() || undefined)
message.success(t('kanban.message.taskCompleted'))
showCompleteInput.value = false
completeSummary.value = ''
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleBlock() {
if (!props.taskId || !blockReason.value.trim()) return
try {
await kanbanStore.blockTask(props.taskId, blockReason.value.trim())
message.success(t('kanban.message.taskBlocked'))
showBlockInput.value = false
blockReason.value = ''
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleUnblock() {
if (!props.taskId) return
try {
await kanbanStore.unblockTasks([props.taskId])
message.success(t('kanban.message.taskUnblocked'))
emit('updated')
emit('close')
} catch (err: any) {
message.error(err.message)
}
}
async function handleAssign() {
if (!props.taskId || !assignProfile.value) return
try {
await kanbanStore.assignTask(props.taskId, assignProfile.value)
message.success(t('kanban.message.taskAssigned'))
assignProfile.value = null
if (detail.value) {
detail.value = await getTask(props.taskId, { board: kanbanStore.selectedBoard })
}
emit('updated')
} catch (err: any) {
message.error(err.message)
}
}
</script>
<template>
<NDrawer :show="!!taskId" :width="420" placement="right" @update:show="(v: boolean) => { if (!v) emit('close') }">
<NDrawerContent :title="detail?.task.title || ''" closable>
<NSpin :show="loading">
<template v-if="detail">
<!-- Metadata -->
<div class="detail-section">
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.status') }}</span>
<span class="detail-value status-badge" :class="detail.task.status">{{ localizedTaskStatus }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.assignee') }}</span>
<span class="detail-value">{{ detail.task.assignee || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.priority') }}</span>
<span class="detail-value">{{ detail.task.priority }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.tenant') }}</span>
<span class="detail-value">{{ detail.task.tenant || '—' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">{{ t('kanban.detail.createdAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.created_at) }}</span>
</div>
<div v-if="detail.task.started_at" class="detail-row">
<span class="detail-label">{{ t('kanban.detail.startedAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.started_at) }}</span>
</div>
<div v-if="detail.task.completed_at" class="detail-row">
<span class="detail-label">{{ t('kanban.detail.completedAt') }}</span>
<span class="detail-value">{{ formatTime(detail.task.completed_at) }}</span>
</div>
</div>
<!-- Body -->
<div v-if="detail.task.body" class="detail-section">
<div class="section-title">{{ t('kanban.form.body') }}</div>
<div class="detail-body">{{ detail.task.body }}</div>
</div>
<!-- Result / Summary -->
<div v-if="completionSummary" class="detail-section">
<div class="section-title">{{ t('kanban.detail.result') }}</div>
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
</div>
<!-- Actions (only for active, mutable tasks) -->
<div v-if="canMutateTask" class="detail-section">
<div class="section-title">{{ t('kanban.action.title') }}</div>
<div class="action-group">
<template v-if="!showCompleteInput">
<NButton size="small" @click="showCompleteInput = true">
{{ t('kanban.action.complete') }}
</NButton>
</template>
<div v-else class="complete-input">
<NInput v-model:value="completeSummary" size="small" :placeholder="t('kanban.action.completeSummary')" />
<NButton size="small" type="primary" @click="handleComplete">{{ t('common.ok') }}</NButton>
<NButton size="small" @click="showCompleteInput = false; completeSummary = ''">{{ t('common.cancel') }}</NButton>
</div>
<template v-if="detail.task.status === 'blocked'">
<NButton size="small" @click="handleUnblock">{{ t('kanban.action.unblock') }}</NButton>
</template>
<template v-else>
<NButton v-if="!showBlockInput" size="small" @click="showBlockInput = true">{{ t('kanban.action.block') }}</NButton>
<div v-else class="block-input">
<NInput v-model:value="blockReason" size="small" :placeholder="t('kanban.action.blockReason')" />
<NButton size="small" type="primary" @click="handleBlock">{{ t('common.ok') }}</NButton>
</div>
</template>
</div>
<div v-if="detail.task.status !== 'running'" class="assign-group">
<NSelect v-model:value="assignProfile" :options="assigneeOptions" size="small" :placeholder="t('kanban.action.assignTo')" style="flex: 1;" />
<NButton size="small" :disabled="!assignProfile" @click="handleAssign">{{ t('kanban.action.assign') }}</NButton>
</div>
</div>
<!-- Related Sessions -->
<div v-if="detail.runs.length > 0" class="detail-section">
<div class="section-title" style="cursor: pointer;" @click="searchTaskSessions">
{{ t('kanban.detail.sessions') }}
<NSpin v-if="sessionLoading" :size="12" style="margin-left: 6px;" />
</div>
<div v-if="showSessions && sessionResults.length > 0" class="session-list">
<div v-for="session in sessionResults" :key="session.id" class="session-item" @click="router.push({ name: 'hermes.chat', query: { session: session.id } })">
<div class="session-title">{{ session.title || session.id }}</div>
<div class="session-meta">
<span>{{ session.source }}</span>
<span>{{ session.model }}</span>
<span>{{ formatTime(session.started_at) }}</span>
</div>
</div>
</div>
<div v-if="showSessions && !sessionLoading && sessionResults.length === 0" class="column-empty">{{ t('kanban.detail.noSessions') }}</div>
</div>
<!-- Runs -->
<div v-if="detail.runs.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.runs') }}</div>
<div v-for="run in detail.runs" :key="run.id" class="run-item">
<div class="run-header">
<span class="run-status" :class="run.status">{{ run.status }}</span>
<span class="run-profile">{{ run.profile || '—' }}</span>
<span class="run-time">{{ formatTime(run.started_at) }}</span>
</div>
<div v-if="run.summary" class="run-summary">{{ run.summary }}</div>
<div v-if="run.error" class="run-error">{{ run.error }}</div>
</div>
</div>
<!-- Comments -->
<div v-if="detail.comments.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.comments') }}</div>
<div v-for="comment in detail.comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-time">{{ formatTime(comment.created_at) }}</span>
</div>
<div class="comment-body">{{ comment.body }}</div>
</div>
</div>
<!-- Events -->
<div v-if="detail.events.length > 0" class="detail-section">
<div class="section-title">{{ t('kanban.detail.events') }}</div>
<div v-for="event in detail.events.slice(-10)" :key="event.id" class="event-item">
<span class="event-kind">{{ event.kind }}</span>
<span class="event-time">{{ formatTime(event.created_at) }}</span>
</div>
</div>
</template>
</NSpin>
</NDrawerContent>
</NDrawer>
<!-- Session messages modal (click result summary) -->
<NModal v-if="historySession" :show="showMessagesModal" preset="card" :title="detail?.task.title || ''" :style="{ width: '900px', maxWidth: 'calc(100vw - 48px)' }" @close="showMessagesModal = false">
<div class="messages-modal-body">
<HistoryMessageList :session="historySession" />
</div>
</NModal>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.detail-section {
margin-bottom: 20px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.result-summary {
cursor: pointer;
border-radius: $radius-sm;
padding: 8px 10px;
background: rgba(var(--accent-primary-rgb), 0.04);
border: 1px solid $border-light;
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
transition: border-color $transition-fast;
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
}
.result-detail {
margin-top: 10px;
padding: 10px;
background: rgba(var(--accent-primary-rgb), 0.02);
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.meta-label {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.3px;
margin: 8px 0 4px;
&:first-child { margin-top: 0; }
}
.meta-list {
list-style: none;
padding: 0;
margin: 0;
li {
font-size: 12px;
color: $text-secondary;
padding: 2px 0;
code {
font-family: $font-code;
font-size: 11px;
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 4px;
border-radius: 3px;
word-break: break-all;
}
}
}
.meta-kv {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-kv-row {
display: flex;
gap: 8px;
font-size: 12px;
}
.meta-kv-key {
color: $text-muted;
font-family: $font-code;
font-size: 11px;
min-width: 100px;
flex-shrink: 0;
}
.meta-kv-val {
color: $text-secondary;
}
.artifact-link {
cursor: pointer;
transition: color $transition-fast;
&:hover { color: $accent-primary; }
}
.artifact-modal-body,
.messages-modal-body {
max-height: 65vh;
overflow: hidden;
padding: 4px 0;
:deep(.message-list) {
max-height: 65vh;
background: transparent;
padding: 0;
}
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid $border-light;
}
.detail-label {
font-size: 12px;
color: $text-muted;
}
.detail-value {
font-size: 12px;
color: $text-primary;
}
.status-badge {
padding: 1px 8px;
border-radius: 4px;
font-weight: 500;
&.triage {
background: rgba(148, 163, 184, 0.14);
color: #94a3b8;
}
&.todo {
background: rgba(56, 189, 248, 0.14);
color: #38bdf8;
}
&.ready {
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.running {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.blocked {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.done {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.archived {
background: rgba(100, 116, 139, 0.14);
color: #94a3b8;
}
}
.detail-body {
font-size: 13px;
color: $text-secondary;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.action-group {
display: flex;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.block-input,
.complete-input {
display: flex;
gap: 6px;
flex: 1;
}
.assign-group {
display: flex;
gap: 8px;
}
.run-item,
.comment-item {
padding: 8px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.run-header,
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.run-status {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
&.running {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.done, &.completed {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.crashed, &.failed {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
}
.run-profile,
.comment-author {
font-size: 12px;
font-weight: 500;
color: $text-primary;
}
.run-time,
.comment-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.run-summary,
.run-error,
.comment-body {
font-size: 12px;
color: $text-secondary;
line-height: 1.4;
}
.run-error {
color: $error;
}
.event-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.event-kind {
font-size: 11px;
font-family: $font-code;
color: $accent-primary;
}
.event-time {
font-size: 11px;
color: $text-muted;
margin-left: auto;
}
.session-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.session-item {
padding: 8px 10px;
border-radius: $radius-sm;
border: 1px solid $border-light;
cursor: pointer;
transition: border-color $transition-fast;
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
}
.session-title {
font-size: 13px;
font-weight: 500;
color: $text-primary;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: $text-muted;
}
.session-messages {
display: flex;
flex-direction: column;
gap: 10px;
}
.session-msg {
padding: 10px 12px;
border-radius: $radius-sm;
border: 1px solid $border-light;
&.user {
background: rgba(var(--accent-primary-rgb), 0.04);
}
&.assistant {
background: transparent;
}
}
.session-msg-role {
font-size: 11px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
margin-bottom: 6px;
}
.session-msg-content {
font-size: 13px;
color: $text-secondary;
line-height: 1.5;
:deep(p) {
margin: 0 0 8px;
&:last-child { margin-bottom: 0; }
}
}
</style>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NSwitch, NPopconfirm } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import type { McpServerInfo } from '@/api/hermes/mcp'
const props = defineProps<{
server: McpServerInfo
toolsByServer: Record<string, Array<{ name: string; description?: string }>>
}>()
const emit = defineEmits<{
edit: [server: McpServerInfo]
test: [server: McpServerInfo]
reload: [name: string]
remove: [server: McpServerInfo]
toggleEnabled: [server: McpServerInfo]
manageTools: [server: McpServerInfo]
}>()
const { t } = useI18n()
function statusClass(server: McpServerInfo) {
if (server.raw_config.enabled === false) return 'disabled'
return server.connected ? 'connected' : 'disconnected'
}
function statusLabel(server: McpServerInfo) {
if (server.raw_config.enabled === false) return t('mcp.disabledStatus')
return server.connected ? t('mcp.connectedStatus') : t('mcp.disconnectedStatus')
}
const tools = computed(() => props.toolsByServer[props.server.name] || [])
const MAX_VISIBLE_TOOLS = 20
</script>
<template>
<div class="mcp-card" :class="{ disconnected: !server.connected, disabled: server.raw_config.enabled === false }">
<!-- 第一行标题 + 标签 -->
<div class="card-header">
<h3 class="server-name">{{ server.name }}</h3>
<div class="server-badges">
<span class="type-badge transport">{{ server.transport }}</span>
<span class="type-badge" :class="statusClass(server)">{{ statusLabel(server) }}</span>
</div>
</div>
<!-- 第二行工具列表 + 数量 -->
<div class="card-body">
<div v-if="server.error" class="error-row">
<span class="error-text">{{ server.error }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('mcp.toolList') }}</span>
<span class="info-value">
{{ server.tools_registered }}/{{ server.tools }}{{ t('mcp.count') }}{{ t('mcp.tools') }}
</span>
</div>
<!-- 工具标签列表 -->
<div v-if="server.tools > 0" class="tools-list">
<span
v-for="tool in tools.slice(0, MAX_VISIBLE_TOOLS)"
:key="tool.name"
class="tool-tag"
:title="tool.description"
>
{{ tool.name }}
</span>
<span v-if="tools.length > MAX_VISIBLE_TOOLS" class="tool-tag tool-tag-more">
+{{ tools.length - MAX_VISIBLE_TOOLS }} {{ t('mcp.more') }}
</span>
</div>
<div v-else class="tools-empty">
<span class="muted">{{ t('mcp.zeroTools') }}</span>
</div>
</div>
<!-- 底部按钮 + 开关 -->
<div class="card-footer">
<div class="card-actions">
<NButton size="tiny" quaternary @click="emit('edit', server)">{{ t('mcp.edit') }}</NButton>
<NButton size="tiny" quaternary :disabled="!server.connected" @click="emit('manageTools', server)">{{ t('mcp.manageTools') }}</NButton>
<NButton size="tiny" quaternary @click="emit('test', server)">{{ t('mcp.test') }}</NButton>
<NButton size="tiny" quaternary @click="emit('reload', server.name)">{{ t('mcp.reload') }}</NButton>
<NPopconfirm @positive-click="emit('remove', server)">
<template #trigger>
<NButton size="tiny" quaternary type="error">{{ t('mcp.remove') }}</NButton>
</template>
{{ t('mcp.confirmRemove', { name: server.name }) }}
</NPopconfirm>
</div>
<NSwitch
:value="server.raw_config.enabled !== false"
size="small"
@update:value="() => emit('toggleEnabled', server)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.mcp-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
&.disconnected {
border-color: rgba(var(--error-rgb), 0.3);
}
&.disabled {
opacity: 0.7;
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.server-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.server-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
&.transport {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.connected {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.disconnected {
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
&.disabled {
background: rgba(var(--text-muted-rgb, 128,128,128), 0.12);
color: $text-muted;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.error-row {
margin-bottom: 4px;
}
.error-text {
color: $error;
font-size: 11px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.tools-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
height: 88px;
overflow-y: auto;
align-content: flex-start;
}
.tool-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
font-size: 10px;
font-family: $font-code;
padding: 2px 6px;
border-radius: 3px;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.16);
}
&-more {
background: rgba(var(--accent-primary-rgb), 0.15);
color: $accent-primary;
font-weight: 500;
}
}
.tools-empty {
height: 88px;
display: flex;
align-items: center;
justify-content: center;
}
.muted {
color: $text-muted;
font-size: 12px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid $border-light;
padding-top: 10px;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startCodexLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err.message || ''
// Try to extract friendly error from response
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollCodexLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.codexApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.codexCopyCode'))
else message.error(t('models.codexCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.codexLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="codex-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="codex-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="codex-login__state">
<p class="codex-login__hint">{{ t('models.codexWaiting') }}</p>
<div class="codex-login__code" @click="copyCode">
<span class="codex-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.codexOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="codex-login__state codex-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.codexApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="codex-login__state">
<p class="codex-login__error">{{ t('models.codexExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="codex-login__state">
<p class="codex-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.codex-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.codex-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.codex-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.codex-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.codex-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.codex-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.codex-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startCopilotLogin, pollCopilotLogin } from '@/api/hermes/copilot-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startCopilotLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err?.message || ''
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollCopilotLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.copilotApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'denied') {
status.value = 'error'
errorMessage.value = t('models.copilotDenied')
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.copilotCopyCode'))
else message.error(t('models.copilotCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.copilotLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="copilot-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="copilot-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="copilot-login__state">
<p class="copilot-login__hint">{{ t('models.copilotWaiting') }}</p>
<div class="copilot-login__code" @click="copyCode">
<span class="copilot-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.copilotOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="copilot-login__state copilot-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.copilotApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="copilot-login__state">
<p class="copilot-login__error">{{ t('models.copilotExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="copilot-login__state">
<p class="copilot-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.copilot-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.copilot-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.copilot-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.copilot-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.copilot-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.copilot-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.copilot-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const userCode = ref('')
const verificationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startNousLogin()
userCode.value = data.user_code
verificationUrl.value = data.verification_url
sessionId.value = data.session_id
status.value = 'waiting'
startPolling()
} catch (err: any) {
status.value = 'error'
const msg = err.message || ''
try {
const match = msg.match(/\{[\s\S]*\}$/)
if (match) {
const body = JSON.parse(match[0])
errorMessage.value = body.error || msg
} else {
errorMessage.value = msg
}
} catch {
errorMessage.value = msg
}
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollNousLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.nousApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'denied') {
status.value = 'error'
errorMessage.value = t('models.nousDenied')
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 3000)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
async function copyCode() {
const ok = await copyToClipboard(userCode.value)
if (ok) message.success(t('models.nousCopyCode'))
else message.error(t('models.nousCopyCode') + ' ✗')
}
function openLink() {
window.open(verificationUrl.value, '_blank')
}
function retry() {
status.value = 'idle'
userCode.value = ''
verificationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(() => {
stopPolling()
})
// Auto-start when modal opens
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.nousLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="nous-login">
<!-- Idle / Loading -->
<div v-if="status === 'idle' || status === 'loading'" class="nous-login__state">
<NSpin size="small" />
</div>
<!-- Waiting for authorization -->
<div v-else-if="status === 'waiting'" class="nous-login__state">
<p class="nous-login__hint">{{ t('models.nousWaiting') }}</p>
<div class="nous-login__code" @click="copyCode">
<span class="nous-login__code-text">{{ userCode }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</div>
<NButton type="primary" block @click="openLink">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</template>
{{ t('models.nousOpenLink') }}
</NButton>
</div>
<!-- Approved -->
<div v-else-if="status === 'approved'" class="nous-login__state nous-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.nousApproved') }}</p>
</div>
<!-- Expired -->
<div v-else-if="status === 'expired'" class="nous-login__state">
<p class="nous-login__error">{{ t('models.nousExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="nous-login__state">
<p class="nous-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.nous-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.nous-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.nous-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.nous-login__code {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--n-border-color, #e0e0e6);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
background: var(--n-color, #fafafa);
&:hover {
border-color: var(--n-primary-color, #18a058);
}
}
.nous-login__code-text {
font-size: 28px;
font-weight: 700;
font-family: monospace;
letter-spacing: 4px;
color: var(--n-text-color, inherit);
}
.nous-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.nous-login__error {
color: #d03050;
text-align: center;
font-size: 13px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,603 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NButton, NCheckbox, NCheckboxGroup, NModal, NInput, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat'
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ provider: AvailableModelGroup }>()
const { t } = useI18n()
const modelsStore = useModelsStore()
const appStore = useAppStore()
const chatStore = useChatStore()
const message = useMessage()
const dialog = useDialog()
const isCustom = computed(() => !props.provider.builtin && props.provider.provider.startsWith('custom:'))
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showAliasListModal = ref(false)
const showAliasModal = ref(false)
const aliasProvider = ref('')
const aliasModel = ref('')
const aliasInput = ref('')
const showVisibilityModal = ref(false)
const visibilitySaving = ref(false)
const selectedVisibleModels = ref<string[]>([])
const sourceProvider = computed(() => modelsStore.allProviders.find(g => g.provider === props.provider.provider))
const allModels = computed(() => props.provider.available_models?.length ? props.provider.available_models : (sourceProvider.value?.models?.length ? sourceProvider.value.models : props.provider.models))
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
function isDefaultModel(model: string) {
return isDefaultProvider.value && modelsStore.defaultModel === model
}
function modelAlias(model: string) {
return appStore.getModelAlias(model, props.provider.provider)
}
function modelDisplayName(model: string) {
return appStore.displayModelName(model, props.provider.provider)
}
function openAliasEditor(model: string) {
aliasProvider.value = props.provider.provider
aliasModel.value = model
aliasInput.value = appStore.getModelAlias(model, props.provider.provider)
showAliasModal.value = true
}
async function saveAlias() {
if (!aliasModel.value || !aliasProvider.value) return
try {
await appStore.setModelAlias(aliasModel.value, aliasProvider.value, aliasInput.value)
showAliasModal.value = false
} catch (e: any) {
message.error(e.message || t('models.aliasSaveFailed'))
}
}
async function clearAlias() {
aliasInput.value = ''
await saveAlias()
}
function openVisibilityModal() {
const rule = appStore.getProviderVisibility(props.provider.provider)
selectedVisibleModels.value = rule.mode === 'include' ? allModels.value.filter(m => rule.models.includes(m)) : [...allModels.value]
showVisibilityModal.value = true
}
async function handleVisibilitySave() {
if (selectedVisibleModels.value.length === 0) {
message.error(t('models.visibilitySelectOne'))
return
}
visibilitySaving.value = true
try {
const selected = selectedVisibleModels.value.filter(m => allModels.value.includes(m))
const mode = selected.length === allModels.value.length ? 'all' : 'include'
await appStore.setModelVisibility(props.provider.provider, { mode, models: selected })
await modelsStore.fetchProviders()
showVisibilityModal.value = false
message.success(t('models.visibilitySaved'))
} catch (e: any) {
message.error(e.message || t('models.visibilitySaveFailed'))
} finally {
visibilitySaving.value = false
}
}
function resetVisibility() {
selectedVisibleModels.value = [...allModels.value]
}
function clearVisibility() {
selectedVisibleModels.value = []
}
async function handleDelete() {
let copilotMsg = ''
if (isCopilot.value) {
// 提前查 source,让用户清楚移除会不会影响 VS Code/gh CLI 等其他工具的登录态
try {
const status = await checkCopilotToken()
if (status.source === 'env') copilotMsg = t('models.copilotDeleteHintEnv')
else if (status.source === 'gh-cli') copilotMsg = t('models.copilotDeleteHintGhCli')
else if (status.source === 'apps-json') copilotMsg = t('models.copilotDeleteHintAppsJson')
} catch { /* ignore — fall back to generic confirm copy */ }
}
dialog.warning({
title: t('models.deleteProvider'),
content: isCopilot.value && copilotMsg
? `${t('models.deleteConfirm', { name: displayName.value })}\n\n${copilotMsg}`
: t('models.deleteConfirm', { name: displayName.value }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
deleting.value = true
try {
if (isCopilot.value) {
// Copilot 走显式 opt-in 模型:disable 把 enabled 置 false
// 仅当 token 来自 ~/.hermes/.env 时才清掉,gh-cli / apps.json 不动。
await disableCopilot()
// 服务端会在默认模型属于 copilot 时清掉 model.default,这里再清理本地
// 会话级 model/provider,避免 Chat 页继续显示已下架的 copilot 模型。
chatStore.clearProviderFromSessions('copilot')
await modelsStore.fetchProviders()
} else {
await modelsStore.removeProvider(props.provider.provider)
}
// 删完之后若已没有默认模型,自动从剩余 provider 里挑一个,避免 chat 页
// "无默认模型"的尴尬态。与 hermes CLI `model` 子命令的隐含行为对齐。
if (!appStore.selectedModel && appStore.modelGroups.length > 0) {
const first = appStore.modelGroups.find(g => g.models.length > 0)
if (first) {
await appStore.switchModel(first.models[0], first.provider)
}
}
message.success(t('models.providerDeleted'))
} catch (e: any) {
message.error(e.message)
} finally {
deleting.value = false
}
},
})
}
</script>
<template>
<div class="provider-card">
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<div class="provider-badges">
<span v-if="isDefaultProvider" class="type-badge default">{{ t('models.currentDefault') }}</span>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('models.provider') }}</span>
<code class="info-value mono">{{ provider.provider }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('models.baseUrl') }}</span>
<code class="info-value mono">{{ provider.base_url }}</code>
</div>
<div class="info-row models-row">
<span class="info-label">{{ t('models.models') }}</span>
<span class="info-value models-count">
{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}
</span>
</div>
<div class="models-list">
<button
v-for="model in provider.models.slice(0, 20)"
:key="model"
class="model-tag model-tag-button"
:class="{ default: isDefaultModel(model) }"
type="button"
:title="t('models.aliasTitleFor', { model })"
@click="openAliasEditor(model)"
>
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
</button>
<span v-if="provider.models.length > 20" class="model-tag model-tag-more">
+{{ provider.models.length - 20 }} {{ t('models.more') }}
</span>
</div>
</div>
<div class="card-actions">
<NButton size="tiny" quaternary @click="showAliasListModal = true">{{ t('models.aliasManage') }}</NButton>
<NButton size="tiny" quaternary @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</NButton>
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
<NModal
v-model:show="showAliasListModal"
preset="card"
:title="t('models.aliasManageFor', { provider: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<div class="alias-list-hint">{{ t('models.aliasHint') }}</div>
<div class="alias-list">
<div v-for="model in provider.models" :key="model" class="alias-row">
<div class="alias-row-text">
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
<code class="alias-row-id">{{ model }}</code>
</div>
<NButton size="tiny" quaternary @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</NButton>
</div>
</div>
</NModal>
<NModal
v-model:show="showAliasModal"
preset="card"
:title="aliasModel ? t('models.aliasTitleFor', { model: aliasModel }) : t('models.aliasTitle')"
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<NInput
v-model:value="aliasInput"
:placeholder="t('models.aliasPlaceholder')"
clearable
@keydown.enter="saveAlias"
/>
<div v-if="aliasModel" class="model-alias-canonical">
{{ t('models.aliasCanonical', { model: aliasModel }) }}
</div>
<div class="model-alias-hint">{{ t('models.aliasHint') }}</div>
<template #footer>
<div class="model-alias-actions">
<NButton quaternary :disabled="!appStore.getModelAlias(aliasModel, aliasProvider)" @click="clearAlias">
{{ t('models.aliasUseOriginal') }}
</NButton>
<div class="model-alias-spacer" />
<NButton @click="showAliasModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" @click="saveAlias">{{ t('common.save') }}</NButton>
</div>
</template>
</NModal>
<NModal
v-model:show="showVisibilityModal"
preset="card"
:title="t('models.manageVisibleModelsFor', { name: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="!visibilitySaving"
>
<p class="visibility-hint">{{ t('models.visibilityHint') }}</p>
<div class="visibility-count">
{{ selectedVisibleModels.length }}/{{ allModels.length }} {{ t('models.count') }}
</div>
<div class="visibility-list">
<NCheckboxGroup v-model:value="selectedVisibleModels">
<NCheckbox
v-for="model in allModels"
:key="model"
:value="model"
class="visibility-model"
>
<code>{{ modelDisplayName(model) }}</code>
<code v-if="modelAlias(model)" class="visibility-model-id">{{ model }}</code>
</NCheckbox>
</NCheckboxGroup>
</div>
<div class="visibility-actions">
<NButton size="small" quaternary :disabled="visibilitySaving" @click="resetVisibility">
{{ t('models.showAllModels') }}
</NButton>
<NButton size="small" quaternary :disabled="visibilitySaving" @click="clearVisibility">
{{ t('models.clearVisibleModels') }}
</NButton>
<div class="visibility-action-spacer" />
<NButton size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
{{ t('common.cancel') }}
</NButton>
<NButton size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
{{ t('common.save') }}
</NButton>
</div>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.provider-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.provider-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.provider-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.custom {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $warning;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 12px;
color: $text-muted;
}
.info-value {
font-size: 12px;
color: $text-secondary;
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.models-row {
margin-top: 4px;
}
.models-count {
color: $text-muted;
font-size: 12px;
}
.models-list {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
margin-top: 6px;
height: 100px;
overflow-y: auto;
align-content: flex-start;
}
.model-tag {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 22px;
font-size: 10px;
font-family: $font-code;
padding: 2px 6px;
border-radius: 3px;
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-secondary;
white-space: nowrap;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
&-more {
background: rgba(var(--accent-primary-rgb), 0.15);
color: $accent-primary;
font-weight: 500;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $text-primary;
}
}
.model-tag-button {
border: 0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.16);
color: $text-primary;
}
}
.model-tag-name,
.model-tag-id {
overflow: hidden;
text-overflow: ellipsis;
}
.model-tag-id {
color: $text-muted;
font-size: 9px;
}
.model-tag-default,
.alias-row-default {
color: $warning;
font-family: $font-ui;
font-size: 10px;
font-weight: 600;
}
.card-actions {
display: flex;
gap: 8px;
border-top: 1px solid $border-light;
padding-top: 10px;
}
.alias-list-hint,
.model-alias-hint {
color: $text-muted;
font-size: 12px;
}
.alias-list-hint {
margin-bottom: 12px;
}
.alias-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 45vh;
overflow-y: auto;
}
.alias-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border: 1px solid $border-light;
border-radius: $radius-sm;
}
.alias-row-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.alias-row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-primary;
font-family: $font-code;
font-size: 12px;
}
.alias-row-id,
.model-alias-canonical {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-muted;
font-family: $font-code;
font-size: 11px;
}
.model-alias-canonical {
margin-top: 8px;
}
.model-alias-hint {
margin-top: 6px;
}
.model-alias-actions {
display: flex;
align-items: center;
gap: 8px;
}
.model-alias-spacer {
flex: 1;
}
.visibility-hint {
margin: 0 0 10px;
color: $text-secondary;
font-size: 13px;
line-height: 1.5;
}
.visibility-count {
color: $text-muted;
font-size: 12px;
margin-bottom: 10px;
}
.visibility-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid $border-light;
border-radius: $radius-sm;
padding: 8px;
}
.visibility-model {
display: flex;
width: 100%;
padding: 4px 2px;
code {
font-family: $font-code;
font-size: 12px;
color: $text-secondary;
}
}
.visibility-model-id {
margin-left: 6px;
color: $text-muted !important;
font-size: 11px !important;
}
.visibility-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.visibility-action-spacer {
flex: 1;
}
</style>
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage, useDialog } from 'naive-ui'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
import CodexLoginModal from './CodexLoginModal.vue'
import NousLoginModal from './NousLoginModal.vue'
import CopilotLoginModal from './CopilotLoginModal.vue'
import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue'
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
import { fetchProviderModels } from '@/api/hermes/system'
import { normalizeCustomProviderBaseUrl } from '@/utils/providerBaseUrl'
const { t } = useI18n()
const emit = defineEmits<{
close: []
saved: []
}>()
const modelsStore = useModelsStore()
const message = useMessage()
const dialog = useDialog()
const showModal = ref(true)
const loading = ref(false)
const fetchingModels = ref(false)
const showCodexLogin = ref(false)
const showNousLogin = ref(false)
const showCopilotLogin = ref(false)
const showXaiLogin = ref(false)
const copilotChecking = ref(false)
const providerType = ref<'preset' | 'custom'>('preset')
const selectedPreset = ref<string | null>(null)
const formData = ref({
name: '',
base_url: '',
api_key: '',
model: '',
context_length: null as number | null,
})
const modelOptions = ref<Array<{ label: string; value: string }>>([])
const CODEX_KEY = 'openai-codex'
const NOUS_KEY = 'nous'
const COPILOT_KEY = 'copilot'
const CLIPROXYAPI_KEY = 'cliproxyapi'
const XAI_OAUTH_KEY = 'xai-oauth'
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
const ALIBABA_CODING_REGIONS = {
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
cn: 'https://coding.dashscope.aliyuncs.com/v1',
} as const
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
const isXaiOAuth = computed(() => selectedPreset.value === XAI_OAUTH_KEY)
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
const presetOptions = computed(() =>
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
)
const selectedPresetProvider = computed(() =>
selectedPreset.value ? modelsStore.allProviders.find(g => g.provider === selectedPreset.value) : null,
)
const canEditPresetBaseUrl = computed(() => !!selectedPresetProvider.value?.base_url_env)
const FUN_LINK_MAP: Record<string, string> = {
'fun-codex': 'https://apikey.fun/register?aff=LIBAPI',
'fun-claude': 'https://apikey.fun/register?aff=LIBAPI',
}
const funProviderLink = computed(() => selectedPreset.value ? FUN_LINK_MAP[selectedPreset.value] || '' : '')
function autoGenerateName(url: string): string {
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
const host = clean.split('/')[0]
if (host.includes('localhost') || host.includes('127.0.0.1')) {
return t('models.local', { host })
}
return host.charAt(0).toUpperCase() + host.slice(1)
}
watch(selectedPreset, (val) => {
formData.value.model = ''
alibabaCodingRegion.value = 'intl'
if (val) {
const group = selectedPresetProvider.value
if (group) {
formData.value.name = group.label
formData.value.base_url = group.base_url
modelOptions.value = group.models.map((m: string) => ({ label: m, value: m }))
if (group.models.length > 0) {
formData.value.model = group.models[0]
}
}
if (val === COPILOT_KEY) {
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
void triggerCopilotAdd()
} else if (val === XAI_OAUTH_KEY) {
showXaiLogin.value = true
}
}
})
watch(alibabaCodingRegion, (region) => {
if (isAlibabaCoding.value) {
formData.value.base_url = ALIBABA_CODING_REGIONS[region]
}
})
watch(() => formData.value.base_url, (url) => {
if (providerType.value === 'custom' && url.trim() && !formData.value.name) {
formData.value.name = autoGenerateName(url.trim())
}
})
watch(providerType, () => {
modelOptions.value = []
formData.value = { name: '', base_url: '', api_key: '', model: '', context_length: null }
selectedPreset.value = null
})
onMounted(() => {
if (modelsStore.providers.length === 0) {
modelsStore.fetchProviders()
}
})
async function fetchModels() {
const { base_url } = formData.value
if (!base_url.trim()) {
message.warning(t('models.enterBaseUrl'))
return
}
fetchingModels.value = true
try {
const data = await fetchProviderModels({
base_url: base_url.trim(),
api_key: formData.value.api_key.trim(),
})
modelOptions.value = data.models.map(m => ({ label: m, value: m }))
if (modelOptions.value.length > 0 && !formData.value.model) {
formData.value.model = modelOptions.value[0].value
}
message.success(t('models.foundModels', { count: modelOptions.value.length }))
} catch (e: any) {
message.error(t('models.fetchFailed') + ': ' + e.message)
} finally {
fetchingModels.value = false
}
}
async function handleSave() {
if (providerType.value === 'preset' && !selectedPreset.value) {
message.warning(t('models.selectProviderRequired'))
return
}
// Codex: 弹出授权码弹窗
if (isCodex.value) {
showCodexLogin.value = true
return
}
// Nous: 弹出 OAuth 设备码弹窗
if (isNous.value) {
showNousLogin.value = true
return
}
// Copilot: 走 token-aware 的添加流程(已有 token → 确认窗;否则 device flow
if (isCopilot.value) {
void triggerCopilotAdd()
return
}
if (isXaiOAuth.value) {
showXaiLogin.value = true
return
}
if (!formData.value.base_url.trim()) {
message.warning(t('models.baseUrlRequired'))
return
}
if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) {
message.warning(t('models.apiKeyRequired'))
return
}
if (!formData.value.model) {
message.warning(t('models.modelRequired'))
return
}
loading.value = true
try {
const providerKey = providerType.value === 'preset'
? selectedPreset.value
: null
const contextLength = formData.value.context_length ?? undefined
const baseUrl = providerType.value === 'custom'
? normalizeCustomProviderBaseUrl(formData.value.base_url)
: formData.value.base_url.trim()
await modelsStore.addProvider({
name: formData.value.name.trim(),
base_url: baseUrl,
api_key: formData.value.api_key.trim(),
model: formData.value.model,
context_length: contextLength,
providerKey,
})
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e.message)
} finally {
loading.value = false
}
}
async function handleCodexSuccess() {
showCodexLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleNousSuccess() {
showNousLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleCopilotSuccess() {
showCopilotLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
async function handleXaiSuccess() {
showXaiLogin.value = false
message.success(t('models.providerAdded'))
emit('saved')
}
function copilotSourceLabel(source: CopilotTokenSource): string {
if (source === 'env') return t('models.copilotAddSourceEnv')
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
if (source === 'apps-json') return t('models.copilotAddSourceAppsJson')
return ''
}
async function triggerCopilotAdd() {
if (copilotChecking.value) return
copilotChecking.value = true
try {
const status = await checkCopilotToken()
if (status.has_token) {
// 已能解析到 token:弹确认窗,用户点 [添加] → enable + saved
const sourceText = copilotSourceLabel(status.source)
dialog.success({
title: t('models.copilotAddDetectedTitle'),
content: sourceText
? `${t('models.copilotAddDetected')}\n\n${sourceText}`
: t('models.copilotAddDetected'),
positiveText: t('common.add'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await enableCopilot()
message.success(t('models.providerAdded'))
emit('saved')
} catch (e: any) {
message.error(e?.message ?? String(e))
}
},
onNegativeClick: () => {
selectedPreset.value = null
},
onClose: () => {
selectedPreset.value = null
},
})
} else {
// 无 tokendevice flow
showCopilotLogin.value = true
}
} catch (e: any) {
message.error(e?.message ?? String(e))
selectedPreset.value = null
} finally {
copilotChecking.value = false
}
}
function handleCopilotClose() {
showCopilotLogin.value = false
// 用户取消 Copilot 引导时,清空选择避免卡在无 api_key 状态
selectedPreset.value = null
}
function handleXaiClose() {
showXaiLogin.value = false
selectedPreset.value = null
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.addProvider')"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin && !showXaiLogin"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('models.providerType')">
<div style="display: flex; gap: 12px">
<NButton
:type="providerType === 'preset' ? 'primary' : 'default'"
size="small"
@click="providerType = 'preset'"
>
{{ t('models.preset') }}
</NButton>
<NButton
:type="providerType === 'custom' ? 'primary' : 'default'"
size="small"
@click="providerType = 'custom'"
>
{{ t('models.custom') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
<NSelect
v-model:value="selectedPreset"
:options="presetOptions"
:placeholder="t('models.chooseProvider')"
filterable
/>
<div v-if="selectedPreset && funProviderLink" class="fun-provider-hint">
<a :href="funProviderLink" target="_blank" rel="noopener noreferrer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{{ t('models.getApiKey') }}
</a>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
<NInput
v-model:value="formData.name"
:placeholder="t('models.autoGeneratedName')"
/>
</NFormItem>
<NFormItem v-if="isAlibabaCoding" :label="t('models.region')">
<NRadioGroup v-model:value="alibabaCodingRegion">
<NRadioButton value="intl">{{ t('models.regionIntl') }}</NRadioButton>
<NRadioButton value="cn">{{ t('models.regionCn') }}</NRadioButton>
</NRadioGroup>
</NFormItem>
<NFormItem v-if="!isCodex && !isNous" :label="t('models.baseUrl')" required>
<NInput
v-model:value="formData.base_url"
:placeholder="t('models.baseUrlPlaceholder')"
:disabled="providerType === 'preset' && !canEditPresetBaseUrl"
/>
</NFormItem>
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi && !isXaiOAuth">
<NInput
v-model:value="formData.api_key"
type="password"
show-password-on="click"
:placeholder="t('models.apiKeyPlaceholder')"
autocomplete="off"
/>
</NFormItem>
<NFormItem :label="t('models.defaultModel')" required>
<div style="display: flex; gap: 8px; width: 100%">
<NSelect
v-model:value="formData.model"
:options="modelOptions"
filterable
tag
:placeholder="t('models.selectOrInput')"
style="flex: 1"
/>
<NButton
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
:loading="fetchingModels"
@click="fetchModels"
>
{{ t('common.fetch') }}
</NButton>
</div>
</NFormItem>
<NFormItem v-if="providerType === 'custom'" :label="t('models.contextLength')">
<NInputNumber
v-model:value="formData.context_length as number | null"
:placeholder="t('models.contextLengthPlaceholder')"
:min="0"
clearable
style="width: 100%"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ t('common.add') }}
</NButton>
</div>
</template>
<CodexLoginModal
v-if="showCodexLogin"
@close="showCodexLogin = false"
@success="handleCodexSuccess"
/>
<NousLoginModal
v-if="showNousLogin"
@close="showNousLogin = false"
@success="handleNousSuccess"
/>
<CopilotLoginModal
v-if="showCopilotLogin"
@close="handleCopilotClose"
@success="handleCopilotSuccess"
/>
<XaiOAuthLoginModal
v-if="showXaiLogin"
@close="handleXaiClose"
@success="handleXaiSuccess"
/>
</NModal>
</template>
<style scoped lang="scss">
.fun-provider-hint {
margin-top: 6px;
font-size: 12px;
a {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
white-space: nowrap;
color: var(--accent-primary);
text-decoration: none;
opacity: 0.7;
transition: opacity 0.2s;
svg {
flex-shrink: 0;
}
&:hover { opacity: 1; }
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import ProviderCard from './ProviderCard.vue'
import { useModelsStore } from '@/stores/hermes/models'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
</script>
<template>
<div v-if="modelsStore.providers.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p>{{ t('models.noProviders') }}</p>
</div>
<div v-else class="providers-grid">
<ProviderCard
v-for="g in modelsStore.providers"
:key="g.provider"
:provider="g"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
</style>
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
const message = useMessage()
const showModal = ref(true)
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
const authorizationUrl = ref('')
const sessionId = ref('')
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
async function startLogin() {
status.value = 'loading'
errorMessage.value = ''
try {
const data = await startXaiLogin()
authorizationUrl.value = data.authorization_url
sessionId.value = data.session_id
status.value = 'waiting'
window.open(authorizationUrl.value, '_blank')
startPolling()
} catch (err: any) {
status.value = 'error'
errorMessage.value = err?.message || String(err)
message.error(errorMessage.value)
}
}
function startPolling() {
stopPolling()
pollTimer = setTimeout(async () => {
try {
const result = await pollXaiLogin(sessionId.value)
if (result.status === 'pending') {
startPolling()
} else if (result.status === 'approved') {
status.value = 'approved'
message.success(t('models.xaiApproved'))
setTimeout(() => {
showModal.value = false
setTimeout(() => emit('success'), 200)
}, 1000)
} else if (result.status === 'expired') {
status.value = 'expired'
} else if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.error || 'Unknown error'
}
} catch {
startPolling()
}
}, 2000)
}
function stopPolling() {
if (pollTimer) clearTimeout(pollTimer)
pollTimer = null
}
function handleClose() {
stopPolling()
showModal.value = false
setTimeout(() => emit('close'), 200)
}
function openLink() {
window.open(authorizationUrl.value, '_blank')
}
async function copyLink() {
const ok = await copyToClipboard(authorizationUrl.value)
if (ok) message.success(t('common.copied'))
else message.error(t('chat.copyFailed'))
}
function retry() {
status.value = 'idle'
authorizationUrl.value = ''
sessionId.value = ''
errorMessage.value = ''
startLogin()
}
onUnmounted(stopPolling)
startLogin()
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('models.xaiLoginTitle')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="status !== 'waiting'"
@after-leave="emit('close')"
>
<div class="xai-login">
<div v-if="status === 'idle' || status === 'loading'" class="xai-login__state">
<NSpin size="small" />
</div>
<div v-else-if="status === 'waiting'" class="xai-login__state">
<p class="xai-login__hint">{{ t('models.xaiWaiting') }}</p>
<NButton type="primary" block @click="openLink">
{{ t('models.xaiOpenLink') }}
</NButton>
<NButton block @click="copyLink">
{{ t('models.xaiCopyLink') }}
</NButton>
</div>
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>{{ t('models.xaiApproved') }}</p>
</div>
<div v-else-if="status === 'expired'" class="xai-login__state">
<p class="xai-login__error">{{ t('models.xaiExpired') }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
<div v-else-if="status === 'error'" class="xai-login__state">
<p class="xai-login__error">{{ errorMessage }}</p>
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
</div>
</div>
<template #footer>
<div class="modal-footer">
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.xai-login {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.xai-login__state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
min-height: 120px;
justify-content: center;
width: 100%;
}
.xai-login__hint {
font-size: 14px;
color: var(--n-text-color, inherit);
text-align: center;
line-height: 1.6;
}
.xai-login__state--success {
color: #18a058;
svg {
stroke: #18a058;
}
}
.xai-login__error {
color: #d03050;
text-align: center;
word-break: break-word;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import multiavatar from '@multiavatar/multiavatar'
import type { ProfileAvatar } from '@/api/hermes/profiles'
const props = withDefaults(defineProps<{
name: string
avatar?: ProfileAvatar | null
size?: number
}>(), {
size: 24,
})
const fallbackSeed = computed(() => props.name || 'default')
const generatedSvg = computed(() => multiavatar(props.avatar?.seed || fallbackSeed.value))
const style = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
flexBasis: `${props.size}px`,
}))
</script>
<template>
<span class="profile-avatar-view" :style="style">
<img
v-if="avatar?.type === 'image' && avatar.dataUrl"
class="profile-avatar-image"
:src="avatar.dataUrl"
alt=""
draggable="false"
>
<span v-else class="profile-avatar-svg" v-html="generatedSvg" />
</span>
</template>
<style scoped>
.profile-avatar-view {
display: inline-flex;
flex: 0 0 auto;
border-radius: 50%;
overflow: hidden;
background: var(--bg-secondary);
}
.profile-avatar-image,
.profile-avatar-svg,
.profile-avatar-svg :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.profile-avatar-image {
object-fit: cover;
}
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NButton, NTag, NSpin, useMessage, useDialog } from 'naive-ui'
import type { HermesProfile, HermesProfileDetail } from '@/api/hermes/profiles'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
import ProfileAvatar from './ProfileAvatar.vue'
const props = defineProps<{ profile: HermesProfile }>()
const emit = defineEmits<{}>()
const { t } = useI18n()
const profilesStore = useProfilesStore()
const message = useMessage()
const dialog = useDialog()
const expanded = ref(false)
const detailLoading = ref(false)
const exporting = ref(false)
const switching = ref(false)
const detail = ref<HermesProfileDetail | null>(null)
const isDefault = computed(() => props.profile.name === 'default')
async function toggleDetail() {
if (expanded.value) {
expanded.value = false
return
}
expanded.value = true
detailLoading.value = true
try {
detail.value = await profilesStore.fetchProfileDetail(props.profile.name)
} finally {
detailLoading.value = false
}
}
async function handleSwitch() {
dialog.warning({
title: t('profiles.switchTo'),
content: t('profiles.switchConfirm', { name: props.profile.name }),
positiveText: t('profiles.switchTo'),
negativeText: t('common.cancel'),
onPositiveClick: performHermesSwitch,
})
}
async function performHermesSwitch() {
switching.value = true
try {
const ok = await profilesStore.switchHermesProfile(props.profile.name)
if (ok) {
message.success(t('profiles.switchSuccess', { name: props.profile.name }))
// Reload to refresh all profile-dependent data
setTimeout(() => window.location.reload(), 500)
} else {
message.error(t('profiles.switchFailed'))
}
} finally {
switching.value = false
}
}
function handleDelete() {
dialog.warning({
title: t('profiles.delete'),
content: t('profiles.deleteConfirm', { name: props.profile.name }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
const ok = await profilesStore.deleteProfile(props.profile.name)
if (ok) {
message.success(t('profiles.deleteSuccess'))
} else {
message.error(t('profiles.deleteFailed'))
}
},
})
}
async function handleExport() {
exporting.value = true
try {
const ok = await profilesStore.exportProfile(props.profile.name)
if (ok) {
message.success(t('profiles.exportSuccess'))
} else {
message.error(t('profiles.exportFailed'))
}
} finally {
exporting.value = false
}
}
</script>
<template>
<div class="profile-card" :class="{ active: profile.active }">
<div class="card-header">
<div class="profile-title">
<ProfileAvatar :name="profile.name" :avatar="profile.avatar" :size="28" />
<h3 class="profile-name">{{ profile.name }}</h3>
</div>
<NTag v-if="profile.active" size="tiny" type="success" :bordered="false">
{{ t('profiles.active') }}
</NTag>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">{{ t('profiles.model') }}</span>
<code class="info-value mono">{{ profile.model }}</code>
</div>
</div>
<div class="card-detail-toggle" @click="toggleDetail">
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="toggle-icon"
:class="{ expanded }"
>
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="toggle-text">{{ expanded ? t('common.collapse') : t('common.expand') }}</span>
</div>
<div v-if="expanded" class="card-detail">
<NSpin :show="detailLoading" size="small">
<template v-if="detail">
<div class="info-row">
<span class="info-label">{{ t('profiles.provider') }}</span>
<span class="info-value">{{ detail.provider }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('profiles.path') }}</span>
<code class="info-value mono detail-path">{{ detail.path }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('profiles.skills') }}</span>
<span class="info-value">{{ detail.skills }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('profiles.hasEnv') }}</span>
<span class="info-value">{{ detail.hasEnv ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ t('profiles.hasSoulMd') }}</span>
<span class="info-value">{{ detail.hasSoulMd ? 'Yes' : 'No' }}</span>
</div>
</template>
</NSpin>
</div>
<div class="card-actions">
<NButton
v-if="!profile.active"
size="tiny"
:loading="switching"
quaternary
type="primary"
@click="handleSwitch"
>
{{ t('profiles.switchTo') }}
</NButton>
<NButton
size="tiny"
quaternary
type="error"
:disabled="isDefault || profile.active"
@click="handleDelete"
>
{{ t('common.delete') }}
</NButton>
<NButton size="tiny" quaternary :loading="exporting" @click="handleExport">
{{ t('profiles.export') }}
</NButton>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.profile-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
transition: border-color $transition-fast;
&:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
&.active {
border-color: rgba(var(--success-rgb), 0.4);
}
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.profile-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.profile-name {
font-size: 15px;
font-weight: 600;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
margin: 0;
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.card-detail-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 0;
cursor: pointer;
color: $text-muted;
font-size: 12px;
user-select: none;
&:hover {
color: $text-secondary;
}
}
.toggle-icon {
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.card-detail {
padding: 8px 0;
border-top: 1px solid $border-light;
margin-bottom: 8px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
}
.info-label {
font-size: 12px;
color: $text-muted;
flex-shrink: 0;
margin-right: 12px;
}
.info-value {
font-size: 12px;
color: $text-secondary;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
font-family: $font-code;
font-size: 12px;
}
.detail-path {
max-width: 260px;
}
.card-actions {
display: flex;
gap: 8px;
border-top: 1px solid $border-light;
padding-top: 10px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSwitch, NText, useMessage } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<{
close: []
saved: []
}>()
const { t } = useI18n()
const profilesStore = useProfilesStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const name = ref('')
const clone = ref(false)
const nameValidationMessage = ref('')
function handleNameInput(value: string) {
// 过滤掉不符合规则的字符,只保留小写字母、数字、下划线和连字符
const filtered = value.toLowerCase().replace(/[^a-z0-9_-]/g, '')
if (filtered !== value) {
nameValidationMessage.value = t('profiles.nameValidation')
} else {
nameValidationMessage.value = ''
}
name.value = filtered
}
async function handleSave() {
if (!name.value) {
message.warning(t('profiles.namePlaceholder'))
return
}
if (!/^[a-z0-9_-]+$/.test(name.value)) {
message.error(t('profiles.nameValidation'))
return
}
loading.value = true
try {
const res = await profilesStore.createProfile(name.value.trim(), clone.value)
if (res.success) {
const stripped = res.strippedCredentials ?? []
const disabled = res.disabledPlatforms ?? []
const cfgStripped = res.strippedConfigCredentials ?? []
if (clone.value && (stripped.length > 0 || disabled.length > 0 || cfgStripped.length > 0)) {
const parts: string[] = []
if (stripped.length > 0) parts.push(t('profiles.cloneStrippedCredentials', { count: stripped.length, list: stripped.join(', ') }))
if (disabled.length > 0) parts.push(t('profiles.cloneDisabledPlatforms', { count: disabled.length, list: disabled.join(', ') }))
if (cfgStripped.length > 0) parts.push(t('profiles.cloneStrippedConfigCredentials', { count: cfgStripped.length, list: cfgStripped.join(', ') }))
message.info(`${t('profiles.createSuccess', { name: name.value.trim() })}\n${parts.join('\n')}`, { duration: 6000 })
} else {
message.success(t('profiles.createSuccess', { name: name.value.trim() }))
}
emit('saved')
} else {
const errorMsg = res.error || t('profiles.createFailed')
message.error(errorMsg)
}
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('profiles.create')"
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('profiles.name')" required>
<NInput
v-model:value="name"
:placeholder="t('profiles.namePlaceholder')"
@input="handleNameInput"
/>
</NFormItem>
<NText v-if="nameValidationMessage" depth="3" type="warning" style="font-size: 12px;">
{{ nameValidationMessage }}
</NText>
<NFormItem :label="t('profiles.cloneFromCurrent')">
<NSwitch v-model:value="clone" />
</NFormItem>
<NText v-if="clone" depth="3" style="font-size: 12px;">
{{ t('profiles.cloneCleanupNotice') }}
</NText>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ t('common.create') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NUpload, NButton, useMessage } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<{
close: []
saved: []
}>()
const { t } = useI18n()
const profilesStore = useProfilesStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const fileList = ref<UploadFileInfo[]>([])
const ACCEPT_TYPES = [
'.tar.gz',
'.tgz',
'.gz',
'.zip',
]
function beforeUpload({ file }: { file: UploadFileInfo }) {
const name = file.name?.toLowerCase() || ''
const valid = ACCEPT_TYPES.some(ext => name.endsWith(ext))
if (!valid) {
message.warning(t('profiles.importInvalidFile'))
return false
}
return true
}
async function handleSave() {
if (!fileList.value.length) {
message.warning(t('profiles.importSelectFile'))
return
}
loading.value = true
try {
const file = fileList.value[0].file
if (!file) {
message.error(t('profiles.importFailed'))
return
}
const ok = await profilesStore.importProfile(file)
if (ok) {
message.success(t('profiles.importSuccess'))
emit('saved')
} else {
message.error(t('profiles.importFailed'))
}
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('profiles.import')"
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NUpload
v-model:file-list="fileList"
:max="1"
:accept="ACCEPT_TYPES.join(',')"
:disabled="loading"
@before-upload="beforeUpload"
>
<NButton>{{ t('profiles.importSelectFile') }}</NButton>
</NUpload>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" :disabled="!fileList.length" @click="handleSave">
{{ t('common.confirm') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ profileName: string }>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { t } = useI18n()
const profilesStore = useProfilesStore()
const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const newName = ref('')
const nameValidationMessage = ref('')
function handleNameInput(value: string) {
// 过滤掉不符合规则的字符,只保留小写字母、数字、下划线和连字符
const filtered = value.toLowerCase().replace(/[^a-z0-9_-]/g, '')
if (filtered !== value) {
nameValidationMessage.value = t('profiles.nameValidation')
} else {
nameValidationMessage.value = ''
}
newName.value = filtered
}
async function handleSave() {
if (!newName.value) {
message.warning(t('profiles.newNamePlaceholder'))
return
}
if (!/^[a-z0-9_-]+$/.test(newName.value)) {
message.error(t('profiles.nameValidation'))
return
}
loading.value = true
try {
const ok = await profilesStore.renameProfile(props.profileName, newName.value.trim())
if (ok) {
message.success(t('profiles.renameSuccess'))
emit('saved')
} else {
message.error(t('profiles.renameFailed'))
}
} finally {
loading.value = false
}
}
function handleClose() {
showModal.value = false
setTimeout(() => emit('close'), 200)
}
</script>
<template>
<NModal
v-model:show="showModal"
preset="card"
:title="t('profiles.rename')"
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('profiles.newName')" required>
<NInput
v-model:value="newName"
:placeholder="t('profiles.newNamePlaceholder')"
@input="handleNameInput"
/>
</NFormItem>
<NText v-if="nameValidationMessage" depth="3" type="warning" style="font-size: 12px;">
{{ nameValidationMessage }}
</NText>
</NForm>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
{{ t('common.confirm') }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped lang="scss">
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import ProfileCard from './ProfileCard.vue'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
defineEmits<{ rename: [name: string] }>()
const { t } = useI18n()
const profilesStore = useProfilesStore()
</script>
<template>
<div v-if="profilesStore.profiles.length === 0" class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<p>{{ t('profiles.noProfiles') }}</p>
</div>
<div v-else class="profiles-grid">
<ProfileCard
v-for="p in profilesStore.profiles"
:key="p.name"
:profile="p"
@rename="$emit('rename', $event)"
/>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: $text-muted;
gap: 12px;
.empty-icon {
opacity: 0.3;
}
p {
font-size: 14px;
}
}
.profiles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { NButton, NInput, NModal, NForm, NFormItem, NPopconfirm, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
import { changePassword, changeUsername, fetchCurrentUser, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth";
import type { LockedIp } from "@/api/auth";
const { t } = useI18n();
const message = useMessage();
const username = ref<string | null>(null);
const loading = ref(false);
// Change password form
const showChangePasswordModal = ref(false);
const currentPasswordForPwd = ref("");
const newPasswordVal = ref("");
const newPasswordConfirm = ref("");
// Change username form
const showChangeUsernameModal = ref(false);
const currentPasswordForName = ref("");
const newUsernameVal = ref("");
onMounted(async () => {
try {
const user = await fetchCurrentUser();
username.value = user.username;
} catch { /* ignore */ }
});
async function handleChangePassword() {
if (newPasswordVal.value !== newPasswordConfirm.value) {
message.error(t("login.passwordMismatch"));
return;
}
if (newPasswordVal.value.length < 6) {
message.error(t("login.passwordTooShort"));
return;
}
loading.value = true;
try {
await changePassword(currentPasswordForPwd.value, newPasswordVal.value);
showChangePasswordModal.value = false;
currentPasswordForPwd.value = "";
newPasswordVal.value = "";
newPasswordConfirm.value = "";
message.success(t("login.passwordChanged"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
async function handleChangeUsername() {
if (newUsernameVal.value.trim().length < 2) {
message.error(t("login.usernameTooShort"));
return;
}
loading.value = true;
try {
await changeUsername(currentPasswordForName.value, newUsernameVal.value.trim());
username.value = newUsernameVal.value.trim();
showChangeUsernameModal.value = false;
currentPasswordForName.value = "";
newUsernameVal.value = "";
message.success(t("login.usernameChanged"));
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
} finally {
loading.value = false;
}
}
function openChangePasswordModal() {
currentPasswordForPwd.value = "";
newPasswordVal.value = "";
newPasswordConfirm.value = "";
showChangePasswordModal.value = true;
}
function openChangeUsernameModal() {
currentPasswordForName.value = "";
newUsernameVal.value = "";
showChangeUsernameModal.value = true;
}
// Locked IPs management
const lockedIps = ref<LockedIp[]>([]);
const loadingLocks = ref(false);
async function loadLockedIps() {
loadingLocks.value = true;
try {
lockedIps.value = await fetchLockedIps();
} catch { /* ignore */ }
finally {
loadingLocks.value = false;
}
}
async function handleUnlockIp(ip: string) {
try {
await unlockSpecificIp(ip);
message.success(t("settings.lockedIps.unlocked"));
await loadLockedIps();
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
}
}
async function handleUnlockAll() {
try {
const count = await unlockAllIps();
message.success(t("settings.lockedIps.allUnlocked", { count }));
await loadLockedIps();
} catch (err: any) {
message.error(err.message || t("common.saveFailed"));
}
}
function formatTime(ts: number): string {
const remaining = Math.max(0, Math.round((ts - Date.now()) / 60000));
return remaining > 0 ? `${remaining} min` : t("common.expired");
}
onMounted(() => { loadLockedIps(); });
</script>
<template>
<div class="account-settings">
<p class="section-desc">{{ t("login.setupDescription") }}</p>
<div class="configured-section">
<div class="action-row">
<span class="action-label">{{ t("login.passwordLoginConfigured", { username }) }}</span>
<div class="action-buttons">
<NButton @click="openChangePasswordModal">{{ t("login.changePassword") }}</NButton>
<NButton @click="openChangeUsernameModal">{{ t("login.changeUsername") }}</NButton>
</div>
</div>
</div>
<!-- Locked IPs management -->
<div class="locked-ips-section">
<h3 class="section-title">{{ t("settings.lockedIps.title") }}</h3>
<div class="action-row" style="margin-bottom: 12px;">
<span class="action-label">{{ t("settings.lockedIps.count", { count: lockedIps.length }) }}</span>
<div class="action-buttons">
<NButton size="small" :loading="loadingLocks" @click="loadLockedIps">{{ t("common.retry") }}</NButton>
<NPopconfirm v-if="lockedIps.length > 0" @positive-click="handleUnlockAll">
<template #trigger>
<NButton size="small" type="warning">{{ t("settings.lockedIps.unlockAll") }}</NButton>
</template>
{{ t("settings.lockedIps.unlockAllConfirm") }}
</NPopconfirm>
</div>
</div>
<div v-if="lockedIps.length > 0" class="locked-list">
<div v-for="lock in lockedIps" :key="lock.ip + lock.type" class="locked-item">
<div class="locked-info">
<span class="locked-ip">{{ lock.ip }}</span>
<span class="locked-badge">{{ lock.type }}</span>
<span class="locked-ttl">{{ formatTime(lock.lockedUntil) }}</span>
</div>
<NButton size="tiny" type="error" ghost @click="handleUnlockIp(lock.ip)">{{ t("settings.lockedIps.unlock") }}</NButton>
</div>
</div>
<p v-else class="empty-hint">{{ t("settings.lockedIps.empty") }}</p>
</div>
<!-- Change password modal -->
<NModal v-model:show="showChangePasswordModal" preset="dialog" :title="t('login.changePassword')">
<NForm label-placement="top">
<NFormItem :label="t('login.currentPassword')">
<NInput v-model:value="currentPasswordForPwd" type="password" show-password-on="click" :placeholder="t('login.currentPassword')" />
</NFormItem>
<NFormItem :label="t('login.newPassword')">
<NInput v-model:value="newPasswordVal" type="password" show-password-on="click" :placeholder="t('login.newPassword')" />
</NFormItem>
<NFormItem :label="t('login.confirmPassword')">
<NInput v-model:value="newPasswordConfirm" type="password" show-password-on="click" :placeholder="t('login.confirmPassword')" @keyup.enter="handleChangePassword" />
</NFormItem>
</NForm>
<template #action>
<NButton @click="showChangePasswordModal = false">{{ t("common.cancel") }}</NButton>
<NButton type="primary" :loading="loading" @click="handleChangePassword">{{ t("common.save") }}</NButton>
</template>
</NModal>
<!-- Change username modal -->
<NModal v-model:show="showChangeUsernameModal" preset="dialog" :title="t('login.changeUsername')">
<NForm label-placement="top">
<NFormItem :label="t('login.currentPassword')">
<NInput v-model:value="currentPasswordForName" type="password" show-password-on="click" :placeholder="t('login.currentPassword')" />
</NFormItem>
<NFormItem :label="t('login.newUsername')">
<NInput v-model:value="newUsernameVal" :placeholder="t('login.usernamePlaceholder')" @keyup.enter="handleChangeUsername" />
</NFormItem>
</NForm>
<template #action>
<NButton @click="showChangeUsernameModal = false">{{ t("common.cancel") }}</NButton>
<NButton type="primary" :loading="loading" @click="handleChangeUsername">{{ t("common.save") }}</NButton>
</template>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.account-settings {
padding: 8px 0;
}
.section-desc {
font-size: 13px;
color: $text-muted;
margin: 0 0 20px;
line-height: 1.6;
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.action-label {
font-size: 14px;
color: $text-secondary;
}
.action-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.locked-ips-section {
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid $border-color;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: $text-primary;
margin: 0 0 16px;
}
.locked-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.locked-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-input;
}
.locked-info {
display: flex;
align-items: center;
gap: 8px;
}
.locked-ip {
font-family: $font-code;
font-size: 13px;
color: $text-primary;
}
.locked-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: rgba($error, 0.1);
color: $error;
}
.locked-ttl {
font-size: 12px;
color: $text-muted;
}
.empty-hint {
font-size: 13px;
color: $text-muted;
margin: 0;
}
</style>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
// 防抖保存:每个字段独立定时器,300ms 内只发最后一次 HTTP 请求
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
function save(values: Record<string, any>) {
// NSelect 等一次性操作,直接保存,不需要防抖
settingsStore.updateLocal('agent', values)
settingsStore.saveSection('agent', values).then(() => {
message.success(t('settings.saved'))
}).catch(() => {
message.error(t('settings.saveFailed'))
})
}
function debouncedSave(key: string, value: any) {
// 先立即更新本地 store(UI 即时响应)
settingsStore.updateLocal('agent', { [key]: value })
// 再防抖发 HTTP 保存
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
debounceTimers[key] = setTimeout(async () => {
try {
await settingsStore.saveSection('agent', { [key]: value })
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}, 300)
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.agent.maxTurns')" :hint="t('settings.agent.maxTurnsHint')">
<NInputNumber
:value="settingsStore.agent.max_turns"
:min="1" :max="200" :step="5"
size="small" class="input-sm"
@update:value="v => v != null && debouncedSave('max_turns', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.gatewayTimeout')" :hint="t('settings.agent.gatewayTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.gateway_timeout"
:min="60" :max="7200" :step="60"
size="small" class="input-sm"
@update:value="v => v != null && debouncedSave('gateway_timeout', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.restartDrainTimeout')" :hint="t('settings.agent.restartDrainTimeoutHint')">
<NInputNumber
:value="settingsStore.agent.restart_drain_timeout"
:min="10" :max="300" :step="10"
size="small" class="input-sm"
@update:value="v => v != null && debouncedSave('restart_drain_timeout', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.agent.toolEnforcement')" :hint="t('settings.agent.toolEnforcementHint')">
<NSelect
:value="settingsStore.agent.tool_use_enforcement || 'auto'"
:options="[
{ label: t('settings.agent.auto'), value: 'auto' },
{ label: t('settings.agent.always'), value: 'always' },
{ label: t('settings.agent.never'), value: 'never' },
]"
size="small" class="input-sm"
@update:value="v => save({ tool_use_enforcement: v })"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { NInputNumber, NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
const defaults = {
enabled: true,
threshold: 0.5,
target_ratio: 0.2,
protect_last_n: 20,
protect_first_n: 3,
}
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
function save(values: Record<string, any>) {
settingsStore.updateLocal('compression', values)
settingsStore.saveSection('compression', values).then(() => {
message.success(t('settings.saved'))
}).catch(() => {
message.error(t('settings.saveFailed'))
})
}
function debouncedSave(key: string, value: any) {
settingsStore.updateLocal('compression', { [key]: value })
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
debounceTimers[key] = setTimeout(async () => {
try {
await settingsStore.saveSection('compression', { [key]: value })
message.success(t('settings.saved'))
} catch {
message.error(t('settings.saveFailed'))
}
}, 300)
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.compression.enabled')" :hint="t('settings.compression.enabledHint')">
<NSwitch
:value="settingsStore.compression.enabled ?? defaults.enabled"
size="small"
@update:value="v => save({ enabled: v })"
/>
</SettingRow>
<SettingRow :label="t('settings.compression.threshold')" :hint="t('settings.compression.thresholdHint')">
<NInputNumber
:value="settingsStore.compression.threshold ?? defaults.threshold"
:min="0.1"
:max="0.95"
:step="0.05"
size="small"
class="input-sm"
@update:value="v => v != null && debouncedSave('threshold', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.compression.targetRatio')" :hint="t('settings.compression.targetRatioHint')">
<NInputNumber
:value="settingsStore.compression.target_ratio ?? defaults.target_ratio"
:min="0.05"
:max="0.8"
:step="0.05"
size="small"
class="input-sm"
@update:value="v => v != null && debouncedSave('target_ratio', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.compression.protectLastN')" :hint="t('settings.compression.protectLastNHint')">
<NInputNumber
:value="settingsStore.compression.protect_last_n ?? defaults.protect_last_n"
:min="0"
:max="200"
:step="1"
size="small"
class="input-sm"
@update:value="v => v != null && debouncedSave('protect_last_n', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.compression.protectFirstN')" :hint="t('settings.compression.protectFirstNHint')">
<NInputNumber
:value="settingsStore.compression.protect_first_n ?? defaults.protect_first_n"
:min="0"
:max="50"
:step="1"
size="small"
class="input-sm"
@update:value="v => v != null && debouncedSave('protect_first_n', v)"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { NSwitch, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { useTheme, type BrightnessMode } from '@/composables/useTheme'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
const { brightness, setBrightness } = useTheme()
const themeOptions = [
{ label: t('settings.display.themeLight'), value: 'light' },
{ label: t('settings.display.themeDark'), value: 'dark' },
{ label: t('settings.display.themeSystem'), value: 'system' },
]
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('display', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
function handleThemeChange(val: string) {
const m = val as BrightnessMode
setBrightness(m)
save({ skin: m })
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.display.theme')" :hint="t('settings.display.themeHint')">
<NSelect :value="brightness" :options="themeOptions" size="small" :consistent-menu-width="false" class="input-sm" @update:value="handleThemeChange" />
</SettingRow>
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.compact')" :hint="t('settings.display.compactHint')">
<NSwitch :value="settingsStore.display.compact" @update:value="v => save({ compact: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showReasoning')" :hint="t('settings.display.showReasoningHint')">
<NSwitch :value="settingsStore.display.show_reasoning" @update:value="v => save({ show_reasoning: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.showCost')" :hint="t('settings.display.showCostHint')">
<NSwitch :value="settingsStore.display.show_cost" @update:value="v => save({ show_cost: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.inlineDiffs')" :hint="t('settings.display.inlineDiffsHint')">
<NSwitch :value="settingsStore.display.inline_diffs" @update:value="v => save({ inline_diffs: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.bellOnComplete')" :hint="t('settings.display.bellOnCompleteHint')">
<NSwitch :value="settingsStore.display.bell_on_complete" @update:value="v => save({ bell_on_complete: v })" />
</SettingRow>
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
<NSwitch :value="settingsStore.display.busy_input_mode === 'interrupt'" @update:value="v => save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,398 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { NAlert, NButton, NDescriptions, NDescriptionsItem, NSelect, NSpace, NTag, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import {
fetchPreviewStatus,
fetchPreviewTags,
installPreview,
preparePreview,
startPreview,
stopPreview,
type PreviewActionResponse,
type PreviewStatus,
type PreviewTag,
} from '@/api/hermes/system'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
const tagsLoading = ref(false)
const actionLoading = ref('')
const tags = ref<PreviewTag[]>([])
const selectedTag = ref('')
const status = ref<PreviewStatus | null>(null)
const lastHandledCompletion = ref('')
const completionNotificationsReady = ref(false)
let pollTimer: number | null = null
const tagOptions = computed(() => tags.value.map(tag => ({
label: tag.name,
value: tag.name,
})))
const actionLog = computed(() => status.value?.action_log || '')
const devLog = computed(() => status.value?.dev_log || '')
const activeAction = computed(() => actionLoading.value || status.value?.active_action || '')
const hasActiveAction = computed(() => Boolean(activeAction.value))
const actionSuccessKeys: Record<string, string> = {
prepare: 'githubPreview.prepareSuccess',
install: 'githubPreview.installSuccess',
start: 'githubPreview.startSuccess',
stop: 'githubPreview.stopSuccess',
}
function applyErrorStatus(err: any) {
const messageText = String(err?.message || '')
const jsonStart = messageText.indexOf('{')
if (jsonStart < 0) return
try {
const parsed = JSON.parse(messageText.slice(jsonStart))
if (parsed && typeof parsed === 'object' && 'preview_dir' in parsed) {
status.value = parsed as PreviewStatus
}
} catch {}
}
function errorCodeMessage(code?: string, fallback?: string): string {
if (code === 'node_environment_missing') return t('githubPreview.nodeEnvironmentMissing')
return fallback || t('githubPreview.actionFailed')
}
function parseErrorPayload(err: any): { message?: string; code?: string } | null {
const messageText = String(err?.message || '')
const jsonStart = messageText.indexOf('{')
if (jsonStart < 0) return null
try {
const parsed = JSON.parse(messageText.slice(jsonStart))
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
async function loadStatus() {
status.value = await fetchPreviewStatus()
if (!selectedTag.value && status.value.current_tag) {
selectedTag.value = status.value.current_tag
}
}
async function loadTags() {
tagsLoading.value = true
try {
const res = await fetchPreviewTags()
tags.value = res.tags
if (!selectedTag.value && tags.value[0]) {
selectedTag.value = tags.value[0].name
}
} finally {
tagsLoading.value = false
}
}
async function handleRefresh() {
loading.value = true
try {
await Promise.all([loadStatus(), loadTags()])
} finally {
loading.value = false
}
}
async function runAction(action: string, fn: () => Promise<PreviewActionResponse>, successKey: string) {
actionLoading.value = action
try {
const res = await fn()
status.value = res
if (res.success === false) {
message.warning(errorCodeMessage(res.code, res.message))
return
}
if (!res.accepted && !res.active_action) {
message.success(t(successKey))
}
} catch (err: any) {
applyErrorStatus(err)
const payload = parseErrorPayload(err)
message.error(errorCodeMessage(payload?.code, payload?.message || err?.message))
} finally {
actionLoading.value = ''
}
}
async function pollStatus() {
try {
await loadStatus()
} catch {}
}
function startPolling() {
if (pollTimer) return
pollTimer = window.setInterval(() => {
void pollStatus()
}, 2000)
}
function stopPolling() {
if (!pollTimer) return
window.clearInterval(pollTimer)
pollTimer = null
}
function requireTag(): string | null {
if (!selectedTag.value) {
message.warning(t('githubPreview.selectTag'))
return null
}
return selectedTag.value
}
async function handlePrepare() {
const tag = requireTag()
if (!tag) return
await runAction('prepare', () => preparePreview(tag), 'githubPreview.prepareSuccess')
}
async function handleInstall() {
await runAction('install', async () => {
const res = await installPreview()
if (res.success !== false && !res.accepted && !res.active_action && !res.installed) {
return {
...res,
success: false,
message: res.message || t('githubPreview.actionFailed'),
}
}
return res
}, 'githubPreview.installSuccess')
}
async function handleStart() {
await runAction('start', () => startPreview(selectedTag.value || undefined), 'githubPreview.startSuccess')
}
async function handleStop() {
await runAction('stop', stopPreview, 'githubPreview.stopSuccess')
}
onMounted(async () => {
await handleRefresh()
lastHandledCompletion.value = status.value?.last_action_completed_at || ''
completionNotificationsReady.value = true
})
onUnmounted(() => {
stopPolling()
})
watch(
() => status.value?.active_action || '',
(action) => {
if (action) startPolling()
else stopPolling()
},
)
watch(
() => status.value?.last_action_completed_at || '',
(completedAt) => {
if (!completedAt) return
if (!completionNotificationsReady.value) {
lastHandledCompletion.value = completedAt
return
}
if (completedAt === lastHandledCompletion.value || actionLoading.value) return
lastHandledCompletion.value = completedAt
const completedAction = status.value?.last_action || ''
if (status.value?.last_action_success === false) {
message.error(errorCodeMessage(status.value.last_action_code, status.value.last_action_message))
return
}
const successKey = actionSuccessKeys[completedAction]
if (successKey) message.success(t(successKey))
},
)
</script>
<template>
<div class="github-preview-settings">
<div class="settings-section">
<div class="control-row">
<NSelect
v-model:value="selectedTag"
class="tag-select"
filterable
:loading="tagsLoading"
:options="tagOptions"
:placeholder="t('githubPreview.selectTag')"
/>
<NSpace>
<NButton type="primary" :loading="activeAction === 'prepare'" :disabled="hasActiveAction || !selectedTag" @click="handlePrepare">
{{ t('githubPreview.prepare') }}
</NButton>
<NButton :loading="activeAction === 'install'" :disabled="hasActiveAction || !status?.has_package" @click="handleInstall">
{{ t('githubPreview.install') }}
</NButton>
<NButton type="success" :loading="activeAction === 'start'" :disabled="hasActiveAction || !status?.installed" @click="handleStart">
{{ t('githubPreview.start') }}
</NButton>
<NButton :loading="activeAction === 'stop'" :disabled="hasActiveAction || !status?.running" @click="handleStop">
{{ t('githubPreview.stop') }}
</NButton>
<NButton :loading="loading || tagsLoading" @click="handleRefresh">
{{ t('githubPreview.refresh') }}
</NButton>
</NSpace>
</div>
<p class="section-description">{{ t('githubPreview.description') }}</p>
<NAlert type="info" :bordered="false" class="preview-note">
{{ t('githubPreview.note') }}
</NAlert>
<NDescriptions v-if="status" :column="1" bordered size="small" class="status-table">
<NDescriptionsItem :label="t('githubPreview.path')">
<code>{{ status.preview_dir }}</code>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.webuiHome')">
<code>{{ status.webui_home }}</code>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.currentTag')">
{{ status.current_tag || '-' }}
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.repoReady')">
<NTag size="small" :type="status.has_package ? 'success' : 'default'">
{{ status.has_package ? t('githubPreview.yes') : t('githubPreview.no') }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.dependencies')">
<NTag size="small" :type="status.installed ? 'success' : 'warning'">
{{ status.installed ? t('githubPreview.yes') : t('githubPreview.no') }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.running')">
<NTag size="small" :type="status.running ? 'success' : 'default'">
{{ status.running ? `PID ${status.pid}` : t('githubPreview.notRunning') }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.open')">
<a :href="status.frontend_url" target="_blank" rel="noopener noreferrer">{{ status.frontend_url }}</a>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.log')">
<code>{{ status.action_log_path }}</code>
</NDescriptionsItem>
<NDescriptionsItem :label="t('githubPreview.devLog')">
<code>{{ status.dev_log_path }}</code>
</NDescriptionsItem>
</NDescriptions>
<div class="log-output">
<div class="log-output-header">{{ t('githubPreview.logOutput') }}</div>
<div class="log-box">
<div class="log-title">{{ t('githubPreview.actionLog') }}</div>
<pre>{{ actionLog || '-' }}</pre>
</div>
<div class="log-box">
<div class="log-title">{{ t('githubPreview.devLog') }}</div>
<pre>{{ devLog || '-' }}</pre>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.github-preview-settings {
width: 100%;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-description {
margin: 0;
color: $text-secondary;
font-size: 13px;
line-height: 1.5;
}
.control-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tag-select {
width: 260px;
}
.preview-note {
width: 100%;
}
.status-table {
width: 100%;
}
.log-output {
width: 100%;
border: 1px solid $border-color;
border-radius: $radius-md;
background: $bg-card;
overflow: hidden;
}
.log-output-header {
padding: 12px 14px;
border-bottom: 1px solid $border-color;
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.log-box {
border-bottom: 1px solid $border-color;
&:last-child {
border-bottom: none;
}
}
.log-title {
padding: 8px 14px;
font-size: 12px;
color: $text-secondary;
background: $bg-secondary;
}
pre {
min-height: 180px;
max-height: 320px;
margin: 0;
padding: 12px 14px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
}
code {
font-size: 12px;
word-break: break-all;
}
@media (max-width: 1100px) {
.control-row {
align-items: stretch;
}
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { NSwitch, NInputNumber, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
// 防抖保存:每个字段独立定时器,300ms 内只发最后一次 HTTP 请求
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
function save(values: Record<string, any>) {
// Switch 等一次性操作,直接保存,不需要防抖
settingsStore.updateLocal('memory', values)
settingsStore.saveSection('memory', values).then(() => {
message.success(t('settings.saved'))
}).catch(() => {
message.error(t('settings.saveFailed'))
})
}
function debouncedSave(key: string, value: any) {
// 先立即更新本地 store(UI 即时响应)
settingsStore.updateLocal('memory', { [key]: value })
// 再防抖发 HTTP 保存
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
debounceTimers[key] = setTimeout(async () => {
try {
await settingsStore.saveSection('memory', { [key]: value })
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}, 300)
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.memory.enabled')" :hint="t('settings.memory.enabledHint')">
<NSwitch :value="settingsStore.memory.memory_enabled" @update:value="v => save({ memory_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.userProfile')" :hint="t('settings.memory.userProfileHint')">
<NSwitch :value="settingsStore.memory.user_profile_enabled" @update:value="v => save({ user_profile_enabled: v })" />
</SettingRow>
<SettingRow :label="t('settings.memory.charLimit')" :hint="t('settings.memory.charLimitHint')">
<NInputNumber
:value="settingsStore.memory.memory_char_limit"
:min="100" :max="10000" :step="100"
size="small" class="input-sm"
@update:value="v => v != null && debouncedSave('memory_char_limit', v)"
/>
</SettingRow>
<SettingRow :label="t('settings.memory.userCharLimit')" :hint="t('settings.memory.userCharLimitHint')">
<NInputNumber
:value="settingsStore.memory.user_char_limit"
:min="100" :max="10000" :step="100"
size="small" class="input-sm"
@update:value="v => v != null && debouncedSave('user_char_limit', v)"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NInput, NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
import { useModelsStore } from '@/stores/hermes/models'
import { updateProvider } from '@/api/hermes/system'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const modelsStore = useModelsStore()
const message = useMessage()
const savingKey = ref<string | null>(null)
const editKeys = ref<Record<string, string>>({})
onMounted(() => {
if (modelsStore.providers.length === 0) {
modelsStore.fetchProviders()
}
})
const isCustom = (provider: string) => {
const g = modelsStore.providers.find(p => p.provider === provider)
return !g?.builtin && provider.startsWith('custom:')
}
function getEditKey(provider: string): string {
if (!(provider in editKeys.value)) {
const g = modelsStore.providers.find(p => p.provider === provider)
editKeys.value[provider] = g?.api_key || ''
}
return editKeys.value[provider]
}
async function handleSaveApiKey(providerKey: string) {
const key = getEditKey(providerKey)
if (!key.trim()) {
message.warning(t('settings.models.apiKeyPlaceholder'))
return
}
savingKey.value = providerKey
try {
await updateProvider(providerKey, { api_key: key.trim() })
message.success(t('settings.models.saved'))
await modelsStore.fetchProviders()
} catch (e: any) {
message.error(e.message || t('settings.models.saveFailed'))
} finally {
savingKey.value = null
}
}
async function handleSaveCustom(providerKey: string) {
const key = getEditKey(providerKey)
savingKey.value = providerKey
try {
await updateProvider(providerKey, { api_key: key.trim() })
message.success(t('settings.models.saved'))
await modelsStore.fetchProviders()
} catch (e: any) {
message.error(e.message || t('settings.models.saveFailed'))
} finally {
savingKey.value = null
}
}
</script>
<template>
<section class="settings-section">
<NSpin :show="modelsStore.loading">
<div v-if="modelsStore.providers.length === 0" class="empty-hint">
<NEmpty :description="t('settings.models.noProviders')" />
</div>
<div v-for="g in modelsStore.providers" :key="g.provider" class="provider-section">
<div class="provider-header">
<h4 class="provider-name">{{ g.label }}</h4>
<span class="type-badge" :class="isCustom(g.provider) ? 'custom' : 'builtin'">
{{ isCustom(g.provider) ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
<!-- Built-in provider: only API key -->
<div v-if="!isCustom(g.provider)" class="provider-fields">
<div class="field-row">
<NInput
:value="getEditKey(g.provider)"
type="password"
show-password-on="click"
:placeholder="t('settings.models.apiKeyPlaceholder')"
autocomplete="off"
@update:value="v => editKeys[g.provider] = v"
/>
<NButton
type="primary"
size="small"
:loading="savingKey === g.provider"
@click="handleSaveApiKey(g.provider)"
>
{{ t('settings.models.save') }}
</NButton>
</div>
</div>
<!-- Custom provider: API key -->
<div v-else class="provider-fields">
<div class="field-row">
<NInput
:value="getEditKey(g.provider)"
type="password"
show-password-on="click"
:placeholder="t('settings.models.apiKeyPlaceholder')"
autocomplete="off"
@update:value="v => editKeys[g.provider] = v"
/>
<NButton
type="primary"
size="small"
:loading="savingKey === g.provider"
@click="handleSaveCustom(g.provider)"
>
{{ t('settings.models.save') }}
</NButton>
</div>
</div>
</div>
</NSpin>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
.empty-hint {
padding: 40px 0;
}
.provider-section {
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
margin-bottom: 14px;
background: $bg-card;
}
.provider-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.provider-name {
font-size: 14px;
font-weight: 600;
color: $text-primary;
margin: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
&.builtin {
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.custom {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
}
.provider-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-row {
display: flex;
align-items: center;
gap: 10px;
.n-input {
flex: 1;
}
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NTag, NAlert } from 'naive-ui'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
name: string
icon: string
config: Record<string, any>
credentials?: Record<string, any>
exclusive?: boolean
}>()
const expanded = ref(true)
const { t } = useI18n()
const configured = computed(() => {
const creds = props.credentials
if (!creds) return false
const keys = ['token', 'api_key', 'app_id', 'client_id', 'secret', 'app_secret', 'client_secret', 'access_token', 'bot_id', 'account_id', 'enabled']
// Check top-level and nested extra.*
const targets = [creds, creds.extra].filter(Boolean)
return targets.some(obj =>
keys.some(key => {
const val = (obj as Record<string, any>)[key]
return val !== undefined && val !== null && val !== '' && val !== false
})
)
})
</script>
<template>
<div class="platform-card" :class="{ configured }">
<div class="platform-card-header" @click="expanded = !expanded">
<div class="platform-info">
<span class="platform-icon" v-html="icon" />
<span class="platform-name">{{ name }}</span>
<NTag :type="configured ? 'success' : 'default'" size="small" round>
{{ configured ? t('common.configured') : t('common.notConfigured') }}
</NTag>
</div>
<span class="expand-icon" :class="{ expanded }">&#9662;</span>
</div>
<div v-if="expanded" class="platform-card-body">
<NAlert v-if="exclusive" type="warning" :show-icon="true" class="exclusive-alert">
{{ t('platform.exclusiveTokenWarning') }}
</NAlert>
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.platform-card {
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
margin-bottom: 12px;
overflow: hidden;
&.configured {
border-color: rgba(var(--success-rgb), 0.2);
}
}
.platform-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(var(--text-primary-rgb), 0.03);
}
}
.platform-info {
display: flex;
align-items: center;
gap: 10px;
}
.platform-icon {
width: 18px;
height: 18px;
color: $text-secondary;
flex-shrink: 0;
}
.platform-name {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
.expand-icon {
font-size: 12px;
color: $text-muted;
transition: transform 0.2s;
&.expanded {
transform: rotate(0deg);
}
&:not(.expanded) {
transform: rotate(-90deg);
}
}
.platform-card-body {
padding: 0 16px 12px;
border-top: 1px solid $border-light;
}
.exclusive-alert {
margin: 12px 0 4px;
font-size: 12px;
}
</style>
@@ -0,0 +1,510 @@
<script setup lang="ts">
import { ref, reactive, onUnmounted, watch } from 'vue'
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/hermes/config'
import PlatformCard from './PlatformCard.vue'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
const saving = reactive<Record<string, boolean>>({})
const configDrafts = reactive<Record<string, Record<string, any>>>({})
const credentialDrafts = reactive<Record<string, Record<string, any>>>({})
const touchedConfig = reactive<Record<string, boolean>>({})
const touchedCredentials = reactive<Record<string, boolean>>({})
function cloneValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value || {}))
}
function mergeDeep(target: Record<string, any>, values: Record<string, any>) {
for (const [key, value] of Object.entries(values)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = mergeDeep({ ...(target[key] || {}) }, value as Record<string, any>)
} else {
target[key] = value
}
}
return target
}
function configDraft(platform: string) {
if (!configDrafts[platform]) {
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
}
return configDrafts[platform]
}
function credentialDraft(platform: string) {
if (!credentialDrafts[platform]) credentialDrafts[platform] = cloneValue(getCreds(platform))
return credentialDrafts[platform]
}
function setConfigDraft(platform: string, values: Record<string, any>) {
configDrafts[platform] = mergeDeep({ ...configDraft(platform) }, values)
touchedConfig[platform] = true
}
function setCredentialDraft(platform: string, values: Record<string, any>) {
credentialDrafts[platform] = mergeDeep({ ...credentialDraft(platform) }, values)
touchedCredentials[platform] = true
}
function sameJson(a: unknown, b: unknown) {
return JSON.stringify(a || {}) === JSON.stringify(b || {})
}
function hasConfigChanges(platform: string) {
return !!touchedConfig[platform] && !!configDrafts[platform] && !sameJson(configDrafts[platform], settingsStore[platform as keyof typeof settingsStore])
}
function hasCredentialChanges(platform: string) {
return !!touchedCredentials[platform] && !!credentialDrafts[platform] && !sameJson(credentialDrafts[platform], getCreds(platform))
}
function hasUnsavedChanges(platform: string) {
return hasConfigChanges(platform) || hasCredentialChanges(platform)
}
function isSavingPlatform(platform: string) {
return !!saving[platform]
}
async function savePlatform(platform: string) {
saving[platform] = true
try {
const configChanged = hasConfigChanges(platform)
const credentialsChanged = hasCredentialChanges(platform)
if (configChanged) {
await settingsStore.saveSection(platform, configDraft(platform), { restart: !credentialsChanged })
}
if (credentialsChanged) {
await saveCredsApi(platform, credentialDraft(platform))
await settingsStore.fetchSettings()
}
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
credentialDrafts[platform] = cloneValue(getCreds(platform))
touchedConfig[platform] = false
touchedCredentials[platform] = false
message.success(t('settings.saved'))
} catch (err: any) {
message.error(err?.message || t('settings.saveFailed'))
} finally {
saving[platform] = false
}
}
function getCreds(key: string) {
return (settingsStore.platforms[key] || {}) as Record<string, any>
}
function boolValue(value: unknown) {
return value === true || value === 'true'
}
// Weixin QR code login state
const wxQrUrl = ref('')
const wxQrId = ref('')
const wxQrStatus = ref<'idle' | 'loading' | 'waiting' | 'scaned' | 'confirmed' | 'error' | 'expired'>('idle')
let wxPollTimer: ReturnType<typeof setTimeout> | null = null
async function startWeixinQrLogin() {
wxQrStatus.value = 'loading'
wxQrUrl.value = ''
wxQrId.value = ''
stopWeixinPoll()
try {
const data = await fetchWeixinQrCode()
wxQrId.value = data.qrcode
wxQrUrl.value = data.qrcode_url
window.open(data.qrcode_url, '_blank')
wxQrStatus.value = 'waiting'
pollWeixinStatus()
} catch (err: any) {
wxQrStatus.value = 'error'
message.error(err.message || t('platform.qrFetching'))
}
}
function pollWeixinStatus() {
if (!wxQrId.value) return
wxPollTimer = setTimeout(async () => {
try {
const data = await pollWeixinQrStatus(wxQrId.value)
if (data.status === 'wait') {
pollWeixinStatus()
} else if (data.status === 'scaned') {
wxQrStatus.value = 'scaned'
pollWeixinStatus()
} else if (data.status === 'expired') {
wxQrStatus.value = 'expired'
} else if (data.status === 'confirmed') {
wxQrStatus.value = 'confirmed'
await saveWeixinCredentials({
account_id: data.account_id!,
token: data.token!,
base_url: data.base_url,
})
await settingsStore.fetchSettings()
message.success(t('settings.saved'))
}
} catch {
pollWeixinStatus()
}
}, 3000)
}
function stopWeixinPoll() {
if (wxPollTimer) {
clearTimeout(wxPollTimer)
wxPollTimer = null
}
}
onUnmounted(() => {
stopWeixinPoll()
})
const platforms = [
{
key: 'telegram',
name: 'Telegram',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
},
{
key: 'discord',
name: 'Discord',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg>',
},
{
key: 'slack',
name: 'Slack',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 0a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V5.042zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1 2.523-2.52h6.313A2.528 2.528 0 0 1 24 18.956a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
},
{
key: 'whatsapp',
name: 'WhatsApp',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>',
},
{
key: 'matrix',
name: 'Matrix',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.48.324.448.217.786.619 1.017 1.205.24-.376.558-.702.956-.98.398-.277.872-.414 1.424-.414.41 0 .784.065 1.122.194.34.13.629.325.87.588.241.263.428.59.56.984.132.393.198.85.198 1.368v5.89h-2.49v-4.893c0-.268-.016-.525-.048-.77a1.627 1.627 0 00-.2-.63 1.028 1.028 0 00-.392-.426 1.294 1.294 0 00-.616-.134c-.277 0-.508.05-.693.15a1.043 1.043 0 00-.43.41 1.768 1.768 0 00-.214.616 4.15 4.15 0 00-.06.74v4.937H9.29v-4.937c0-.25-.01-.498-.032-.742a1.84 1.84 0 00-.166-.638.998.998 0 00-.363-.448 1.206 1.206 0 00-.624-.154c-.26 0-.483.048-.67.144a1.055 1.055 0 00-.436.402 1.744 1.744 0 00-.227.616 4.108 4.108 0 00-.063.74v4.937H5.21V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>',
},
{
key: 'feishu',
name: 'Feishu',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
},
{
key: 'dingtalk',
name: 'DingTalk',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.76 7.05c-.23-.52-.7-.9-1.26-1.02L5.35 3.2c-.77-.16-1.51.38-1.58 1.16-.22 2.55.17 5.4 1.13 7.66.97 2.29 2.52 4.11 4.45 4.82l-1.28 3.03c-.17.4.24.79.63.59l9.47-4.83c.34-.17.55-.52.55-.9v-3.12c.73-.4 1.22-1.17 1.22-2.06 0-.87-.08-1.73-.18-2.5zm-3.66 5.95-5.19 2.65.76-1.8c.12-.29-.03-.62-.33-.72-2.1-.73-3.56-3.54-3.95-6.73l9.27 2c.04.38.07.76.07 1.15 0 .45-.36.81-.81.81h-2.79c-.35 0-.63.28-.63.63s.28.63.63.63h2.97V13z"/></svg>',
},
{
key: 'qqbot',
name: 'QQBot',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C7.58 2 4 5.27 4 9.31c0 2.3 1.15 4.34 2.95 5.68-.13.58-.48 1.62-1.26 2.53-.24.28-.05.72.32.73 1.72.05 3.02-.68 3.69-1.15.72.16 1.49.25 2.3.25 4.42 0 8-3.27 8-7.31S16.42 2 12 2zm-3.2 7.63c-.63 0-1.14-.55-1.14-1.23s.51-1.23 1.14-1.23 1.14.55 1.14 1.23-.51 1.23-1.14 1.23zm6.4 0c-.63 0-1.14-.55-1.14-1.23s.51-1.23 1.14-1.23 1.14.55 1.14 1.23-.51 1.23-1.14 1.23zM5.5 20.5a.5.5 0 0 1 .5-.5h12a.5.5 0 0 1 0 1H6a.5.5 0 0 1-.5-.5z"/></svg>',
},
{
key: 'weixin',
name: 'Weixin',
exclusive: true,
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
{
key: 'wecom',
name: 'WeCom',
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
]
watch(
() => platforms.map((platform) => ({
key: platform.key,
config: settingsStore[platform.key as keyof typeof settingsStore],
credentials: getCreds(platform.key),
})),
(items) => {
for (const item of items) {
if (!touchedConfig[item.key]) {
configDrafts[item.key] = cloneValue(item.config as Record<string, any>)
}
if (!touchedCredentials[item.key]) {
credentialDrafts[item.key] = cloneValue(item.credentials)
}
}
},
{ deep: true, immediate: true },
)
</script>
<template>
<section class="settings-section">
<PlatformCard
v-for="p in platforms"
:key="p.key"
:name="p.name"
:icon="p.icon"
:exclusive="p.exclusive"
:config="settingsStore[p.key as keyof typeof settingsStore] as Record<string, any>"
:credentials="getCreds(p.key)"
>
<!-- Telegram -->
<template v-if="p.key === 'telegram'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="credentialDraft('telegram').token || ''" :loading="isSavingPlatform('telegram')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => setCredentialDraft('telegram', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="configDraft('telegram').require_mention" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="configDraft('telegram').reactions" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="configDraft('telegram').free_response_chats || ''" :loading="isSavingPlatform('telegram')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('telegram', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(configDraft('telegram').mention_patterns || []).join(', ')" :loading="isSavingPlatform('telegram')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Discord -->
<template v-if="p.key === 'discord'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="credentialDraft('discord').token || ''" :loading="isSavingPlatform('discord')" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => setCredentialDraft('discord', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="configDraft('discord').require_mention" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
<NSwitch :value="configDraft('discord').auto_thread" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="configDraft('discord').reactions" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="configDraft('discord').free_response_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { free_response_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
<NInput :value="configDraft('discord').allowed_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { allowed_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
<NInput :value="configDraft('discord').ignored_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { ignored_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
<NInput :value="configDraft('discord').no_thread_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { no_thread_channels: v })" />
</SettingRow>
</template>
<!-- Slack -->
<template v-if="p.key === 'slack'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="credentialDraft('slack').token || ''" :loading="isSavingPlatform('slack')" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => setCredentialDraft('slack', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="configDraft('slack').require_mention" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
<NSwitch :value="configDraft('slack').allow_bots" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { allow_bots: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :value="configDraft('slack').free_response_channels || ''" :loading="isSavingPlatform('slack')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('slack', { free_response_channels: v })" />
</SettingRow>
</template>
<!-- WhatsApp -->
<template v-if="p.key === 'whatsapp'">
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
<NSwitch :value="credentialDraft('whatsapp').enabled" :loading="isSavingPlatform('whatsapp')" @update:value="v => setCredentialDraft('whatsapp', { enabled: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="configDraft('whatsapp').require_mention" :loading="isSavingPlatform('whatsapp')" @update:value="v => setConfigDraft('whatsapp', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="configDraft('whatsapp').free_response_chats || ''" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('whatsapp', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :value="(configDraft('whatsapp').mention_patterns || []).join(', ')" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Matrix -->
<template v-if="p.key === 'matrix'">
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
<NInput :value="credentialDraft('matrix').token || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => setCredentialDraft('matrix', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
<NInput :value="credentialDraft('matrix').extra?.homeserver || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => setCredentialDraft('matrix', { extra: { ...credentialDraft('matrix').extra, homeserver: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
<NSwitch :value="configDraft('matrix').require_mention" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
<NSwitch :value="configDraft('matrix').auto_thread" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
<NSwitch :value="configDraft('matrix').dm_mention_threads" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { dm_mention_threads: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
<NInput :value="configDraft('matrix').free_response_rooms || ''" :loading="isSavingPlatform('matrix')" size="small" placeholder="room_id1,room_id2" @update:value="v => setConfigDraft('matrix', { free_response_rooms: v })" />
</SettingRow>
</template>
<!-- Feishu -->
<template v-if="p.key === 'feishu'">
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
<NInput :value="credentialDraft('feishu').extra?.app_id || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
<NInput :value="credentialDraft('feishu').extra?.app_secret || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="configDraft('feishu').require_mention" :loading="isSavingPlatform('feishu')" @update:value="v => setConfigDraft('feishu', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="configDraft('feishu').free_response_chats || ''" :loading="isSavingPlatform('feishu')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('feishu', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- DingTalk -->
<template v-if="p.key === 'dingtalk'">
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
<NInput :value="credentialDraft('dingtalk').extra?.client_id || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :value="credentialDraft('dingtalk').extra?.client_secret || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.cardTemplateId')" :hint="t('platform.cardTemplateIdHint')">
<NInput :value="credentialDraft('dingtalk').extra?.card_template_id || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="AI Card Template ID" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, card_template_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
<NSwitch :value="boolValue(credentialDraft('dingtalk').allow_all_users)" :loading="isSavingPlatform('dingtalk')" @update:value="v => setCredentialDraft('dingtalk', { allow_all_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
<NInput :value="credentialDraft('dingtalk').allowed_users || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @update:value="v => setCredentialDraft('dingtalk', { allowed_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="configDraft('dingtalk').require_mention" :loading="isSavingPlatform('dingtalk')" @update:value="v => setConfigDraft('dingtalk', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :value="configDraft('dingtalk').free_response_chats || ''" :loading="isSavingPlatform('dingtalk')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('dingtalk', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- QQBot -->
<template v-if="p.key === 'qqbot'">
<SettingRow :label="t('platform.qqAppId')" :hint="t('platform.qqAppIdHint')">
<NInput :value="credentialDraft('qqbot').extra?.app_id || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App ID" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.qqAppSecret')" :hint="t('platform.qqAppSecretHint')">
<NInput :value="credentialDraft('qqbot').extra?.client_secret || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
<NInput :value="credentialDraft('qqbot').allowed_users || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @update:value="v => setCredentialDraft('qqbot', { allowed_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
<NSwitch :value="boolValue(credentialDraft('qqbot').allow_all_users)" :loading="isSavingPlatform('qqbot')" @update:value="v => setCredentialDraft('qqbot', { allow_all_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.qqMarkdown')" :hint="t('platform.qqMarkdownHint')">
<NSwitch :value="configDraft('qqbot').extra?.markdown_support ?? true" :loading="isSavingPlatform('qqbot')" @update:value="v => setConfigDraft('qqbot', { extra: { ...configDraft('qqbot').extra, markdown_support: v } })" />
</SettingRow>
</template>
<!-- Weixin -->
<template v-if="p.key === 'weixin'">
<div class="weixin-qr-section">
<NButton
v-if="wxQrStatus === 'idle' || wxQrStatus === 'error' || wxQrStatus === 'expired' || wxQrStatus === 'confirmed'"
type="primary"
size="small"
@click="startWeixinQrLogin"
>
{{ wxQrStatus === 'confirmed' ? t('platform.qrRelogin') : t('platform.qrLogin') }}
</NButton>
<div v-if="wxQrStatus === 'loading'" class="weixin-qr-loading">
<NSpin size="small" />
<span>{{ t('platform.qrFetching') }}</span>
</div>
<div v-if="wxQrStatus === 'waiting' || wxQrStatus === 'scaned'" class="weixin-qr-hint">
{{ wxQrStatus === 'scaned' ? t('platform.qrScanedHint') : t('platform.qrScanHint') }}
</div>
</div>
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
<NInput :value="credentialDraft('weixin').token || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => setCredentialDraft('weixin', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
<NInput :value="credentialDraft('weixin').extra?.account_id || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => setCredentialDraft('weixin', { extra: { ...credentialDraft('weixin').extra, account_id: v } })" />
</SettingRow>
</template>
<!-- WeCom -->
<template v-if="p.key === 'wecom'">
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
<NInput :value="credentialDraft('wecom').extra?.bot_id || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, bot_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
<NInput :value="credentialDraft('wecom').extra?.secret || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, secret: v } })" />
</SettingRow>
</template>
<div class="platform-actions">
<NButton
type="primary"
size="small"
:loading="isSavingPlatform(p.key)"
:disabled="!hasUnsavedChanges(p.key)"
@click="savePlatform(p.key)"
>
{{ t('common.save') }}
</NButton>
</div>
</PlatformCard>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
.weixin-qr-section {
margin-top: 12px;
margin-bottom: 12px;
}
.weixin-qr-loading {
display: flex;
align-items: center;
gap: 8px;
color: $text-muted;
font-size: 13px;
}
.weixin-qr-hint {
font-size: 13px;
color: $text-secondary;
}
.platform-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid $border-light;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
async function save(values: Record<string, any>) {
try {
await settingsStore.saveSection('privacy', values)
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
}
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.privacy.redactPii')" :hint="t('settings.privacy.redactPiiHint')">
<NSwitch :value="settingsStore.privacy.redact_pii" @update:value="v => save({ redact_pii: v })" />
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { NInputNumber, NSelect, NSwitch, useMessage } from "naive-ui";
import { useI18n } from "vue-i18n";
import { useSettingsStore } from "@/stores/hermes/settings";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import SettingRow from "./SettingRow.vue";
const settingsStore = useSettingsStore();
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
const message = useMessage();
const { t } = useI18n();
// 防抖保存:每个字段独立定时器,300ms 内只发最后一次 HTTP 请求
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function save(values: Record<string, any>) {
// NSelect/NSwitch 等一次性操作,直接保存,不需要防抖
settingsStore.updateLocal('session_reset', values)
settingsStore.saveSection('session_reset', values).then(() => {
message.success(t("settings.saved"));
}).catch(() => {
message.error(t("settings.saveFailed"));
});
}
function debouncedSave(key: string, value: any) {
// 先立即更新本地 store(UI 即时响应)
settingsStore.updateLocal('session_reset', { [key]: value });
// 再防抖发 HTTP 保存
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
debounceTimers[key] = setTimeout(async () => {
try {
await settingsStore.saveSection('session_reset', { [key]: value });
message.success(t("settings.saved"));
} catch (err: any) {
message.error(t("settings.saveFailed"));
}
}, 300);
}
async function toggleRequireAuth(value: boolean) {
try {
await settingsStore.saveSection("approvals", { mode: value ? "manual" : "off" });
message.success(t("settings.saved"));
} catch (err: any) {
message.error(t("settings.saveFailed"));
}
}
</script>
<template>
<section class="settings-section">
<SettingRow
:label="t('settings.session.requireAuth')"
:hint="t('settings.session.requireAuthHint')"
>
<NSwitch :value="settingsStore.approvals.mode === 'manual'" @update:value="toggleRequireAuth" />
</SettingRow>
<SettingRow
:label="t('settings.session.mode')"
:hint="t('settings.session.modeHint')"
>
<NSelect
:value="settingsStore.sessionReset.mode || 'both'"
:options="[
{ label: t('settings.session.modeBoth'), value: 'both' },
{ label: t('settings.session.modeIdle'), value: 'idle' },
{ label: t('settings.session.modeDaily'), value: 'daily' },
{ label: t('settings.session.modeNone'), value: 'none' },
]"
size="small"
class="input-md"
@update:value="(v) => save({ mode: v })"
/>
</SettingRow>
<SettingRow
:label="t('settings.session.idleMinutes')"
:hint="t('settings.session.idleMinutesHint')"
>
<NInputNumber
:value="settingsStore.sessionReset.idle_minutes"
:min="10"
:max="10080"
:step="30"
size="small"
class="input-sm"
@update:value="(v) => v != null && debouncedSave('idle_minutes', v)"
/>
</SettingRow>
<SettingRow
:label="t('settings.session.atHour')"
:hint="t('settings.session.atHourHint')"
>
<NInputNumber
:value="settingsStore.sessionReset.at_hour"
:min="0"
:max="23"
:step="1"
size="small"
class="input-sm"
@update:value="(v) => v != null && debouncedSave('at_hour', v)"
/>
</SettingRow>
<SettingRow
:label="t('settings.session.liveMonitorHumanOnly')"
:hint="t('settings.session.liveMonitorHumanOnlyHint')"
>
<NSwitch
:value="sessionBrowserPrefsStore.humanOnly"
@update:value="(value) => sessionBrowserPrefsStore.setHumanOnly(value)"
/>
</SettingRow>
</section>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.settings-section {
margin-top: 16px;
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
label: string
hint?: string
}>()
</script>
<template>
<div class="setting-row">
<div class="setting-info">
<label class="setting-label">{{ label }}</label>
<p v-if="hint" class="setting-hint">{{ hint }}</p>
</div>
<div class="setting-control">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid $border-light;
&:last-child {
border-bottom: none;
}
}
.setting-info {
flex: 1;
margin-right: 16px;
}
.setting-label {
font-size: 13px;
color: $text-primary;
display: block;
}
.setting-hint {
font-size: 12px;
color: $text-muted;
margin-top: 2px;
}
.setting-control {
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.setting-info {
margin-right: 0;
}
.setting-control {
width: 100%;
}
}
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSelect, NSpace, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import {
createManagedUser,
deleteManagedUser,
fetchManagedUsers,
updateManagedUser,
type ManagedUser,
type UserRole,
type UserStatus,
} from '@/api/auth'
const { t } = useI18n()
const message = useMessage()
const loading = ref(false)
const saving = ref(false)
const users = ref<ManagedUser[]>([])
const profiles = ref<string[]>([])
const showModal = ref(false)
const editingUser = ref<ManagedUser | null>(null)
const form = reactive({
username: '',
password: '',
role: 'admin' as UserRole,
status: 'active' as UserStatus,
profiles: [] as string[],
})
const roleOptions = computed(() => [
{ label: t('users.roles.admin'), value: 'admin' },
{ label: t('users.roles.superAdmin'), value: 'super_admin' },
])
const statusOptions = computed(() => [
{ label: t('users.status.active'), value: 'active' },
{ label: t('users.status.disabled'), value: 'disabled' },
])
const profileOptions = computed(() => profiles.value.map(profile => ({ label: profile, value: profile })))
function resetForm() {
editingUser.value = null
form.username = ''
form.password = ''
form.role = 'admin'
form.status = 'active'
form.profiles = []
}
async function loadUsers() {
loading.value = true
try {
const res = await fetchManagedUsers()
users.value = res.users
profiles.value = res.profiles
} catch (err: any) {
message.error(err.message || t('users.loadFailed'))
} finally {
loading.value = false
}
}
function openCreate() {
resetForm()
showModal.value = true
}
function openEdit(user: ManagedUser) {
editingUser.value = user
form.username = user.username
form.password = ''
form.role = user.role
form.status = user.status
form.profiles = [...user.profiles]
showModal.value = true
}
async function submit() {
if (form.username.trim().length < 2) {
message.error(t('login.usernameTooShort'))
return
}
if (!editingUser.value && form.password.length < 6) {
message.error(t('login.passwordTooShort'))
return
}
if (form.password && form.password.length < 6) {
message.error(t('login.passwordTooShort'))
return
}
saving.value = true
try {
const payload = {
username: form.username.trim(),
password: form.password || undefined,
role: form.role,
status: form.status,
profiles: form.role === 'super_admin' ? [] : form.profiles,
defaultProfile: form.profiles[0] || null,
}
const res = editingUser.value
? await updateManagedUser(editingUser.value.id, payload)
: await createManagedUser({ ...payload, password: form.password })
users.value = res.users
profiles.value = res.profiles
showModal.value = false
resetForm()
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function setStatus(user: ManagedUser, status: UserStatus) {
saving.value = true
try {
const res = await updateManagedUser(user.id, { status })
users.value = res.users
profiles.value = res.profiles
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
} finally {
saving.value = false
}
}
async function removeUser(user: ManagedUser) {
saving.value = true
try {
const res = await deleteManagedUser(user.id)
users.value = res.users
profiles.value = res.profiles
message.success(t('common.saved'))
} catch (err: any) {
message.error(err.message || t('common.deleteFailed'))
} finally {
saving.value = false
}
}
function formatTime(value: number | null): string {
if (!value) return '-'
return new Date(value).toLocaleString()
}
const columns = computed<DataTableColumns<ManagedUser>>(() => [
{
title: t('users.username'),
key: 'username',
minWidth: 140,
},
{
title: t('users.role'),
key: 'role',
width: 130,
render: (row) => h(NTag, { size: 'small', type: row.role === 'super_admin' ? 'warning' : 'default' }, {
default: () => row.role === 'super_admin' ? t('users.roles.superAdmin') : t('users.roles.admin'),
}),
},
{
title: t('users.statusLabel'),
key: 'status',
width: 110,
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'error' }, {
default: () => row.status === 'active' ? t('users.status.active') : t('users.status.disabled'),
}),
},
{
title: t('users.profiles'),
key: 'profiles',
minWidth: 200,
render: (row) => row.role === 'super_admin'
? h('span', { class: 'muted' }, t('users.allProfiles'))
: h(NSpace, { size: 4 }, {
default: () => row.profiles.length
? row.profiles.map(profile => h(NTag, { size: 'small', bordered: false }, { default: () => profile }))
: h('span', { class: 'muted' }, t('users.noProfiles')),
}),
},
{
title: t('users.lastLogin'),
key: 'last_login_at',
minWidth: 170,
render: (row) => formatTime(row.last_login_at),
},
{
title: t('common.edit'),
key: 'actions',
width: 280,
render: (row) => h(NSpace, { size: 8 }, {
default: () => [
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => t('common.edit') }),
h(NButton, {
size: 'small',
type: row.status === 'active' ? 'warning' : 'primary',
ghost: true,
loading: saving.value,
onClick: () => setStatus(row, row.status === 'active' ? 'disabled' : 'active'),
}, { default: () => row.status === 'active' ? t('users.disable') : t('users.enable') }),
h(NPopconfirm, { onPositiveClick: () => removeUser(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true, loading: saving.value }, { default: () => t('common.delete') }),
default: () => t('users.deleteConfirm'),
}),
],
}),
},
])
onMounted(loadUsers)
</script>
<template>
<div class="user-management">
<div class="toolbar">
<div>
<h3 class="section-title">{{ t('users.title') }}</h3>
<p class="section-desc">{{ t('users.description') }}</p>
</div>
<NButton type="primary" @click="openCreate">{{ t('users.create') }}</NButton>
</div>
<NDataTable
:columns="columns"
:data="users"
:loading="loading"
:bordered="false"
:single-line="false"
size="small"
/>
<NModal v-model:show="showModal" preset="dialog" :title="editingUser ? t('users.edit') : t('users.create')">
<NForm label-placement="top">
<NFormItem :label="t('users.username')">
<NInput v-model:value="form.username" :placeholder="t('login.usernamePlaceholder')" />
</NFormItem>
<NFormItem :label="editingUser ? t('users.newPasswordOptional') : t('login.newPassword')">
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('users.role')">
<NSelect v-model:value="form.role" :options="roleOptions" />
</NFormItem>
<NFormItem :label="t('users.statusLabel')">
<NSelect v-model:value="form.status" :options="statusOptions" />
</NFormItem>
<NFormItem v-if="form.role !== 'super_admin'" :label="t('users.profiles')">
<NSelect
v-model:value="form.profiles"
multiple
filterable
:options="profileOptions"
:placeholder="t('users.profilesPlaceholder')"
/>
</NFormItem>
</NForm>
<template #action>
<NButton @click="showModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="saving" @click="submit">{{ t('common.save') }}</NButton>
</template>
</NModal>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.user-management {
padding: 8px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.section-title {
margin: 0 0 6px;
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.section-desc {
margin: 0;
font-size: 13px;
color: $text-muted;
}
:deep(.muted) {
color: $text-muted;
}
</style>
@@ -0,0 +1,517 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NSelect, NInput, NButton, NSlider } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useVoiceSettings } from '@/composables/useVoiceSettings'
import { useSpeech } from '@/composables/useSpeech'
import { speedToEdgeRate, hzToEdgePitch } from '@/utils/ttsHelpers'
import SettingRow from './SettingRow.vue'
const { t } = useI18n()
const vs = useVoiceSettings()
const speech = useSpeech()
const testText = ref(t('settings.voice.testTextDefault'))
const testPlaying = ref(false)
const providerOptions = [
{ label: t('settings.voice.providerWebSpeech'), value: 'webspeech' },
{ label: t('settings.voice.providerOpenai'), value: 'openai' },
{ label: t('settings.voice.providerCustom'), value: 'custom' },
{ label: t('settings.voice.providerEdge'), value: 'edge' },
{ label: t('settings.voice.providerMimo'), value: 'mimo' },
]
const openaiModelOptions = [
{ label: 'tts-1', value: 'tts-1' },
{ label: 'tts-1-hd', value: 'tts-1-hd' },
]
const openaiVoiceOptions = [
{ label: 'Alloy', value: 'alloy' },
{ label: 'Echo', value: 'echo' },
{ label: 'Fable', value: 'fable' },
{ label: 'Nova', value: 'nova' },
{ label: 'Onyx', value: 'onyx' },
{ label: 'Shimmer', value: 'shimmer' },
]
const edgeVoiceOptions = [
{ label: '晓晓 (zh-CN-XiaoxiaoNeural)', value: 'zh-CN-XiaoxiaoNeural' },
{ label: '晓萱 (zh-CN-XiaoxuanNeural)', value: 'zh-CN-XiaoxuanNeural' },
{ label: '云希 (zh-CN-YunxiNeural)', value: 'zh-CN-YunxiNeural' },
{ label: '云健 (zh-CN-YunjianNeural)', value: 'zh-CN-YunjianNeural' },
{ label: '云扬 (zh-CN-YunyangNeural)', value: 'zh-CN-YunyangNeural' },
{ label: '小晨 (zh-TW-HsiaoChenNeural)', value: 'zh-TW-HsiaoChenNeural' },
{ label: '小宇 (zh-TW-HsiaoYuNeural)', value: 'zh-TW-HsiaoYuNeural' },
{ label: '云哲 (zh-TW-YunJheNeural)', value: 'zh-TW-YunJheNeural' },
{ label: '希雅 (zh-HK-HiuGaaiNeural)', value: 'zh-HK-HiuGaaiNeural' },
{ label: '希文 (zh-HK-HiuMaanNeural)', value: 'zh-HK-HiuMaanNeural' },
{ label: '文龙 (zh-HK-WanLungNeural)', value: 'zh-HK-WanLungNeural' },
{ label: 'Jenny (en-US-JennyNeural)', value: 'en-US-JennyNeural' },
{ label: 'Aria (en-US-AriaNeural)', value: 'en-US-AriaNeural' },
{ label: 'Guy (en-US-GuyNeural)', value: 'en-US-GuyNeural' },
{ label: 'Sonia (en-GB-SoniaNeural)', value: 'en-GB-SoniaNeural' },
{ label: 'Ryan (en-GB-RyanNeural)', value: 'en-GB-RyanNeural' },
{ label: 'Nanami (ja-JP-NanamiNeural)', value: 'ja-JP-NanamiNeural' },
{ label: 'Keita (ja-JP-KeitaNeural)', value: 'ja-JP-KeitaNeural' },
{ label: 'Sun-Hi (ko-KR-SunHiNeural)', value: 'ko-KR-SunHiNeural' },
{ label: 'InJoon (ko-KR-InJoonNeural)', value: 'ko-KR-InJoonNeural' },
{ label: 'Denise (fr-FR-DeniseNeural)', value: 'fr-FR-DeniseNeural' },
{ label: 'Henri (fr-FR-HenriNeural)', value: 'fr-FR-HenriNeural' },
{ label: 'Katja (de-DE-KatjaNeural)', value: 'de-DE-KatjaNeural' },
{ label: 'Conrad (de-DE-ConradNeural)', value: 'de-DE-ConradNeural' },
]
// Get WebSpeech voices list on mount
const webspeechVoices = ref<SpeechSynthesisVoice[]>([])
onMounted(() => {
if ('speechSynthesis' in window) {
const voices = window.speechSynthesis.getVoices()
if (voices.length) {
webspeechVoices.value = voices
}
window.speechSynthesis.onvoiceschanged = () => {
webspeechVoices.value = window.speechSynthesis.getVoices()
}
}
})
// ── MiMo TTS options ──
const mimoBaseUrlOptions = [
{ label: 'https://api.xiaomimimo.com/v1', value: 'https://api.xiaomimimo.com/v1' },
{ label: 'https://token-plan-cn.xiaomimimo.com/v1', value: 'https://token-plan-cn.xiaomimimo.com/v1' },
]
const mimoModelOptions = [
{ label: t('settings.voice.mimoModelPreset'), value: 'mimo-v2.5-tts' },
{ label: t('settings.voice.mimoModelVoiceDesign'), value: 'mimo-v2.5-tts-voicedesign' },
]
const mimoVoiceOptions = [
{ label: '冰糖 (中文·女)', value: '冰糖' },
{ label: '茉莉 (中文·女)', value: '茉莉' },
{ label: '苏打 (中文·男)', value: '苏打' },
{ label: '白桦 (中文·男)', value: '白桦' },
{ label: 'Mia (English·Female)', value: 'Mia' },
{ label: 'Chloe (English·Female)', value: 'Chloe' },
{ label: 'Milo (English·Male)', value: 'Milo' },
{ label: 'Dean (English·Male)', value: 'Dean' },
]
async function handleTest() {
const text = testText.value.trim()
if (!text) return
testPlaying.value = true
try {
if (vs.provider.value === 'webspeech') {
speech.stop(false)
speech.speakViaBrowser('__test__', text, {
voiceName: vs.webspeechVoice.value || undefined,
})
} else if (vs.provider.value === 'openai') {
if (!vs.openaiBaseUrl.value) {
console.warn('[VoiceSettings] OpenAI base URL empty')
return
}
await speech.openaiPlay('__test__', text, {
baseUrl: vs.openaiBaseUrl.value,
apiKey: vs.openaiApiKey.value || undefined,
model: vs.openaiModel.value,
voice: vs.openaiVoice.value,
})
} else if (vs.provider.value === 'custom') {
if (!vs.customUrl.value) {
console.warn('[VoiceSettings] Custom URL empty')
return
}
await speech.openaiPlay('__test__', text, {
baseUrl: vs.customUrl.value,
apiKey: vs.customApiKey.value || undefined,
})
} else if (vs.provider.value === 'edge') {
await speech.openaiPlay('__test__', text, {
baseUrl: '/api/tts/proxy',
voice: vs.edgeVoice.value,
rate: speedToEdgeRate(vs.edgeRate.value),
pitch: hzToEdgePitch(vs.edgePitchHz.value),
})
} else if (vs.provider.value === 'mimo') {
if (!vs.mimoApiKey.value) {
console.warn('[VoiceSettings] MiMo API Key empty')
return
}
await speech.mimoPlay('__test__', text, {
baseUrl: vs.mimoBaseUrl.value,
apiKey: vs.mimoApiKey.value,
model: vs.mimoModel.value,
voice: vs.mimoVoice.value,
voiceDesignDesc: vs.mimoVoiceDesignDesc.value || undefined,
stylePrompt: vs.mimoStylePrompt.value || undefined,
})
}
} catch (err) {
console.error('[VoiceSettings] Test failed:', err)
} finally {
testPlaying.value = false
}
}
</script>
<template>
<div class="voice-settings">
<SettingRow
:label="t('settings.voice.ttsProvider')"
:hint="t('settings.voice.ttsProviderHint')"
>
<NSelect
:value="vs.provider.value"
:options="providerOptions"
size="small"
style="width: 300px"
@update:value="vs.setProvider"
/>
</SettingRow>
<!-- WebSpeech API -->
<template v-if="vs.provider.value === 'webspeech'">
<SettingRow
:label="t('settings.voice.webspeechVoice')"
:hint="t('settings.voice.webspeechVoiceHint')"
>
<NSelect
:value="vs.webspeechVoice.value"
size="small"
filterable
style="width: 320px"
:placeholder="t('settings.voice.webspeechVoicePlaceholder')"
:consistent-menu-width="false"
:options="webspeechVoices.map(v => ({
label: `${v.name} (${v.lang})`,
value: v.name,
}))"
@update:value="vs.setWebSpeechVoice"
/>
</SettingRow>
</template>
<!-- OpenAI TTS -->
<template v-if="vs.provider.value === 'openai'">
<SettingRow
:label="t('settings.voice.openaiKey')"
:hint="t('settings.voice.openaiKeyHint')"
>
<NInput
:value="vs.openaiApiKey.value"
type="password"
size="small"
show-password-on="click"
style="width: 360px"
placeholder="sk-..."
@update:value="vs.setOpenaiApiKey"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.openaiUrl')"
:hint="t('settings.voice.openaiUrlHint')"
>
<NInput
:value="vs.openaiBaseUrl.value"
size="small"
style="width: 360px"
placeholder="https://api.openai.com/v1/audio/speech"
@update:value="vs.setOpenaiBaseUrl"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.openaiModel')"
:hint="t('settings.voice.openaiModelHint')"
>
<NSelect
:value="vs.openaiModel.value"
:options="openaiModelOptions"
size="small"
style="width: 200px"
@update:value="vs.setOpenaiModel"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.openaiVoice')"
:hint="t('settings.voice.openaiVoiceHint')"
>
<NSelect
:value="vs.openaiVoice.value"
:options="openaiVoiceOptions"
size="small"
style="width: 200px"
@update:value="vs.setOpenaiVoice"
/>
</SettingRow>
</template>
<!-- Custom Endpoint -->
<template v-if="vs.provider.value === 'custom'">
<div class="provider-hint">
{{ t('settings.voice.customHint') }}
</div>
<SettingRow
:label="t('settings.voice.customUrl')"
:hint="t('settings.voice.customUrlHint')"
>
<NInput
:value="vs.customUrl.value"
size="small"
style="width: 360px"
:placeholder="t('settings.voice.customUrlPlaceholder')"
@update:value="vs.setCustomUrl"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.customApiKey')"
:hint="t('settings.voice.customApiKeyHint')"
>
<NInput
:value="vs.customApiKey.value"
type="password"
size="small"
show-password-on="click"
style="width: 360px"
:placeholder="t('settings.voice.customApiKeyPlaceholder')"
@update:value="vs.setCustomApiKey"
/>
</SettingRow>
</template>
<!-- Edge TTS -->
<template v-if="vs.provider.value === 'edge'">
<div class="provider-hint">
{{ t('settings.voice.edgeHint') }}
</div>
<SettingRow
:label="t('settings.voice.edgeVoice')"
:hint="t('settings.voice.edgeVoiceHint')"
>
<NSelect
:value="vs.edgeVoice.value"
:options="edgeVoiceOptions"
size="small"
filterable
style="width: 320px"
:consistent-menu-width="false"
@update:value="vs.setEdgeVoice"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.edgeRate')"
:hint="t('settings.voice.edgeRateHint')"
>
<div class="slider-row">
<NSlider
:value="vs.edgeRate.value"
:min="0.5"
:max="2.0"
:step="0.05"
style="width: 200px"
@update:value="vs.setEdgeRate"
/>
<span class="slider-value">{{ vs.edgeRate.value.toFixed(2) }}x ({{ speedToEdgeRate(vs.edgeRate.value) }})</span>
</div>
</SettingRow>
<SettingRow
:label="t('settings.voice.edgePitch')"
:hint="t('settings.voice.edgePitchHint')"
>
<div class="slider-row">
<NSlider
:value="vs.edgePitchHz.value"
:min="-20"
:max="20"
:step="1"
style="width: 200px"
@update:value="vs.setEdgePitchHz"
/>
<span class="slider-value">{{ vs.edgePitchHz.value > 0 ? '+' : '' }}{{ vs.edgePitchHz.value }} Hz ({{ hzToEdgePitch(vs.edgePitchHz.value) }})</span>
</div>
</SettingRow>
</template>
<!-- MiMo TTS -->
<template v-if="vs.provider.value === 'mimo'">
<div class="provider-hint">
{{ t('settings.voice.mimoHint') }}
</div>
<SettingRow
:label="t('settings.voice.mimoApiKey')"
:hint="t('settings.voice.mimoApiKeyHint')"
>
<NInput
:value="vs.mimoApiKey.value"
type="password"
size="small"
show-password-on="click"
style="width: 360px"
:placeholder="t('settings.voice.mimoApiKeyPlaceholder')"
@update:value="vs.setMimoApiKey"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.mimoBaseUrl')"
:hint="t('settings.voice.mimoBaseUrlHint')"
>
<NSelect
:value="vs.mimoBaseUrl.value"
:options="mimoBaseUrlOptions"
size="small"
filterable
tag
style="width: 360px"
@update:value="vs.setMimoBaseUrl"
/>
</SettingRow>
<SettingRow
:label="t('settings.voice.mimoModel')"
:hint="t('settings.voice.mimoModelHint')"
>
<NSelect
:value="vs.mimoModel.value"
:options="mimoModelOptions"
size="small"
style="width: 320px"
@update:value="vs.setMimoModel"
/>
</SettingRow>
<!-- Preset voice mode -->
<SettingRow
v-if="vs.mimoModel.value === 'mimo-v2.5-tts'"
:label="t('settings.voice.mimoVoice')"
:hint="t('settings.voice.mimoVoiceHint')"
>
<NSelect
:value="vs.mimoVoice.value"
:options="mimoVoiceOptions"
size="small"
style="width: 200px"
@update:value="vs.setMimoVoice"
/>
</SettingRow>
<!-- Voice design mode -->
<SettingRow
v-if="vs.mimoModel.value === 'mimo-v2.5-tts-voicedesign'"
:label="t('settings.voice.mimoVoiceDesignPrompt')"
:hint="t('settings.voice.mimoVoiceDesignPromptHint')"
>
<NInput
:value="vs.mimoVoiceDesignDesc.value"
type="textarea"
size="small"
style="width: 360px"
:rows="3"
:placeholder="t('settings.voice.mimoVoiceDesignPromptPlaceholder')"
@update:value="vs.setMimoVoiceDesignDesc"
/>
</SettingRow>
<!-- Style prompt (available for all models) -->
<SettingRow
:label="t('settings.voice.mimoStylePrompt')"
:hint="t('settings.voice.mimoStylePromptHint')"
>
<NInput
:value="vs.mimoStylePrompt.value"
type="textarea"
size="small"
style="width: 360px"
:rows="2"
:placeholder="t('settings.voice.mimoStylePromptPlaceholder')"
@update:value="vs.setMimoStylePrompt"
/>
</SettingRow>
</template>
<!-- Test / Audition -->
<div class="test-section">
<h4 class="test-title">{{ t('settings.voice.testTitle') }}</h4>
<div class="test-row">
<NInput
v-model:value="testText"
size="small"
style="width: 360px"
:placeholder="t('settings.voice.testTextPlaceholder')"
:disabled="testPlaying"
@keyup.enter="handleTest"
/>
<NButton
size="small"
type="primary"
:loading="testPlaying"
:disabled="testPlaying"
@click="handleTest"
>
{{ testPlaying ? t('settings.voice.testButtonPlaying') : t('settings.voice.testButton') }}
</NButton>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.voice-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.provider-hint {
font-size: 12px;
color: #888;
line-height: 1.5;
padding: 0 0 4px 0;
}
.test-section {
padding-top: 16px;
.test-title {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
}
.test-row {
display: flex;
gap: 8px;
align-items: center;
}
}
.slider-row {
display: flex;
align-items: center;
gap: 12px;
}
.slider-value {
font-size: 12px;
color: #999;
white-space: nowrap;
min-width: 120px;
}
</style>
@@ -0,0 +1,350 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
import { fetchSkillContent, fetchSkillFiles, pinSkillApi, type SkillFileEntry } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
const { t } = useI18n()
const message = useMessage()
const props = defineProps<{
category: string
skill: string
skillName: string
patchCount?: number
useCount?: number
viewCount?: number
pinned?: boolean
}>()
const emit = defineEmits<{
pinToggled: [name: string, pinned: boolean]
}>()
const content = ref('')
const files = ref<SkillFileEntry[]>([])
const loading = ref(false)
const fileContent = ref('')
const viewingFile = ref<string | null>(null)
const fileLoading = ref(false)
async function loadSkill() {
loading.value = true
viewingFile.value = null
fileContent.value = ''
files.value = []
content.value = ''
try {
const skillPath = `${props.category}/${props.skill}/SKILL.md`
const [skillContent, skillFiles] = await Promise.all([
fetchSkillContent(skillPath),
fetchSkillFiles(props.category, props.skill),
])
content.value = skillContent
files.value = skillFiles.filter(f => !f.isDir && f.path !== 'SKILL.md')
} catch (err: any) {
content.value = t('skills.loadFailed') + `: ${err.message}`
} finally {
loading.value = false
}
}
async function viewFile(filePath: string) {
fileLoading.value = true
viewingFile.value = filePath
try {
// filePath might be absolute or relative; normalize to relative under category/skill/
const base = `${props.category}/${props.skill}/`
let relPath = filePath
if (filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) {
// Strip absolute prefix to get relative path
const normalizedPath = filePath.replace(/\\/g, '/')
const segments = normalizedPath.split(/(?:^|\/)(?:\.hermes|hermes)\/skills\//)[1]
if (segments) {
const afterSkillDir = segments.split('/').slice(2).join('/')
relPath = afterSkillDir
}
}
fileContent.value = await fetchSkillContent(`${base}${relPath}`)
} catch (err: any) {
fileContent.value = t('skills.fileLoadFailed') + `: ${err.message}`
} finally {
fileLoading.value = false
}
}
function backToSkill() {
viewingFile.value = null
fileContent.value = ''
}
const pinLoading = ref(false)
async function handlePinToggle() {
if (pinLoading.value) return
pinLoading.value = true
try {
const newPinned = !props.pinned
await pinSkillApi(props.skillName, newPinned)
emit('pinToggled', props.skillName, newPinned)
} catch (err: any) {
message.error(t('skills.pinFailed') + `: ${err.message}`)
} finally {
pinLoading.value = false
}
}
watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
</script>
<template>
<div class="skill-detail">
<!-- Skill title -->
<div class="detail-title">
<span class="detail-category">{{ category }}</span>
<span class="detail-separator">/</span>
<span class="detail-name">{{ skill }}</span>
<div class="usage-stats">
<button class="pin-toggle" :class="{ active: pinned }" :disabled="pinLoading" :title="pinned ? t('skills.unpin') : t('skills.pin')" @click="handlePinToggle">
<svg width="16" height="16" viewBox="0 0 24 24" :fill="pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>
</button>
<span v-if="viewCount != null" class="usage-stat" title="Views">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{{ viewCount }}
</span>
<span v-if="useCount != null" class="usage-stat" title="Uses">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{{ useCount }}
</span>
<span v-if="patchCount != null" class="usage-stat" title="Patches">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
{{ patchCount }}
</span>
</div>
</div>
<div v-if="loading && !content" class="detail-loading">{{ t('common.loading') }}</div>
<template v-else>
<!-- Breadcrumb for file view -->
<div v-if="viewingFile" class="detail-breadcrumb">
<button class="back-btn" @click="backToSkill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6" />
</svg>
{{ t('skills.backTo') }} {{ skill }}
</button>
<span class="breadcrumb-path">{{ viewingFile }}</span>
</div>
<!-- Skill content -->
<div class="detail-content">
<MarkdownRenderer v-if="viewingFile" :content="fileContent" />
<MarkdownRenderer v-else :content="content" />
</div>
<!-- Attached files -->
<div v-if="!viewingFile && files.length > 0" class="detail-files">
<div class="files-header">{{ t('skills.attachedFiles') }}</div>
<div class="files-list">
<button
v-for="f in files"
:key="f.path"
class="file-item"
@click="viewFile(f.path)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span>{{ f.path }}</span>
</button>
</div>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.skill-detail {
height: 100%;
display: flex;
flex-direction: column;
}
.detail-title {
flex-shrink: 0;
padding-bottom: 12px;
border-bottom: 1px solid $border-color;
margin-bottom: 12px;
font-size: 15px;
display: flex;
align-items: center;
}
.detail-category {
color: $text-muted;
font-size: 13px;
}
.detail-separator {
color: $text-muted;
margin: 0 6px;
}
.detail-name {
color: $text-primary;
font-weight: 600;
}
.usage-stats {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
padding-left: 12px;
}
.usage-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
color: $text-secondary;
white-space: nowrap;
svg {
opacity: 0.7;
}
}
.pin-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: none;
color: $text-muted;
cursor: pointer;
padding: 4px;
border-radius: 6px;
opacity: 0.5;
transition: all $transition-fast;
&:hover {
opacity: 1;
color: $accent-primary;
background: rgba(var(--accent-primary-rgb), 0.08);
border-color: rgba(var(--accent-primary-rgb), 0.15);
}
&.active {
opacity: 1;
color: $accent-primary;
}
&:disabled {
cursor: wait;
opacity: 0.3;
}
}
.detail-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: $text-muted;
}
.detail-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 12px;
border-bottom: 1px solid $border-color;
margin-bottom: 12px;
flex-shrink: 0;
}
.back-btn {
display: flex;
align-items: center;
gap: 4px;
border: none;
background: none;
color: $accent-primary;
font-size: 13px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.06);
}
}
.breadcrumb-path {
font-size: 13px;
color: $text-muted;
}
.detail-content {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: 12px;
:deep(hr) {
border: none;
margin: 12px 0;
}
}
.detail-files {
flex-shrink: 0;
border-top: 1px solid $border-color;
padding-top: 12px;
margin-top: 12px;
}
.files-header {
font-size: 12px;
font-weight: 600;
color: $text-muted;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 6px;
}
.files-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-secondary;
color: $text-secondary;
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: $accent-primary;
color: $accent-primary;
}
}
</style>
@@ -0,0 +1,339 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NSwitch, useMessage } from 'naive-ui'
import type { SkillCategory, SkillSource, SkillInfo } from '@/api/hermes/skills'
import { toggleSkill } from '@/api/hermes/skills'
import { useI18n } from 'vue-i18n'
type SourceFilter = SkillSource | 'modified'
const { t } = useI18n()
const message = useMessage()
const props = defineProps<{
categories: SkillCategory[]
archived: SkillInfo[]
selectedSkill: string | null
searchQuery: string
sourceFilter: SourceFilter | null
}>()
const emit = defineEmits<{
select: [category: string, skill: string]
}>()
const collapsedCategories = ref<Set<string>>(new Set())
const archiveCollapsed = ref(true)
const togglingSkills = ref<Set<string>>(new Set())
const filteredArchived = computed(() => {
let result = props.archived
if (props.sourceFilter && props.sourceFilter !== 'modified') {
result = result.filter(s => (s.source || 'local') === props.sourceFilter)
}
if (props.searchQuery) {
const q = props.searchQuery.toLowerCase()
result = result.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
}
return result
})
const filteredCategories = computed(() => {
let result = props.categories
// Filter by source
if (props.sourceFilter) {
result = result
.map(cat => ({
...cat,
skills: cat.skills.filter(s => {
if (props.sourceFilter === 'modified') return s.modified
return (s.source || 'local') === props.sourceFilter
}),
}))
.filter(cat => cat.skills.length > 0)
}
// Filter by search query
if (props.searchQuery) {
const q = props.searchQuery.toLowerCase()
result = result
.map(cat => ({
...cat,
skills: cat.skills.filter(
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
),
}))
.filter(cat => cat.skills.length > 0 || cat.name.toLowerCase().includes(q))
}
return result
})
function toggleCategory(name: string) {
if (collapsedCategories.value.has(name)) {
collapsedCategories.value.delete(name)
} else {
collapsedCategories.value.add(name)
}
}
function handleSelect(category: string, skillName: string) {
emit('select', category, skillName)
}
/** Unique key for selection tracking */
function skillKey(catName: string, skill: { name: string }): string {
return `${catName}/${skill.name}`
}
async function handleToggle(category: string, skillName: string, newEnabled: boolean) {
if (togglingSkills.value.has(skillName)) return
togglingSkills.value.add(skillName)
try {
await toggleSkill(skillName, newEnabled)
// Update local state
const cat = props.categories.find(c => c.name === category)
const skill = cat?.skills.find(s => s.name === skillName)
if (skill) skill.enabled = newEnabled
} catch (err: any) {
message.error(t('skills.toggleFailed') + `: ${err.message}`)
} finally {
togglingSkills.value.delete(skillName)
}
}
</script>
<template>
<div class="skill-list">
<div v-if="filteredCategories.length === 0" class="skill-empty">
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
</div>
<div v-for="cat in filteredCategories" :key="cat.name" class="skill-category">
<button class="category-header" @click="toggleCategory(cat.name)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="category-arrow" :class="{ collapsed: collapsedCategories.has(cat.name) }">
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="category-name">{{ cat.name }}</span>
<span class="category-count">{{ cat.skills.length }}</span>
</button>
<div v-if="!collapsedCategories.has(cat.name)" class="category-skills">
<button v-for="skill in cat.skills" :key="skillKey(cat.name, skill)" class="skill-item" :class="[
{ active: selectedSkill === skillKey(cat.name, skill) },
`source-${skill.source || 'local'}`,
]" @click="handleSelect(cat.name, skill.name)">
<div class="skill-info">
<span class="skill-name">
<span class="source-dot" :class="`dot-${skill.source || 'local'}`"
:title="t(`skills.source.${skill.source || 'local'}`)" />
{{ skill.name }}
<span v-if="skill.modified" class="modified-badge"
:title="t('skills.modified')"></span>
</span>
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
</div>
<NSwitch size="small" :value="skill.enabled !== false" :loading="togglingSkills.has(skill.name)"
@update:value="handleToggle(cat.name, skill.name, $event)" @click.stop />
</button>
</div>
</div>
<!-- Archived skills (separate section) -->
<div v-if="filteredArchived.length > 0 || archived.length > 0" class="skill-category archive-section">
<button class="category-header archive-header" @click="archiveCollapsed = !archiveCollapsed">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
class="category-arrow" :class="{ collapsed: archiveCollapsed }">
<polyline points="6 9 12 15 18 9" />
</svg>
<span class="category-name">{{ t('skills.archived') }}</span>
<span class="category-count">{{ archived.length }}</span>
</button>
<div v-if="!archiveCollapsed" class="category-skills">
<button v-for="skill in filteredArchived" :key="skillKey('.archive', skill)" class="skill-item skill-archived"
:class="{ active: selectedSkill === skillKey('.archive', skill) }"
@click="handleSelect('.archive', skill.name)">
<div class="skill-info">
<span class="skill-name">
<span class="source-dot" :class="`dot-${skill.source || 'local'}`"
:title="t(`skills.source.${skill.source || 'local'}`)" />
{{ skill.name }}
</span>
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
</div>
</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.skill-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.skill-empty {
padding: 24px 16px;
font-size: 13px;
color: $text-muted;
text-align: center;
}
.skill-category {
margin-bottom: 4px;
}
.category-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
color: $text-secondary;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
border-radius: $radius-sm;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.04);
}
}
.category-arrow {
flex-shrink: 0;
transition: transform $transition-fast;
&.collapsed {
transform: rotate(-90deg);
}
}
.category-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-count {
font-size: 11px;
color: $text-muted;
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 6px;
border-radius: 8px;
}
.category-skills {
padding: 2px 0 4px;
}
.skill-item {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 6px 10px 6px 28px;
border: none;
background: none;
color: $text-secondary;
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: $radius-sm;
transition: all $transition-fast;
gap: 8px;
&:hover {
background: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
}
&.active {
background: rgba(var(--accent-primary-rgb), 0.1);
color: $text-primary;
font-weight: 500;
}
}
// Source indicator dot
.source-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
flex-shrink: 0;
vertical-align: middle;
}
.dot-builtin {
background: #888;
}
.dot-hub {
background: #4a90d9;
}
.dot-local {
background: #66bb6a;
}
.dot-external {
background: #f59e0b;
}
.skill-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.skill-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modified-badge {
font-size: 11px;
color: $warning;
margin-left: 2px;
opacity: 0.7;
}
.skill-desc {
font-size: 11px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.archive-section {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid $border-color;
}
.archive-header {
color: $text-muted;
}
.skill-archived {
opacity: 0.6;
padding-left: 28px;
}
</style>
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
function formatCost(n: number): string {
if (n === 0) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
function cacheHitRate(d: { input_tokens: number; cache_read_tokens: number }): string {
const total = d.input_tokens + d.cache_read_tokens
if (total === 0) return '--'
return ((d.cache_read_tokens / total) * 100).toFixed(1) + '%'
}
const maxTokens = computed(() =>
Math.max(...usageStore.dailyUsage.map(d => d.visualTokens), 1),
)
</script>
<template>
<div class="daily-trend">
<h3 class="section-title">{{ t('usage.dailyTrend') }}</h3>
<div class="bar-chart">
<div
v-for="d in usageStore.dailyUsage"
:key="d.date"
class="bar-col"
>
<div class="bar-track">
<div
class="bar-stack"
:style="{ height: (d.visualTokens / maxTokens * 100) + '%' }"
>
<div
v-if="d.output_tokens > 0"
class="bar-segment output"
:style="{ height: d.outputPercent + '%' }"
/>
<div
v-if="d.input_tokens > 0"
class="bar-segment input"
:style="{ height: d.inputPercent + '%' }"
/>
<div
v-if="d.cache_read_tokens > 0"
class="bar-segment cache"
:style="{ height: d.cachePercent + '%' }"
/>
</div>
</div>
<div class="bar-tooltip">
<div class="tooltip-date">{{ d.date }}</div>
<div class="tooltip-row">{{ t('usage.inputTokens') }}: {{ formatTokens(d.input_tokens) }}</div>
<div class="tooltip-row">{{ t('usage.outputTokens') }}: {{ formatTokens(d.output_tokens) }}</div>
<div class="tooltip-row">{{ t('usage.cacheRead') }}: {{ formatTokens(d.cache_read_tokens) }}</div>
<div class="tooltip-row">{{ t('usage.cacheWrite') }}: {{ formatTokens(d.cache_write_tokens) }}</div>
<div class="tooltip-row">{{ t('usage.cacheHitRate') }}: {{ cacheHitRate(d) }}</div>
<div class="tooltip-row">{{ t('usage.sessions') }}: {{ d.sessions }}</div>
<div class="tooltip-row">{{ t('usage.cost') }}: {{ formatCost(d.cost) }}</div>
</div>
</div>
</div>
<div class="bar-dates">
<span>{{ usageStore.dailyUsage[0]?.date.slice(5) }}</span>
<span>{{ usageStore.dailyUsage[usageStore.dailyUsage.length - 1]?.date.slice(5) }}</span>
</div>
<div class="chart-legend" aria-label="Token type legend">
<div class="legend-item"><span class="legend-swatch input" />{{ t('usage.inputTokens') }}</div>
<div class="legend-item"><span class="legend-swatch output" />{{ t('usage.outputTokens') }}</div>
<div class="legend-item"><span class="legend-swatch cache" />{{ t('usage.cacheRead') }}</div>
</div>
<div class="trend-table">
<table>
<thead>
<tr>
<th>{{ t('usage.date') }}</th>
<th>{{ t('usage.inputTokens') }}</th>
<th>{{ t('usage.outputTokens') }}</th>
<th>{{ t('usage.cacheRead') }}</th>
<th>{{ t('usage.cacheWrite') }}</th>
<th>{{ t('usage.cacheHitRate') }}</th>
<th>{{ t('usage.sessions') }}</th>
<th>{{ t('usage.cost') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="d in [...usageStore.dailyUsage].reverse()" :key="d.date">
<td>{{ d.date }}</td>
<td>{{ formatTokens(d.input_tokens) }}</td>
<td>{{ formatTokens(d.output_tokens) }}</td>
<td>{{ formatTokens(d.cache_read_tokens) }}</td>
<td>{{ formatTokens(d.cache_write_tokens) }}</td>
<td>{{ cacheHitRate(d) }}</td>
<td>{{ d.sessions }}</td>
<td>{{ formatCost(d.cost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.daily-trend {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
margin: 0 0 12px;
}
.bar-chart {
display: flex;
gap: 2px;
margin-bottom: 16px;
}
.bar-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.bar-track {
width: 100%;
height: 140px;
background: $bg-secondary;
border-radius: 2px 2px 0 0;
display: flex;
align-items: flex-end;
overflow: hidden;
position: relative;
}
.bar-stack {
width: 100%;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
overflow: hidden;
transition: height 0.3s ease;
}
.bar-segment {
width: 100%;
min-height: 0;
transition: height 0.3s ease;
}
.bar-segment.output,
.legend-swatch.output {
background: #26a69a;
}
.bar-segment.input,
.legend-swatch.input {
background: #5c6bc0;
}
.bar-segment.cache,
.legend-swatch.cache {
background: #f6ad55;
}
.bar-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: $text-primary;
color: var(--text-on-accent);
padding: 6px 10px;
border-radius: $radius-sm;
font-size: 11px;
white-space: nowrap;
z-index: 10;
pointer-events: none;
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: $text-primary;
}
}
.bar-col:hover .bar-tooltip {
display: block;
}
.tooltip-date {
font-weight: 600;
margin-bottom: 4px;
}
.tooltip-row {
line-height: 1.5;
}
.bar-dates {
display: flex;
justify-content: space-between;
font-size: 10px;
color: $text-muted;
margin-bottom: 12px;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin: 0 0 16px;
color: $text-muted;
font-size: 11px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend-swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.trend-table {
overflow-x: auto;
table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
th,
td {
text-align: right;
padding: 6px 8px;
border-bottom: 1px solid $border-color;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
color: $text-muted;
font-weight: 500;
}
td {
color: $text-secondary;
}
}
</style>
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
const maxModelTokens = computed(() => Math.max(...usageStore.modelUsage.map(m => m.visualTokens), 1))
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
function cacheHitRate(m: { inputTokens: number; cacheTokens: number }): string {
const total = m.inputTokens + m.cacheTokens
if (total === 0) return '--'
return ((m.cacheTokens / total) * 100).toFixed(1) + '%'
}
</script>
<template>
<div class="model-breakdown">
<h3 class="section-title">{{ t('usage.modelBreakdown') }}</h3>
<div class="model-legend" aria-label="Token type legend">
<div class="legend-item"><span class="legend-swatch input" />{{ t('usage.inputTokens') }}</div>
<div class="legend-item"><span class="legend-swatch output" />{{ t('usage.outputTokens') }}</div>
<div class="legend-item"><span class="legend-swatch cache" />{{ t('usage.cacheRead') }}</div>
</div>
<div class="model-list">
<div v-for="m in usageStore.modelUsage" :key="m.model" class="model-row">
<span class="model-swatch" :style="{ background: m.color }" />
<span class="model-name" :title="m.model">{{ m.model }}</span>
<div class="model-bar-wrap">
<div
class="model-bar"
:style="{ width: (m.visualTokens / maxModelTokens * 100) + '%' }"
>
<div
v-if="m.inputTokens > 0"
class="model-bar-segment input"
:style="{ width: m.inputPercent + '%' }"
/>
<div
v-if="m.outputTokens > 0"
class="model-bar-segment output"
:style="{ width: m.outputPercent + '%' }"
/>
<div
v-if="m.cacheTokens > 0"
class="model-bar-segment cache"
:style="{ width: m.cachePercent + '%' }"
/>
</div>
</div>
<span class="model-tokens" :title="`${t('usage.inputTokens')}: ${formatTokens(m.inputTokens)} · ${t('usage.outputTokens')}: ${formatTokens(m.outputTokens)} · ${t('usage.cacheRead')}: ${formatTokens(m.cacheTokens)} · ${t('usage.cacheHitRate')}: ${cacheHitRate(m)}`">
{{ formatTokens(m.totalTokens) }}
<small v-if="m.cacheTokens > 0">+{{ formatTokens(m.cacheTokens) }}</small>
</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.model-breakdown {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
margin-bottom: 20px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: $text-secondary;
margin: 0 0 12px;
}
.model-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin: 0 0 12px;
color: $text-muted;
font-size: 11px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legend-swatch,
.model-swatch {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-swatch.input,
.model-bar-segment.input {
background: #5c6bc0;
}
.legend-swatch.output,
.model-bar-segment.output {
background: #26a69a;
}
.legend-swatch.cache,
.model-bar-segment.cache {
background: #f6ad55;
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-row {
display: flex;
align-items: center;
gap: 10px;
}
.model-name {
font-size: 12px;
font-family: $font-code;
color: $text-secondary;
width: 140px;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-bar-wrap {
flex: 1;
height: 16px;
background: $bg-secondary;
border-radius: 3px;
overflow: hidden;
}
.model-bar {
height: 100%;
border-radius: 3px;
min-width: 2px;
transition: width 0.3s ease;
display: flex;
overflow: hidden;
}
.model-bar-segment {
height: 100%;
min-width: 0;
}
.model-tokens {
font-size: 12px;
color: $text-muted;
width: 86px;
text-align: right;
flex-shrink: 0;
small {
color: #f6ad55;
margin-left: 4px;
font-size: 10px;
}
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useUsageStore } from '@/stores/hermes/usage'
const { t } = useI18n()
const usageStore = useUsageStore()
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return String(n)
}
function formatCost(n: number): string {
if (n === 0) return '$0.00'
if (n < 0.01) return '<$0.01'
return '$' + n.toFixed(2)
}
</script>
<template>
<div class="stat-cards">
<div class="stat-card">
<div class="stat-label">{{ t('usage.totalTokens') }}</div>
<div class="stat-value">{{ formatTokens(usageStore.totalTokens) }}</div>
<div class="stat-sub">
{{ formatTokens(usageStore.totalInputTokens) }} {{ t('usage.inputTokens') }} /
{{ formatTokens(usageStore.totalOutputTokens) }} {{ t('usage.outputTokens') }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.totalSessions') }}</div>
<div class="stat-value">{{ usageStore.totalSessions }}</div>
<div class="stat-sub">{{ t('usage.avgPerDay', { n: usageStore.avgSessionsPerDay.toFixed(1) }) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.estimatedCost') }}</div>
<div class="stat-value">{{ formatCost(usageStore.estimatedCost) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">{{ t('usage.cacheHitRate') }}</div>
<div class="stat-value">{{ usageStore.cacheHitRate !== null ? usageStore.cacheHitRate.toFixed(1) + '%' : '--' }}</div>
<div class="stat-sub" v-if="usageStore.cacheHitRate !== null">
{{ formatTokens(usageStore.totalCacheTokens) }} {{ t('usage.tokens') }}
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 16px;
}
.stat-label {
font-size: 12px;
color: $text-muted;
margin-bottom: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
color: $text-primary;
line-height: 1.2;
}
.stat-sub {
font-size: 11px;
color: $text-muted;
margin-top: 4px;
}
@media (max-width: 768px) {
.stat-cards {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stat-cards {
grid-template-columns: 1fr;
}
}
</style>