refactor: restructure project for multi-agent extensibility
- Migrate source to packages/client and packages/server directories - Namespace all Hermes-specific code under hermes/ subdirectories (api/hermes/, components/hermes/, views/hermes/, stores/hermes/) - Add hermes.* route names and /hermes/* path prefixes - Upgrade @koa/router to v15, adapt path-to-regexp v8 syntax - Fix proxy path rewriting: /api/hermes/v1/* → /v1/*, /api/hermes/* → /api/* - Fix frontend API paths to match backend /api/hermes/* routes - Fix WebSocket terminal path to /api/hermes/terminal - Add proxyMiddleware for reliable unmatched route proxying - Add profiles route module and hermes-cli profile commands - Update CLAUDE.md development guide with new architecture - Add Chinese README (README_zh.md) - Add Web Terminal feature to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
<script setup lang="ts">
|
||||
import type { Attachment } from '@/stores/hermes/chat'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import { NButton, NTooltip } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { t } = useI18n()
|
||||
const inputText = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
const isComposing = ref(false)
|
||||
|
||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||
|
||||
// --- Voice input (Web Speech API) ---
|
||||
// TODO: re-enable when needed — browser-native speech-to-text
|
||||
// const hasSpeechRecognition = ref(false)
|
||||
// let recognition: SpeechRecognition | null = null
|
||||
// let finalTranscript = ''
|
||||
// let prefixText = ''
|
||||
// onMounted(() => {
|
||||
// const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
// if (!SR) return
|
||||
// recognition = new SR()
|
||||
// recognition.continuous = false
|
||||
// recognition.interimResults = true
|
||||
// recognition.lang = 'en-US'
|
||||
// hasSpeechRecognition.value = true
|
||||
// recognition.onresult = (event: SpeechRecognitionEvent) => { ... }
|
||||
// recognition.onend = () => { ... }
|
||||
// recognition.onerror = (event: SpeechRecognitionErrorEvent) => { ... }
|
||||
// })
|
||||
// onUnmounted(() => { if (recognition && isRecording.value) recognition.stop() })
|
||||
|
||||
// --- File attachment helpers ---
|
||||
|
||||
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)
|
||||
const url = URL.createObjectURL(file)
|
||||
attachments.value.push({
|
||||
id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url,
|
||||
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 = ''
|
||||
}
|
||||
|
||||
// --- Paste image ---
|
||||
|
||||
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'
|
||||
const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type })
|
||||
addFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag and drop ---
|
||||
|
||||
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
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (!files.length) return
|
||||
for (const file of files) addFile(file)
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
// --- Send ---
|
||||
|
||||
function handleSend() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text && attachments.value.length === 0) return
|
||||
|
||||
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
||||
inputText.value = ''
|
||||
attachments.value = []
|
||||
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
requestAnimationFrame(() => {
|
||||
isComposing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function isImeEnter(e: KeyboardEvent): boolean {
|
||||
return isComposing.value || e.isComposing || e.keyCode === 229
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || e.shiftKey) return
|
||||
if (isImeEnter(e)) return
|
||||
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||
}
|
||||
|
||||
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">
|
||||
<!-- Attachment previews -->
|
||||
<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) }"
|
||||
>
|
||||
<template v-if="isImage(att.type)">
|
||||
<img :src="att.url" :alt="att.name" class="attachment-thumb" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="attachment-file">
|
||||
<svg width="20" height="20" 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="file-name">{{ att.name }}</span>
|
||||
<span class="file-size">{{ formatSize(att.size) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<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"
|
||||
/>
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="inputText"
|
||||
class="input-textarea"
|
||||
:placeholder="t('chat.inputPlaceholder')"
|
||||
rows="1"
|
||||
@keydown="handleKeydown"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="handleAttachClick" circle>
|
||||
<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>
|
||||
<NButton
|
||||
v-if="chatStore.isStreaming"
|
||||
size="small"
|
||||
type="error"
|
||||
@click="chatStore.stopStreaming()"
|
||||
>
|
||||
{{ t('chat.stop') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!canSend || chatStore.isStreaming"
|
||||
@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>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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: #fff;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 100px;
|
||||
min-height: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Drag-over state
|
||||
.input-wrapper.drag-over {
|
||||
border-color: #4a90d9;
|
||||
border-style: dashed;
|
||||
background-color: rgba(74, 144, 217, 0.04);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,711 @@
|
||||
<script setup lang="ts">
|
||||
import { renameSession } from '@/api/hermes/sessions'
|
||||
import { useChatStore, type Session } from '@/stores/hermes/chat'
|
||||
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ChatInput from './ChatInput.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showSessions = ref(true)
|
||||
let mobileQuery: MediaQueryList | null = null
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
chatStore.switchSession(sessionId)
|
||||
if (mobileQuery?.matches) showSessions.value = false
|
||||
}
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches && showSessions.value) {
|
||||
showSessions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||
handleMobileChange(mobileQuery)
|
||||
mobileQuery.addEventListener('change', handleMobileChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||
})
|
||||
const showRenameModal = ref(false)
|
||||
const renameValue = ref('')
|
||||
const renameSessionId = ref<string | null>(null)
|
||||
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem('hermes_collapsed_groups') || '[]')))
|
||||
|
||||
const sourceLabelKeys: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
api_server: 'API Server',
|
||||
cli: 'CLI',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
matrix: 'Matrix',
|
||||
whatsapp: 'WhatsApp',
|
||||
signal: 'Signal',
|
||||
email: 'Email',
|
||||
sms: 'SMS',
|
||||
dingtalk: 'DingTalk',
|
||||
feishu: 'Feishu',
|
||||
wecom: 'WeCom',
|
||||
weixin: 'WeChat',
|
||||
bluebubbles: 'iMessage',
|
||||
mattermost: 'Mattermost',
|
||||
cron: 'Cron',
|
||||
}
|
||||
|
||||
function getSourceLabel(source?: string): string {
|
||||
if (!source) return ''
|
||||
return sourceLabelKeys[source] || source
|
||||
}
|
||||
|
||||
// Source sort order: api_server first, cron last, others alphabetical
|
||||
function sourceSortKey(source: string): number {
|
||||
if (source === 'api_server') return -1
|
||||
if (source === 'cron') return 999
|
||||
return 0
|
||||
}
|
||||
|
||||
// Group sessions by source, with sort order
|
||||
interface SessionGroup {
|
||||
source: string
|
||||
label: string
|
||||
sessions: Session[]
|
||||
}
|
||||
|
||||
const groupedSessions = computed<SessionGroup[]>(() => {
|
||||
const all = [...chatStore.sessions].sort((a, b) => b.createdAt - a.createdAt)
|
||||
|
||||
const map = new Map<string, Session[]>()
|
||||
for (const s of all) {
|
||||
const key = s.source || ''
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(s)
|
||||
}
|
||||
|
||||
const keys = [...map.keys()].sort((a, b) => {
|
||||
const ka = sourceSortKey(a)
|
||||
const kb = sourceSortKey(b)
|
||||
if (ka !== kb) return ka - kb
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return keys.map(key => ({
|
||||
source: key,
|
||||
label: key ? getSourceLabel(key) : t('chat.other'),
|
||||
sessions: map.get(key)!,
|
||||
}))
|
||||
})
|
||||
|
||||
function toggleGroup(source: string) {
|
||||
const isExpanded = !collapsedGroups.value.has(source)
|
||||
if (isExpanded) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value, source])
|
||||
} else {
|
||||
collapsedGroups.value = new Set(
|
||||
groupedSessions.value.map(g => g.source).filter(s => s !== source)
|
||||
)
|
||||
// Auto-select the first session in the expanded group
|
||||
const group = groupedSessions.value.find(g => g.source === source)
|
||||
if (group?.sessions.length) {
|
||||
chatStore.switchSession(group.sessions[0].id)
|
||||
}
|
||||
}
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}
|
||||
|
||||
// Ensure the active session's group is expanded
|
||||
watch(groupedSessions, (groups) => {
|
||||
if (localStorage.getItem('hermes_collapsed_groups') !== null) {
|
||||
// Has saved state — still ensure active session's group is visible
|
||||
const activeSource = chatStore.activeSession?.source
|
||||
if (activeSource && collapsedGroups.value.has(activeSource)) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== activeSource))
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}
|
||||
return
|
||||
}
|
||||
// No saved state: expand only the first group
|
||||
collapsedGroups.value = new Set(groups.slice(1).map(g => g.source))
|
||||
localStorage.setItem('hermes_collapsed_groups', JSON.stringify([...collapsedGroups.value]))
|
||||
}, { once: true })
|
||||
|
||||
const activeSessionTitle = computed(() =>
|
||||
chatStore.activeSession?.title || t('chat.newChat'),
|
||||
)
|
||||
|
||||
const totalTokens = computed(() => {
|
||||
const input = chatStore.activeSession?.inputTokens ?? 0
|
||||
const output = chatStore.activeSession?.outputTokens ?? 0
|
||||
return input + output
|
||||
})
|
||||
|
||||
const MODEL_CONTEXT: Record<string, number> = {
|
||||
'claude-opus-4': 200000,
|
||||
'claude-sonnet-4': 200000,
|
||||
'claude-haiku-4': 200000,
|
||||
'claude-3.5-sonnet': 200000,
|
||||
'claude-3.5-haiku': 200000,
|
||||
'claude-3-opus': 200000,
|
||||
'claude-3-sonnet': 200000,
|
||||
'claude-3-haiku': 200000,
|
||||
'gpt-4o': 128000,
|
||||
'gpt-4o-mini': 128000,
|
||||
'gpt-4-turbo': 128000,
|
||||
'gpt-4': 8192,
|
||||
'gpt-3.5-turbo': 16385,
|
||||
'o1': 200000,
|
||||
'o1-mini': 128000,
|
||||
'o3': 200000,
|
||||
'o3-mini': 200000,
|
||||
'o4-mini': 200000,
|
||||
'deepseek-chat': 65536,
|
||||
'deepseek-reasoner': 65536,
|
||||
'gemini-2.5-pro': 1000000,
|
||||
'gemini-2.5-flash': 1000000,
|
||||
'gemini-2.0-flash': 1000000,
|
||||
'glm-4-plus': 128000,
|
||||
'glm-4': 128000,
|
||||
'qwen-max': 128000,
|
||||
'qwen-plus': 128000,
|
||||
'qwen-turbo': 128000,
|
||||
}
|
||||
|
||||
const contextWindow = computed(() => {
|
||||
const model = chatStore.activeSession?.model || ''
|
||||
for (const [key, val] of Object.entries(MODEL_CONTEXT)) {
|
||||
if (model.includes(key)) return val
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const activeSessionSource = computed(() =>
|
||||
chatStore.activeSession?.source || '',
|
||||
)
|
||||
|
||||
function handleNewChat() {
|
||||
chatStore.newChat()
|
||||
}
|
||||
|
||||
function copySessionId(id?: string) {
|
||||
const sessionId = id || chatStore.activeSessionId
|
||||
if (sessionId) {
|
||||
navigator.clipboard.writeText(sessionId)
|
||||
message.success(t('common.copied'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteSession(id: string) {
|
||||
chatStore.deleteSession(id)
|
||||
message.success(t('chat.sessionDeleted'))
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const isToday = d.toDateString() === now.toDateString()
|
||||
if (isToday) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Context menu
|
||||
const contextMenuOptions = computed(() => [
|
||||
{ label: t('chat.rename'), key: 'rename' },
|
||||
{ label: t('chat.copySessionId'), key: 'copy-id' },
|
||||
])
|
||||
const contextSessionId = ref<string | null>(null)
|
||||
|
||||
function handleContextMenu(e: MouseEvent, sessionId: string) {
|
||||
e.preventDefault()
|
||||
contextSessionId.value = sessionId
|
||||
showContextMenu.value = true
|
||||
contextMenuX.value = e.clientX
|
||||
contextMenuY.value = e.clientY
|
||||
}
|
||||
|
||||
const showContextMenu = ref(false)
|
||||
const contextMenuX = ref(0)
|
||||
const contextMenuY = ref(0)
|
||||
|
||||
function handleContextMenuSelect(key: string) {
|
||||
showContextMenu.value = false
|
||||
if (!contextSessionId.value) return
|
||||
if (key === 'copy-id') {
|
||||
copySessionId(contextSessionId.value)
|
||||
} else if (key === 'rename') {
|
||||
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
|
||||
renameSessionId.value = contextSessionId.value
|
||||
renameValue.value = session?.title || ''
|
||||
showRenameModal.value = true
|
||||
nextTick(() => {
|
||||
renameInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showContextMenu.value = false
|
||||
}
|
||||
|
||||
async function handleRenameConfirm() {
|
||||
if (!renameSessionId.value || !renameValue.value.trim()) return
|
||||
const ok = await renameSession(renameSessionId.value, renameValue.value.trim())
|
||||
if (ok) {
|
||||
const session = chatStore.sessions.find(s => s.id === renameSessionId.value)
|
||||
if (session) session.title = renameValue.value.trim()
|
||||
if (chatStore.activeSession?.id === renameSessionId.value) {
|
||||
chatStore.activeSession.title = renameValue.value.trim()
|
||||
}
|
||||
message.success(t('chat.renamed'))
|
||||
} else {
|
||||
message.error(t('chat.renameFailed'))
|
||||
}
|
||||
showRenameModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-panel">
|
||||
<!-- Session List -->
|
||||
<div class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
|
||||
<aside class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span>
|
||||
<div class="session-list-actions">
|
||||
<button class="session-close-btn" @click="showSessions = false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" 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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="chatStore.sessions.length === 0" class="session-empty">{{ t('chat.noSessions') }}</div>
|
||||
<template v-for="group in groupedSessions" :key="group.source">
|
||||
<div class="session-group-header" @click="toggleGroup(group.source)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="group-chevron" :class="{ collapsed: collapsedGroups.has(group.source) }"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<span class="session-group-label">{{ group.label }}</span>
|
||||
<span class="session-group-count">{{ group.sessions.length }}</span>
|
||||
</div>
|
||||
<template v-if="!collapsedGroups.has(group.source)">
|
||||
<button
|
||||
v-for="s in group.sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === chatStore.activeSessionId }"
|
||||
@click="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span v-if="s.model" class="session-item-model">{{ s.model }}</span>
|
||||
<span class="session-item-time">{{ formatTime(s.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm
|
||||
v-if="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1"
|
||||
@positive-click="handleDeleteSession(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('chat.deleteSession') }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<NDropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="contextMenuX"
|
||||
:y="contextMenuY"
|
||||
:options="contextMenuOptions"
|
||||
:show="showContextMenu"
|
||||
@select="handleContextMenuSelect"
|
||||
@clickoutside="handleClickOutside"
|
||||
/>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<NModal
|
||||
v-model:show="showRenameModal"
|
||||
preset="dialog"
|
||||
:title="t('chat.renameSession')"
|
||||
:positive-text="t('common.ok')"
|
||||
:negative-text="t('common.cancel')"
|
||||
@positive-click="handleRenameConfirm"
|
||||
>
|
||||
<NInput
|
||||
ref="renameInputRef"
|
||||
v-model:value="renameValue"
|
||||
:placeholder="t('chat.enterNewTitle')"
|
||||
@keydown.enter="handleRenameConfirm"
|
||||
/>
|
||||
</NModal>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="chat-main">
|
||||
<header class="chat-header">
|
||||
<div class="header-left">
|
||||
<NButton quaternary size="small" @click="showSessions = !showSessions" circle>
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ activeSessionTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small" @click="copySessionId()" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.copySessionId') }}
|
||||
</NTooltip>
|
||||
<NButton size="small" @click="handleNewChat">
|
||||
<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('chat.newChat') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MessageList />
|
||||
<div v-if="contextWindow !== null" class="context-info">
|
||||
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
|
||||
</div>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
width: 220px;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: width $transition-normal, opacity $transition-normal;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
width: 0;
|
||||
border-right: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
width: 280px;
|
||||
|
||||
&.collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.session-close-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.session-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-close-btn {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.session-list-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
transform: rotate(90deg);
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.session-group-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-group-count {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.session-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 6px 12px;
|
||||
}
|
||||
|
||||
.session-loading,
|
||||
.session-empty {
|
||||
padding: 16px 10px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
transition: all $transition-fast;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
color: $text-primary;
|
||||
|
||||
.session-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($accent-primary, 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-model {
|
||||
font-size: 10px;
|
||||
color: $accent-primary;
|
||||
background: rgba($accent-primary, 0.08);
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba($error, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 21px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
background: rgba($text-muted, 0.12);
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-info {
|
||||
padding: 0 20px 4px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.chat-header {
|
||||
padding: 16px 12px 16px 52px;
|
||||
}
|
||||
|
||||
.context-info {
|
||||
padding: 0 12px 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
const props = defineProps<{ content: string }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight(str: string, lang: string): string {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return `<pre class="hljs-code-block"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs language-${lang}">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return `<pre class="hljs-code-block"><div class="code-header"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.hljs-code-block').querySelector('code').textContent)">${t('common.copy')}</button></div><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
const renderedHtml = computed(() => md.render(props.content))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderedHtml"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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($accent-primary, 0.08);
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid $border-color;
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hljs-code-block {
|
||||
margin: 8px 0;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background: $code-bg;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
.code-lang {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// highlight.js theme override — pure ink B&W
|
||||
.hljs {
|
||||
color: #2a2a2a;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag { color: #1a1a1a; font-weight: 600; }
|
||||
.hljs-string,
|
||||
.hljs-attr { color: #555555; }
|
||||
.hljs-number { color: #333333; }
|
||||
.hljs-comment { color: #999999; font-style: italic; }
|
||||
.hljs-built_in { color: #444444; }
|
||||
.hljs-type { color: #3a3a3a; }
|
||||
.hljs-variable { color: #1a1a1a; }
|
||||
.hljs-title,
|
||||
.hljs-title\.function_ { color: #1a1a1a; }
|
||||
.hljs-params { color: #2a2a2a; }
|
||||
.hljs-meta { color: #999999; }
|
||||
</style>
|
||||
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from "@/stores/hermes/chat";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||
|
||||
const props = defineProps<{ message: Message }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSystem = computed(() => props.message.role === "system");
|
||||
const toolExpanded = ref(false);
|
||||
|
||||
const timeStr = computed(() => {
|
||||
const d = new Date(props.message.timestamp);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
});
|
||||
|
||||
function isImage(type: string): boolean {
|
||||
return type.startsWith("image/");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
const hasAttachments = computed(
|
||||
() => (props.message.attachments?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const hasToolDetails = computed(
|
||||
() => !!(props.message.toolArgs || props.message.toolResult),
|
||||
);
|
||||
|
||||
const formattedToolArgs = computed(() => {
|
||||
if (!props.message.toolArgs) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2);
|
||||
} catch {
|
||||
return props.message.toolArgs;
|
||||
}
|
||||
});
|
||||
|
||||
const formattedToolResult = computed(() => {
|
||||
if (!props.message.toolResult) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(props.message.toolResult);
|
||||
const str = JSON.stringify(parsed, null, 2);
|
||||
// Truncate very long output
|
||||
if (str.length > 2000)
|
||||
return str.slice(0, 2000) + "\n" + t("chat.truncated");
|
||||
return str;
|
||||
} catch {
|
||||
const raw = props.message.toolResult;
|
||||
if (raw.length > 2000)
|
||||
return raw.slice(0, 2000) + "\n" + t("chat.truncated");
|
||||
return raw;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message" :class="[message.role]">
|
||||
<template v-if="message.role === 'tool'">
|
||||
<div
|
||||
class="tool-line"
|
||||
:class="{ expandable: hasToolDetails }"
|
||||
@click="hasToolDetails && (toolExpanded = !toolExpanded)"
|
||||
>
|
||||
<svg
|
||||
v-if="hasToolDetails"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="tool-chevron"
|
||||
:class="{ rotated: toolExpanded }"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-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-name">{{ message.toolName }}</span>
|
||||
<span
|
||||
v-if="message.toolPreview && !toolExpanded"
|
||||
class="tool-preview"
|
||||
>{{ message.toolPreview }}</span
|
||||
>
|
||||
<span
|
||||
v-if="message.toolStatus === 'running'"
|
||||
class="tool-spinner"
|
||||
></span>
|
||||
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{
|
||||
t("chat.error")
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
|
||||
<div v-if="formattedToolArgs" class="tool-detail-section">
|
||||
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
|
||||
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
|
||||
</div>
|
||||
<div v-if="formattedToolResult" class="tool-detail-section">
|
||||
<div class="tool-detail-label">{{ t("chat.result") }}</div>
|
||||
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-body">
|
||||
<img
|
||||
v-if="message.role === 'assistant'"
|
||||
src="/logo.png"
|
||||
alt="Hermes"
|
||||
class="msg-avatar"
|
||||
/>
|
||||
<div class="msg-content" :class="message.role">
|
||||
<div class="message-bubble" :class="{ system: isSystem }">
|
||||
<div v-if="hasAttachments" class="msg-attachments">
|
||||
<div
|
||||
v-for="att in message.attachments"
|
||||
:key="att.id"
|
||||
class="msg-attachment"
|
||||
:class="{ image: isImage(att.type) }"
|
||||
>
|
||||
<template v-if="isImage(att.type) && att.url">
|
||||
<img
|
||||
:src="att.url"
|
||||
:alt="att.name"
|
||||
class="msg-attachment-thumb"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="msg-attachment-file">
|
||||
<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">{{ att.name }}</span>
|
||||
<span class="att-size">{{ formatSize(att.size) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content"
|
||||
/>
|
||||
|
||||
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="message-time">{{ timeStr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.user {
|
||||
align-items: flex-end;
|
||||
|
||||
.msg-body {
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.msg-content.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background-color: $msg-user-bg;
|
||||
border-radius: $radius-md $radius-md 4px $radius-md;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.msg-body {
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background-color: $msg-assistant-bg;
|
||||
border-radius: $radius-md $radius-md $radius-md 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.tool {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.system {
|
||||
align-items: flex-start;
|
||||
|
||||
.message-bubble.system {
|
||||
border-left: 3px solid $warning;
|
||||
border-radius: $radius-sm;
|
||||
max-width: 80%;
|
||||
background-color: rgba($warning, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.msg-attachment {
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid $border-light;
|
||||
|
||||
&.image {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-attachment-thumb {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.msg-attachment-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
|
||||
.att-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.att-size {
|
||||
color: $text-muted;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.tool-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
padding: 2px 4px;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&.expandable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-family: $font-code;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-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-error-badge {
|
||||
font-size: 9px;
|
||||
color: $error;
|
||||
background: rgba($error, 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.tool-details {
|
||||
margin-left: 16px;
|
||||
margin-top: 2px;
|
||||
border-left: 2px solid $border-light;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.tool-detail-section {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tool-detail-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tool-detail-code {
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: $text-secondary;
|
||||
background: $code-bg;
|
||||
border-radius: $radius-sm;
|
||||
padding: 6px 8px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background-color: $text-muted;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink 0.8s infinite;
|
||||
}
|
||||
|
||||
.streaming-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: $text-muted;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.4s infinite ease-in-out;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.message.user .msg-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message.assistant .msg-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message.system .msg-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import thinkingVideo from '@/assets/thinking.mp4'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { t } = useI18n()
|
||||
const listRef = ref<HTMLElement>()
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
chatStore.messages.filter(m => m.role !== 'tool'),
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (listRef.value) {
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => chatStore.messages.length, scrollToBottom)
|
||||
watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
|
||||
watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
|
||||
watch(currentToolCalls, scrollToBottom)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="message-list">
|
||||
<div v-if="chatStore.messages.length === 0" class="empty-state">
|
||||
<img src="/logo.png" alt="Hermes" class="empty-logo" />
|
||||
<p>{{ t('chat.emptyState') }}</p>
|
||||
</div>
|
||||
<MessageItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
<Transition name="fade">
|
||||
<div v-if="chatStore.isStreaming" class="streaming-indicator">
|
||||
<video :src="thinkingVideo" autoplay loop muted playsinline class="thinking-video" />
|
||||
<div v-if="currentToolCalls.length > 0" class="tool-calls-panel">
|
||||
<div
|
||||
v-for="tc in currentToolCalls"
|
||||
: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.toolStatus === 'running'" class="tool-call-spinner"></span>
|
||||
<span v-if="tc.toolStatus === 'error'" class="tool-call-error">{{ t('chat.error') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
.thinking-video {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: $radius-md;
|
||||
object-fit: contain;
|
||||
flex-shrink: 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;
|
||||
|
||||
.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 {
|
||||
font-size: 9px;
|
||||
color: $error;
|
||||
background: rgba($error, 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NTooltip, useMessage } from 'naive-ui'
|
||||
import type { Job } from '@/api/hermes/jobs'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ job: Job }>()
|
||||
const emit = defineEmits<{
|
||||
edit: [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(() => {
|
||||
const s = props.job.schedule
|
||||
if (typeof s === 'string') return s
|
||||
return s?.expr || 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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="job-card">
|
||||
<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.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="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="handleResume">{{ t('jobs.action.resume') }}</NButton>
|
||||
</template>
|
||||
{{ t('jobs.action.resumeJob') }}
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary @click="handleRun">{{ t('jobs.action.runNow') }}</NButton>
|
||||
</template>
|
||||
{{ t('jobs.action.triggerImmediately') }}
|
||||
</NTooltip>
|
||||
<NButton size="tiny" quaternary @click="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="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;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba($accent-primary, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.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($success, 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: rgba($accent-primary, 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: rgba($warning, 0.12);
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba($error, 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,191 @@
|
||||
<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 { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const jobsStore = useJobsStore()
|
||||
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 * *' },
|
||||
])
|
||||
|
||||
const targetOptions = computed(() => [
|
||||
{ label: t('jobs.origin'), value: 'origin' },
|
||||
{ label: t('jobs.local'), value: 'local' },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.jobId) {
|
||||
try {
|
||||
const { getJob } = await import('@/api/hermes/jobs')
|
||||
const job = await getJob(props.jobId)
|
||||
formData.value = {
|
||||
name: job.name,
|
||||
schedule: typeof job.schedule === 'string' ? job.schedule : (job.schedule?.expr || job.schedule_display || ''),
|
||||
prompt: job.prompt,
|
||||
deliver: job.deliver || 'origin',
|
||||
repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
|
||||
}
|
||||
} 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 {
|
||||
const payload = {
|
||||
name: formData.value.name,
|
||||
schedule: formData.value.schedule,
|
||||
prompt: formData.value.prompt,
|
||||
deliver: formData.value.deliver,
|
||||
repeat: formData.value.repeat_times ?? undefined,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await jobsStore.updateJob(props.jobId!, payload)
|
||||
message.success(t('jobs.jobUpdated'))
|
||||
} else {
|
||||
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,61 @@
|
||||
<script setup lang="ts">
|
||||
import JobCard from './JobCard.vue'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [jobId: string]
|
||||
}>()
|
||||
|
||||
const jobsStore = useJobsStore()
|
||||
</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"
|
||||
@edit="emit('edit', job.id)"
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ provider: AvailableModelGroup }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const isCustom = computed(() => props.provider.provider.startsWith('custom:'))
|
||||
const displayName = computed(() => props.provider.label)
|
||||
|
||||
async function handleDelete() {
|
||||
dialog.warning({
|
||||
title: t('models.deleteProvider'),
|
||||
content: t('models.deleteConfirm', { name: displayName.value }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
message.success(t('models.providerDeleted'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-card">
|
||||
<div class="card-header">
|
||||
<h3 class="provider-name">{{ displayName }}</h3>
|
||||
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
|
||||
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
</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($accent-primary, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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%;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.builtin {
|
||||
background: rgba($accent-primary, 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
background: rgba($success, 0.12);
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, useMessage } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { PROVIDER_PRESETS } from '@/shared/providers'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const fetchingModels = ref(false)
|
||||
|
||||
const providerType = ref<'preset' | 'custom'>('preset')
|
||||
const selectedPreset = ref<string | null>(null)
|
||||
const formData = ref({
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
})
|
||||
|
||||
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const PRESET_PROVIDERS = PROVIDER_PRESETS as any[]
|
||||
|
||||
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 = ''
|
||||
if (val) {
|
||||
const preset = PRESET_PROVIDERS.find(p => p.value === val)
|
||||
if (preset) {
|
||||
formData.value.name = preset.label
|
||||
formData.value.base_url = preset.base_url
|
||||
modelOptions.value = preset.models.map((m: string) => ({ label: m, value: m }))
|
||||
if (preset.models.length > 0) {
|
||||
formData.value.model = preset.models[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => formData.value.base_url, (url) => {
|
||||
if (providerType.value === 'custom' && url.trim()) {
|
||||
formData.value.name = autoGenerateName(url.trim())
|
||||
}
|
||||
})
|
||||
|
||||
watch(providerType, () => {
|
||||
modelOptions.value = []
|
||||
formData.value = { name: '', base_url: '', api_key: '', model: '' }
|
||||
selectedPreset.value = null
|
||||
})
|
||||
|
||||
async function fetchModels() {
|
||||
const { base_url } = formData.value
|
||||
if (!base_url.trim()) {
|
||||
message.warning(t('models.enterBaseUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
fetchingModels.value = true
|
||||
try {
|
||||
const url = base_url.replace(/\/+$/, '') + '/models'
|
||||
const headers: Record<string, string> = {}
|
||||
if (formData.value.api_key.trim()) {
|
||||
headers['Authorization'] = `Bearer ${formData.value.api_key.trim()}`
|
||||
}
|
||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||
if (!Array.isArray(data.data)) throw new Error(t('models.unexpectedFormat'))
|
||||
|
||||
modelOptions.value = data.data.map(m => ({ label: m.id, value: m.id }))
|
||||
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
|
||||
}
|
||||
if (!formData.value.base_url.trim()) {
|
||||
message.warning(t('models.baseUrlRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.api_key.trim()) {
|
||||
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'
|
||||
? (PRESET_PROVIDERS.find(p => p.value === selectedPreset.value)?.value || null)
|
||||
: null
|
||||
|
||||
await modelsStore.addProvider({
|
||||
name: formData.value.name.trim(),
|
||||
base_url: formData.value.base_url.trim(),
|
||||
api_key: formData.value.api_key.trim(),
|
||||
model: formData.value.model,
|
||||
providerKey,
|
||||
})
|
||||
message.success(t('models.providerAdded'))
|
||||
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="t('models.addProvider')"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading"
|
||||
@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="PRESET_PROVIDERS"
|
||||
:placeholder="t('models.chooseProvider')"
|
||||
filterable
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
|
||||
<NInput
|
||||
v-model:value="formData.name"
|
||||
:placeholder="t('models.autoGeneratedName')"
|
||||
disabled
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.baseUrl')" required>
|
||||
<NInput
|
||||
v-model:value="formData.base_url"
|
||||
:placeholder="t('models.baseUrlPlaceholder')"
|
||||
:disabled="providerType === 'preset'"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.apiKey')" required>
|
||||
<NInput
|
||||
v-model:value="formData.api_key"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('models.apiKeyPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.defaultModel')" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<NSelect
|
||||
v-model:value="formData.model"
|
||||
:options="modelOptions"
|
||||
filterable
|
||||
:placeholder="t('models.selectModel')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<NButton
|
||||
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
|
||||
:loading="fetchingModels"
|
||||
@click="fetchModels"
|
||||
>
|
||||
{{ t('common.fetch') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</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>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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,68 @@
|
||||
<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()
|
||||
|
||||
async function save(values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection('agent', values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
</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 && save({ 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 && save({ 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 && save({ 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,53 @@
|
||||
<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('display', values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<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,54 @@
|
||||
<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()
|
||||
|
||||
async function save(values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection('memory', values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
</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 && save({ 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 && save({ user_char_limit: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
icon: string
|
||||
config: Record<string, any>
|
||||
credentials?: Record<string, any>
|
||||
}>()
|
||||
|
||||
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 }">▾</span>
|
||||
</div>
|
||||
<div v-if="expanded" class="platform-card-body">
|
||||
<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($success, 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($text-primary, 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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NSwitch, NInput, NButton, 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()
|
||||
|
||||
async function saveChannel(platform: string, values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection(platform, values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Save credentials to .env (matching hermes gateway setup behavior)
|
||||
const savingCreds = ref(false)
|
||||
|
||||
async function saveCredentials(platform: string, values: Record<string, any>) {
|
||||
savingCreds.value = true
|
||||
try {
|
||||
await saveCredsApi(platform, values)
|
||||
await settingsStore.fetchSettings()
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
} finally {
|
||||
savingCreds.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCreds(key: string) {
|
||||
return (settingsStore.platforms[key] || {}) as Record<string, any>
|
||||
}
|
||||
|
||||
// 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'
|
||||
// Save credentials to .env
|
||||
await saveWeixinCredentials({
|
||||
account_id: data.account_id!,
|
||||
token: data.token!,
|
||||
base_url: data.base_url,
|
||||
})
|
||||
await settingsStore.fetchSettings()
|
||||
message.success(t('settings.saved'))
|
||||
}
|
||||
} catch {
|
||||
// Retry poll on network error
|
||||
pollWeixinStatus()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopWeixinPoll() {
|
||||
if (wxPollTimer) {
|
||||
clearTimeout(wxPollTimer)
|
||||
wxPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWeixinPoll()
|
||||
})
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
key: 'telegram',
|
||||
name: 'Telegram',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
// icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221l-1.897 6.376a.5.5 0 0 1-.957-.016l-1.238-3.81a.5.5 0 0 0-.477-.354l-3.81-.324a.5.5 0 0 1-.074-.993l6.376-1.897a.5.5 0 0 1 .577.718z"/></svg>',
|
||||
// },
|
||||
{
|
||||
key: 'weixin',
|
||||
name: 'Weixin',
|
||||
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>',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<PlatformCard
|
||||
v-for="p in platforms"
|
||||
:key="p.key"
|
||||
:name="p.name"
|
||||
:icon="p.icon"
|
||||
: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="getCreds('telegram').token || ''" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => saveCredentials('telegram', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.telegram.require_mention" @update:value="v => saveChannel('telegram', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
|
||||
<NSwitch :value="settingsStore.telegram.reactions" @update:value="v => saveChannel('telegram', { reactions: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="settingsStore.telegram.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('telegram', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||
<NInput :value="(settingsStore.telegram.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('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="getCreds('discord').token || ''" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => saveCredentials('discord', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||
<NSwitch :value="settingsStore.discord.require_mention" @update:value="v => saveChannel('discord', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
|
||||
<NSwitch :value="settingsStore.discord.auto_thread" @update:value="v => saveChannel('discord', { auto_thread: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
|
||||
<NSwitch :value="settingsStore.discord.reactions" @update:value="v => saveChannel('discord', { reactions: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||
<NInput :value="settingsStore.discord.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { free_response_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
|
||||
<NInput :value="settingsStore.discord.allowed_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { allowed_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
|
||||
<NInput :value="settingsStore.discord.ignored_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('discord', { ignored_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
|
||||
<NInput :value="settingsStore.discord.no_thread_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('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="getCreds('slack').token || ''" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => saveCredentials('slack', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||
<NSwitch :value="settingsStore.slack.require_mention" @update:value="v => saveChannel('slack', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
|
||||
<NSwitch :value="settingsStore.slack.allow_bots" @update:value="v => saveChannel('slack', { allow_bots: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||
<NInput :value="settingsStore.slack.free_response_channels || ''" size="small" placeholder="channel_id1,channel_id2" @update:value="v => saveChannel('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="getCreds('whatsapp').enabled" @update:value="v => saveCredentials('whatsapp', { enabled: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.whatsapp.require_mention" @update:value="v => saveChannel('whatsapp', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="settingsStore.whatsapp.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('whatsapp', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||
<NInput :value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" size="small" placeholder="pattern1, pattern2" @update:value="v => saveChannel('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="getCreds('matrix').token || ''" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => saveCredentials('matrix', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
|
||||
<NInput :value="getCreds('matrix').extra?.homeserver || ''" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => saveCredentials('matrix', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
|
||||
<NSwitch :value="settingsStore.matrix.require_mention" @update:value="v => saveChannel('matrix', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
|
||||
<NSwitch :value="settingsStore.matrix.auto_thread" @update:value="v => saveChannel('matrix', { auto_thread: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
|
||||
<NSwitch :value="settingsStore.matrix.dm_mention_threads" @update:value="v => saveChannel('matrix', { dm_mention_threads: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
|
||||
<NInput :value="settingsStore.matrix.free_response_rooms || ''" size="small" placeholder="room_id1,room_id2" @update:value="v => saveChannel('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="getCreds('feishu').extra?.app_id || ''" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
|
||||
<NInput :value="getCreds('feishu').extra?.app_secret || ''" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.feishu.require_mention" @update:value="v => saveChannel('feishu', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="settingsStore.feishu.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('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="getCreds('dingtalk').extra?.client_id || ''" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
|
||||
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="settingsStore.dingtalk.require_mention" @update:value="v => saveChannel('dingtalk', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="settingsStore.dingtalk.free_response_chats || ''" size="small" placeholder="chat_id1,chat_id2" @update:value="v => saveChannel('dingtalk', { free_response_chats: 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="getCreds('weixin').token || ''" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => saveCredentials('weixin', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
|
||||
<NInput :value="getCreds('weixin').extra?.account_id || ''" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => saveCredentials('weixin', { extra: { ...getCreds('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="getCreds('wecom').extra?.bot_id || ''" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
|
||||
<NInput :value="getCreds('wecom').extra?.secret || ''" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, secret: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
</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;
|
||||
}
|
||||
</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,60 @@
|
||||
<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()
|
||||
|
||||
async function save(values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection('session_reset', values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<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.modeHourly'), value: 'hourly' },
|
||||
]"
|
||||
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 && save({ 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 && save({ at_hour: v })"
|
||||
/>
|
||||
</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,250 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
import { fetchSkillContent, fetchSkillFiles, type SkillFileEntry } from '@/api/hermes/skills'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
category: string
|
||||
skill: string
|
||||
}>()
|
||||
|
||||
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('/')) {
|
||||
// Strip absolute prefix to get relative path
|
||||
const segments = filePath.split('/.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 = ''
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
.detail-category {
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-separator {
|
||||
color: $text-muted;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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($accent-primary, 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,235 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NSwitch, useMessage } from 'naive-ui'
|
||||
import type { SkillCategory } from '@/api/hermes/skills'
|
||||
import { toggleSkill } from '@/api/hermes/skills'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const props = defineProps<{
|
||||
categories: SkillCategory[]
|
||||
selectedSkill: string | null
|
||||
searchQuery: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [category: string, skill: string]
|
||||
}>()
|
||||
|
||||
const collapsedCategories = ref<Set<string>>(new Set())
|
||||
const togglingSkills = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
if (!props.searchQuery) return props.categories
|
||||
const q = props.searchQuery.toLowerCase()
|
||||
return props.categories
|
||||
.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))
|
||||
})
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
if (collapsedCategories.value.has(name)) {
|
||||
collapsedCategories.value.delete(name)
|
||||
} else {
|
||||
collapsedCategories.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(category: string, skill: string) {
|
||||
emit('select', category, skill)
|
||||
}
|
||||
|
||||
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="skill.name"
|
||||
class="skill-item"
|
||||
:class="{
|
||||
active: selectedSkill === `${cat.name}/${skill.name}`,
|
||||
}"
|
||||
@click="handleSelect(cat.name, skill.name)"
|
||||
>
|
||||
<div class="skill-info">
|
||||
<span class="skill-name">{{ skill.name }}</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>
|
||||
</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($accent-primary, 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($accent-primary, 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($accent-primary, 0.06);
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba($accent-primary, 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<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)
|
||||
}
|
||||
|
||||
const maxTokens = computed(() =>
|
||||
Math.max(...usageStore.dailyUsage.map(d => d.tokens), 1),
|
||||
)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed } from 'vue'
|
||||
</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-fill"
|
||||
:style="{ height: (d.tokens / maxTokens * 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="bar-tooltip">
|
||||
<div class="tooltip-date">{{ d.date }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.tokens') }}: {{ formatTokens(d.tokens) }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.cache') }}: {{ formatTokens(d.cache) }}</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="trend-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('usage.date') }}</th>
|
||||
<th>{{ t('usage.tokens') }}</th>
|
||||
<th>{{ t('usage.cache') }}</th>
|
||||
<th>{{ t('usage.sessions') }}</th>
|
||||
<th>{{ t('usage.cost') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in [...usageStore.dailyUsage].reverse().slice(0, 30)" :key="d.date">
|
||||
<td>{{ d.date }}</td>
|
||||
<td>{{ formatTokens(d.tokens) }}</td>
|
||||
<td>{{ formatTokens(d.cache) }}</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;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: $bg-secondary;
|
||||
border-radius: 2px 2px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: $text-primary;
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-height: 0;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: $text-primary;
|
||||
color: #fff;
|
||||
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: 2px;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
font-size: 10px;
|
||||
opacity: 0.85;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar-dates {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.trend-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
border-bottom: 1px solid $border-color;
|
||||
background: $bg-card;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
color: $text-secondary;
|
||||
border-bottom: 1px solid $border-light;
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
</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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-breakdown">
|
||||
<h3 class="section-title">{{ t('usage.modelBreakdown') }}</h3>
|
||||
<div class="model-list">
|
||||
<div v-for="m in usageStore.modelUsage" :key="m.model" class="model-row">
|
||||
<span class="model-name">{{ m.model }}</span>
|
||||
<div class="model-bar-wrap">
|
||||
<div
|
||||
class="model-bar"
|
||||
:style="{ width: (m.totalTokens / usageStore.modelUsage[0].totalTokens * 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="model-tokens">{{ formatTokens(m.totalTokens) }}</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-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%;
|
||||
background: $text-primary;
|
||||
border-radius: 3px;
|
||||
min-width: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.model-tokens {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,496 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useAppStore } from "@/stores/hermes/app";
|
||||
import ModelSelector from "./ModelSelector.vue";
|
||||
import LanguageSwitch from "./LanguageSwitch.vue";
|
||||
import danceVideo from "@/assets/dance.mp4";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
|
||||
const selectedKey = computed(() => route.name as string);
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = danceVideo;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.autoplay = true;
|
||||
|
||||
video.addEventListener("loadeddata", () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
});
|
||||
|
||||
function draw() {
|
||||
if (video.readyState >= 2 && ctx && canvas) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
if (video.currentTime >= video.duration - 0.05) {
|
||||
video.currentTime = 0;
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
video.addEventListener("canplay", () => {
|
||||
draw();
|
||||
});
|
||||
|
||||
video.play();
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible" && video.paused) {
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
});
|
||||
});
|
||||
|
||||
function handleNav(key: string) {
|
||||
router.push({ name: key });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ open: appStore.sidebarOpen }">
|
||||
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
|
||||
<img src="/logo.png" alt="Hermes" class="logo-img" />
|
||||
<span class="logo-text">Hermes</span>
|
||||
<canvas ref="canvasRef" class="logo-dance" />
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.chat' }"
|
||||
@click="handleNav('hermes.chat')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t("sidebar.chat") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.jobs' }"
|
||||
@click="handleNav('hermes.jobs')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.jobs") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.models' }"
|
||||
@click="handleNav('hermes.models')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v4" />
|
||||
<path d="M12 19v4" />
|
||||
<path d="M1 12h4" />
|
||||
<path d="M19 12h4" />
|
||||
<path d="M4.22 4.22l2.83 2.83" />
|
||||
<path d="M16.95 16.95l2.83 2.83" />
|
||||
<path d="M4.22 19.78l2.83-2.83" />
|
||||
<path d="M16.95 7.05l2.83-2.83" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.models") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.channels' }"
|
||||
@click="handleNav('hermes.channels')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.channels") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.skills' }"
|
||||
@click="handleNav('hermes.skills')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.skills") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.memory' }"
|
||||
@click="handleNav('hermes.memory')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.memory") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.logs' }"
|
||||
@click="handleNav('hermes.logs')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.logs") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.usage' }"
|
||||
@click="handleNav('hermes.usage')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.usage") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.terminal' }"
|
||||
@click="handleNav('hermes.terminal')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.terminal") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: selectedKey === 'hermes.settings' }"
|
||||
@click="handleNav('hermes.settings')"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t("sidebar.settings") }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ModelSelector />
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="status-row">
|
||||
<div
|
||||
class="status-indicator"
|
||||
:class="{
|
||||
connected: appStore.connected,
|
||||
disconnected: !appStore.connected,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{
|
||||
appStore.connected
|
||||
? t("sidebar.connected")
|
||||
: t("sidebar.disconnected")
|
||||
}}</span>
|
||||
</div>
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
<div class="version-info">
|
||||
Hermes {{ appStore.serverVersion || "v0.1.0" }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.sidebar {
|
||||
width: $sidebar-width;
|
||||
height: calc(100 * var(--vh));
|
||||
background-color: $bg-sidebar;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px 20px;
|
||||
flex-shrink: 0;
|
||||
transition: width $transition-normal;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 12px;
|
||||
margin: 0 -12px;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.logo-dance {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 100px;
|
||||
border-radius: $radius-md;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding-top: 12px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
font-size: 14px;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($accent-primary, 0.06);
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba($accent-primary, 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.connected .status-dot {
|
||||
background-color: $success;
|
||||
box-shadow: 0 0 6px rgba($success, 0.5);
|
||||
}
|
||||
|
||||
&.disconnected .status-dot {
|
||||
background-color: $error;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.version-info {
|
||||
padding: 2px 12px 8px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.logo-dance {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform $transition-normal;
|
||||
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
// Override global utility — sidebar is always 240px wide
|
||||
.input-sm {
|
||||
width: 90px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NSelect } from 'naive-ui'
|
||||
|
||||
const { locale, availableLocales, t } = useI18n()
|
||||
|
||||
const options = computed(() =>
|
||||
availableLocales.map(loc => ({
|
||||
label: loc === 'zh' ? t('language.zh') : t('language.en'),
|
||||
value: loc,
|
||||
})),
|
||||
)
|
||||
|
||||
function handleChange(val: string) {
|
||||
locale.value = val
|
||||
localStorage.setItem('hermes_locale', val)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSelect
|
||||
:value="locale"
|
||||
:options="options"
|
||||
size="tiny"
|
||||
:consistent-menu-width="false"
|
||||
class="input-sm"
|
||||
@update:value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const options = computed(() =>
|
||||
appStore.modelGroups.map(g => ({
|
||||
label: g.label,
|
||||
type: 'group' as const,
|
||||
key: g.provider,
|
||||
children: g.models.map(m => ({
|
||||
label: m,
|
||||
value: m,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
function handleChange(value: string | number | Array<string | number>) {
|
||||
if (typeof value === 'string') {
|
||||
appStore.switchModel(value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-selector">
|
||||
<div class="model-label">{{ t('models.title') }}</div>
|
||||
<NSelect
|
||||
:value="appStore.selectedModel"
|
||||
:options="options"
|
||||
size="small"
|
||||
@update:value="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.model-selector {
|
||||
padding: 0 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user