feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { fetchConversationDetail, fetchConversationSummaries, type ConversationDetail, type ConversationSummary } from '@/api/hermes/conversations'
|
||||
import { formatTimestampSeconds, getSourceLabel } from '@/shared/session-display'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ humanOnly: boolean }>()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const POLL_INTERVAL_MS = 15000
|
||||
|
||||
const sessions = ref<ConversationSummary[]>([])
|
||||
const selectedSessionId = ref<string | null>(null)
|
||||
const detail = ref<ConversationDetail | null>(null)
|
||||
const sessionsLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const error = ref('')
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sessionsRequestId = 0
|
||||
let detailRequestId = 0
|
||||
|
||||
const selectedSession = computed(() => sessions.value.find(session => session.id === selectedSessionId.value) || null)
|
||||
const selectedSessionModelName = computed(() =>
|
||||
selectedSession.value?.model
|
||||
? appStore.displayModelName(selectedSession.value.model, selectedSession.value.provider)
|
||||
: '',
|
||||
)
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
return role === 'user' ? t('chat.monitorRoleUser') : t('chat.monitorRoleAssistant')
|
||||
}
|
||||
|
||||
function linkedSessionsLabel(count: number): string {
|
||||
return t('chat.linkedSessions', { count })
|
||||
}
|
||||
|
||||
function invalidateRequests() {
|
||||
sessionsRequestId += 1
|
||||
detailRequestId += 1
|
||||
}
|
||||
|
||||
async function loadSessions(silent = false) {
|
||||
const requestId = ++sessionsRequestId
|
||||
if (!silent) {
|
||||
sessionsLoading.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = await fetchConversationSummaries({ humanOnly: props.humanOnly })
|
||||
if (requestId !== sessionsRequestId) return
|
||||
|
||||
sessions.value = loaded
|
||||
if (!loaded.length) {
|
||||
selectedSessionId.value = null
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedSessionId.value || !loaded.some(session => session.id === selectedSessionId.value)) {
|
||||
selectedSessionId.value = loaded[0].id
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (requestId !== sessionsRequestId || silent) return
|
||||
error.value = err?.message || String(err)
|
||||
sessions.value = []
|
||||
selectedSessionId.value = null
|
||||
detail.value = null
|
||||
} finally {
|
||||
if (!silent && requestId === sessionsRequestId) sessionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(sessionId: string | null, silent = false) {
|
||||
const requestId = ++detailRequestId
|
||||
if (!sessionId) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const requestedHumanOnly = props.humanOnly
|
||||
if (!silent) {
|
||||
detailLoading.value = true
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
const loaded = await fetchConversationDetail(sessionId, { humanOnly: requestedHumanOnly })
|
||||
if (
|
||||
requestId !== detailRequestId
|
||||
|| sessionId !== selectedSessionId.value
|
||||
|| requestedHumanOnly !== props.humanOnly
|
||||
) {
|
||||
return
|
||||
}
|
||||
detail.value = loaded
|
||||
} catch (err: any) {
|
||||
if (requestId !== detailRequestId || silent) return
|
||||
error.value = err?.message || String(err)
|
||||
detail.value = null
|
||||
} finally {
|
||||
if (!silent && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedSessionId, async sessionId => {
|
||||
await loadDetail(sessionId, false)
|
||||
})
|
||||
|
||||
watch(() => props.humanOnly, async () => {
|
||||
invalidateRequests()
|
||||
selectedSessionId.value = null
|
||||
detail.value = null
|
||||
await loadSessions(false)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSessions(false)
|
||||
refreshTimer = setInterval(async () => {
|
||||
await loadSessions(true)
|
||||
if (selectedSessionId.value) {
|
||||
await loadDetail(selectedSessionId.value, true)
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
invalidateRequests()
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversation-monitor">
|
||||
<aside class="conversation-monitor__sidebar">
|
||||
<div v-if="sessionsLoading && sessions.length === 0" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="sessions.length === 0" class="conversation-monitor__empty">{{ t('chat.noSessions') }}</div>
|
||||
<button
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="conversation-monitor__session"
|
||||
:class="{ active: session.id === selectedSessionId }"
|
||||
:aria-pressed="session.id === selectedSessionId"
|
||||
@click="selectedSessionId = session.id"
|
||||
>
|
||||
<div class="conversation-monitor__session-title-row">
|
||||
<span class="conversation-monitor__session-title">{{ session.title || session.preview || session.id }}</span>
|
||||
<span v-if="session.is_active" class="conversation-monitor__session-live">{{ t('chat.recentBadge') }}</span>
|
||||
</div>
|
||||
<div class="conversation-monitor__session-meta">{{ getSourceLabel(session.source) }} · {{ formatTimestampSeconds(session.last_active) }}</div>
|
||||
<div v-if="session.preview" class="conversation-monitor__session-preview">{{ session.preview }}</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="conversation-monitor__detail">
|
||||
<header v-if="selectedSession" class="conversation-monitor__detail-header">
|
||||
<div class="conversation-monitor__detail-title">{{ selectedSession.title || selectedSession.preview || selectedSession.id }}</div>
|
||||
<div class="conversation-monitor__detail-meta">
|
||||
<span>{{ getSourceLabel(selectedSession.source) }}</span>
|
||||
<span>·</span>
|
||||
<span :title="selectedSession.model">{{ selectedSessionModelName }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ linkedSessionsLabel(selectedSession.thread_session_count) }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="conversation-monitor__empty conversation-monitor__empty--error">{{ error }}</div>
|
||||
<div v-else-if="detailLoading && !detail" class="conversation-monitor__empty">{{ t('common.loading') }}</div>
|
||||
<div v-else-if="!detail || detail.messages.length === 0" class="conversation-monitor__empty">{{ t('chat.noVisibleMessages') }}</div>
|
||||
<div v-else class="conversation-monitor__messages">
|
||||
<article
|
||||
v-for="message in detail.messages"
|
||||
:key="`${message.session_id}-${message.id}`"
|
||||
class="conversation-monitor__message"
|
||||
:class="`role-${message.role}`"
|
||||
>
|
||||
<div class="conversation-monitor__message-meta">{{ roleLabel(message.role) }} · {{ formatTimestampSeconds(message.timestamp) }}</div>
|
||||
<div class="conversation-monitor__message-content">{{ message.content }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.conversation-monitor {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conversation-monitor__sidebar {
|
||||
width: 260px;
|
||||
border-right: 1px solid $border-color;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba($text-muted, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba($text-muted, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-monitor__session {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba($border-color, 0.6);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.active .conversation-monitor__session-title {
|
||||
color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-monitor__session-title-row,
|
||||
.conversation-monitor__detail-meta,
|
||||
.conversation-monitor__message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-title,
|
||||
.conversation-monitor__detail-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-live {
|
||||
font-size: 11px;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-meta,
|
||||
.conversation-monitor__session-preview,
|
||||
.conversation-monitor__detail-meta,
|
||||
.conversation-monitor__message-meta {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.conversation-monitor__session-preview,
|
||||
.conversation-monitor__message-content {
|
||||
margin-top: 6px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.conversation-monitor__detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-monitor__detail-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.conversation-monitor__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conversation-monitor__message {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba($bg-secondary, 0.8);
|
||||
|
||||
&.role-user {
|
||||
border: 1px solid rgba($accent-primary, 0.18);
|
||||
}
|
||||
|
||||
&.role-assistant {
|
||||
border: 1px solid rgba($border-color, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-monitor__empty {
|
||||
padding: 24px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.conversation-monitor__empty--error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.conversation-monitor {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-monitor__sidebar {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-monitor__detail {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TerminalPanel from './TerminalPanel.vue'
|
||||
import FilesPanel from './FilesPanel.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
activeTab?: 'terminal' | 'files'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeTab: 'files'
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<'terminal' | 'files'>(props.activeTab)
|
||||
|
||||
watch(() => props.activeTab, (newVal) => {
|
||||
if (newVal) activeTab.value = newVal
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="drawer-overlay" @click="handleClose"></div>
|
||||
<div :class="['drawer-panel', { show }]">
|
||||
<div class="drawer-header">
|
||||
<div class="drawer-tabs">
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'files' }]"
|
||||
@click="activeTab = 'files'"
|
||||
>
|
||||
{{ t('drawer.files') }}
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-button', { active: activeTab === 'terminal' }]"
|
||||
@click="activeTab = 'terminal'"
|
||||
>
|
||||
{{ t('drawer.terminal') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-button" @click="handleClose">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<div v-show="activeTab === 'files'" class="drawer-pane">
|
||||
<FilesPanel />
|
||||
</div>
|
||||
<div v-show="activeTab === 'terminal'" class="drawer-pane">
|
||||
<TerminalPanel :visible="activeTab === 'terminal' && show" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: min(-1180px, -88vw);
|
||||
width: min(1180px, 88vw);
|
||||
height: calc(100 * var(--vh));
|
||||
max-height: calc(100 * var(--vh));
|
||||
background: $bg-card;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: right 0.3s ease;
|
||||
|
||||
&.show {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
|
||||
&.show {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent-primary);
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.drawer-pane {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton } from 'naive-ui'
|
||||
import FileTree from '@/components/hermes/files/FileTree.vue'
|
||||
import FileBreadcrumb from '@/components/hermes/files/FileBreadcrumb.vue'
|
||||
import FileToolbar from '@/components/hermes/files/FileToolbar.vue'
|
||||
import FileList from '@/components/hermes/files/FileList.vue'
|
||||
import FileContextMenu from '@/components/hermes/files/FileContextMenu.vue'
|
||||
import FileEditor from '@/components/hermes/files/FileEditor.vue'
|
||||
import FilePreview from '@/components/hermes/files/FilePreview.vue'
|
||||
import FileUploadModal from '@/components/hermes/files/FileUploadModal.vue'
|
||||
import FileRenameModal from '@/components/hermes/files/FileRenameModal.vue'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const filesStore = useFilesStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof FileContextMenu> | null>(null)
|
||||
const showUpload = ref(false)
|
||||
const showRenameModal = ref(false)
|
||||
const renameMode = ref<'newFile' | 'newFolder' | 'rename'>('newFile')
|
||||
const renameEntry = ref<FileEntry | null>(null)
|
||||
const showSidebar = ref(false)
|
||||
|
||||
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
|
||||
contextMenuRef.value?.show(e, entry)
|
||||
}
|
||||
|
||||
function handleShowNewFile() {
|
||||
renameMode.value = 'newFile'
|
||||
renameEntry.value = null
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
function handleShowNewFolder() {
|
||||
renameMode.value = 'newFolder'
|
||||
renameEntry.value = null
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
function handleRename(entry: FileEntry) {
|
||||
renameMode.value = 'rename'
|
||||
renameEntry.value = entry
|
||||
showRenameModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
filesStore.fetchEntries('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-panel-drawer">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
<div
|
||||
class="files-tree-panel"
|
||||
:class="{ 'mobile-visible': showSidebar }"
|
||||
>
|
||||
<FileTree />
|
||||
</div>
|
||||
<div class="files-main-panel">
|
||||
<div class="main-toolbar">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showSidebar = !showSidebar"
|
||||
class="sidebar-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t('files.fileTree') }}
|
||||
</NButton>
|
||||
<FileToolbar
|
||||
@show-new-file="handleShowNewFile"
|
||||
@show-new-folder="handleShowNewFolder"
|
||||
@show-upload="showUpload = true"
|
||||
/>
|
||||
</div>
|
||||
<FileBreadcrumb />
|
||||
<div class="files-content">
|
||||
<FileEditor v-if="filesStore.editingFile" />
|
||||
<FilePreview v-else-if="filesStore.previewFile" />
|
||||
<FileList v-else @contextmenu-entry="handleContextMenu" />
|
||||
</div>
|
||||
</div>
|
||||
<FileContextMenu ref="contextMenuRef" @rename="handleRename" />
|
||||
<FileUploadModal v-model:show="showUpload" />
|
||||
<FileRenameModal v-model:show="showRenameModal" :mode="renameMode" :entry="renameEntry" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.files-panel-drawer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.files-tree-panel {
|
||||
width: 200px;
|
||||
min-width: 150px;
|
||||
max-width: 300px;
|
||||
border-right: 1px solid $border-color;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
z-index: 51;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-main-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
gap: 4px;
|
||||
padding: 8px 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.files-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import { request } from '@/api/client'
|
||||
|
||||
interface FolderEntry {
|
||||
name: string
|
||||
path: string
|
||||
fullPath: string
|
||||
}
|
||||
|
||||
interface FolderListResponse {
|
||||
base: string
|
||||
current: string
|
||||
folders: FolderEntry[]
|
||||
}
|
||||
|
||||
/** Flat display node for rendering tree without recursion */
|
||||
interface FlatNode {
|
||||
folder: FolderEntry
|
||||
depth: number
|
||||
isExpanded: boolean
|
||||
isLoading: boolean
|
||||
hasChildren: boolean | null // null = unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const basePath = ref('')
|
||||
const folders = ref<FolderEntry[]>([])
|
||||
const expandedPaths = ref<Set<string>>(new Set())
|
||||
const childrenCache = ref<Map<string, FolderEntry[]>>(new Map())
|
||||
const loadingPaths = ref<Set<string>>(new Set())
|
||||
const selectedPath = ref(props.modelValue || '')
|
||||
|
||||
watch(() => props.modelValue, (v) => { selectedPath.value = v || '' })
|
||||
|
||||
async function loadFolders(subPath = ''): Promise<FolderListResponse | null> {
|
||||
try {
|
||||
const query = subPath ? `?path=${encodeURIComponent(subPath)}` : ''
|
||||
return await request<FolderListResponse>(`/api/hermes/workspace/folders${query}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const res = await loadFolders()
|
||||
if (res) {
|
||||
basePath.value = res.base
|
||||
folders.value = res.folders
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
async function toggleExpand(folder: FolderEntry) {
|
||||
if (expandedPaths.value.has(folder.path)) {
|
||||
expandedPaths.value.delete(folder.path)
|
||||
expandedPaths.value = new Set(expandedPaths.value)
|
||||
return
|
||||
}
|
||||
|
||||
expandedPaths.value.add(folder.path)
|
||||
expandedPaths.value = new Set(expandedPaths.value)
|
||||
|
||||
if (!childrenCache.value.has(folder.path)) {
|
||||
loadingPaths.value.add(folder.path)
|
||||
loadingPaths.value = new Set(loadingPaths.value)
|
||||
const res = await loadFolders(folder.path)
|
||||
childrenCache.value.set(folder.path, res?.folders || [])
|
||||
childrenCache.value = new Map(childrenCache.value)
|
||||
loadingPaths.value.delete(folder.path)
|
||||
loadingPaths.value = new Set(loadingPaths.value)
|
||||
}
|
||||
}
|
||||
|
||||
function selectFolder(folder: FolderEntry) {
|
||||
const fullPath = `${basePath.value}/${folder.path}`
|
||||
selectedPath.value = fullPath
|
||||
emit('update:modelValue', fullPath)
|
||||
}
|
||||
|
||||
function selectBase() {
|
||||
selectedPath.value = basePath.value
|
||||
emit('update:modelValue', basePath.value)
|
||||
}
|
||||
|
||||
/** Build a flat list by DFS traversal of expanded nodes */
|
||||
const flatNodes = computed<FlatNode[]>(() => {
|
||||
const result: FlatNode[] = []
|
||||
|
||||
function traverse(entries: FolderEntry[], depth: number) {
|
||||
for (const folder of entries) {
|
||||
const isExpanded = expandedPaths.value.has(folder.path)
|
||||
const isLoading = loadingPaths.value.has(folder.path)
|
||||
const children = childrenCache.value.get(folder.path)
|
||||
result.push({
|
||||
folder,
|
||||
depth,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
hasChildren: children ? children.length > 0 : null,
|
||||
})
|
||||
if (isExpanded && children && children.length > 0) {
|
||||
traverse(children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(folders.value, 0)
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-picker">
|
||||
<div v-if="loading" class="folder-picker-loading">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else class="folder-tree">
|
||||
<!-- Base path as root -->
|
||||
<div
|
||||
class="folder-item root"
|
||||
:class="{ selected: selectedPath === basePath }"
|
||||
@click="selectBase"
|
||||
>
|
||||
<span class="folder-icon">📂</span>
|
||||
<span class="folder-name">{{ basePath || '/' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Flat rendered tree -->
|
||||
<div
|
||||
v-for="node in flatNodes"
|
||||
:key="node.folder.path"
|
||||
class="folder-item"
|
||||
:class="{ selected: selectedPath === `${basePath}/${node.folder.path}` }"
|
||||
:style="{ paddingLeft: `${12 + node.depth * 16}px` }"
|
||||
>
|
||||
<span class="folder-expand" @click.stop="toggleExpand(node.folder)">
|
||||
<template v-if="node.isLoading">⏳</template>
|
||||
<template v-else>{{ node.isExpanded ? '▼' : '▶' }}</template>
|
||||
</span>
|
||||
<span class="folder-icon" @click="selectFolder(node.folder)">📁</span>
|
||||
<span class="folder-name" @click="selectFolder(node.folder)">{{ node.folder.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty children indicator for expanded folders with no children -->
|
||||
<template v-for="node in flatNodes" :key="'empty-' + node.folder.path">
|
||||
<div
|
||||
v-if="node.isExpanded && !node.isLoading && node.hasChildren === false"
|
||||
class="folder-item empty"
|
||||
:style="{ paddingLeft: `${28 + node.depth * 16}px` }"
|
||||
>
|
||||
<span class="folder-empty-text">(空)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="folders.length === 0 && !loading" class="folder-empty">
|
||||
暂无工作区文件夹
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected path display -->
|
||||
<div v-if="selectedPath" class="folder-selected">
|
||||
<span class="folder-selected-label">已选择:</span>
|
||||
<span class="folder-selected-path">{{ selectedPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.folder-picker {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.folder-picker-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(64, 158, 255, 0.15);
|
||||
outline: 1px solid rgba(64, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
&.root {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.folder-expand {
|
||||
width: 14px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-empty-text {
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.folder-empty {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.folder-selected {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-selected-label {
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-selected-path {
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
type HistorySessionScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
wasNearBottom: boolean;
|
||||
}
|
||||
|
||||
const historySessionScrollPositions = new Map<string, HistorySessionScrollSnapshot>();
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import VirtualMessageList from "./VirtualMessageList.vue";
|
||||
import MessageItem from "./MessageItem.vue";
|
||||
import { useChatStore } from "@/stores/hermes/chat";
|
||||
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||
import type { Session } from "@/stores/hermes/chat";
|
||||
|
||||
const props = defineProps<{
|
||||
session?: Session | null; // Optional: use this session instead of chatStore.activeSession
|
||||
loadOlder?: (sessionId: string) => Promise<boolean>;
|
||||
}>();
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const { t } = useI18n();
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
||||
const pendingInitialScrollSessionId = ref<string | null>(null);
|
||||
const activeSession = computed(() => props.session || null);
|
||||
const listInstanceKey = computed(() => activeSession.value?.id ? `history-${activeSession.value.id}` : "history-empty");
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
(activeSession.value?.messages || []).filter((m) => {
|
||||
// Tool messages without a name are internal use only and remain hidden.
|
||||
if (m.role === 'tool') return toolTraceVisible.value && !!m.toolName
|
||||
// Filter out messages with empty content.
|
||||
if (!m.content?.trim()) return false
|
||||
return true
|
||||
}),
|
||||
);
|
||||
|
||||
function isNearBottom(threshold = 200): boolean {
|
||||
return listRef.value?.isNearBottom(threshold) ?? true;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
listRef.value?.scrollToBottom();
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: string) {
|
||||
listRef.value?.scrollToMessage(messageId);
|
||||
}
|
||||
|
||||
function scrollToAnchor(messageId: string, anchorId: string) {
|
||||
listRef.value?.scrollToAnchor(messageId, anchorId);
|
||||
}
|
||||
|
||||
function saveSessionScrollPosition(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return;
|
||||
const snapshot = listRef.value?.captureViewportPosition() ?? null;
|
||||
if (snapshot) historySessionScrollPositions.set(sessionId, snapshot);
|
||||
}
|
||||
|
||||
function applyInitialSessionScroll(sessionId: string) {
|
||||
if (activeSession.value?.id !== sessionId) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
pendingInitialScrollSessionId.value = null;
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = historySessionScrollPositions.get(sessionId);
|
||||
if (snapshot) {
|
||||
pendingInitialScrollSessionId.value = null;
|
||||
if (snapshot.wasNearBottom) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
listRef.value?.restoreViewportPosition(snapshot);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
if ((activeSession.value?.messages.length || 0) > 0) pendingInitialScrollSessionId.value = null;
|
||||
}
|
||||
|
||||
async function handleTopReach() {
|
||||
const session = activeSession.value;
|
||||
if (!session?.hasMoreBefore || session.isLoadingOlderMessages || !props.loadOlder) return;
|
||||
const snapshot = listRef.value?.captureScrollPosition() ?? null;
|
||||
const loaded = await props.loadOlder(session.id);
|
||||
if (!loaded) return;
|
||||
await nextTick();
|
||||
listRef.value?.restoreScrollPosition(snapshot);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => activeSession.value?.id,
|
||||
async (id, previousId) => {
|
||||
saveSessionScrollPosition(previousId);
|
||||
if (!id) return;
|
||||
pendingInitialScrollSessionId.value = id;
|
||||
await nextTick();
|
||||
applyInitialSessionScroll(id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.focusMessageId,
|
||||
(messageId) => {
|
||||
if (!messageId) return;
|
||||
scrollToMessage(messageId);
|
||||
},
|
||||
);
|
||||
|
||||
// During streaming, only auto-scroll if the user is already near the bottom
|
||||
watch(
|
||||
() => (activeSession.value?.messages || [])[((activeSession.value?.messages || []).length - 1)]?.content,
|
||||
(content) => {
|
||||
if (pendingInitialScrollSessionId.value === activeSession.value?.id) return;
|
||||
if (!content) return
|
||||
if (!isNearBottom()) return;
|
||||
scrollToBottom();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => (activeSession.value?.messages || []).length,
|
||||
(length) => {
|
||||
if (length === 0) return
|
||||
const id = activeSession.value?.id
|
||||
if (id && pendingInitialScrollSessionId.value === id) {
|
||||
applyInitialSessionScroll(id);
|
||||
return;
|
||||
}
|
||||
if (!isNearBottom()) return;
|
||||
scrollToBottom();
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveSessionScrollPosition(activeSession.value?.id);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
scrollToAnchor,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualMessageList
|
||||
:key="listInstanceKey"
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #before>
|
||||
<div
|
||||
v-if="activeSession?.hasMoreBefore || activeSession?.isLoadingOlderMessages"
|
||||
class="history-loader"
|
||||
>
|
||||
<span v-if="activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<MessageItem
|
||||
:message="msg"
|
||||
:highlight="chatStore.focusMessageId === msg.id"
|
||||
/>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-muted;
|
||||
gap: 12px;
|
||||
|
||||
.empty-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-loader {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.history-loader-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.16);
|
||||
border-top-color: $accent-primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
|
||||
.dark & {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-top-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,780 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NDrawer, NDrawerContent, NSpin, useMessage } from 'naive-ui'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import MarkdownItConstructor from 'markdown-it'
|
||||
import katex from 'katex'
|
||||
import markdownItKatex from '@vscode/markdown-it-katex'
|
||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
||||
import {
|
||||
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
|
||||
MERMAID_MAX_SOURCE_LENGTH,
|
||||
MERMAID_RENDER_TIMEOUT_MS,
|
||||
decodeMermaidSource,
|
||||
isMermaidFence,
|
||||
renderMermaidPlaceholder,
|
||||
SUPPORT_PREVIEW_FILE_TYPES,
|
||||
} from './mermaidRenderer'
|
||||
import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download'
|
||||
|
||||
const LATEX_FENCE_LANGS = new Set(['latex', 'tex', 'math', 'katex'])
|
||||
const PREVIEW_AREA_WIDTH = 'min(800px, 100vw)'
|
||||
|
||||
function getFenceLanguage(info: string): string {
|
||||
return info.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
function isLatexFence(info: string): boolean {
|
||||
return LATEX_FENCE_LANGS.has(getFenceLanguage(info))
|
||||
}
|
||||
|
||||
function normalizeLatexFenceContent(content: string): string {
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) {
|
||||
return trimmed.slice(2, -2).trim()
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function renderLatexFence(content: string): string {
|
||||
const latex = normalizeLatexFenceContent(content)
|
||||
return `<div class="latex-block">${katex.renderToString(latex, {
|
||||
displayMode: true,
|
||||
output: 'htmlAndMathml',
|
||||
throwOnError: false,
|
||||
strict: 'ignore',
|
||||
})}</div>`
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: string
|
||||
mentionNames?: string[]
|
||||
headingIdPrefix?: string
|
||||
}>(), {
|
||||
mentionNames: () => [],
|
||||
headingIdPrefix: '',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const md: MarkdownIt = new MarkdownItConstructor({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight(str: string, lang: string): string {
|
||||
return renderHighlightedCodeBlock(str, lang, t('common.copy'))
|
||||
},
|
||||
})
|
||||
|
||||
md.use(markdownItKatex, {
|
||||
katex,
|
||||
throwOnError: false,
|
||||
strict: 'ignore',
|
||||
})
|
||||
|
||||
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
if (isLatexFence(token.info)) {
|
||||
return renderLatexFence(token.content)
|
||||
}
|
||||
|
||||
if (isMermaidFence(token.info)) {
|
||||
return renderMermaidPlaceholder(token.content)
|
||||
}
|
||||
|
||||
if (defaultFenceRenderer) {
|
||||
return defaultFenceRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
const markdownBody = ref<HTMLElement | null>(null)
|
||||
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
|
||||
const previewUrl = ref<string | null>(null)
|
||||
|
||||
// Preview config variable
|
||||
const textPreviewContent = ref<string | null>(null)
|
||||
const textPreviewFileName = ref('')
|
||||
const textPreviewLoading = ref(false)
|
||||
const textPreviewVisible = ref(false)
|
||||
|
||||
const textPreviewIsMarkdown = computed(() => /\.(md|markdown)$/i.test(textPreviewFileName.value))
|
||||
|
||||
let renderGeneration = 0
|
||||
let unmounted = false
|
||||
|
||||
function isLocalFilePath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path)
|
||||
}
|
||||
|
||||
function normalizeLocalFilePath(path: string): string {
|
||||
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
|
||||
}
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
let html = md.render(repairNestedMarkdownFences(props.content))
|
||||
|
||||
// Add IDs to headings for anchor links
|
||||
const prefix = props.headingIdPrefix ? `${props.headingIdPrefix}-` : ''
|
||||
let headingCounter = 0
|
||||
// Match any h1-h6 tags, with or without attributes
|
||||
html = html.replace(/<(h[1-6])([^>]*)>/g, (match, tag, attrs) => {
|
||||
headingCounter++
|
||||
const id = `${prefix}heading-${headingCounter}`
|
||||
|
||||
// Check if id attribute already exists
|
||||
if (attrs.includes('id=')) {
|
||||
// Replace existing id
|
||||
return match.replace(/id="[^"]*"/, `id="${id}"`).replace(/id='[^']*'/, `id="${id}"`)
|
||||
}
|
||||
|
||||
// Add new id
|
||||
if (attrs.trim() === '') {
|
||||
return `<${tag} id="${id}">`
|
||||
}
|
||||
return `<${tag} ${attrs.trim()} id="${id}">`
|
||||
})
|
||||
|
||||
// Replace image src paths with download URLs
|
||||
html = html.replace(/\bsrc=(["'])([^"']+)\1/g, (match, quote, path) => {
|
||||
if (!isLocalFilePath(path)) return match
|
||||
const downloadUrl = getDownloadUrl(normalizeLocalFilePath(path))
|
||||
return `src=${quote}${downloadUrl}${quote}`
|
||||
})
|
||||
|
||||
// Replace local file links with file card UI or video player
|
||||
// Match <a href="/tmp/file.pdf">filename</a> or <a href="C:/tmp/file.pdf">filename</a>
|
||||
html = html.replace(/<a href="([^"]+)">([^<]+)<\/a>/g, (match, rawPath, filename) => {
|
||||
if (!isLocalFilePath(rawPath)) return match
|
||||
|
||||
const path = normalizeLocalFilePath(rawPath)
|
||||
const fileName = filename.trim()
|
||||
const ext = path.split('.').pop()?.toLowerCase()
|
||||
|
||||
// Video files: render as video player
|
||||
if (ext === 'mp4' || ext === 'webm' || ext === 'mov') {
|
||||
const downloadUrl = getDownloadUrl(path)
|
||||
return `<div class="markdown-video-container">
|
||||
<video class="markdown-video" controls preload="metadata" src="${downloadUrl}"></video>
|
||||
<div class="markdown-video-footer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<span class="att-name">${fileName}</span>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// Other files: render as file card
|
||||
return `<div class="markdown-file-card" data-path="${path}" data-filename="${fileName}" title="${t('download.downloadFile')}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span class="att-name">${fileName}</span>
|
||||
<button class="att-download-btn" type="button" title="${t('download.downloadFile')}" aria-label="${t('download.downloadFile')}">
|
||||
<svg class="att-download-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>`
|
||||
})
|
||||
|
||||
if (props.mentionNames && props.mentionNames.length > 0) {
|
||||
const escaped = [...props.mentionNames]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
const re = new RegExp(`(?<=[\\s>({\\[<]|^)@(${escaped.join('|')})(?=[\\s.,!?;:,。!?;:)\\]}>]|<|$)`, 'gi')
|
||||
html = html.replace(re, '<span class="mention-highlight">@$1</span>')
|
||||
}
|
||||
return html
|
||||
})
|
||||
|
||||
function renderMermaidFallback(element: HTMLElement, source: string): void {
|
||||
element.outerHTML = renderHighlightedCodeBlock(source, 'mermaid', t('common.copy'))
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
if (!el) return null
|
||||
let current: HTMLElement | null = el.parentElement
|
||||
while (current) {
|
||||
const { overflow, overflowY } = getComputedStyle(current)
|
||||
if (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isNearScrollBottom(el: HTMLElement, threshold = 200): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
|
||||
}
|
||||
|
||||
function cleanupMermaidRenderArtifacts(id: string): void {
|
||||
document.getElementById(id)?.remove()
|
||||
document.getElementById(`d${id}`)?.remove()
|
||||
}
|
||||
|
||||
async function renderMermaidDiagrams(): Promise<void> {
|
||||
const generation = ++renderGeneration
|
||||
await nextTick()
|
||||
|
||||
const root = markdownBody.value
|
||||
if (unmounted || generation !== renderGeneration || !root) return
|
||||
|
||||
const pendingDiagrams = Array.from(root.querySelectorAll<HTMLElement>('[data-mermaid-pending="true"]'))
|
||||
if (pendingDiagrams.length === 0) return
|
||||
|
||||
const diagramsToRender = pendingDiagrams.slice(0, MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
|
||||
const diagramsToFallback = pendingDiagrams.slice(MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
|
||||
|
||||
for (const element of diagramsToFallback) {
|
||||
renderMermaidFallback(element, decodeMermaidSource(element.getAttribute('data-mermaid-source')))
|
||||
}
|
||||
|
||||
const renderCandidates = diagramsToRender
|
||||
.map(element => ({
|
||||
element,
|
||||
source: decodeMermaidSource(element.getAttribute('data-mermaid-source')),
|
||||
}))
|
||||
|
||||
const validDiagrams = [] as typeof renderCandidates
|
||||
for (const candidate of renderCandidates) {
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(candidate.element)) return
|
||||
|
||||
if (!candidate.source || candidate.source.length > MERMAID_MAX_SOURCE_LENGTH) {
|
||||
renderMermaidFallback(candidate.element, candidate.source)
|
||||
continue
|
||||
}
|
||||
|
||||
validDiagrams.push(candidate)
|
||||
}
|
||||
|
||||
if (validDiagrams.length === 0) return
|
||||
|
||||
let mermaid: typeof import('mermaid').default
|
||||
|
||||
try {
|
||||
mermaid = (await withTimeout(import('mermaid'), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid import')).default
|
||||
if (unmounted || generation !== renderGeneration) return
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
})
|
||||
} catch {
|
||||
if (unmounted || generation !== renderGeneration) return
|
||||
for (const { element, source } of validDiagrams) {
|
||||
if (root.contains(element)) {
|
||||
renderMermaidFallback(element, source)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const [index, { element, source }] of validDiagrams.entries()) {
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
|
||||
try {
|
||||
const id = `${componentId}-${generation}-${index}`
|
||||
const result = await withTimeout(mermaid.render(id, source), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid render')
|
||||
cleanupMermaidRenderArtifacts(id)
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
|
||||
const scrollParent = getScrollParent(markdownBody.value)
|
||||
const shouldKeepBottom = scrollParent ? isNearScrollBottom(scrollParent) : false
|
||||
element.removeAttribute('data-mermaid-pending')
|
||||
element.removeAttribute('data-mermaid-source')
|
||||
element.innerHTML = result.svg
|
||||
if (scrollParent && shouldKeepBottom) {
|
||||
nextTick(() => {
|
||||
scrollParent.scrollTop = scrollParent.scrollHeight
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
renderMermaidFallback(element, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void renderMermaidDiagrams()
|
||||
})
|
||||
|
||||
watch(renderedHtml, () => {
|
||||
void renderMermaidDiagrams()
|
||||
}, { flush: 'post' })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
renderGeneration += 1
|
||||
})
|
||||
|
||||
async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
||||
const copyResult = await handleCodeBlockCopyClick(event)
|
||||
if (copyResult !== null) {
|
||||
if (copyResult) {
|
||||
message.success(t('common.copied'))
|
||||
} else {
|
||||
message.error(t('chat.copyFailed'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
// Handle image clicks for preview
|
||||
const img = target.closest('img') as HTMLImageElement | null
|
||||
if (img) {
|
||||
event.preventDefault()
|
||||
previewUrl.value = img.src
|
||||
return
|
||||
}
|
||||
|
||||
// Handle file card clicks for download
|
||||
const fileCard = target.closest('.markdown-file-card') as HTMLElement | null
|
||||
if (fileCard) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const path = fileCard.getAttribute('data-path')
|
||||
const fileName = fileCard.getAttribute('data-filename') || undefined
|
||||
|
||||
const isDownloadBtn = target.closest('.att-download-btn')
|
||||
|
||||
if (isDownloadBtn && path) { // Only download file with download icon clicked.
|
||||
message.info(t('download.downloading'))
|
||||
downloadFile(path, fileName).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path) {
|
||||
const ext = fileName?.split('.').pop()?.toLowerCase()
|
||||
if (SUPPORT_PREVIEW_FILE_TYPES.includes(ext || '')) {
|
||||
previewTextFile(path, fileName || '')
|
||||
} else { // Download file immediately
|
||||
downloadFile(path, fileName).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle file path link clicks for download
|
||||
const link = target.closest('a') as HTMLAnchorElement | null
|
||||
if (!link) return
|
||||
|
||||
const href = link.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
// Let http(s) links behave normally — use window.open to prevent
|
||||
// the hash-based router from intercepting the click
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
event.preventDefault()
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Full download URL: open directly (already has /api/hermes/download?path=...)
|
||||
if (href.startsWith('/api/hermes/download?')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const linkText = link.textContent || ''
|
||||
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
|
||||
message.info(t('download.downloading'))
|
||||
// Parse the real file path from the existing query param
|
||||
const url = new URL(href, window.location.origin)
|
||||
const realPath = url.searchParams.get('path') || href
|
||||
downloadFile(realPath, fileName || undefined).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// File path links: intercept and download
|
||||
if (isLocalFilePath(href)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const linkText = link.textContent || ''
|
||||
const fileName = linkText.startsWith('File: ') ? linkText.slice(6).trim() : linkText.trim()
|
||||
message.info(t('download.downloading'))
|
||||
downloadFile(normalizeLocalFilePath(href), fileName || undefined).catch((err: Error) => {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get file content and show preview area.
|
||||
async function previewTextFile(path: string, fileName: string): Promise<void> {
|
||||
textPreviewLoading.value = true
|
||||
textPreviewVisible.value = true
|
||||
textPreviewFileName.value = fileName
|
||||
textPreviewContent.value = null
|
||||
try {
|
||||
textPreviewContent.value = await fetchFileText(path, fileName)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('download.downloadFailed'))
|
||||
} finally {
|
||||
textPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeTextPreview(): void {
|
||||
textPreviewVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
|
||||
<!-- File preview area -->
|
||||
<NDrawer
|
||||
v-model:show="textPreviewVisible"
|
||||
:width="PREVIEW_AREA_WIDTH"
|
||||
placement="right"
|
||||
:show-mask="false"
|
||||
:trap-focus="false"
|
||||
class="markdown-text-preview-drawer"
|
||||
>
|
||||
<NDrawerContent
|
||||
:title="t('download.contentDisplay')"
|
||||
closable
|
||||
:body-content-style="{ padding: 0 }"
|
||||
@close="closeTextPreview"
|
||||
>
|
||||
<NSpin :show="textPreviewLoading">
|
||||
<div v-if="textPreviewContent !== null && textPreviewIsMarkdown" class="text-preview-markdown">
|
||||
<MarkdownRenderer :content="textPreviewContent" />
|
||||
</div>
|
||||
<pre v-else-if="textPreviewContent !== null" class="text-preview-body">{{ textPreviewContent }}</pre>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<Teleport to="body">
|
||||
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
|
||||
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 20px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
em {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $accent-primary;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: $accent-hover;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 200px;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-video-container {
|
||||
margin: 12px 0;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.markdown-video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.markdown-video-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
|
||||
.att-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-file-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $radius-sm;
|
||||
margin: 8px 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
border-color: $border-color;
|
||||
}
|
||||
|
||||
.att-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.att-download-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.att-download-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover .att-download-icon,
|
||||
.att-download-btn:hover .att-download-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 4px 12px;
|
||||
border-left: 3px solid $border-color;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
code:not(.hljs) {
|
||||
background: $code-bg;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
|
||||
th, td {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid $border-color;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid $border-color;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.mermaid-diagram {
|
||||
margin: 10px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
overflow-x: auto;
|
||||
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-loading {
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
font-family: $font-code;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-preview-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-preview-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.text-preview-markdown {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-text-preview-drawer {
|
||||
max-width: 100vw;
|
||||
|
||||
.n-drawer-content,
|
||||
.n-drawer-body-content-wrapper {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.markdown-text-preview-drawer {
|
||||
max-width: 100vw;
|
||||
|
||||
.n-drawer-content,
|
||||
.n-drawer-body-content-wrapper {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.text-preview-body {
|
||||
padding: 12px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.text-preview-markdown {
|
||||
padding: 12px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,798 @@
|
||||
<script lang="ts">
|
||||
type SessionScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
wasNearBottom: boolean;
|
||||
}
|
||||
|
||||
const sessionScrollPositions = new Map<string, SessionScrollSnapshot>();
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onBeforeUnmount, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import VirtualMessageList from "./VirtualMessageList.vue";
|
||||
import MessageItem from "./MessageItem.vue";
|
||||
import { useChatStore } from "@/stores/hermes/chat";
|
||||
import ThinkingIndicator from "./ThinkingIndicator.vue";
|
||||
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { t } = useI18n();
|
||||
const { toolTraceVisible } = useToolTraceVisibility();
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null);
|
||||
const pendingInitialScrollSessionId = ref<string | null>(null);
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatToolDuration(seconds: number): string {
|
||||
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`
|
||||
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.round(seconds % 60)
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
const currentToolCalls = computed(() => {
|
||||
const msgs = chatStore.messages;
|
||||
// Find the last user message index
|
||||
let lastUserIdx = -1;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === "user") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Only tool calls after the last user message, newest on top
|
||||
const tools = msgs.filter((m, i) => m.role === "tool" && i > lastUserIdx);
|
||||
return [...tools].reverse();
|
||||
});
|
||||
|
||||
const visibleToolCalls = computed(() =>
|
||||
currentToolCalls.value.filter((tool) => !!tool.toolName),
|
||||
);
|
||||
|
||||
const displayMessages = computed(() => {
|
||||
const currentToolIds = new Set(currentToolCalls.value.map((tool) => tool.id));
|
||||
return chatStore.messages.filter((m) => {
|
||||
if (m.role === "tool") {
|
||||
return toolTraceVisible.value && !!m.toolName && !(chatStore.isRunActive && currentToolIds.has(m.id));
|
||||
}
|
||||
if (
|
||||
m.role === "assistant" &&
|
||||
m.isStreaming &&
|
||||
!m.content?.trim() &&
|
||||
!!m.reasoning?.trim() &&
|
||||
currentToolCalls.value.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const queuedMessages = computed(() => {
|
||||
const sid = chatStore.activeSessionId;
|
||||
if (!sid) return [];
|
||||
return chatStore.queuedUserMessages.get(sid) || [];
|
||||
});
|
||||
|
||||
function removeQueuedMessage(messageId: string) {
|
||||
const sid = chatStore.activeSessionId;
|
||||
if (!sid) return;
|
||||
chatStore.removeQueuedMessage(sid, messageId);
|
||||
}
|
||||
|
||||
function queuedPreview(content: string): string {
|
||||
const normalized = content.replace(/\s+/g, " ").trim();
|
||||
return normalized.length > 48 ? `${normalized.slice(0, 48)}...` : normalized;
|
||||
}
|
||||
|
||||
function isNearBottom(threshold = 200): boolean {
|
||||
return listRef.value?.isNearBottom(threshold) ?? true;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
listRef.value?.scrollToBottom();
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: string) {
|
||||
listRef.value?.scrollToMessage(messageId);
|
||||
}
|
||||
|
||||
function scrollToAnchor(messageId: string, anchorId: string) {
|
||||
listRef.value?.scrollToAnchor(messageId, anchorId);
|
||||
}
|
||||
|
||||
function saveSessionScrollPosition(sessionId: string | null | undefined) {
|
||||
if (!sessionId) return;
|
||||
const snapshot = listRef.value?.captureViewportPosition() ?? null;
|
||||
if (snapshot) sessionScrollPositions.set(sessionId, snapshot);
|
||||
}
|
||||
|
||||
function applyInitialSessionScroll(sessionId: string) {
|
||||
if (chatStore.activeSessionId !== sessionId) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
pendingInitialScrollSessionId.value = null;
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = sessionScrollPositions.get(sessionId);
|
||||
if (snapshot) {
|
||||
pendingInitialScrollSessionId.value = null;
|
||||
if (snapshot.wasNearBottom) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
listRef.value?.restoreViewportPosition(snapshot);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
if (chatStore.messages.length > 0) pendingInitialScrollSessionId.value = null;
|
||||
}
|
||||
|
||||
async function handleTopReach() {
|
||||
const session = chatStore.activeSession;
|
||||
if (!session?.hasMoreBefore || session.isLoadingOlderMessages) return;
|
||||
const snapshot = listRef.value?.captureScrollPosition() ?? null;
|
||||
const loaded = await chatStore.loadOlderMessages(session.id);
|
||||
if (!loaded) return;
|
||||
await nextTick();
|
||||
listRef.value?.restoreScrollPosition(snapshot);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => chatStore.activeSessionId,
|
||||
async (id, previousId) => {
|
||||
saveSessionScrollPosition(previousId);
|
||||
if (!id) return;
|
||||
pendingInitialScrollSessionId.value = id;
|
||||
await nextTick();
|
||||
applyInitialSessionScroll(id);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [chatStore.activeSessionId, chatStore.messages.length] as const,
|
||||
([id, length]) => {
|
||||
if (!id || pendingInitialScrollSessionId.value !== id || length === 0) return;
|
||||
applyInitialSessionScroll(id);
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => chatStore.focusMessageId,
|
||||
(messageId) => {
|
||||
if (!messageId) return;
|
||||
scrollToMessage(messageId);
|
||||
},
|
||||
);
|
||||
|
||||
// When a run starts (user just sent a message), always scroll to bottom once
|
||||
watch(
|
||||
() => chatStore.isRunActive,
|
||||
(v) => {
|
||||
if (v) scrollToBottom();
|
||||
},
|
||||
);
|
||||
|
||||
// During streaming, only auto-scroll if the user is already near the bottom
|
||||
watch(
|
||||
() => chatStore.messages[chatStore.messages.length - 1]?.content,
|
||||
() => {
|
||||
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
if (!isNearBottom()) return;
|
||||
scrollToBottom();
|
||||
},
|
||||
);
|
||||
watch(currentToolCalls, () => {
|
||||
if (pendingInitialScrollSessionId.value === chatStore.activeSessionId) return;
|
||||
if (chatStore.focusMessageId) {
|
||||
scrollToMessage(chatStore.focusMessageId);
|
||||
return;
|
||||
}
|
||||
if (!isNearBottom()) return;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveSessionScrollPosition(chatStore.activeSessionId);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
scrollToAnchor,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualMessageList
|
||||
:key="chatStore.activeSessionId || 'chat-empty'"
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #before>
|
||||
<div
|
||||
v-if="chatStore.activeSession?.hasMoreBefore || chatStore.activeSession?.isLoadingOlderMessages"
|
||||
class="history-loader"
|
||||
>
|
||||
<span v-if="chatStore.activeSession?.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<MessageItem
|
||||
:message="msg"
|
||||
:highlight="chatStore.focusMessageId === msg.id"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<Transition name="fade">
|
||||
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
|
||||
<ThinkingIndicator />
|
||||
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
|
||||
<!-- Abort indicator -->
|
||||
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
|
||||
<svg
|
||||
v-if="chatStore.abortState.aborting"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-call-icon"
|
||||
>
|
||||
<path d="M10 9v6m4-6v6M5 5h14v14H5z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-call-icon"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="tool-call-name">
|
||||
{{
|
||||
chatStore.abortState.aborting
|
||||
? 'Pausing... waiting for the run to stop and sync'
|
||||
: chatStore.abortState.synced
|
||||
? 'Paused and synced'
|
||||
: 'Paused'
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="chatStore.abortState.aborting"
|
||||
class="tool-call-spinner"
|
||||
></span>
|
||||
</div>
|
||||
<!-- Compression indicator -->
|
||||
<div v-if="chatStore.compressionState" class="tool-call-item compression-item">
|
||||
<svg
|
||||
v-if="chatStore.compressionState.compressing"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-call-icon"
|
||||
>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="chatStore.compressionState.compressed"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-call-icon"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="tool-call-name">
|
||||
{{
|
||||
chatStore.compressionState.compressing
|
||||
? `Compressing... (${chatStore.compressionState.messageCount} msgs, ~${formatTokens(chatStore.compressionState.beforeTokens)} tokens)`
|
||||
: chatStore.compressionState.compressed
|
||||
? `Compressed ${chatStore.compressionState.messageCount} msgs: ~${formatTokens(chatStore.compressionState.beforeTokens)} → ~${formatTokens(chatStore.compressionState.afterTokens)} tokens`
|
||||
: `Compression skipped`
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="chatStore.compressionState.compressing"
|
||||
class="tool-call-spinner"
|
||||
></span>
|
||||
</div>
|
||||
<!-- Tool calls -->
|
||||
<div
|
||||
v-for="tc in visibleToolCalls"
|
||||
:key="tc.id"
|
||||
class="tool-call-item"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="tool-call-icon"
|
||||
>
|
||||
<path
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="tool-call-name">{{ tc.toolName }}</span>
|
||||
<span v-if="tc.toolPreview" class="tool-call-preview">{{
|
||||
tc.toolPreview
|
||||
}}</span>
|
||||
<span
|
||||
v-if="tc.toolDuration && tc.toolStatus !== 'running'"
|
||||
class="tool-call-duration"
|
||||
:title="$t('chat.executionDuration')"
|
||||
>{{ formatToolDuration(tc.toolDuration) }}</span
|
||||
>
|
||||
<svg
|
||||
v-if="tc.toolStatus === 'done'"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
class="tool-call-success-icon"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" fill-opacity="0.15"/>
|
||||
<path
|
||||
d="M8 12L11 15L16 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
v-if="tc.toolStatus === 'running'"
|
||||
class="tool-call-spinner"
|
||||
></span>
|
||||
<svg
|
||||
v-if="tc.toolStatus === 'error'"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
class="tool-call-error-icon"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" fill-opacity="0.15"/>
|
||||
<path
|
||||
d="M15 9L9 15M9 9L15 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="queue-float">
|
||||
<div v-if="queuedMessages.length > 0" class="queue-float-panel">
|
||||
<div class="queue-float-header">
|
||||
<span class="queue-orbit" aria-hidden="true">
|
||||
<span></span>
|
||||
</span>
|
||||
<span>{{ t('chat.messageQueue') }}</span>
|
||||
<strong>{{ queuedMessages.length }}</strong>
|
||||
</div>
|
||||
<div class="queue-float-list">
|
||||
<div
|
||||
v-for="(message, index) in queuedMessages"
|
||||
:key="message.id"
|
||||
class="queue-float-item"
|
||||
>
|
||||
<span class="queue-index">{{ index + 1 }}</span>
|
||||
<span class="queue-text">{{ queuedPreview(message.content) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="queue-remove"
|
||||
:title="t('chat.removeQueuedMessage')"
|
||||
@click="removeQueuedMessage(message.id)"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.queue-float-panel {
|
||||
position: sticky;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 4;
|
||||
width: min(340px, calc(100% - 16px));
|
||||
margin-top: 16px;
|
||||
margin-left: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.14);
|
||||
backdrop-filter: blur(14px);
|
||||
|
||||
.dark & {
|
||||
background: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 4px 8px;
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
strong {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-info-rgb), 0.16);
|
||||
color: var(--accent-info);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-orbit {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(var(--accent-info-rgb), 0.28);
|
||||
position: relative;
|
||||
animation: queue-spin 1.6s linear infinite;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
right: -2px;
|
||||
top: 5px;
|
||||
background: var(--accent-info);
|
||||
box-shadow: 0 0 12px rgba(var(--accent-info-rgb), 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 172px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.queue-float-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 11px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
color: $text-primary;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--accent-info);
|
||||
background: rgba(var(--accent-info-rgb), 0.12);
|
||||
}
|
||||
|
||||
.queue-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queue-remove {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-muted;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba($error, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.queue-float-panel {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: min(260px, calc(100% - 8px));
|
||||
padding: 7px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.queue-float-header {
|
||||
padding: 0 2px;
|
||||
font-size: 11px;
|
||||
|
||||
span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-orbit {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-list {
|
||||
margin-top: 6px;
|
||||
max-height: min(220px, 34dvh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.queue-float-item {
|
||||
min-height: 30px;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
.queue-index {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.queue-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.queue-remove {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes queue-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-float-enter-active,
|
||||
.queue-float-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.queue-float-enter-from,
|
||||
.queue-float-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-muted;
|
||||
gap: 12px;
|
||||
|
||||
.empty-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-loader {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.history-loader-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.16);
|
||||
border-top-color: $accent-primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
|
||||
.dark & {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-top-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.streaming-indicator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 4px 4px 4px 0;
|
||||
}
|
||||
|
||||
.tool-calls-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding-top: 4px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $text-secondary;
|
||||
padding: 3px 8px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: $radius-sm;
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&.compression-item {
|
||||
color: $text-muted;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
flex-shrink: 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.tool-call-name {
|
||||
font-family: $font-code;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-call-preview {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1.5px solid $text-muted;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-call-error-icon {
|
||||
color: #ff4d4f;
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tool-call-duration {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
font-family: $font-code;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-call-success-icon {
|
||||
color: #52c41a;
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Message } from '@/stores/hermes/chat'
|
||||
|
||||
interface OutlineItem {
|
||||
id: string
|
||||
type: 'user' | 'outline'
|
||||
content: string
|
||||
messageId: string
|
||||
level: number
|
||||
anchorId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [target: { messageId: string; anchorId: string }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function extractAllHeadings(text: string, messageId: string): OutlineItem[] {
|
||||
const items: OutlineItem[] = []
|
||||
let cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
|
||||
const lines = cleanedText.split('\n')
|
||||
|
||||
let headingIndex = 0
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
const h1Match = trimmed.match(/^#\s+(.+)/)
|
||||
const h2Match = trimmed.match(/^##\s+(.+)/)
|
||||
const h3Match = trimmed.match(/^###\s+(.+)/)
|
||||
|
||||
if (h1Match) {
|
||||
headingIndex++
|
||||
items.push({
|
||||
id: `outline-${messageId}-h${headingIndex}`,
|
||||
type: 'outline',
|
||||
content: h1Match[1].trim(),
|
||||
messageId,
|
||||
level: 1,
|
||||
anchorId: `msg-${messageId}-heading-${headingIndex}`
|
||||
})
|
||||
} else if (h2Match) {
|
||||
headingIndex++
|
||||
items.push({
|
||||
id: `outline-${messageId}-h${headingIndex}`,
|
||||
type: 'outline',
|
||||
content: h2Match[1].trim(),
|
||||
messageId,
|
||||
level: 2,
|
||||
anchorId: `msg-${messageId}-heading-${headingIndex}`
|
||||
})
|
||||
} else if (h3Match) {
|
||||
headingIndex++
|
||||
items.push({
|
||||
id: `outline-${messageId}-h${headingIndex}`,
|
||||
type: 'outline',
|
||||
content: h3Match[1].trim(),
|
||||
messageId,
|
||||
level: 3,
|
||||
anchorId: `msg-${messageId}-heading-${headingIndex}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function extractUserQuestion(text: string): string {
|
||||
const cleanedText = text.replace(/<think>[\s\S]*?<\/think>/g, '')
|
||||
const firstLine = cleanedText.split('\n')[0] || ''
|
||||
if (firstLine.length > 50) {
|
||||
return firstLine.slice(0, 50) + '...'
|
||||
}
|
||||
return firstLine || t('chat.outlineUserQuestion')
|
||||
}
|
||||
|
||||
const outlineItems = computed<OutlineItem[]>(() => {
|
||||
const items: OutlineItem[] = []
|
||||
let i = 0
|
||||
const filteredMessages = props.messages.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
|
||||
while (i < filteredMessages.length) {
|
||||
const msg = filteredMessages[i]
|
||||
if (msg.role === 'user') {
|
||||
items.push({
|
||||
id: `user-${msg.id}`,
|
||||
type: 'user',
|
||||
content: extractUserQuestion(msg.content || ''),
|
||||
messageId: msg.id,
|
||||
level: 0,
|
||||
anchorId: `message-${msg.id}`
|
||||
})
|
||||
i++
|
||||
while (i < filteredMessages.length && filteredMessages[i].role !== 'assistant') {
|
||||
i++
|
||||
}
|
||||
if (i < filteredMessages.length) {
|
||||
const assistantMsg = filteredMessages[i]
|
||||
const headings = extractAllHeadings(assistantMsg.content || '', assistantMsg.id)
|
||||
items.push(...headings)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
function scrollToTarget(item: OutlineItem) {
|
||||
emit('navigate', {
|
||||
messageId: item.messageId,
|
||||
anchorId: item.anchorId,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outline-panel">
|
||||
<div class="outline-header">
|
||||
<span class="outline-title">{{ t('chat.outlineTitle') }}</span>
|
||||
</div>
|
||||
<div class="outline-content">
|
||||
<template v-if="outlineItems.length > 0">
|
||||
<template v-for="item in outlineItems" :key="item.id">
|
||||
<div
|
||||
v-if="item.type === 'user'"
|
||||
class="outline-item user-item"
|
||||
@click="scrollToTarget(item)"
|
||||
>
|
||||
<div class="user-question">
|
||||
<span class="q-label">Q:</span>
|
||||
<span class="q-text">{{ item.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="outline-item outline-heading-item"
|
||||
:class="`level-${item.level}`"
|
||||
@click="scrollToTarget(item)"
|
||||
>
|
||||
<div class="heading-item">
|
||||
<span class="heading-text">{{ item.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div v-else class="outline-empty">{{ t('chat.outlineEmpty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.outline-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: $bg-card;
|
||||
border-left: 1px solid $border-color;
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(280px, 86vw);
|
||||
z-index: 8;
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.outline-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.outline-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.outline-item {
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.user-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.user-question {
|
||||
background-color: $bg-secondary;
|
||||
color: $text-primary;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
|
||||
.dark & {
|
||||
background-color: $bg-input;
|
||||
}
|
||||
|
||||
.q-label {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.q-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.outline-heading-item {
|
||||
&.level-1 {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.level-2 {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
&.level-3 {
|
||||
padding-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
|
||||
.dark & {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.level-1 & {
|
||||
.heading-marker {
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
.heading-text {
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.level-2 & {
|
||||
.heading-marker {
|
||||
color: $text-secondary;
|
||||
}
|
||||
.heading-text {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.level-3 & {
|
||||
.heading-marker {
|
||||
color: $text-muted;
|
||||
}
|
||||
.heading-text {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heading-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.outline-empty {
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import { NPopconfirm, NCheckbox, NTooltip } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Session } from '@/stores/hermes/chat'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||
import { formatTimestampMs } from '@/shared/session-display'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
session: Session
|
||||
active: boolean
|
||||
pinned: boolean
|
||||
canDelete: boolean
|
||||
streaming?: boolean
|
||||
selectable?: boolean
|
||||
selected?: boolean
|
||||
showProfile?: boolean
|
||||
to?: string
|
||||
}>(), {
|
||||
showProfile: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
contextmenu: [event: MouseEvent]
|
||||
delete: []
|
||||
'toggle-select': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const profilesStore = useProfilesStore()
|
||||
const sessionModelName = computed(() =>
|
||||
props.session.model
|
||||
? appStore.displayModelName(props.session.model, props.session.provider)
|
||||
: '',
|
||||
)
|
||||
const profileName = computed(() => props.session.profile || 'default')
|
||||
const profileAvatar = computed(() => profilesStore.profiles.find(profile => profile.name === profileName.value)?.avatar)
|
||||
const profileHasModels = computed(() => {
|
||||
const profileModels = appStore.profileModelGroups.find(profile => profile.profile === profileName.value)
|
||||
return !!profileModels?.groups?.some(group => group.models.length > 0)
|
||||
})
|
||||
const profileModelsMissing = computed(() =>
|
||||
appStore.profileModelGroups.length > 0 && !profileHasModels.value,
|
||||
)
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const longPressTriggered = ref(false)
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
longPressTriggered.value = false
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTriggered.value = true
|
||||
const touch = e.touches[0]
|
||||
const syntheticEvent = new MouseEvent('contextmenu', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
bubbles: true,
|
||||
})
|
||||
emit('contextmenu', syntheticEvent)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function isModifiedNavigation(event?: MouseEvent) {
|
||||
return !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
|
||||
}
|
||||
|
||||
function onClick(event?: MouseEvent) {
|
||||
if (longPressTriggered.value) {
|
||||
longPressTriggered.value = false
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isModifiedNavigation(event)) return
|
||||
if (props.to && !props.selectable) event?.preventDefault()
|
||||
emit('select')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (longPressTimer) clearTimeout(longPressTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="selectable || !to ? 'button' : 'a'"
|
||||
class="session-item"
|
||||
:class="{ active, 'batch-mode': selectable, 'missing-models': profileModelsMissing }"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
:href="!selectable ? to : undefined"
|
||||
:type="selectable || !to ? 'button' : undefined"
|
||||
@click="onClick"
|
||||
@contextmenu="emit('contextmenu', $event)"
|
||||
@touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd"
|
||||
@touchmove="onTouchMove"
|
||||
>
|
||||
<div v-if="selectable" class="session-item-checkbox">
|
||||
<NCheckbox :checked="selected" @click.stop="emit('toggle-select')" />
|
||||
</div>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title-row">
|
||||
<span v-if="pinned" class="session-item-pin" aria-hidden="true">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M5 8l14 0" />
|
||||
<path d="M8 3l8 0 0 5 3 5-14 0 3-5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="session-item-title">
|
||||
<svg v-if="streaming" class="session-item-streaming" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
||||
{{ session.title }}
|
||||
</span>
|
||||
<NTooltip v-if="profileModelsMissing" trigger="click" placement="top">
|
||||
<template #trigger>
|
||||
<button class="session-item-warning" type="button" @click.stop.prevent>
|
||||
!
|
||||
</button>
|
||||
</template>
|
||||
{{ t('chat.profileMissingModelsTip', { profile: profileName }) }}
|
||||
</NTooltip>
|
||||
</span>
|
||||
<span class="session-item-meta">
|
||||
<span v-if="sessionModelName" class="session-item-model" :title="session.model">{{ sessionModelName }}</span>
|
||||
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
|
||||
</span>
|
||||
<span v-if="props.showProfile" class="session-item-profile">
|
||||
<ProfileAvatar class="session-item-profile-avatar" :name="profileName" :avatar="profileAvatar" :size="16" />
|
||||
<span class="session-item-profile-name">{{ profileName }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop.prevent>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
{{ t('chat.deleteSession') }}
|
||||
</NPopconfirm>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-item-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.session-item-profile-avatar {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.session-item-profile-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-item-warning {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid rgba(180, 35, 24, 0.35);
|
||||
border-radius: 50%;
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: #b42318;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NModal, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchSessions, searchSessions, type SessionSearchResult, type SessionSummary } from '@/api/hermes/sessions'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import { useSessionSearch } from '@/composables/useSessionSearch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const chatStore = useChatStore()
|
||||
const { sessionSearchOpen } = useSessionSearch()
|
||||
|
||||
const query = ref('')
|
||||
const loading = ref(false)
|
||||
const recentSessions = ref<SessionSummary[]>([])
|
||||
const searchResults = ref<SessionSearchResult[]>([])
|
||||
const activeIndex = ref(0)
|
||||
const inputRef = ref<InstanceType<typeof NInput> | null>(null)
|
||||
const profileFilter = computed(() => chatStore.sessionProfileFilter || undefined)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestSeq = 0
|
||||
|
||||
type SearchItem = SessionSearchResult | (SessionSummary & {
|
||||
snippet?: string
|
||||
matched_message_id: number | null
|
||||
rank: number
|
||||
})
|
||||
|
||||
const hasQuery = computed(() => query.value.trim().length > 0)
|
||||
|
||||
const items = computed<SearchItem[]>(() => {
|
||||
if (hasQuery.value) return searchResults.value
|
||||
return recentSessions.value.map(session => ({
|
||||
...session,
|
||||
matched_message_id: null,
|
||||
snippet: session.preview || '',
|
||||
rank: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
function formatSource(source: string): string {
|
||||
const map: Record<string, string> = {
|
||||
api_server: 'API Server',
|
||||
cli: 'CLI',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
matrix: 'Matrix',
|
||||
whatsapp: 'WhatsApp',
|
||||
signal: 'Signal',
|
||||
cron: 'Cron',
|
||||
weixin: 'WeChat',
|
||||
}
|
||||
return map[source] || source
|
||||
}
|
||||
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function getItemTitle(item: SearchItem): string {
|
||||
const title = item.title?.trim()
|
||||
if (title) return title
|
||||
if (item.preview?.trim()) return item.preview.trim()
|
||||
return item.id
|
||||
}
|
||||
|
||||
async function loadRecentSessions() {
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const sessions = profileFilter.value
|
||||
? await fetchSessions(undefined, 8, profileFilter.value)
|
||||
: await fetchSessions(undefined, 8)
|
||||
if (seq !== requestSeq) return
|
||||
recentSessions.value = sessions
|
||||
searchResults.value = []
|
||||
activeIndex.value = 0
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq) return
|
||||
message.error(err instanceof Error ? err.message : t('chat.searchFailed'))
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(text: string) {
|
||||
const seq = ++requestSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const results = text.trim()
|
||||
? profileFilter.value
|
||||
? await searchSessions(text.trim(), undefined, 10, profileFilter.value)
|
||||
: await searchSessions(text.trim(), undefined, 10)
|
||||
: []
|
||||
if (seq !== requestSeq) return
|
||||
searchResults.value = results
|
||||
activeIndex.value = 0
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq) return
|
||||
message.error(err instanceof Error ? err.message : t('chat.searchFailed'))
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureChatSessionsLoaded() {
|
||||
if (chatStore.sessions.length === 0) {
|
||||
await chatStore.loadSessions(chatStore.sessionProfileFilter)
|
||||
}
|
||||
}
|
||||
|
||||
async function openItem(item: SearchItem) {
|
||||
const messageId = item.matched_message_id != null ? String(item.matched_message_id) : null
|
||||
sessionSearchOpen.value = false
|
||||
|
||||
await ensureChatSessionsLoaded()
|
||||
if (!chatStore.sessions.some(session => session.id === item.id) && typeof chatStore.addOrUpdateSession === 'function') {
|
||||
chatStore.addOrUpdateSession({
|
||||
id: item.id,
|
||||
profile: item.profile || 'default',
|
||||
title: item.title || '',
|
||||
source: item.source,
|
||||
messages: [],
|
||||
createdAt: Math.round(item.started_at * 1000),
|
||||
updatedAt: Math.round((item.last_active || item.ended_at || item.started_at) * 1000),
|
||||
model: item.model,
|
||||
provider: item.provider || item.billing_provider || '',
|
||||
messageCount: item.message_count,
|
||||
endedAt: item.ended_at != null ? Math.round(item.ended_at * 1000) : null,
|
||||
lastActiveAt: item.last_active != null ? Math.round(item.last_active * 1000) : undefined,
|
||||
workspace: item.workspace || null,
|
||||
})
|
||||
}
|
||||
await chatStore.switchSession(item.id, messageId)
|
||||
if (router.currentRoute.value.name !== 'hermes.chat') {
|
||||
await router.push({ name: 'hermes.chat' })
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
sessionSearchOpen.value = false
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
const list = items.value
|
||||
if (list.length === 0) return
|
||||
const next = activeIndex.value + delta
|
||||
activeIndex.value = (next + list.length) % list.length
|
||||
}
|
||||
|
||||
async function handleKeydown(e: KeyboardEvent) {
|
||||
if (!sessionSearchOpen.value) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
moveSelection(1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
moveSelection(-1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = items.value[activeIndex.value]
|
||||
if (item) {
|
||||
await openItem(item)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => sessionSearchOpen.value,
|
||||
async (open) => {
|
||||
if (!open) {
|
||||
query.value = ''
|
||||
searchResults.value = []
|
||||
recentSessions.value = []
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
query.value = ''
|
||||
searchResults.value = []
|
||||
activeIndex.value = 0
|
||||
await loadRecentSessions()
|
||||
await nextTick()
|
||||
inputRef.value?.focus?.()
|
||||
},
|
||||
)
|
||||
|
||||
watch(query, (value) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (!sessionSearchOpen.value) return
|
||||
void runSearch(value)
|
||||
}, 160)
|
||||
})
|
||||
|
||||
watch(items, () => {
|
||||
if (activeIndex.value >= items.value.length) {
|
||||
activeIndex.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="sessionSearchOpen"
|
||||
preset="card"
|
||||
:title="t('chat.searchTitle')"
|
||||
:style="{ width: 'min(760px, calc(100vw - 24px))' }"
|
||||
:mask-closable="true"
|
||||
:auto-focus="false"
|
||||
>
|
||||
<div class="session-search-modal">
|
||||
<div class="search-header">
|
||||
<div class="search-title">{{ t('chat.searchSubtitle') }}</div>
|
||||
<div class="search-hint">{{ t('chat.searchHint') }}</div>
|
||||
</div>
|
||||
<div class="search-scope">{{ t('chat.searchScope') }}</div>
|
||||
|
||||
<NInput
|
||||
ref="inputRef"
|
||||
v-model:value="query"
|
||||
:placeholder="t('chat.searchPlaceholder')"
|
||||
clearable
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<div class="search-body">
|
||||
<NSpin :show="loading">
|
||||
<div v-if="items.length === 0" class="search-empty">
|
||||
{{ hasQuery ? t('chat.searchNoResults') : t('chat.searchEmpty') }}
|
||||
</div>
|
||||
<div v-else class="result-list">
|
||||
<button
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
class="result-item"
|
||||
:class="{ active: idx === activeIndex }"
|
||||
@click="openItem(item)"
|
||||
@mouseenter="activeIndex = idx"
|
||||
>
|
||||
<div class="result-main">
|
||||
<div class="result-title-row">
|
||||
<span class="result-title">{{ getItemTitle(item) }}</span>
|
||||
<span class="result-source">{{ formatSource(item.source) }}</span>
|
||||
</div>
|
||||
<div class="result-snippet">
|
||||
{{ hasQuery ? item.snippet || t('chat.searchNoSnippet') : item.preview || t('chat.searchRecent') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<span class="result-time">{{ formatTime(item.last_active || item.started_at) }}</span>
|
||||
<span v-if="hasQuery && item.matched_message_id != null" class="result-match">
|
||||
#{{ item.matched_message_id }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<div class="search-footer">
|
||||
<span>{{ t('chat.searchEnterHint') }}</span>
|
||||
<NButton quaternary size="small" @click="closeModal">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.session-search-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.search-hint {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.search-scope {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.search-body {
|
||||
max-height: min(60vh, 540px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
padding: 28px 0;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: min(60vh, 540px);
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-card;
|
||||
color: $text-primary;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-fast, background-color $transition-fast, transform $transition-fast;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
border-color: $accent-muted;
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.result-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-source {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-match {
|
||||
font-family: $font-code;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
:deep(.n-modal-body-wrapper) {
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.search-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,920 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, computed, watch } from "vue";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { getApiKey, getBaseUrlValue } from "@/api/client";
|
||||
import { NButton, NPopconfirm, NTooltip, NSelect, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
const props = defineProps<{ visible?: boolean; initialCommand?: string }>();
|
||||
|
||||
// ─── Terminal themes ────────────────────────────────────────────
|
||||
|
||||
const TERMINAL_THEMES: Record<string, { label: string; theme: ITheme }> = {
|
||||
default: {
|
||||
label: "Default",
|
||||
theme: {
|
||||
background: "#1a1a2e",
|
||||
foreground: "#e0e0e0",
|
||||
cursor: "#4cc9f0",
|
||||
cursorAccent: "#1a1a2e",
|
||||
selectionBackground: "rgba(76, 201, 240, 0.3)",
|
||||
black: "#000000", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
|
||||
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
|
||||
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2", brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
"solarized-dark": {
|
||||
label: "Solarized Dark",
|
||||
theme: {
|
||||
background: "#002b36", foreground: "#839496",
|
||||
cursor: "#93a1a1", cursorAccent: "#002b36",
|
||||
selectionBackground: "rgba(147, 161, 161, 0.3)",
|
||||
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
|
||||
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
|
||||
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
|
||||
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
"tokyo-night": {
|
||||
label: "Tokyo Night",
|
||||
theme: {
|
||||
background: "#1a1b26", foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5", cursorAccent: "#1a1b26",
|
||||
selectionBackground: "rgba(192, 202, 245, 0.2)",
|
||||
black: "#15161e", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68",
|
||||
blue: "#7aa2f7", magenta: "#bb9af7", cyan: "#7dcfff", white: "#a9b1d6",
|
||||
brightBlack: "#414868", brightRed: "#f7768e", brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68", brightBlue: "#7aa2f7", brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff", brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
"github-dark": {
|
||||
label: "GitHub Dark",
|
||||
theme: {
|
||||
background: "#0d1117", foreground: "#c9d1d9",
|
||||
cursor: "#58a6ff", cursorAccent: "#0d1117",
|
||||
selectionBackground: "rgba(88, 166, 255, 0.25)",
|
||||
black: "#484f58", red: "#ff7b72", green: "#7ee787", yellow: "#ffa657",
|
||||
blue: "#79c0ff", magenta: "#d2a8ff", cyan: "#a5d6ff", white: "#c9d1d9",
|
||||
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341", brightBlue: "#58a6ff", brightMagenta: "#bc8cff",
|
||||
brightCyan: "#79c0ff", brightWhite: "#f0f6fc",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY_THEME = "hermes_terminal_theme";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
shell: string;
|
||||
pid: number;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
exited: boolean;
|
||||
}
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
const sessions = ref<SessionInfo[]>([]);
|
||||
const activeSessionId = ref<string | null>(null);
|
||||
const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default");
|
||||
const connectionError = ref<string | null>(null);
|
||||
const isConnecting = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
const termMap = new Map<string, { term: Terminal; fitAddon: FitAddon; opened: boolean }>();
|
||||
let activeTerm: Terminal | null = null;
|
||||
let activeFitAddon: FitAddon | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 3;
|
||||
let touchScrollLastY: number | null = null;
|
||||
let touchScrollRemainder = 0;
|
||||
const TOUCH_SCROLL_LINE_PX = 18;
|
||||
const initialCommandSent = ref(false);
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
|
||||
const activeSession = computed(
|
||||
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
|
||||
);
|
||||
|
||||
const themeOptions = computed(() =>
|
||||
Object.entries(TERMINAL_THEMES).map(([key, val]) => ({
|
||||
label: val.label,
|
||||
value: key,
|
||||
})),
|
||||
);
|
||||
|
||||
const terminalBg = computed(
|
||||
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
|
||||
);
|
||||
|
||||
// ─── WebSocket ──────────────────────────────────────────────────
|
||||
|
||||
function formatHostForPort(hostname: string, port: number): string {
|
||||
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
||||
return `${hostname}:${port}`;
|
||||
}
|
||||
return hostname.includes(":") ? `[${hostname}]:${port}` : `${hostname}:${port}`;
|
||||
}
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const token = getApiKey();
|
||||
const base = getBaseUrlValue();
|
||||
const wsProtocol = base
|
||||
? base.startsWith("https")
|
||||
? "wss:"
|
||||
: "ws:"
|
||||
: location.protocol === "https:"
|
||||
? "wss:"
|
||||
: "ws:";
|
||||
|
||||
if (base) {
|
||||
return `${wsProtocol}//${new URL(base).host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT;
|
||||
const host = import.meta.env.DEV && directDevPort
|
||||
? formatHostForPort(location.hostname, Number(directDevPort))
|
||||
: location.host;
|
||||
return `${wsProtocol}//${host}/api/hermes/terminal${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
connectionError.value = t('terminal.connectionFailed');
|
||||
isConnecting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWsUrl();
|
||||
connectionError.value = null;
|
||||
isConnecting.value = true;
|
||||
reconnectAttempts++;
|
||||
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnecting.value = false;
|
||||
connectionError.value = null;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = typeof event.data === "string" ? event.data : "";
|
||||
if (data.charCodeAt(0) === 0x7b) {
|
||||
try {
|
||||
handleControl(JSON.parse(data));
|
||||
} catch {}
|
||||
} else {
|
||||
activeTerm?.write(data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
isConnecting.value = false;
|
||||
|
||||
// 如果是正常关闭(code 1000)或认证失败,不重连
|
||||
if (event.code === 1000 || event.code === 1003 || event.code === 1008) {
|
||||
connectionError.value = t('terminal.connectionClosed');
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他情况尝试重连
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[Terminal] WebSocket error:', error);
|
||||
connectionError.value = t('terminal.connectionError');
|
||||
};
|
||||
}
|
||||
|
||||
function send(data: object | string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data));
|
||||
}
|
||||
|
||||
// ─── Control message handlers ──────────────────────────────────
|
||||
|
||||
function handleControl(msg: any) {
|
||||
switch (msg.type) {
|
||||
case "created":
|
||||
reconnectAttempts = 0;
|
||||
sessions.value.push({
|
||||
id: msg.id,
|
||||
shell: msg.shell,
|
||||
pid: msg.pid,
|
||||
title: `${msg.shell} #${sessions.value.length + 1}`,
|
||||
createdAt: Date.now(),
|
||||
exited: false,
|
||||
});
|
||||
switchSession(msg.id);
|
||||
runInitialCommand();
|
||||
break;
|
||||
|
||||
case "exited": {
|
||||
const s = sessions.value.find((s) => s.id === msg.id);
|
||||
if (s) {
|
||||
s.exited = true;
|
||||
if (activeSessionId.value === msg.id) {
|
||||
activeTerm?.write(
|
||||
`\r\n\x1b[90m[${t("terminal.processExited", { code: msg.exitCode })}]\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
message.error(msg.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session actions ────────────────────────────────────────────
|
||||
|
||||
function createSession() {
|
||||
send({ type: "create" });
|
||||
}
|
||||
|
||||
function runInitialCommand() {
|
||||
const command = props.initialCommand?.trim();
|
||||
if (!command || initialCommandSent.value) return;
|
||||
initialCommandSent.value = true;
|
||||
setTimeout(() => {
|
||||
send(`${command}\r`);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
|
||||
let entry = termMap.get(id);
|
||||
if (!entry) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: { ...TERMINAL_THEMES[selectedTheme.value].theme },
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.onData((data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
entry = { term, fitAddon, opened: false };
|
||||
termMap.set(id, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function switchSession(id: string) {
|
||||
if (activeSessionId.value === id) return;
|
||||
activeSessionId.value = id;
|
||||
const entry = getOrCreateTerm(id);
|
||||
activeTerm = entry.term;
|
||||
activeFitAddon = entry.fitAddon;
|
||||
mountActiveTerminal();
|
||||
send({ type: "switch", sessionId: id });
|
||||
}
|
||||
|
||||
function closeSession(id: string) {
|
||||
send({ type: "close", sessionId: id });
|
||||
sessions.value = sessions.value.filter((s) => s.id !== id);
|
||||
const entry = termMap.get(id);
|
||||
if (entry) {
|
||||
entry.term.dispose();
|
||||
termMap.delete(id);
|
||||
}
|
||||
if (activeSessionId.value === id) {
|
||||
activeSessionId.value = sessions.value.length > 0 ? sessions.value[0].id : null;
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
if (activeSessionId.value) {
|
||||
switchSession(activeSessionId.value);
|
||||
} else {
|
||||
unmountActiveTerminal();
|
||||
createSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Terminal mount/unmount ─────────────────────────────────────
|
||||
|
||||
function mountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
const entry = termMap.get(activeSessionId.value!);
|
||||
if (!entry) return;
|
||||
|
||||
if (!entry.opened) {
|
||||
entry.term.open(container);
|
||||
entry.opened = true;
|
||||
} else {
|
||||
const termEl = entry.term.element;
|
||||
if (termEl) {
|
||||
container.appendChild(termEl);
|
||||
}
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
tryFit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.value);
|
||||
|
||||
setTimeout(() => tryFit(), 50);
|
||||
setTimeout(() => tryFit(), 200);
|
||||
}
|
||||
|
||||
function unmountActiveTerminal() {
|
||||
if (!terminalRef.value) return;
|
||||
const container = terminalRef.value;
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
function tryFit() {
|
||||
if (!activeFitAddon) return;
|
||||
try {
|
||||
activeFitAddon.fit();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!activeTerm || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
send({
|
||||
type: "resize",
|
||||
cols: activeTerm.cols,
|
||||
rows: activeTerm.rows,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleTerminalTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1) {
|
||||
touchScrollLastY = null;
|
||||
touchScrollRemainder = 0;
|
||||
return;
|
||||
}
|
||||
touchScrollLastY = event.touches[0].clientY;
|
||||
touchScrollRemainder = 0;
|
||||
}
|
||||
|
||||
function handleTerminalTouchMove(event: TouchEvent) {
|
||||
if (!activeTerm || event.touches.length !== 1 || touchScrollLastY === null) return;
|
||||
const nextY = event.touches[0].clientY;
|
||||
touchScrollRemainder += touchScrollLastY - nextY;
|
||||
touchScrollLastY = nextY;
|
||||
|
||||
const lines = Math.trunc(touchScrollRemainder / TOUCH_SCROLL_LINE_PX);
|
||||
if (lines === 0) return;
|
||||
|
||||
activeTerm.scrollLines(lines);
|
||||
touchScrollRemainder -= lines * TOUCH_SCROLL_LINE_PX;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleTerminalTouchEnd() {
|
||||
touchScrollLastY = null;
|
||||
touchScrollRemainder = 0;
|
||||
}
|
||||
|
||||
// ─── Theme ───────────────────────────────────────────────────────
|
||||
|
||||
function applyTheme(themeName: string) {
|
||||
selectedTheme.value = themeName;
|
||||
localStorage.setItem(STORAGE_KEY_THEME, themeName);
|
||||
const themeObj = TERMINAL_THEMES[themeName]?.theme;
|
||||
if (!themeObj) return;
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.options.theme = { ...themeObj };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
let hasConnected = false;
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible && !hasConnected && !ws) {
|
||||
hasConnected = true;
|
||||
connect();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
unmountActiveTerminal();
|
||||
for (const entry of termMap.values()) {
|
||||
entry.term.dispose();
|
||||
}
|
||||
termMap.clear();
|
||||
activeTerm = null;
|
||||
activeFitAddon = null;
|
||||
ws?.close();
|
||||
ws = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-panel-drawer">
|
||||
<div
|
||||
v-if="showSidebar"
|
||||
class="sidebar-overlay"
|
||||
@click="showSidebar = false"
|
||||
></div>
|
||||
<div
|
||||
class="terminal-sidebar"
|
||||
:class="{ 'mobile-visible': showSidebar }"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">{{ t("terminal.sessions") }}</span>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" @click="createSession" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div class="session-list">
|
||||
<div v-if="connectionError" class="session-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{{ connectionError }}</span>
|
||||
<NButton size="tiny" @click="connect">{{ t("common.retry") }}</NButton>
|
||||
</div>
|
||||
<div v-else-if="sessions.length === 0" class="session-empty">
|
||||
<template v-if="isConnecting">
|
||||
{{ t("common.loading") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t("terminal.noSessions") }}
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === activeSessionId, exited: s.exited }"
|
||||
@click="switchSession(s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
<span class="session-item-title">{{ s.title }}</span>
|
||||
<span class="session-item-meta">
|
||||
<span class="session-item-shell">{{ s.shell }}</span>
|
||||
<span v-if="s.exited" class="session-item-status">{{
|
||||
t("terminal.sessionExited")
|
||||
}}</span>
|
||||
<span v-else class="session-item-time">{{
|
||||
formatTime(s.createdAt)
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<NPopconfirm v-if="sessions.length > 1" @positive-click="closeSession(s.id)">
|
||||
<template #trigger>
|
||||
<button class="session-item-delete" @click.stop>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
{{ t("terminal.closeSession") }}
|
||||
</NPopconfirm>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-main">
|
||||
<header class="terminal-header">
|
||||
<span v-if="activeSession" class="header-session-title">{{
|
||||
activeSession.title
|
||||
}}</span>
|
||||
<div class="header-actions">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="showSidebar = !showSidebar"
|
||||
class="sidebar-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.sessions") }}
|
||||
</NButton>
|
||||
<NSelect
|
||||
:value="selectedTheme"
|
||||
:options="themeOptions"
|
||||
size="small"
|
||||
:consistent-menu-width="false"
|
||||
class="theme-select"
|
||||
@update:value="applyTheme"
|
||||
/>
|
||||
<NButton size="small" @click="createSession">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ t("terminal.newTab") }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="terminal-container">
|
||||
<div
|
||||
ref="terminalRef"
|
||||
class="terminal-xterm"
|
||||
:style="{ backgroundColor: terminalBg }"
|
||||
@touchstart="handleTerminalTouchStart"
|
||||
@touchmove="handleTerminalTouchMove"
|
||||
@touchend="handleTerminalTouchEnd"
|
||||
@touchcancel="handleTerminalTouchEnd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.terminal-panel-drawer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-sidebar {
|
||||
width: 180px;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
z-index: 51;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&.mobile-visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.session-empty {
|
||||
padding: 16px 8px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 12px;
|
||||
font-size: 12px;
|
||||
color: $error;
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
transition: all $transition-fast;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
color: $text-primary;
|
||||
|
||||
.session-item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.exited {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.session-item-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-item-shell {
|
||||
font-size: 9px;
|
||||
color: $accent-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.session-item-time,
|
||||
.session-item-status {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
background: rgba(var(--error-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@media (min-width: $breakpoint-mobile + 1) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
margin: 8px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
:deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport) {
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-screen) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element) {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.terminal-panel-drawer {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.terminal-main {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
padding: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-session-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
margin: 6px;
|
||||
margin-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.terminal-xterm {
|
||||
border-radius: $radius-sm;
|
||||
|
||||
:deep(.xterm) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport),
|
||||
:deep(.xterm-scrollable-element) {
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin !important;
|
||||
}
|
||||
|
||||
:deep(.xterm-viewport::-webkit-scrollbar),
|
||||
:deep(.xterm-scrollable-element::-webkit-scrollbar) {
|
||||
display: block !important;
|
||||
width: 6px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="thinking-indicator" role="status" :aria-label="label">
|
||||
<div class="thinking-indicator__mark">
|
||||
<svg viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="thinking-grad" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2563eb" />
|
||||
<stop offset="1" stop-color="#0891b2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="36" height="36" rx="10" fill="url(#thinking-grad)" class="thinking-indicator__bg" />
|
||||
<path d="M24 14 L30 24 L24 34 L18 24 Z" fill="rgba(255,255,255,0.9)" />
|
||||
<circle cx="24" cy="22" r="3" fill="#ffffff" />
|
||||
</svg>
|
||||
<span class="thinking-indicator__ring" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="thinking-indicator__dots" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
label?: string
|
||||
}>(), {
|
||||
label: '思考中',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.thinking-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-indicator__mark {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
filter: drop-shadow(0 2px 8px rgba(var(--accent-primary-rgb), 0.25));
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-indicator__bg {
|
||||
transform-origin: center;
|
||||
animation: thinking-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.thinking-indicator__ring {
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(var(--accent-primary-rgb), 0.35);
|
||||
animation: thinking-ring 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
.thinking-indicator__dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-primary;
|
||||
animation: thinking-dot 1.2s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
@keyframes thinking-ring {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.35);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-dot {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
DynamicScroller,
|
||||
DynamicScrollerItem,
|
||||
type DynamicScrollerExposed,
|
||||
type ScrollToOptions,
|
||||
} from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
|
||||
type VirtualItem = {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
type AnchorAlign = "start" | "center";
|
||||
type AnchorTarget = {
|
||||
token: number;
|
||||
index: number;
|
||||
messageId: string;
|
||||
anchorId: string;
|
||||
align: AnchorAlign;
|
||||
}
|
||||
type BottomScrollOptions = number | {
|
||||
frames?: number;
|
||||
keepAliveMs?: number;
|
||||
}
|
||||
type ViewportScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
wasNearBottom: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
messages: VirtualItem[];
|
||||
estimatedItemHeight?: number;
|
||||
overscan?: number;
|
||||
rowGap?: number;
|
||||
padding?: string;
|
||||
topThreshold?: number;
|
||||
}>(), {
|
||||
estimatedItemHeight: 180,
|
||||
overscan: 8,
|
||||
rowGap: 16,
|
||||
padding: "20px",
|
||||
topThreshold: 120,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
scroll: [];
|
||||
topReach: [];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
empty?: () => any;
|
||||
before?: () => any;
|
||||
item?: (props: { message: any }) => any;
|
||||
after?: () => any;
|
||||
}>();
|
||||
|
||||
const hostRef = ref<HTMLElement | null>(null);
|
||||
const scrollerRef = ref<DynamicScrollerExposed<VirtualItem> | null>(null);
|
||||
const scrollTop = ref(0);
|
||||
const viewportHeight = ref(0);
|
||||
let keepBottomUntil = 0;
|
||||
let bottomFrame: number | null = null;
|
||||
let bottomFrameRemaining = 0;
|
||||
let bottomFrameAttempts = 0;
|
||||
let anchorFrame: number | null = null;
|
||||
let anchorToken = 0;
|
||||
let activeAnchorTarget: AnchorTarget | null = null;
|
||||
let viewportRestoreFrame: number | null = null;
|
||||
|
||||
const messageKeys = computed(() => props.messages.map(messageKey));
|
||||
const bufferPx = computed(() => Math.max(props.estimatedItemHeight, props.estimatedItemHeight * props.overscan));
|
||||
|
||||
function messageKey(message: VirtualItem): string {
|
||||
return String(message.id);
|
||||
}
|
||||
|
||||
function getScrollerElement(): HTMLElement | null {
|
||||
return hostRef.value?.querySelector<HTMLElement>(".virtual-message-list") ?? null;
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return;
|
||||
scrollTop.value = el.scrollTop;
|
||||
viewportHeight.value = el.clientHeight;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
syncViewport();
|
||||
emit("scroll");
|
||||
if (scrollTop.value <= props.topThreshold) emit("topReach");
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
syncViewport();
|
||||
if (Date.now() < keepBottomUntil || isNearBottom(64)) scheduleScrollToBottom(2);
|
||||
if (activeAnchorTarget) scheduleAnchorAlignment(activeAnchorTarget.token, 4);
|
||||
}
|
||||
|
||||
function isNearBottom(threshold = 200): boolean {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
}
|
||||
|
||||
function scrollToBottom(options: BottomScrollOptions = {}) {
|
||||
const frames = typeof options === "number" ? options : options.frames ?? 5;
|
||||
const keepAliveMs = typeof options === "number" ? 700 : options.keepAliveMs ?? 700;
|
||||
keepBottomUntil = Date.now() + keepAliveMs;
|
||||
nextTick(() => {
|
||||
scheduleScrollToBottom(frames);
|
||||
});
|
||||
}
|
||||
|
||||
function setScrollToBottomNow(): boolean {
|
||||
const el = getScrollerElement();
|
||||
scrollerRef.value?.scrollToBottom();
|
||||
if (el) {
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
syncViewport();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scheduleScrollToBottom(frames = 1) {
|
||||
bottomFrameRemaining = Math.max(bottomFrameRemaining, frames);
|
||||
if (bottomFrame != null) return;
|
||||
|
||||
const step = () => {
|
||||
const scrolled = setScrollToBottomNow();
|
||||
if (scrolled) {
|
||||
bottomFrameAttempts = 0;
|
||||
bottomFrameRemaining -= 1;
|
||||
} else {
|
||||
bottomFrameAttempts += 1;
|
||||
}
|
||||
if (bottomFrameRemaining <= 0) {
|
||||
bottomFrame = null;
|
||||
bottomFrameRemaining = 0;
|
||||
bottomFrameAttempts = 0;
|
||||
return;
|
||||
}
|
||||
if (bottomFrameAttempts > 30) {
|
||||
bottomFrame = null;
|
||||
bottomFrameRemaining = 0;
|
||||
bottomFrameAttempts = 0;
|
||||
return;
|
||||
}
|
||||
bottomFrame = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
bottomFrame = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function findTargetElement(messageId: string, anchorId: string): HTMLElement | null {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return null;
|
||||
|
||||
const anchor = document.getElementById(anchorId);
|
||||
if (anchor instanceof HTMLElement && el.contains(anchor)) return anchor;
|
||||
|
||||
const message = document.getElementById(`message-${messageId}`);
|
||||
if (message instanceof HTMLElement && el.contains(message)) return message;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function alignElement(targetEl: HTMLElement, align: AnchorAlign) {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return;
|
||||
|
||||
const scrollerRect = el.getBoundingClientRect();
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
const delta = align === "center"
|
||||
? targetRect.top + targetRect.height / 2 - (scrollerRect.top + scrollerRect.height / 2)
|
||||
: targetRect.top - scrollerRect.top - 24;
|
||||
|
||||
if (Math.abs(delta) > 1) {
|
||||
el.scrollTop = Math.max(0, el.scrollTop + delta);
|
||||
}
|
||||
syncViewport();
|
||||
}
|
||||
|
||||
function scrollToItem(index: number, options?: ScrollToOptions) {
|
||||
scrollerRef.value?.scrollToItem(index, options);
|
||||
syncViewport();
|
||||
}
|
||||
|
||||
function scheduleAnchorAlignment(token: number, frames = 1) {
|
||||
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
|
||||
|
||||
const step = (remaining: number) => {
|
||||
const target = activeAnchorTarget;
|
||||
if (!target || target.token !== token) {
|
||||
anchorFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetEl = findTargetElement(target.messageId, target.anchorId);
|
||||
if (targetEl) {
|
||||
alignElement(targetEl, target.align);
|
||||
} else {
|
||||
scrollToItem(target.index, {
|
||||
align: target.align,
|
||||
offset: target.align === "start" ? -24 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (remaining <= 1) {
|
||||
anchorFrame = null;
|
||||
activeAnchorTarget = null;
|
||||
return;
|
||||
}
|
||||
anchorFrame = requestAnimationFrame(() => step(remaining - 1));
|
||||
};
|
||||
|
||||
anchorFrame = requestAnimationFrame(() => step(frames));
|
||||
}
|
||||
|
||||
function cancelAnchorAlignment() {
|
||||
anchorToken += 1;
|
||||
activeAnchorTarget = null;
|
||||
if (anchorFrame != null) {
|
||||
cancelAnimationFrame(anchorFrame);
|
||||
anchorFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToMessage(messageId: string) {
|
||||
const index = props.messages.findIndex(message => String(message.id) === messageId);
|
||||
if (index < 0) return;
|
||||
|
||||
cancelAnchorAlignment();
|
||||
const token = anchorToken;
|
||||
activeAnchorTarget = {
|
||||
token,
|
||||
index,
|
||||
messageId,
|
||||
anchorId: `message-${messageId}`,
|
||||
align: "center",
|
||||
};
|
||||
|
||||
nextTick(() => {
|
||||
scrollToItem(index, { align: "center" });
|
||||
scheduleAnchorAlignment(token, 8);
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToAnchor(messageId: string, anchorId: string) {
|
||||
const index = props.messages.findIndex(message => String(message.id) === messageId);
|
||||
if (index < 0) return;
|
||||
|
||||
cancelAnchorAlignment();
|
||||
const token = anchorToken;
|
||||
activeAnchorTarget = {
|
||||
token,
|
||||
index,
|
||||
messageId,
|
||||
anchorId,
|
||||
align: "start",
|
||||
};
|
||||
|
||||
nextTick(() => {
|
||||
scrollToItem(index, { align: "start", offset: -24 });
|
||||
scheduleAnchorAlignment(token, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function captureScrollPosition() {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return null;
|
||||
return {
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreScrollPosition(snapshot: { scrollTop: number; scrollHeight: number } | null) {
|
||||
if (!snapshot) return;
|
||||
nextTick(() => {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return;
|
||||
const nextScrollTop = Math.max(0, el.scrollHeight - snapshot.scrollHeight + snapshot.scrollTop);
|
||||
scrollerRef.value?.scrollToPosition(nextScrollTop);
|
||||
el.scrollTop = nextScrollTop;
|
||||
syncViewport();
|
||||
});
|
||||
}
|
||||
|
||||
function captureViewportPosition(): ViewportScrollSnapshot | null {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return null;
|
||||
return {
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
wasNearBottom: isNearBottom(64),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreViewportPosition(snapshot: ViewportScrollSnapshot | null, frames = 4) {
|
||||
if (!snapshot) return;
|
||||
keepBottomUntil = 0;
|
||||
if (bottomFrame != null) {
|
||||
cancelAnimationFrame(bottomFrame);
|
||||
bottomFrame = null;
|
||||
bottomFrameRemaining = 0;
|
||||
bottomFrameAttempts = 0;
|
||||
}
|
||||
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
|
||||
|
||||
nextTick(() => {
|
||||
let remaining = frames;
|
||||
const step = () => {
|
||||
const el = getScrollerElement();
|
||||
if (!el) {
|
||||
viewportRestoreFrame = null;
|
||||
return;
|
||||
}
|
||||
const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
const nextScrollTop = Math.min(maxScrollTop, Math.max(0, snapshot.scrollTop));
|
||||
scrollerRef.value?.scrollToPosition(nextScrollTop);
|
||||
el.scrollTop = nextScrollTop;
|
||||
syncViewport();
|
||||
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
viewportRestoreFrame = null;
|
||||
return;
|
||||
}
|
||||
viewportRestoreFrame = requestAnimationFrame(step);
|
||||
};
|
||||
viewportRestoreFrame = requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
syncViewport();
|
||||
const el = getScrollerElement();
|
||||
if (el && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (bottomFrame != null) cancelAnimationFrame(bottomFrame);
|
||||
bottomFrameRemaining = 0;
|
||||
bottomFrameAttempts = 0;
|
||||
if (anchorFrame != null) cancelAnimationFrame(anchorFrame);
|
||||
if (viewportRestoreFrame != null) cancelAnimationFrame(viewportRestoreFrame);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
watch(messageKeys, () => {
|
||||
cancelAnchorAlignment();
|
||||
nextTick(syncViewport);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
isNearBottom,
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
scrollToAnchor,
|
||||
captureScrollPosition,
|
||||
restoreScrollPosition,
|
||||
captureViewportPosition,
|
||||
restoreViewportPosition,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="hostRef"
|
||||
class="virtual-message-list-host"
|
||||
:style="{ '--virtual-row-gap': `${rowGap}px`, '--virtual-list-padding': padding }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="scrollerRef"
|
||||
class="virtual-message-list"
|
||||
:items="messages"
|
||||
key-field="id"
|
||||
:min-item-size="estimatedItemHeight"
|
||||
:buffer="bufferPx"
|
||||
:flow-mode="true"
|
||||
:prerender="overscan"
|
||||
@scroll.passive="handleScroll"
|
||||
@resize="handleResize"
|
||||
@visible="syncViewport"
|
||||
>
|
||||
<template #before>
|
||||
<slot v-if="messages.length > 0" name="before" />
|
||||
</template>
|
||||
<template #default="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:index="index"
|
||||
:active="active"
|
||||
class="virtual-row"
|
||||
>
|
||||
<slot v-if="active" name="item" :message="item" />
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<slot v-if="messages.length > 0" name="after" />
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<div v-if="messages.length === 0 && $slots.empty" class="virtual-message-list-empty">
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.virtual-message-list-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
animation: message-list-fade-in 1.5s ease both;
|
||||
}
|
||||
|
||||
.virtual-message-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--virtual-list-padding);
|
||||
box-sizing: border-box;
|
||||
background-color: $bg-card;
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-row {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: var(--virtual-row-gap);
|
||||
}
|
||||
|
||||
.virtual-message-list-empty {
|
||||
position: absolute;
|
||||
inset: var(--virtual-list-padding);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.virtual-message-list-empty :deep(.empty-state) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@keyframes message-list-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.virtual-message-list-host {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
import hljs from 'highlight.js'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
shellscript: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
yml: 'yaml',
|
||||
vue: 'xml',
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function sanitizeLanguageClass(value: string): string {
|
||||
return value.replace(/[^a-z0-9_-]/gi, '-') || 'plain'
|
||||
}
|
||||
|
||||
export function normalizeHighlightLanguage(lang?: string): string {
|
||||
const normalized = lang?.trim().toLowerCase() || ''
|
||||
return LANGUAGE_ALIASES[normalized] || normalized
|
||||
}
|
||||
|
||||
export function inferStructuredLanguage(content: string): string | undefined {
|
||||
try {
|
||||
JSON.parse(content)
|
||||
return 'json'
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type RenderHighlightedCodeBlockOptions = {
|
||||
maxHighlightLength?: number
|
||||
}
|
||||
|
||||
export function renderHighlightedCodeBlock(
|
||||
content: string,
|
||||
lang: string | undefined,
|
||||
copyLabel: string,
|
||||
options: RenderHighlightedCodeBlockOptions = {},
|
||||
): string {
|
||||
const requestedLanguage = lang?.trim().toLowerCase() || ''
|
||||
const normalizedLanguage = normalizeHighlightLanguage(requestedLanguage)
|
||||
const highlightLimit = options.maxHighlightLength ?? Number.POSITIVE_INFINITY
|
||||
|
||||
let highlighted = ''
|
||||
let codeClassLanguage = normalizedLanguage || requestedLanguage || 'plain'
|
||||
let labelLanguage = requestedLanguage
|
||||
|
||||
try {
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage) && content.length <= highlightLimit) {
|
||||
highlighted = hljs.highlight(content, {
|
||||
language: normalizedLanguage,
|
||||
ignoreIllegals: true,
|
||||
}).value
|
||||
codeClassLanguage = normalizedLanguage
|
||||
} else {
|
||||
highlighted = escapeHtml(content)
|
||||
if (!labelLanguage) {
|
||||
labelLanguage = 'text'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
highlighted = escapeHtml(content)
|
||||
if (!labelLanguage) {
|
||||
labelLanguage = 'text'
|
||||
}
|
||||
}
|
||||
|
||||
const languageLabelHtml = labelLanguage
|
||||
? `<span class="code-lang">${escapeHtml(labelLanguage)}</span>`
|
||||
: ''
|
||||
|
||||
return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
return copyToClipboard(text)
|
||||
}
|
||||
|
||||
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<boolean | null> {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
|
||||
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
|
||||
if (!button) return null
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const block = button.closest('.hljs-code-block')
|
||||
const code = block?.querySelector('code')
|
||||
const text = code?.textContent ?? ''
|
||||
if (!text) return false
|
||||
|
||||
return copyTextToClipboard(text)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
const MARKDOWN_FENCE_LANGUAGES = new Set(['md', 'markdown', 'mdown', 'mkd'])
|
||||
|
||||
type FenceInfo = {
|
||||
indent: string
|
||||
marker: string
|
||||
fence: string
|
||||
length: number
|
||||
info: string
|
||||
}
|
||||
|
||||
function parseFence(line: string): FenceInfo | null {
|
||||
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/)
|
||||
if (!match) return null
|
||||
|
||||
const [, indent, fence, rawInfo = ''] = match
|
||||
const marker = fence[0]
|
||||
const info = rawInfo.trim()
|
||||
|
||||
// CommonMark permits backticks in tilde-fence info strings, but not in
|
||||
// backtick-fence info strings. Keeping this distinction prevents inline-ish
|
||||
// malformed backtick text from being promoted into a fence opener.
|
||||
if (marker === '`' && info.includes('`')) return null
|
||||
|
||||
return {
|
||||
indent,
|
||||
marker,
|
||||
fence,
|
||||
length: fence.length,
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeFence(fence: FenceInfo, length = fence.length, info = fence.info): string {
|
||||
return `${fence.indent}${fence.marker.repeat(length)}${info ? ` ${info}` : ''}`
|
||||
}
|
||||
|
||||
function isMarkdownFence(fence: FenceInfo): boolean {
|
||||
const language = fence.info.split(/\s+/)[0]?.toLowerCase()
|
||||
return MARKDOWN_FENCE_LANGUAGES.has(language)
|
||||
}
|
||||
|
||||
function isClosingFence(line: string, opener: FenceInfo): boolean {
|
||||
const fence = parseFence(line)
|
||||
return Boolean(
|
||||
fence
|
||||
&& fence.marker === opener.marker
|
||||
&& fence.length >= opener.length
|
||||
&& fence.info === '',
|
||||
)
|
||||
}
|
||||
|
||||
function findLastNonEmptyLine(lines: string[], start = lines.length - 1): number {
|
||||
let index = start
|
||||
while (index >= 0 && lines[index].trim() === '') {
|
||||
index -= 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function findFinalClosingFence(lines: string[], opener: FenceInfo, start: number): number {
|
||||
for (let i = findLastNonEmptyLine(lines); i > start; i -= 1) {
|
||||
if (isClosingFence(lines[i], opener)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
type OpenFence = {
|
||||
marker: string
|
||||
length: number
|
||||
}
|
||||
|
||||
function canBalanceNestedFences(lines: string[], marker: string): boolean {
|
||||
const stack: OpenFence[] = []
|
||||
let sawFence = false
|
||||
|
||||
for (const line of lines) {
|
||||
const fence = parseFence(line)
|
||||
if (!fence || fence.marker !== marker) continue
|
||||
|
||||
sawFence = true
|
||||
const current = stack[stack.length - 1]
|
||||
if (fence.info === '' && current && fence.length >= current.length) {
|
||||
stack.pop()
|
||||
continue
|
||||
}
|
||||
|
||||
// Inside a Markdown example, an unlabeled fence can be either a closing
|
||||
// fence or a literal nested unlabeled example opener. If there is no nested
|
||||
// opener waiting to close, treat it as the latter while evaluating a later
|
||||
// candidate closing fence for the outer example.
|
||||
stack.push({ marker: fence.marker, length: fence.length })
|
||||
}
|
||||
|
||||
return sawFence && stack.length === 0
|
||||
}
|
||||
|
||||
function findBalancedClosingFence(lines: string[], opener: FenceInfo, start: number): number {
|
||||
const candidates: number[] = []
|
||||
|
||||
for (let i = start; i < lines.length; i += 1) {
|
||||
const fence = parseFence(lines[i])
|
||||
if (
|
||||
fence
|
||||
&& fence.marker === opener.marker
|
||||
&& fence.info === ''
|
||||
&& fence.length >= opener.length
|
||||
) {
|
||||
candidates.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = candidates.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = candidates[i]
|
||||
if (canBalanceNestedFences(lines.slice(start, candidate), opener.marker)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ?? -1
|
||||
}
|
||||
|
||||
function maxFenceLength(lines: string[], marker: string): number {
|
||||
let maxLength = 0
|
||||
for (const line of lines) {
|
||||
const fence = parseFence(line)
|
||||
if (fence?.marker === marker) {
|
||||
maxLength = Math.max(maxLength, fence.length)
|
||||
}
|
||||
}
|
||||
return maxLength
|
||||
}
|
||||
|
||||
function promoteMarkdownExampleFences(lines: string[]): string[] {
|
||||
const output: string[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const opener = parseFence(lines[i])
|
||||
if (!opener || !isMarkdownFence(opener)) {
|
||||
output.push(lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
const balancedClose = findBalancedClosingFence(lines, opener, i + 1)
|
||||
if (balancedClose === -1) {
|
||||
output.push(lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
const body = lines.slice(i + 1, balancedClose)
|
||||
const innerMaxLength = maxFenceLength(body, opener.marker)
|
||||
if (innerMaxLength >= opener.length) {
|
||||
const promotedLength = innerMaxLength + 1
|
||||
output.push(serializeFence(opener, promotedLength))
|
||||
output.push(...body)
|
||||
output.push(serializeFence(opener, promotedLength, ''))
|
||||
} else {
|
||||
output.push(lines[i])
|
||||
output.push(...body)
|
||||
output.push(lines[balancedClose])
|
||||
}
|
||||
|
||||
i = balancedClose
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* LLMs often wrap a complete PR draft or Markdown answer in an outer
|
||||
* ```md fence. Showing that outer wrapper as a code block makes the UI look
|
||||
* like Markdown rendering is broken: headings, lists, and inline code remain
|
||||
* literal text. Strip only that outer draft wrapper before handing content to
|
||||
* markdown-it.
|
||||
*
|
||||
* The unwrapped draft can still contain Markdown examples that themselves
|
||||
* contain fenced examples. CommonMark closes fences at the first same-marker
|
||||
* line with at least the opener length, so a malformed example like
|
||||
* ```md ... ```md ... ``` ... ``` must be normalized by making the example's
|
||||
* outer fence longer than the literal fences inside it.
|
||||
*/
|
||||
export function repairNestedMarkdownFences(content: string): string {
|
||||
if (!content.includes('```') && !content.includes('~~~')) return content
|
||||
|
||||
const lines = content.split('\n')
|
||||
const output: string[] = []
|
||||
let changed = false
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const opener = parseFence(lines[i])
|
||||
if (!opener || !isMarkdownFence(opener)) {
|
||||
output.push(lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
const finalClose = findFinalClosingFence(lines, opener, i + 1)
|
||||
if (finalClose === -1) {
|
||||
output.push(lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
const lastNonEmpty = findLastNonEmptyLine(lines)
|
||||
if (finalClose !== lastNonEmpty) {
|
||||
output.push(lines[i])
|
||||
continue
|
||||
}
|
||||
|
||||
output.push(...promoteMarkdownExampleFences(lines.slice(i + 1, finalClose)))
|
||||
output.push(...lines.slice(finalClose + 1))
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
|
||||
return changed ? output.join('\n') : content
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const MERMAID_LANGUAGE = 'mermaid'
|
||||
|
||||
export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
|
||||
export const MERMAID_MAX_SOURCE_LENGTH = 20_000
|
||||
export const MERMAID_RENDER_TIMEOUT_MS = 5_000
|
||||
export const SUPPORT_PREVIEW_FILE_TYPES = ['txt', 'md', 'json', 'csv', 'log', 'py', 'yaml', 'yml', 'toml', 'sh', 'xml', 'html', 'css', 'js', 'ts', 'rs', 'go', 'java', 'c', 'cpp', 'h']
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
export function getFenceLanguage(info: string | undefined): string {
|
||||
return info?.trim().split(/\s+/)[0]?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
export function isMermaidFence(info: string | undefined): boolean {
|
||||
return getFenceLanguage(info) === MERMAID_LANGUAGE
|
||||
}
|
||||
|
||||
export function encodeMermaidSource(source: string): string {
|
||||
return encodeURIComponent(source)
|
||||
}
|
||||
|
||||
export function decodeMermaidSource(encoded: string | null | undefined): string {
|
||||
if (!encoded) return ''
|
||||
|
||||
try {
|
||||
return decodeURIComponent(encoded)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMermaidPlaceholder(source: string): string {
|
||||
return [
|
||||
'<div class="mermaid-diagram" data-mermaid-pending="true"',
|
||||
` data-mermaid-source="${escapeHtml(encodeMermaidSource(source))}">`,
|
||||
'<div class="mermaid-loading">Rendering Mermaid diagram…</div>',
|
||||
'</div>',
|
||||
].join('')
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NBreadcrumb, NBreadcrumbItem } from 'naive-ui'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
function handleClick(index: number) {
|
||||
if (index < 0) {
|
||||
filesStore.navigateTo('')
|
||||
} else {
|
||||
const path = filesStore.pathSegments.slice(0, index + 1).join('/')
|
||||
filesStore.navigateTo(path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-breadcrumb">
|
||||
<NBreadcrumb>
|
||||
<NBreadcrumbItem @click="handleClick(-1)">
|
||||
{{ t('files.breadcrumbRoot') }}
|
||||
</NBreadcrumbItem>
|
||||
<NBreadcrumbItem
|
||||
v-for="(segment, index) in filesStore.pathSegments"
|
||||
:key="index"
|
||||
@click="handleClick(index)"
|
||||
>
|
||||
{{ segment }}
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.file-breadcrumb {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { NDropdown, useMessage, useDialog } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores/hermes/files'
|
||||
import { downloadFile } from '@/api/hermes/download'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import { getClipboardPathForEntry } from '@/utils/file-path'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const showMenu = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
const targetEntry = ref<FileEntry | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename', entry: FileEntry): void
|
||||
}>()
|
||||
|
||||
function show(e: MouseEvent, entry: FileEntry) {
|
||||
targetEntry.value = entry
|
||||
menuX.value = e.clientX
|
||||
menuY.value = e.clientY
|
||||
showMenu.value = false
|
||||
nextTick(() => {
|
||||
showMenu.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function getOptions() {
|
||||
const entry = targetEntry.value
|
||||
if (!entry) return []
|
||||
const options: any[] = []
|
||||
|
||||
if (entry.isDir) {
|
||||
options.push({ label: t('files.open'), key: 'open' })
|
||||
} else {
|
||||
if (isTextFile(entry.name)) {
|
||||
options.push({ label: t('files.edit'), key: 'edit' })
|
||||
}
|
||||
if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
|
||||
options.push({ label: t('files.preview'), key: 'preview' })
|
||||
}
|
||||
options.push({ label: t('files.download'), key: 'download' })
|
||||
}
|
||||
options.push({ type: 'divider', key: 'd1' })
|
||||
options.push({ label: t('files.copyPath'), key: 'copyPath' })
|
||||
options.push({ label: t('files.rename'), key: 'rename' })
|
||||
options.push({ type: 'divider', key: 'd2' })
|
||||
options.push({ label: t('files.delete'), key: 'delete' })
|
||||
return options
|
||||
}
|
||||
|
||||
async function handleSelect(key: string) {
|
||||
showMenu.value = false
|
||||
const entry = targetEntry.value
|
||||
if (!entry) return
|
||||
|
||||
switch (key) {
|
||||
case 'open':
|
||||
filesStore.navigateTo(entry.path)
|
||||
break
|
||||
case 'edit':
|
||||
try { await filesStore.openEditor(entry.path) } catch { message.error(t('files.backendError')) }
|
||||
break
|
||||
case 'preview':
|
||||
try { await filesStore.openPreview(entry) } catch { message.error(t('files.backendError')) }
|
||||
break
|
||||
case 'download':
|
||||
try { await downloadFile(entry.path, entry.name) } catch (err: any) { message.error(err.message) }
|
||||
break
|
||||
case 'copyPath': {
|
||||
const ok = await copyToClipboard(getClipboardPathForEntry(entry))
|
||||
if (ok) {
|
||||
message.success(t('files.pathCopied'))
|
||||
} else {
|
||||
message.error(t('files.pathCopied') + ' ✗')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'rename':
|
||||
emit('rename', entry)
|
||||
break
|
||||
case 'delete':
|
||||
dialog.warning({
|
||||
title: t('files.delete'),
|
||||
content: entry.isDir ? t('files.confirmDeleteDir', { name: entry.name }) : t('files.confirmDelete', { name: entry.name }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await filesStore.deleteEntry(entry)
|
||||
message.success(t('files.deleted'))
|
||||
} catch {
|
||||
message.error(t('files.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside() {
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown
|
||||
:show="showMenu"
|
||||
:x="menuX"
|
||||
:y="menuY"
|
||||
:options="getOptions()"
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
@select="handleSelect"
|
||||
@clickoutside="handleClickOutside"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { NButton, NSpace, useMessage, useDialog } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
// Configure Monaco workers using import.meta.url
|
||||
;(self as any).MonacoEnvironment = {
|
||||
getWorker(_: any, _label: string) {
|
||||
return new Worker(
|
||||
new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url),
|
||||
{ type: 'module' }
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialogApi = useDialog()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
let editor: monaco.editor.IStandaloneCodeEditor | null = null
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!editorContainer.value || !filesStore.editingFile) return
|
||||
|
||||
editor = monaco.editor.create(editorContainer.value, {
|
||||
value: filesStore.editingFile.content,
|
||||
language: filesStore.editingFile.language,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'vs-dark' : 'vs',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
})
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
if (filesStore.editingFile) {
|
||||
filesStore.editingFile.content = editor!.getValue()
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl/Cmd + S to save
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSave()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor?.dispose()
|
||||
editor = null
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await filesStore.saveEditor()
|
||||
message.success(t('files.saved'))
|
||||
} catch {
|
||||
message.error(t('files.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (filesStore.hasUnsavedChanges) {
|
||||
dialogApi.warning({
|
||||
title: t('files.unsavedChanges'),
|
||||
positiveText: t('common.ok'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
filesStore.closeEditor()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
filesStore.closeEditor()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-editor">
|
||||
<div class="editor-header">
|
||||
<span class="editor-filename">{{ filesStore.editingFile?.path }}</span>
|
||||
<NSpace>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">
|
||||
{{ t('files.saveFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleClose">
|
||||
{{ t('files.closeEditor') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
<div ref="editorContainer" class="editor-container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
background-color: $bg-card;
|
||||
}
|
||||
|
||||
.editor-filename {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
max-width: 120px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore, isImageFile, isMarkdownFile, isTextFile } from '@/stores/hermes/files'
|
||||
import { downloadFile } from '@/api/hermes/download'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'contextmenu-entry', event: MouseEvent, entry: FileEntry): void
|
||||
}>()
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
function getFileIcon(entry: FileEntry): string {
|
||||
if (entry.isDir) return '📁'
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || ''
|
||||
const iconMap: Record<string, string> = {
|
||||
yaml: '⚙️', yml: '⚙️', json: '📋', toml: '⚙️',
|
||||
md: '📝', txt: '📄', log: '📄',
|
||||
py: '🐍', js: '📜', ts: '📜', vue: '💚',
|
||||
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||||
zip: '📦', gz: '📦', tar: '📦',
|
||||
sh: '⚡', bash: '⚡',
|
||||
}
|
||||
return iconMap[ext] || '📄'
|
||||
}
|
||||
|
||||
function handleDoubleClick(entry: FileEntry) {
|
||||
if (entry.isDir) {
|
||||
filesStore.navigateTo(entry.path)
|
||||
} else if (isTextFile(entry.name)) {
|
||||
filesStore.openEditor(entry.path)
|
||||
} else if (isImageFile(entry.name) || isMarkdownFile(entry.name)) {
|
||||
filesStore.openPreview(entry)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, entry: FileEntry) {
|
||||
e.preventDefault()
|
||||
emit('contextmenu-entry', e, entry)
|
||||
}
|
||||
|
||||
async function handleDownload(entry: FileEntry) {
|
||||
try {
|
||||
await downloadFile(entry.path, entry.name)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('files.backendError'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-list">
|
||||
<NSpin :show="filesStore.loading">
|
||||
<NEmpty v-if="!filesStore.loading && filesStore.sortedEntries.length === 0" :description="t('files.emptyDir')" />
|
||||
<div v-else class="file-list-items">
|
||||
<div class="file-list-header">
|
||||
<div class="file-name sort-header" @click="filesStore.setSort('name')">
|
||||
{{ t('files.name') }}
|
||||
<span v-if="filesStore.sortBy === 'name'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
|
||||
</div>
|
||||
<div class="file-size sort-header" @click="filesStore.setSort('size')">
|
||||
{{ t('files.size') }}
|
||||
<span v-if="filesStore.sortBy === 'size'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
|
||||
</div>
|
||||
<div class="file-date sort-header" @click="filesStore.setSort('modTime')">
|
||||
{{ t('files.modified') }}
|
||||
<span v-if="filesStore.sortBy === 'modTime'" class="sort-indicator">{{ filesStore.sortOrder === 'asc' ? '↑' : '↓' }}</span>
|
||||
</div>
|
||||
<div class="file-actions-placeholder" />
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in filesStore.sortedEntries"
|
||||
:key="entry.path"
|
||||
class="file-list-row"
|
||||
@dblclick="handleDoubleClick(entry)"
|
||||
@contextmenu="handleContextMenu($event, entry)"
|
||||
>
|
||||
<div class="file-name">
|
||||
<span class="file-icon">{{ getFileIcon(entry) }}</span>
|
||||
<span>{{ entry.name }}</span>
|
||||
</div>
|
||||
<div class="file-size">{{ entry.isDir ? '—' : formatSize(entry.size) }}</div>
|
||||
<div class="file-date">{{ formatDate(entry.modTime) }}</div>
|
||||
<div class="file-actions">
|
||||
<NButton v-if="isTextFile(entry.name) && !entry.isDir" size="tiny" quaternary @click.stop="filesStore.openEditor(entry.path)" :title="t('files.edit')">✏️</NButton>
|
||||
<NButton v-if="!entry.isDir" size="tiny" quaternary @click.stop="handleDownload(entry)" :title="t('files.download')">⬇️</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-list {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-muted;
|
||||
border-bottom: 1px solid $border-light;
|
||||
margin-bottom: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-header {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.file-actions-placeholder {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.06);
|
||||
|
||||
.file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
color: $text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-date {
|
||||
width: 160px;
|
||||
color: $text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
opacity: 0;
|
||||
transition: opacity $transition-fast;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.file-size, .file-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import { getFileDownloadUrl } from '@/api/hermes/files'
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
function getImageUrl(): string {
|
||||
if (!filesStore.previewFile) return ''
|
||||
return getFileDownloadUrl(filesStore.previewFile.path)
|
||||
}
|
||||
|
||||
const CloseIcon = () =>
|
||||
h(
|
||||
'svg',
|
||||
{ viewBox: '0 0 24 24', width: '14', height: '14', fill: 'currentColor' },
|
||||
[h('path', { d: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z' })],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-preview" v-if="filesStore.previewFile">
|
||||
<div class="preview-header">
|
||||
<span class="preview-filename">{{ filesStore.previewFile.path }}</span>
|
||||
<NButton size="small" quaternary @click="filesStore.closePreview()">
|
||||
<template #icon>
|
||||
<NIcon><CloseIcon /></NIcon>
|
||||
</template>
|
||||
{{ t('files.closePreview') }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<img
|
||||
v-if="filesStore.previewFile.type === 'image'"
|
||||
:src="getImageUrl()"
|
||||
class="preview-image"
|
||||
:alt="filesStore.previewFile.path"
|
||||
/>
|
||||
<div v-else-if="filesStore.previewFile.type === 'markdown'" class="preview-markdown">
|
||||
<MarkdownRenderer :content="filesStore.previewFile.content || ''" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.preview-filename {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-markdown {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { NModal, NInput, NButton, NSpace, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import type { FileEntry } from '@/api/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
mode: 'newFile' | 'newFolder' | 'rename'
|
||||
entry?: FileEntry | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
}>()
|
||||
|
||||
const inputValue = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
if (props.mode === 'rename' && props.entry) {
|
||||
inputValue.value = props.entry.name
|
||||
} else {
|
||||
inputValue.value = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
switch (props.mode) {
|
||||
case 'newFile': return t('files.newFile')
|
||||
case 'newFolder': return t('files.newFolder')
|
||||
case 'rename': return t('files.rename')
|
||||
}
|
||||
})
|
||||
|
||||
const placeholder = computed(() => {
|
||||
switch (props.mode) {
|
||||
case 'newFile': return t('files.newFileName')
|
||||
case 'newFolder': return t('files.newFolderName')
|
||||
case 'rename': return t('files.renameTo')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!inputValue.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
switch (props.mode) {
|
||||
case 'newFile':
|
||||
await filesStore.createFile(inputValue.value.trim())
|
||||
message.success(t('files.created'))
|
||||
break
|
||||
case 'newFolder':
|
||||
await filesStore.createDir(inputValue.value.trim())
|
||||
message.success(t('files.created'))
|
||||
break
|
||||
case 'rename':
|
||||
if (props.entry) {
|
||||
await filesStore.renameEntry(props.entry, inputValue.value.trim())
|
||||
message.success(t('files.renamed'))
|
||||
}
|
||||
break
|
||||
}
|
||||
emit('update:show', false)
|
||||
} catch (err: any) {
|
||||
const msg = props.mode === 'rename' ? t('files.renameFailed') : t('files.createFailed')
|
||||
message.error(err.message || msg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="props.show" preset="dialog" :title="title" @update:show="emit('update:show', false)" style="width: 400px;">
|
||||
<NInput
|
||||
v-model:value="inputValue"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter="handleSubmit"
|
||||
autofocus
|
||||
/>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton @click="emit('update:show', false)">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="submitting" :disabled="!inputValue.trim()" @click="handleSubmit">
|
||||
{{ t('common.ok') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpace, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'showNewFile'): void
|
||||
(e: 'showNewFolder'): void
|
||||
(e: 'showUpload'): void
|
||||
}>()
|
||||
|
||||
async function handleRefresh() {
|
||||
try {
|
||||
await filesStore.fetchEntries()
|
||||
} catch {
|
||||
message.error(t('files.backendError'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-toolbar">
|
||||
<NSpace :size="8" :wrap="true" class="toolbar-space">
|
||||
<NButton size="small" @click="emit('showNewFile')" class="toolbar-btn">
|
||||
{{ t('files.newFile') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showNewFolder')" class="toolbar-btn">
|
||||
{{ t('files.newFolder') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="emit('showUpload')" class="toolbar-btn">
|
||||
{{ t('files.upload') }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="handleRefresh" class="toolbar-btn">
|
||||
{{ t('files.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-toolbar {
|
||||
padding: 12px 16px;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-space {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
:deep(.n-space) {
|
||||
gap: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NTree } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
import * as filesApi from '@/api/hermes/files'
|
||||
import type { TreeOption } from 'naive-ui'
|
||||
|
||||
const { t } = useI18n()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const treeData = ref<TreeOption[]>([])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
|
||||
async function loadChildren(path: string): Promise<TreeOption[]> {
|
||||
try {
|
||||
const result = await filesApi.listFiles(path)
|
||||
return result.entries
|
||||
.filter(e => e.isDir)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(e => ({
|
||||
key: e.path,
|
||||
label: e.name,
|
||||
isLeaf: false,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoad(node: TreeOption): Promise<void> {
|
||||
node.children = await loadChildren(node.key as string)
|
||||
}
|
||||
|
||||
function handleSelect(keys: string[]) {
|
||||
if (keys.length > 0) {
|
||||
selectedKeys.value = keys
|
||||
filesStore.navigateTo(keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
function handleRootClick() {
|
||||
selectedKeys.value = []
|
||||
filesStore.navigateTo('')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
treeData.value = await loadChildren('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-tree">
|
||||
<div class="tree-header" @click="handleRootClick">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
<span>{{ t('files.breadcrumbRoot') }}</span>
|
||||
</div>
|
||||
<NTree
|
||||
:data="treeData"
|
||||
:selected-keys="selectedKeys"
|
||||
:on-load="handleLoad"
|
||||
expand-on-click
|
||||
block-line
|
||||
@update:selected-keys="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.file-tree {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NButton, NUpload, NSpace, useMessage } from 'naive-ui'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFilesStore } from '@/stores/hermes/files'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const filesStore = useFilesStore()
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:show', value: boolean): void }>()
|
||||
|
||||
const uploading = ref(false)
|
||||
const fileList = ref<File[]>([])
|
||||
|
||||
function handleFileChange(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
|
||||
fileList.value = data.fileList
|
||||
.map((f: UploadFileInfo) => f.file)
|
||||
.filter((f): f is File => f != null)
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (fileList.value.length === 0) return
|
||||
uploading.value = true
|
||||
try {
|
||||
await filesStore.uploadFiles(fileList.value)
|
||||
message.success(t('files.uploadSuccess', { count: fileList.value.length }))
|
||||
fileList.value = []
|
||||
emit('update:show', false)
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('files.uploadFailed'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
fileList.value = []
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="props.show" preset="dialog" :title="t('files.upload')" @update:show="handleClose" style="width: 500px;">
|
||||
<NUpload
|
||||
multiple
|
||||
directory-dnd
|
||||
:default-upload="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="upload-dragger">
|
||||
<p>{{ t('files.dragDropHint') }}</p>
|
||||
</div>
|
||||
</NUpload>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleUpload">
|
||||
{{ t('files.upload') }} ({{ fileList.length }})
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-dragger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-dragger p {
|
||||
margin-top: 12px;
|
||||
opacity: 0.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
|
||||
type InputLikeInstance = {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
submit: [name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const roomName = ref('')
|
||||
const inviteCode = ref('')
|
||||
const userName = ref(localStorage.getItem('gc_user_name') || '')
|
||||
const description = ref(localStorage.getItem('gc_user_description') || '')
|
||||
const roomInput = ref<InputLikeInstance | null>(null)
|
||||
|
||||
const compression = ref({
|
||||
triggerTokens: 100000,
|
||||
maxHistoryTokens: 32000,
|
||||
tailMessageCount: 10,
|
||||
})
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const name = roomName.value.trim()
|
||||
const code = inviteCode.value.trim() || generateCode()
|
||||
const user = userName.value.trim()
|
||||
if (!name || !user) return
|
||||
emit('submit', name, code, user, description.value.trim(), { ...compression.value })
|
||||
}
|
||||
|
||||
function focusRoomInput() {
|
||||
nextTick(() => roomInput.value?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.yourName') }}</label>
|
||||
<NInput
|
||||
v-model:value="userName"
|
||||
:placeholder="t('groupChat.yourNamePlaceholder')"
|
||||
@keyup.enter="focusRoomInput"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.yourDescription') }}</label>
|
||||
<NInput
|
||||
v-model:value="description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="t('groupChat.yourDescriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.roomName') }}</label>
|
||||
<NInput
|
||||
ref="roomInput"
|
||||
v-model:value="roomName"
|
||||
:placeholder="t('groupChat.roomNamePlaceholder')"
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.inviteCode') }}</label>
|
||||
<div class="code-row">
|
||||
<NInput
|
||||
v-model:value="inviteCode"
|
||||
:placeholder="t('groupChat.autoGenerate')"
|
||||
/>
|
||||
<NButton size="small" @click="inviteCode = generateCode()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NCollapse class="compression-collapse">
|
||||
<NCollapseItem :title="t('groupChat.compressionSettings')" name="compression">
|
||||
<div class="compression-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.triggerTokens') }}</label>
|
||||
<NInputNumber v-model:value="compression.triggerTokens" :min="1000" :step="1000" style="width: 100%" />
|
||||
<p class="form-hint">{{ t('groupChat.triggerTokensDesc') }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.maxHistoryTokens') }}</label>
|
||||
<NInputNumber v-model:value="compression.maxHistoryTokens" :min="1000" :step="1000" style="width: 100%" />
|
||||
<p class="form-hint">{{ t('groupChat.maxHistoryTokensDesc') }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('groupChat.tailMessageCount') }}</label>
|
||||
<NInputNumber v-model:value="compression.tailMessageCount" :min="1" :step="5" style="width: 100%" />
|
||||
<p class="form-hint">{{ t('groupChat.tailMessageCountDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
|
||||
<div class="modal-actions">
|
||||
<NSpace justify="end">
|
||||
<NButton @click="emit('cancel')">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :disabled="!roomName.trim() || !userName.trim()" @click="handleCreate">{{ t('common.create') }}</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.create-form {
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compression-collapse {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.compression-fields {
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,774 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NSwitch, NTooltip } from 'naive-ui'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import { buildMentionOptions, type MentionOption } from './mention-options'
|
||||
import type { Attachment } from '@/stores/hermes/chat'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
|
||||
const store = useGroupChatStore()
|
||||
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||
|
||||
const inputText = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement>()
|
||||
const dropdownRef = ref<HTMLDivElement>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
const isComposing = ref(false)
|
||||
const autoPlaySpeech = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('autoPlaySpeech')
|
||||
if (saved !== null) {
|
||||
autoPlaySpeech.value = saved === 'true'
|
||||
store.setAutoPlaySpeech(autoPlaySpeech.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(autoPlaySpeech, (value) => {
|
||||
localStorage.setItem('autoPlaySpeech', String(value))
|
||||
store.setAutoPlaySpeech(value)
|
||||
})
|
||||
|
||||
// 自定义高度拖拽
|
||||
const textareaHeight = ref<number | null>(null)
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
const startHeight = el.clientHeight
|
||||
const startY = e.clientY
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const deltaY = e.clientY - startY
|
||||
const newHeight = startHeight - deltaY
|
||||
textareaHeight.value = Math.max(20, Math.min(400, Math.round(newHeight)))
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// ─── Mention State ───────────────────────────────────────
|
||||
|
||||
const mentionActive = ref(false)
|
||||
const mentionQuery = ref('')
|
||||
const mentionStartIndex = ref(-1)
|
||||
const dropdownX = ref(0)
|
||||
const dropdownY = ref(0)
|
||||
const dropdownBottom = ref(0)
|
||||
const placement = ref<'bottom' | 'top'>('bottom')
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const filteredMentionOptions = computed(() => buildMentionOptions(store.agents, mentionQuery.value))
|
||||
|
||||
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
|
||||
|
||||
// ─── Scroll active item into view ──────────────────────
|
||||
|
||||
function scrollToActive() {
|
||||
nextTick(() => {
|
||||
if (!dropdownRef.value) return
|
||||
const active = dropdownRef.value.querySelector('.active') as HTMLElement | null
|
||||
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'instant' })
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Mention Logic ───────────────────────────────────────
|
||||
|
||||
function updateMentionState() {
|
||||
const el = textareaRef.value
|
||||
if (!el) { mentionActive.value = false; return }
|
||||
|
||||
const text = inputText.value
|
||||
const cursorPos = el.selectionStart
|
||||
|
||||
// Find the last @ before the cursor
|
||||
let atPos = -1
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (text[i] === '@') { atPos = i; break }
|
||||
if (text[i] === ' ' || text[i] === '\n') break
|
||||
}
|
||||
|
||||
if (atPos === -1) {
|
||||
mentionActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the @ is not part of a word (preceded by space or start of line)
|
||||
if (atPos > 0 && text[atPos - 1] !== ' ' && text[atPos - 1] !== '\n') {
|
||||
mentionActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const query = text.slice(atPos + 1, cursorPos)
|
||||
if (query.includes(' ')) {
|
||||
mentionActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
mentionQuery.value = query
|
||||
mentionStartIndex.value = atPos
|
||||
activeIndex.value = 0
|
||||
|
||||
// Calculate dropdown position using mirror span
|
||||
const mirror = document.createElement('span')
|
||||
const style = getComputedStyle(el)
|
||||
const props = ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent', 'border', 'padding', 'boxSizing', 'lineHeight']
|
||||
props.forEach(p => { (mirror.style as any)[p] = style[p as any] })
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.whiteSpace = 'nowrap'
|
||||
mirror.textContent = text.slice(0, atPos + 1)
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
document.body.appendChild(mirror)
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
dropdownX.value = rect.left + mirrorRect.width - el.scrollLeft
|
||||
|
||||
// Decide placement: if dropdown would go below viewport, flip upward
|
||||
const estimatedHeight = Math.min(filteredMentionOptions.value.length * 36 + 8, 240)
|
||||
const spaceBelow = window.innerHeight - rect.top + el.scrollTop - 8
|
||||
if (spaceBelow < estimatedHeight && rect.top - el.scrollTop - 8 > estimatedHeight) {
|
||||
placement.value = 'top'
|
||||
dropdownY.value = rect.top - el.scrollTop - 8
|
||||
} else {
|
||||
placement.value = 'bottom'
|
||||
dropdownY.value = rect.top - el.scrollTop - 8
|
||||
}
|
||||
|
||||
dropdownBottom.value = window.innerHeight - dropdownY.value
|
||||
|
||||
mentionActive.value = filteredMentionOptions.value.length > 0
|
||||
}
|
||||
|
||||
function selectMention(name: string) {
|
||||
const el = textareaRef.value
|
||||
if (!el || mentionStartIndex.value === -1) return
|
||||
|
||||
const before = inputText.value.slice(0, mentionStartIndex.value)
|
||||
const after = inputText.value.slice(el.selectionStart)
|
||||
inputText.value = `${before}@${name} ${after}`
|
||||
mentionActive.value = false
|
||||
|
||||
nextTick(() => {
|
||||
if (el) {
|
||||
const newPos = before.length + name.length + 2
|
||||
el.setSelectionRange(newPos, newPos)
|
||||
el.focus()
|
||||
if (textareaHeight.value === null) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Event Handlers ──────────────────────────────────────
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Mention navigation — fully custom, no NDropdown interference
|
||||
if (mentionActive.value && filteredMentionOptions.value.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % filteredMentionOptions.value.length
|
||||
scrollToActive()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value - 1 + filteredMentionOptions.value.length) % filteredMentionOptions.value.length
|
||||
scrollToActive()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
selectMention(filteredMentionOptions.value[activeIndex.value].name)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
mentionActive.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key !== 'Enter' || e.shiftKey) return
|
||||
if (isComposing.value || e.isComposing || e.keyCode === 229) return
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
const content = inputText.value.trim()
|
||||
if (!content && attachments.value.length === 0) return
|
||||
|
||||
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
|
||||
inputText.value = ''
|
||||
attachments.value = []
|
||||
mentionActive.value = false
|
||||
// 发送后重置到自定义高度(不清除拖拽状态)
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
// 用户手动拖拽自定义高度时,不覆盖
|
||||
if (textareaHeight.value !== null) return
|
||||
store.emitTyping()
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
|
||||
|
||||
if (!isComposing.value) {
|
||||
updateMentionState()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentionClick(option: MentionOption) {
|
||||
selectMention(option.name)
|
||||
}
|
||||
|
||||
function handleMentionHover(index: number) {
|
||||
activeIndex.value = index
|
||||
}
|
||||
|
||||
// ─── Click outside to close dropdown ─────────────────
|
||||
|
||||
function onDocumentMousedown(e: MouseEvent) {
|
||||
if (!mentionActive.value) return
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.mention-dropdown')) {
|
||||
mentionActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocumentMousedown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMousedown)
|
||||
})
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
requestAnimationFrame(() => {
|
||||
isComposing.value = false
|
||||
updateMentionState()
|
||||
})
|
||||
}
|
||||
|
||||
function addFile(file: File) {
|
||||
if (attachments.value.find(a => a.name === file.name)) return
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
||||
attachments.value.push({
|
||||
id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
url: URL.createObjectURL(file),
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
function handleAttachClick() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
for (const file of input.files) addFile(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const items = Array.from(e.clipboardData?.items || [])
|
||||
const imageItems = items.filter(i => i.type.startsWith('image/'))
|
||||
if (!imageItems.length) return
|
||||
e.preventDefault()
|
||||
for (const item of imageItems) {
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) continue
|
||||
const ext = item.type.split('/')[1] || 'png'
|
||||
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handleDragEnter(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value <= 0) {
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false
|
||||
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function removeAttachment(id: string) {
|
||||
const idx = attachments.value.findIndex(a => a.id === id)
|
||||
if (idx !== -1) {
|
||||
URL.revokeObjectURL(attachments.value[idx].url)
|
||||
attachments.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function isImage(type: string): boolean {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-input-area">
|
||||
<div class="input-top-bar">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" circle @click="handleAttachClick">
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ t('chat.attachFiles') }}
|
||||
</NTooltip>
|
||||
<div class="auto-play-speech-switch">
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="switch-label">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
{{ t('chat.autoPlaySpeech') }}
|
||||
</NTooltip>
|
||||
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
|
||||
</div>
|
||||
<NTooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
|
||||
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
|
||||
</svg>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div v-if="attachments.length > 0" class="attachment-previews">
|
||||
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
|
||||
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
|
||||
<div v-else class="attachment-file">
|
||||
<span class="file-name">{{ att.name }}</span>
|
||||
<span class="file-size">{{ formatSize(att.size) }}</span>
|
||||
</div>
|
||||
<button class="attachment-remove" @click="removeAttachment(att.id)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="input-wrapper"
|
||||
:class="{ 'drag-over': isDragging }"
|
||||
@dragover="handleDragOver"
|
||||
@dragenter="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="inputText"
|
||||
class="input-textarea"
|
||||
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
|
||||
:placeholder="t('groupChat.inputPlaceholder')"
|
||||
rows="1"
|
||||
@keydown="handleKeydown"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!canSend"
|
||||
@click="handleSend"
|
||||
>
|
||||
<template #icon>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</template>
|
||||
{{ t('chat.send') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="dropdown-fade">
|
||||
<div
|
||||
v-if="mentionActive && filteredMentionOptions.length > 0"
|
||||
ref="dropdownRef"
|
||||
class="mention-dropdown"
|
||||
:class="{ 'placement-top': placement === 'top' }"
|
||||
:style="{
|
||||
left: dropdownX + 'px',
|
||||
top: placement === 'bottom' ? dropdownY + 'px' : 'auto',
|
||||
bottom: placement === 'top' ? dropdownBottom + 'px' : 'auto',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(option, i) in filteredMentionOptions"
|
||||
:key="option.key"
|
||||
class="mention-dropdown-item"
|
||||
:class="{ active: i === activeIndex, 'mention-all-option': option.type === 'all' }"
|
||||
@mousedown.prevent="handleMentionClick(option)"
|
||||
@mouseenter="handleMentionHover(i)"
|
||||
>
|
||||
<span class="mention-name">{{ option.label }}</span>
|
||||
<span class="mention-profile">{{ option.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.chat-input-area {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.auto-play-speech-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid $border-light;
|
||||
margin-left: 4px;
|
||||
|
||||
.switch-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999999;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 22px;
|
||||
margin-left: -4px;
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
|
||||
:deep(.n-button__state-border),
|
||||
:deep(.n-button__border),
|
||||
:deep(.n-button__ripple) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-trace-icon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-previews {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
position: relative;
|
||||
border-radius: $radius-sm;
|
||||
overflow: hidden;
|
||||
background-color: $bg-secondary;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&.image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment-file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
min-width: 80px;
|
||||
max-width: 140px;
|
||||
color: $text-secondary;
|
||||
|
||||
.file-name {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--text-on-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
.attachment-preview:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: $text-muted;
|
||||
animation: typing-bounce 1.2s infinite;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-3px); opacity: 1; }
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background-color: $bg-input;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 10px 12px;
|
||||
position: relative;
|
||||
transition: border-color $transition-fast, background-color $transition-fast;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
|
||||
&.drag-over {
|
||||
border-color: $accent-primary;
|
||||
background-color: rgba($accent-primary, 0.08);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
cursor: row-resize;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-textarea {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: $text-primary;
|
||||
font-family: $font-ui;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
max-height: 400px;
|
||||
min-height: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Custom mention dropdown (replaces NDropdown) ── */
|
||||
|
||||
.mention-dropdown {
|
||||
position: fixed;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
min-width: 200px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
z-index: 9999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.mention-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: rgba(var(--text-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.mention-name {
|
||||
color: $text-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mention-profile {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.mention-all-option .mention-name {
|
||||
color: $accent-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dropdown fade/scale animation (matching NDropdown) ── */
|
||||
|
||||
.dropdown-fade-enter-active {
|
||||
transition: opacity 0.2s cubic-bezier(0, 0, .2, 1), transform 0.2s cubic-bezier(0, 0, .2, 1);
|
||||
transform-origin: top;
|
||||
}
|
||||
.dropdown-fade-leave-active {
|
||||
transition: opacity 0.2s cubic-bezier(.4, 0, 1, 1), transform 0.2s cubic-bezier(.4, 0, 1, 1);
|
||||
transform-origin: top;
|
||||
}
|
||||
.dropdown-fade-enter-from,
|
||||
.dropdown-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.placement-top.dropdown-fade-enter-active,
|
||||
.placement-top.dropdown-fade-leave-active {
|
||||
transform-origin: bottom;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGroupChatStore } from '@/stores/hermes/group-chat'
|
||||
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
|
||||
import GroupMessageItem from './GroupMessageItem.vue'
|
||||
import VirtualMessageList from '../chat/VirtualMessageList.vue'
|
||||
|
||||
const store = useGroupChatStore()
|
||||
const { t } = useI18n()
|
||||
const { toolTraceVisible } = useToolTraceVisibility()
|
||||
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
|
||||
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
|
||||
let pendingInitialBottomRoomId: string | null = store.currentRoomId
|
||||
|
||||
type BottomScrollOptions = number | {
|
||||
frames?: number
|
||||
keepAliveMs?: number
|
||||
}
|
||||
|
||||
function scrollToBottom(options?: BottomScrollOptions): void {
|
||||
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
|
||||
scrollToBottom: (options?: BottomScrollOptions) => void
|
||||
}) | null
|
||||
list?.scrollToBottom(options)
|
||||
}
|
||||
|
||||
async function handleTopReach(): Promise<void> {
|
||||
if (!store.hasMoreBefore || store.isLoadingOlderMessages) return
|
||||
const snapshot = listRef.value?.captureScrollPosition() ?? null
|
||||
const loaded = await store.loadOlderMessages()
|
||||
if (!loaded) return
|
||||
await nextTick()
|
||||
listRef.value?.restoreScrollPosition(snapshot)
|
||||
}
|
||||
|
||||
watch(() => store.currentRoomId, (roomId) => {
|
||||
pendingInitialBottomRoomId = roomId
|
||||
})
|
||||
|
||||
watch(() => displayMessages.value.map(msg => [
|
||||
msg.id,
|
||||
msg.content?.length ?? 0,
|
||||
msg.reasoning?.length ?? 0,
|
||||
msg.reasoning_content?.length ?? 0,
|
||||
msg.toolStatus ?? '',
|
||||
].join(':')).join('|'), async () => {
|
||||
const shouldForceInitialBottom = !!store.currentRoomId &&
|
||||
pendingInitialBottomRoomId === store.currentRoomId &&
|
||||
displayMessages.value.length > 0
|
||||
const shouldScroll = shouldForceInitialBottom || (listRef.value?.isNearBottom(200) ?? true)
|
||||
await nextTick()
|
||||
if (shouldScroll) {
|
||||
scrollToBottom(shouldForceInitialBottom ? { frames: 5, keepAliveMs: 700 } : { frames: 1, keepAliveMs: 120 })
|
||||
if (shouldForceInitialBottom) pendingInitialBottomRoomId = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.currentRoomId || displayMessages.value.length === 0) return
|
||||
pendingInitialBottomRoomId = null
|
||||
await nextTick()
|
||||
scrollToBottom({ frames: 5, keepAliveMs: 700 })
|
||||
})
|
||||
|
||||
defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualMessageList
|
||||
ref="listRef"
|
||||
:messages="displayMessages"
|
||||
:estimated-item-height="170"
|
||||
:row-gap="12"
|
||||
padding="16px 20px"
|
||||
@top-reach="handleTopReach"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="empty-state">
|
||||
<img src="/logo.svg" alt="灵犀" class="empty-logo" />
|
||||
<p>{{ t("chat.emptyState") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #before>
|
||||
<div
|
||||
v-if="store.hasMoreBefore || store.isLoadingOlderMessages"
|
||||
class="history-loader"
|
||||
>
|
||||
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ message: msg }">
|
||||
<GroupMessageItem
|
||||
:message="msg"
|
||||
:agents="store.agents"
|
||||
:current-user-id="store.userId"
|
||||
/>
|
||||
</template>
|
||||
</VirtualMessageList>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
|
||||
.empty-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.history-loader {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.history-loader-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.16);
|
||||
border-top-color: $accent-primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
|
||||
.dark & {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-top-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
export type MentionOption = {
|
||||
key: string
|
||||
type: 'all' | 'agent'
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type MentionAgent = {
|
||||
name: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function isReservedMentionName(name: string): boolean {
|
||||
return name.trim().toLowerCase() === 'all'
|
||||
}
|
||||
|
||||
export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const options: MentionOption[] = []
|
||||
|
||||
if (!normalizedQuery || 'all'.includes(normalizedQuery)) {
|
||||
options.push({
|
||||
key: 'special:all',
|
||||
type: 'all',
|
||||
name: 'all',
|
||||
label: '@all',
|
||||
description: 'All agents',
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentName = agent.name || ''
|
||||
if (isReservedMentionName(agentName)) continue
|
||||
if (!agentName.toLowerCase().includes(normalizedQuery)) continue
|
||||
options.push({
|
||||
key: `agent:${agentName}`,
|
||||
type: 'agent',
|
||||
name: agentName,
|
||||
label: `@${agentName}`,
|
||||
description: agent.profile || '',
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NTooltip, useMessage } from 'naive-ui'
|
||||
import type { Job } from '@/api/hermes/jobs'
|
||||
import { scheduleToDisplayText } from '@/api/hermes/jobs'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
job: Job
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [jobId: string]
|
||||
select: [jobId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const jobsStore = useJobsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const jobId = computed(() => props.job.job_id || props.job.id)
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (props.job.state === 'running') return t('jobs.status.running')
|
||||
if (props.job.state === 'paused') return t('jobs.status.paused')
|
||||
if (!props.job.enabled) return t('jobs.status.disabled')
|
||||
return t('jobs.status.scheduled')
|
||||
})
|
||||
|
||||
const statusType = computed(() => {
|
||||
if (props.job.state === 'running') return 'info' as const
|
||||
if (props.job.state === 'paused') return 'warning' as const
|
||||
if (!props.job.enabled) return 'error' as const
|
||||
return 'success' as const
|
||||
})
|
||||
|
||||
const scheduleExpr = computed(() => scheduleToDisplayText(props.job.schedule, props.job.schedule_display || '—'))
|
||||
|
||||
const formatTime = (t?: string | null) => {
|
||||
if (!t) return '—'
|
||||
return new Date(t).toLocaleString()
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await jobsStore.pauseJob(jobId.value)
|
||||
message.success(t('jobs.jobPaused'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResume() {
|
||||
try {
|
||||
await jobsStore.resumeJob(jobId.value)
|
||||
message.success(t('jobs.jobResumed'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
try {
|
||||
await jobsStore.runJob(jobId.value)
|
||||
message.info(t('jobs.jobTriggered'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await jobsStore.deleteJob(jobId.value)
|
||||
message.success(t('jobs.jobDeleted'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.card-actions')) return
|
||||
emit('select', jobId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="job-card" :class="{ selected }" @click="handleCardClick">
|
||||
<div class="card-header">
|
||||
<h3 class="job-name">{{ job.name }}</h3>
|
||||
<span class="status-badge" :class="statusType">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
|
||||
<code class="info-value mono">{{ scheduleExpr }}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('jobs.info.model') }}</span>
|
||||
<span class="info-value mono">{{ job.model || '—' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
|
||||
<span class="info-value">
|
||||
{{ formatTime(job.last_run_at) }}
|
||||
<span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
|
||||
{{ job.last_status === 'ok' ? t('common.ok') : job.last_status }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('jobs.info.nextRun') }}</span>
|
||||
<span class="info-value">{{ formatTime(job.next_run_at) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('jobs.info.deliver') }}</span>
|
||||
<span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
|
||||
</div>
|
||||
<div v-if="job.repeat" class="info-row">
|
||||
<span class="info-label">{{ t('jobs.info.repeat') }}</span>
|
||||
<span class="info-value">
|
||||
<template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
|
||||
<template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '∞' }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<NTooltip v-if="job.state !== 'paused' && job.enabled">
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary @click.stop="handlePause">{{ t('jobs.action.pause') }}</NButton>
|
||||
</template>
|
||||
{{ t('jobs.action.pauseJob') }}
|
||||
</NTooltip>
|
||||
<NTooltip v-else-if="job.state === 'paused'">
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary @click.stop="handleResume">{{ t('jobs.action.resume') }}</NButton>
|
||||
</template>
|
||||
{{ t('jobs.action.resumeJob') }}
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary @click.stop="handleRun">{{ t('jobs.action.runNow') }}</NButton>
|
||||
</template>
|
||||
{{ t('jobs.action.triggerImmediately') }}
|
||||
</NTooltip>
|
||||
<NButton size="tiny" quaternary @click.stop="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click.stop="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.job-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.6);
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: rgba(var(--warning-rgb), 0.12);
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.run-status {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
&.ok { color: $success; }
|
||||
&.err { color: $error; }
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import {
|
||||
buildJobUpdateRequest,
|
||||
getJob,
|
||||
jobRepeatToEditValue,
|
||||
scheduleToEditableInput,
|
||||
} from '@/api/hermes/jobs'
|
||||
import type { CreateJobRequest, Job } from '@/api/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const jobsStore = useJobsStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
schedule: '',
|
||||
prompt: '',
|
||||
deliver: 'origin',
|
||||
repeat_times: null as number | null,
|
||||
})
|
||||
|
||||
const presetValue = ref<string | null>(null)
|
||||
|
||||
const isEdit = computed(() => !!props.jobId)
|
||||
|
||||
const schedulePresets = computed(() => [
|
||||
{ label: t('jobs.presetEveryMinute'), value: '* * * * *' },
|
||||
{ label: t('jobs.presetEvery5Min'), value: '*/5 * * * *' },
|
||||
{ label: t('jobs.presetEveryHour'), value: '0 * * * *' },
|
||||
{ label: t('jobs.presetEveryDay'), value: '0 0 * * *' },
|
||||
{ label: t('jobs.presetEveryDay9'), value: '0 9 * * *' },
|
||||
{ label: t('jobs.presetEveryMonday'), value: '0 9 * * 1' },
|
||||
{ label: t('jobs.presetEveryMonth'), value: '0 9 1 * *' },
|
||||
])
|
||||
|
||||
function hasText(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function isDeliverTargetConfigured(key: string): boolean {
|
||||
const config = settingsStore.platforms[key] || {}
|
||||
switch (key) {
|
||||
case 'telegram':
|
||||
case 'discord':
|
||||
case 'slack':
|
||||
return hasText(config.token)
|
||||
case 'whatsapp':
|
||||
return config.enabled === true || config.enabled === 'true'
|
||||
case 'matrix':
|
||||
return hasText(config.token) && hasText(config.extra?.homeserver)
|
||||
case 'weixin':
|
||||
return hasText(config.token) && hasText(config.extra?.account_id)
|
||||
case 'wecom':
|
||||
return hasText(config.extra?.bot_id) && hasText(config.extra?.secret)
|
||||
case 'feishu':
|
||||
return hasText(config.extra?.app_id) && hasText(config.extra?.app_secret)
|
||||
case 'dingtalk':
|
||||
return (hasText(config.extra?.client_id) && hasText(config.extra?.client_secret))
|
||||
|| (hasText(config.extra?.app_key) && hasText(config.extra?.client_secret))
|
||||
case 'qqbot':
|
||||
return hasText(config.extra?.app_id) && hasText(config.extra?.client_secret)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const targetOptions = computed(() => {
|
||||
const options: Array<{ label: string; value: string; disabled?: boolean }> = [
|
||||
{ label: t('jobs.origin'), value: 'origin' },
|
||||
{ label: t('jobs.local'), value: 'local' },
|
||||
]
|
||||
const channels = [
|
||||
{ key: 'telegram', label: 'Telegram' },
|
||||
{ key: 'discord', label: 'Discord' },
|
||||
{ key: 'slack', label: 'Slack' },
|
||||
{ key: 'whatsapp', label: 'WhatsApp' },
|
||||
{ key: 'matrix', label: 'Matrix' },
|
||||
{ key: 'weixin', label: 'WeChat' },
|
||||
{ key: 'wecom', label: 'WeCom' },
|
||||
{ key: 'feishu', label: 'Feishu' },
|
||||
{ key: 'dingtalk', label: 'DingTalk' },
|
||||
{ key: 'qqbot', label: 'QQBot' },
|
||||
]
|
||||
for (const ch of channels) {
|
||||
options.push({
|
||||
label: ch.label,
|
||||
value: ch.key,
|
||||
disabled: !isDeliverTargetConfigured(ch.key),
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const originalJob = ref<Job | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (Object.keys(settingsStore.platforms || {}).length === 0) {
|
||||
await settingsStore.fetchSettings()
|
||||
}
|
||||
|
||||
if (props.jobId) {
|
||||
try {
|
||||
const job = await getJob(props.jobId)
|
||||
originalJob.value = job
|
||||
formData.value = {
|
||||
name: job.name,
|
||||
schedule: scheduleToEditableInput(job.schedule, job.schedule_display || ''),
|
||||
prompt: job.prompt,
|
||||
deliver: job.deliver || 'origin',
|
||||
repeat_times: jobRepeatToEditValue(job.repeat),
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(t('jobs.loadFailed') + ': ' + e.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.value.name.trim()) {
|
||||
message.warning(t('jobs.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.schedule.trim()) {
|
||||
message.warning(t('jobs.scheduleRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
if (!originalJob.value) {
|
||||
message.error(t('jobs.loadFailed'))
|
||||
return
|
||||
}
|
||||
const payload = buildJobUpdateRequest(originalJob.value, formData.value)
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.success(t('jobs.jobUpdated'))
|
||||
emit('saved')
|
||||
return
|
||||
}
|
||||
await jobsStore.updateJob(props.jobId!, payload)
|
||||
message.success(t('jobs.jobUpdated'))
|
||||
} else {
|
||||
const payload: CreateJobRequest = {
|
||||
name: formData.value.name,
|
||||
schedule: formData.value.schedule,
|
||||
prompt: formData.value.prompt,
|
||||
deliver: formData.value.deliver,
|
||||
repeat: formData.value.repeat_times ?? undefined,
|
||||
}
|
||||
await jobsStore.createJob(payload)
|
||||
message.success(t('jobs.jobCreated'))
|
||||
}
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('jobs.name')" required>
|
||||
<NInput
|
||||
v-model:value="formData.name"
|
||||
:placeholder="t('jobs.namePlaceholder')"
|
||||
maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('jobs.schedule')" required>
|
||||
<NInput
|
||||
v-model:value="formData.schedule"
|
||||
:placeholder="t('jobs.schedulePlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('jobs.quickPresets')">
|
||||
<NSelect
|
||||
v-model:value="presetValue"
|
||||
:options="schedulePresets"
|
||||
:placeholder="t('jobs.selectPreset')"
|
||||
@update:value="v => formData.schedule = v"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('jobs.prompt')" required>
|
||||
<NInput
|
||||
v-model:value="formData.prompt"
|
||||
type="textarea"
|
||||
:placeholder="t('jobs.promptPlaceholder')"
|
||||
:rows="4"
|
||||
maxlength="5000"
|
||||
show-count
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('jobs.deliverTarget')">
|
||||
<NSelect
|
||||
v-model:value="formData.deliver"
|
||||
:options="targetOptions"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('jobs.repeatCount')">
|
||||
<NInputNumber
|
||||
v-model:value="formData.repeat_times"
|
||||
:min="1"
|
||||
:placeholder="t('jobs.repeatPlaceholder')"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleSave">
|
||||
{{ isEdit ? t('common.update') : t('common.create') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { NSpin, NEmpty, NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { listCronRuns, readCronRun } from '@/api/hermes/cron-history'
|
||||
import type { RunEntry, RunDetail } from '@/api/hermes/cron-history'
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedJobId: string | null
|
||||
jobNameMap: Record<string, string>
|
||||
profileKey: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const runs = ref<RunEntry[]>([])
|
||||
const expandedContent = ref<Record<string, string>>({})
|
||||
const loadingContent = ref<Record<string, boolean>>({})
|
||||
|
||||
const filteredRuns = computed(() => {
|
||||
if (!props.selectedJobId) return runs.value
|
||||
return runs.value.filter(r => r.jobId === props.selectedJobId)
|
||||
})
|
||||
|
||||
async function fetchRuns() {
|
||||
loading.value = true
|
||||
try {
|
||||
runs.value = await listCronRuns(props.selectedJobId ?? undefined)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cron runs:', err)
|
||||
runs.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExpand(key: string | number | Array<string | number>) {
|
||||
// accordion mode emits a single value; non-accordion emits an array
|
||||
const keys = Array.isArray(key) ? key : key != null ? [key] : []
|
||||
for (const raw of keys) {
|
||||
const k = String(raw)
|
||||
if (expandedContent.value[k] || loadingContent.value[k]) continue
|
||||
|
||||
const run = filteredRuns.value.find(r => `${r.jobId}/${r.fileName}` === k)
|
||||
if (!run) continue
|
||||
|
||||
loadingContent.value[k] = true
|
||||
try {
|
||||
const detail: RunDetail = await readCronRun(run.jobId, run.fileName)
|
||||
expandedContent.value[k] = detail.content
|
||||
} catch (err) {
|
||||
expandedContent.value[k] = `[Error loading content]`
|
||||
} finally {
|
||||
loadingContent.value[k] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function getJobName(jobId: string): string {
|
||||
return props.jobNameMap[jobId] || jobId
|
||||
}
|
||||
|
||||
watch(() => [props.selectedJobId, props.profileKey], () => {
|
||||
expandedContent.value = {}
|
||||
fetchRuns()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="run-history">
|
||||
<div class="history-header">
|
||||
<span class="history-title">{{ t('jobs.runHistory.title') }}</span>
|
||||
<span class="history-count">{{ filteredRuns.length }} {{ t('jobs.runHistory.runs') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="history-body">
|
||||
<NSpin :show="loading">
|
||||
<NEmpty v-if="!loading && filteredRuns.length === 0" :description="t('jobs.runHistory.noRuns')" />
|
||||
|
||||
<NCollapse
|
||||
v-else
|
||||
accordion
|
||||
@update:expanded-names="handleExpand"
|
||||
>
|
||||
<NCollapseItem
|
||||
v-for="run in filteredRuns"
|
||||
:key="`${run.jobId}/${run.fileName}`"
|
||||
:title="`${getJobName(run.jobId)} — ${run.runTime}`"
|
||||
:name="`${run.jobId}/${run.fileName}`"
|
||||
>
|
||||
<template #header-extra>
|
||||
<span class="run-meta">{{ formatSize(run.size) }}</span>
|
||||
</template>
|
||||
|
||||
<NSpin v-if="loadingContent[`${run.jobId}/${run.fileName}`]" size="small" />
|
||||
<MarkdownRenderer v-else-if="expandedContent[`${run.jobId}/${run.fileName}`]" :content="expandedContent[`${run.jobId}/${run.fileName}`]" />
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.run-history {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-light;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.history-count {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.history-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 20px 20px;
|
||||
}
|
||||
|
||||
.run-meta {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
font-family: $font-code;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import JobCard from './JobCard.vue'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedJobId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [jobId: string]
|
||||
select: [jobId: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const jobsStore = useJobsStore()
|
||||
|
||||
function handleSelect(jobId: string) {
|
||||
emit('select', props.selectedJobId === jobId ? null : jobId)
|
||||
}
|
||||
|
||||
function handleDeselect() {
|
||||
if (props.selectedJobId) {
|
||||
emit('select', null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="jobsStore.jobs.length === 0" class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<p>{{ t('jobs.noJobs') }}</p>
|
||||
</div>
|
||||
<div v-else class="jobs-grid">
|
||||
<JobCard
|
||||
v-for="job in jobsStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
:selected="selectedJobId === (job.job_id || job.id)"
|
||||
@edit="emit('edit', job.id)"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
<!-- Click outside cards to deselect -->
|
||||
<div
|
||||
v-if="selectedJobId"
|
||||
class="deselect-overlay"
|
||||
@click="handleDeselect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
gap: 12px;
|
||||
|
||||
.empty-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.jobs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.deselect-overlay {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NCollapse, NCollapseItem } from 'naive-ui'
|
||||
import KanbanTaskCard from './KanbanTaskCard.vue'
|
||||
import type { KanbanTask, KanbanTaskStatus } from '@/api/hermes/kanban'
|
||||
|
||||
const props = defineProps<{
|
||||
status: KanbanTaskStatus
|
||||
tasks: KanbanTask[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
taskClick: [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const statusLabel = computed(() => t(`kanban.columns.${props.status}`, props.status))
|
||||
const statusCount = computed(() => props.tasks.length)
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'todo': return '○'
|
||||
case 'ready': return '◎'
|
||||
case 'running': return '●'
|
||||
case 'blocked': return '⊘'
|
||||
case 'done': return '✓'
|
||||
default: return '○'
|
||||
}
|
||||
})
|
||||
|
||||
const headerTitle = computed(() => `${statusIcon.value} ${statusLabel.value} (${statusCount.value})`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-column">
|
||||
<NCollapse :default-expanded-names="[status]" display-directive="show">
|
||||
<NCollapseItem :title="headerTitle" :name="status">
|
||||
<KanbanTaskCard
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="emit('taskClick', task.id)"
|
||||
/>
|
||||
<div v-if="tasks.length === 0" class="column-empty">
|
||||
{{ t('kanban.noTasks') }}
|
||||
</div>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.kanban-column {
|
||||
flex: 1 1 calc(20% - 12px);
|
||||
min-width: 200px;
|
||||
background-color: rgba(var(--accent-primary-rgb), 0.02);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid $border-light;
|
||||
|
||||
:deep(.n-collapse) {
|
||||
--n-title-font-size: 13px;
|
||||
--n-title-font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.n-collapse-item__header-main) {
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
:deep(.n-collapse-item__content-wrapper) {
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
:deep(.n-collapse-item) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.column-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60px;
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NSelect, NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
created: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const kanbanStore = useKanbanStore()
|
||||
|
||||
const title = ref('')
|
||||
const body = ref('')
|
||||
const assignee = ref<string | null>(null)
|
||||
const priority = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const priorityOptions = computed(() => [
|
||||
{ label: t('kanban.card.priority.low'), value: 1 },
|
||||
{ label: t('kanban.card.priority.medium'), value: 2 },
|
||||
{ label: t('kanban.card.priority.high'), value: 3 },
|
||||
])
|
||||
|
||||
const assigneeOptions = computed(() => {
|
||||
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
|
||||
.map(a => ({ label: a.name, value: a.name }))
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.value.trim()) {
|
||||
message.warning(t('kanban.form.titleRequired'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await kanbanStore.createTask({
|
||||
title: title.value.trim(),
|
||||
body: body.value.trim() || undefined,
|
||||
assignee: assignee.value || undefined,
|
||||
priority: priority.value ?? undefined,
|
||||
})
|
||||
message.success(t('kanban.message.taskCreated'))
|
||||
emit('created')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="true" preset="dialog" :title="t('kanban.createTask')" style="width: 480px;" @close="emit('close')">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('kanban.form.title')">
|
||||
<NInput v-model:value="title" :placeholder="t('kanban.form.titlePlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('kanban.form.body')">
|
||||
<NInput v-model:value="body" type="textarea" :rows="3" :placeholder="t('kanban.form.bodyPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('kanban.form.assignee')">
|
||||
<NSelect v-model:value="assignee" :options="assigneeOptions" :placeholder="t('kanban.form.selectAssignee')" clearable />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('kanban.form.priority')">
|
||||
<NSelect v-model:value="priority" :options="priorityOptions" :placeholder="t('kanban.form.selectPriority')" clearable />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #action>
|
||||
<NButton @click="emit('close')">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="handleSubmit">{{ t('common.create') }}</NButton>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NTooltip } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue'
|
||||
import type { KanbanTask } from '@/api/hermes/kanban'
|
||||
import type { ProfileAvatar as ProfileAvatarData } from '@/api/hermes/profiles'
|
||||
|
||||
const props = defineProps<{
|
||||
task: KanbanTask
|
||||
assigneeAvatar?: ProfileAvatarData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [taskId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const timeAgo = computed(() => {
|
||||
const diff = Date.now() / 1000 - props.task.created_at
|
||||
if (diff < 60) return t('kanban.card.timeAgo.justNow')
|
||||
if (diff < 3600) return t('kanban.card.timeAgo.minutes', { count: Math.floor(diff / 60) })
|
||||
if (diff < 86400) return t('kanban.card.timeAgo.hours', { count: Math.floor(diff / 3600) })
|
||||
return t('kanban.card.timeAgo.days', { count: Math.floor(diff / 86400) })
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
if (props.task.priority >= 3) return 'high'
|
||||
if (props.task.priority === 2) return 'medium'
|
||||
return 'low'
|
||||
})
|
||||
|
||||
const priorityText = computed(() => {
|
||||
return t(`kanban.card.priority.${priorityLabel.value}`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-task-card" :class="`status-${task.status}`" @click="emit('click', task.id)">
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div class="card-meta">
|
||||
<NTooltip v-if="task.assignee" trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="meta-tag assignee-tag">
|
||||
<ProfileAvatar
|
||||
class="assignee-profile-avatar"
|
||||
:name="task.assignee"
|
||||
:avatar="assigneeAvatar"
|
||||
:size="18"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ task.assignee }}</span>
|
||||
</span>
|
||||
</template>
|
||||
{{ t('kanban.card.assigneeTooltip') }}
|
||||
</NTooltip>
|
||||
<span v-if="task.priority >= 2" class="meta-tag priority-tag" :class="priorityLabel">{{ priorityText }}</span>
|
||||
<span class="meta-time">{{ timeAgo }}</span>
|
||||
</div>
|
||||
<div v-if="task.body" class="card-body-preview">{{ task.body.slice(0, 80) }}{{ task.body.length > 80 ? '...' : '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.kanban-task-card {
|
||||
--kanban-card-status-color: #64748b;
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-left: 3px solid var(--kanban-card-status-color);
|
||||
border-radius: $radius-md;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&.status-triage { --kanban-card-status-color: #94a3b8; }
|
||||
&.status-todo { --kanban-card-status-color: #38bdf8; }
|
||||
&.status-ready { --kanban-card-status-color: #f59e0b; }
|
||||
&.status-running { --kanban-card-status-color: #2563eb; }
|
||||
&.status-blocked { --kanban-card-status-color: #ef4444; }
|
||||
&.status-done { --kanban-card-status-color: #22c55e; }
|
||||
&.status-archived { --kanban-card-status-color: #64748b; }
|
||||
|
||||
&:hover {
|
||||
border-color: var(--kanban-card-status-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignee-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
color: $accent-primary;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.assignee-profile-avatar {
|
||||
box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.28);
|
||||
}
|
||||
|
||||
.priority-tag {
|
||||
&.high {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.medium {
|
||||
background: rgba(var(--warning-rgb), 0.12);
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.low {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-body-preview {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,704 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NDrawer, NDrawerContent, NButton, NSelect, NInput, NSpin, NModal, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { request } from '@/api/client'
|
||||
import { getTask } from '@/api/hermes/kanban'
|
||||
import { useKanbanStore } from '@/stores/hermes/kanban'
|
||||
import { withDefaultAssignee } from '@/utils/hermes/kanban-assignees'
|
||||
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
|
||||
import type { Session, Message } from '@/stores/hermes/chat'
|
||||
import type { KanbanTaskDetail } from '@/api/hermes/kanban'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const kanbanStore = useKanbanStore()
|
||||
|
||||
const detail = ref<KanbanTaskDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const assignProfile = ref<string | null>(null)
|
||||
const blockReason = ref('')
|
||||
const showBlockInput = ref(false)
|
||||
const completeSummary = ref('')
|
||||
const showCompleteInput = ref(false)
|
||||
const showMessagesModal = ref(false)
|
||||
|
||||
const completionSummary = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
return detail.value.task.result || detail.value.latest_summary || ''
|
||||
})
|
||||
|
||||
const localizedTaskStatus = computed(() => {
|
||||
if (!detail.value) return ''
|
||||
return t(`kanban.columns.${detail.value.task.status}`, detail.value.task.status)
|
||||
})
|
||||
|
||||
const canMutateTask = computed(() => {
|
||||
const status = detail.value?.task.status
|
||||
return status !== 'done' && status !== 'archived'
|
||||
})
|
||||
|
||||
const sessionResults = ref<any[]>([])
|
||||
const sessionLoading = ref(false)
|
||||
const showSessions = ref(false)
|
||||
|
||||
const latestRunProfile = computed(() => {
|
||||
if (!detail.value) return null
|
||||
return [...detail.value.runs].reverse().find(run => run.profile)?.profile || null
|
||||
})
|
||||
|
||||
async function searchTaskSessions() {
|
||||
if (!detail.value) return
|
||||
const profile = latestRunProfile.value
|
||||
if (!profile) return
|
||||
showSessions.value = !showSessions.value
|
||||
if (!showSessions.value) return
|
||||
sessionLoading.value = true
|
||||
try {
|
||||
const res = await request<{ results: any[] }>(
|
||||
`/api/hermes/kanban/search-sessions?task_id=${encodeURIComponent(detail.value.task.id)}&profile=${encodeURIComponent(profile)}&board=${encodeURIComponent(kanbanStore.selectedBoard)}`
|
||||
)
|
||||
sessionResults.value = res.results
|
||||
} catch {
|
||||
sessionResults.value = []
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openResultDetail() {
|
||||
if (detail.value?.session) {
|
||||
showMessagesModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const historySession = computed<Session | null>(() => {
|
||||
const s = detail.value?.session
|
||||
if (!s) return null
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title || '',
|
||||
source: s.source,
|
||||
messages: s.messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({
|
||||
id: String(m.id),
|
||||
role: m.role as Message['role'],
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})),
|
||||
createdAt: s.started_at,
|
||||
updatedAt: s.ended_at || s.started_at,
|
||||
model: s.model,
|
||||
messageCount: s.messages.length,
|
||||
endedAt: s.ended_at,
|
||||
}
|
||||
})
|
||||
|
||||
const assigneeOptions = computed(() => {
|
||||
return withDefaultAssignee(kanbanStore.assignees, kanbanStore.stats?.by_assignee || {})
|
||||
.map(a => ({ label: a.name, value: a.name }))
|
||||
})
|
||||
|
||||
watch(() => [props.taskId, kanbanStore.selectedBoard] as const, async ([id, board]) => {
|
||||
if (!id) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const nextDetail = await getTask(id, { board })
|
||||
if (props.taskId === id && kanbanStore.selectedBoard === board) {
|
||||
detail.value = nextDetail
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (props.taskId === id && kanbanStore.selectedBoard === board) {
|
||||
message.error(t('kanban.message.loadFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (props.taskId === id && kanbanStore.selectedBoard === board) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatTime(ts: number | null) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (!props.taskId) return
|
||||
if (!showCompleteInput.value) {
|
||||
showCompleteInput.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
await kanbanStore.completeTasks([props.taskId], completeSummary.value.trim() || undefined)
|
||||
message.success(t('kanban.message.taskCompleted'))
|
||||
showCompleteInput.value = false
|
||||
completeSummary.value = ''
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlock() {
|
||||
if (!props.taskId || !blockReason.value.trim()) return
|
||||
try {
|
||||
await kanbanStore.blockTask(props.taskId, blockReason.value.trim())
|
||||
message.success(t('kanban.message.taskBlocked'))
|
||||
showBlockInput.value = false
|
||||
blockReason.value = ''
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnblock() {
|
||||
if (!props.taskId) return
|
||||
try {
|
||||
await kanbanStore.unblockTasks([props.taskId])
|
||||
message.success(t('kanban.message.taskUnblocked'))
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssign() {
|
||||
if (!props.taskId || !assignProfile.value) return
|
||||
try {
|
||||
await kanbanStore.assignTask(props.taskId, assignProfile.value)
|
||||
message.success(t('kanban.message.taskAssigned'))
|
||||
assignProfile.value = null
|
||||
if (detail.value) {
|
||||
detail.value = await getTask(props.taskId, { board: kanbanStore.selectedBoard })
|
||||
}
|
||||
emit('updated')
|
||||
} catch (err: any) {
|
||||
message.error(err.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer :show="!!taskId" :width="420" placement="right" @update:show="(v: boolean) => { if (!v) emit('close') }">
|
||||
<NDrawerContent :title="detail?.task.title || ''" closable>
|
||||
<NSpin :show="loading">
|
||||
<template v-if="detail">
|
||||
<!-- Metadata -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.status') }}</span>
|
||||
<span class="detail-value status-badge" :class="detail.task.status">{{ localizedTaskStatus }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.assignee') }}</span>
|
||||
<span class="detail-value">{{ detail.task.assignee || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.priority') }}</span>
|
||||
<span class="detail-value">{{ detail.task.priority }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.tenant') }}</span>
|
||||
<span class="detail-value">{{ detail.task.tenant || '—' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.createdAt') }}</span>
|
||||
<span class="detail-value">{{ formatTime(detail.task.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="detail.task.started_at" class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.startedAt') }}</span>
|
||||
<span class="detail-value">{{ formatTime(detail.task.started_at) }}</span>
|
||||
</div>
|
||||
<div v-if="detail.task.completed_at" class="detail-row">
|
||||
<span class="detail-label">{{ t('kanban.detail.completedAt') }}</span>
|
||||
<span class="detail-value">{{ formatTime(detail.task.completed_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-if="detail.task.body" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.form.body') }}</div>
|
||||
<div class="detail-body">{{ detail.task.body }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Result / Summary -->
|
||||
<div v-if="completionSummary" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.result') }}</div>
|
||||
<div class="result-summary" @click="openResultDetail">{{ completionSummary }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions (only for active, mutable tasks) -->
|
||||
<div v-if="canMutateTask" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.action.title') }}</div>
|
||||
<div class="action-group">
|
||||
<template v-if="!showCompleteInput">
|
||||
<NButton size="small" @click="showCompleteInput = true">
|
||||
{{ t('kanban.action.complete') }}
|
||||
</NButton>
|
||||
</template>
|
||||
<div v-else class="complete-input">
|
||||
<NInput v-model:value="completeSummary" size="small" :placeholder="t('kanban.action.completeSummary')" />
|
||||
<NButton size="small" type="primary" @click="handleComplete">{{ t('common.ok') }}</NButton>
|
||||
<NButton size="small" @click="showCompleteInput = false; completeSummary = ''">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
<template v-if="detail.task.status === 'blocked'">
|
||||
<NButton size="small" @click="handleUnblock">{{ t('kanban.action.unblock') }}</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NButton v-if="!showBlockInput" size="small" @click="showBlockInput = true">{{ t('kanban.action.block') }}</NButton>
|
||||
<div v-else class="block-input">
|
||||
<NInput v-model:value="blockReason" size="small" :placeholder="t('kanban.action.blockReason')" />
|
||||
<NButton size="small" type="primary" @click="handleBlock">{{ t('common.ok') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="detail.task.status !== 'running'" class="assign-group">
|
||||
<NSelect v-model:value="assignProfile" :options="assigneeOptions" size="small" :placeholder="t('kanban.action.assignTo')" style="flex: 1;" />
|
||||
<NButton size="small" :disabled="!assignProfile" @click="handleAssign">{{ t('kanban.action.assign') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Sessions -->
|
||||
<div v-if="detail.runs.length > 0" class="detail-section">
|
||||
<div class="section-title" style="cursor: pointer;" @click="searchTaskSessions">
|
||||
{{ t('kanban.detail.sessions') }}
|
||||
<NSpin v-if="sessionLoading" :size="12" style="margin-left: 6px;" />
|
||||
</div>
|
||||
<div v-if="showSessions && sessionResults.length > 0" class="session-list">
|
||||
<div v-for="session in sessionResults" :key="session.id" class="session-item" @click="router.push({ name: 'hermes.chat', query: { session: session.id } })">
|
||||
<div class="session-title">{{ session.title || session.id }}</div>
|
||||
<div class="session-meta">
|
||||
<span>{{ session.source }}</span>
|
||||
<span>{{ session.model }}</span>
|
||||
<span>{{ formatTime(session.started_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions && !sessionLoading && sessionResults.length === 0" class="column-empty">{{ t('kanban.detail.noSessions') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Runs -->
|
||||
<div v-if="detail.runs.length > 0" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.runs') }}</div>
|
||||
<div v-for="run in detail.runs" :key="run.id" class="run-item">
|
||||
<div class="run-header">
|
||||
<span class="run-status" :class="run.status">{{ run.status }}</span>
|
||||
<span class="run-profile">{{ run.profile || '—' }}</span>
|
||||
<span class="run-time">{{ formatTime(run.started_at) }}</span>
|
||||
</div>
|
||||
<div v-if="run.summary" class="run-summary">{{ run.summary }}</div>
|
||||
<div v-if="run.error" class="run-error">{{ run.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div v-if="detail.comments.length > 0" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.comments') }}</div>
|
||||
<div v-for="comment in detail.comments" :key="comment.id" class="comment-item">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{ comment.author }}</span>
|
||||
<span class="comment-time">{{ formatTime(comment.created_at) }}</span>
|
||||
</div>
|
||||
<div class="comment-body">{{ comment.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events -->
|
||||
<div v-if="detail.events.length > 0" class="detail-section">
|
||||
<div class="section-title">{{ t('kanban.detail.events') }}</div>
|
||||
<div v-for="event in detail.events.slice(-10)" :key="event.id" class="event-item">
|
||||
<span class="event-kind">{{ event.kind }}</span>
|
||||
<span class="event-time">{{ formatTime(event.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
|
||||
<!-- Session messages modal (click result summary) -->
|
||||
<NModal v-if="historySession" :show="showMessagesModal" preset="card" :title="detail?.task.title || ''" :style="{ width: '900px', maxWidth: 'calc(100vw - 48px)' }" @close="showMessagesModal = false">
|
||||
<div class="messages-modal-body">
|
||||
<HistoryMessageList :session="historySession" />
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
padding: 8px 10px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
border: 1px solid $border-light;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.02);
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin: 8px 0 4px;
|
||||
|
||||
&:first-child { margin-top: 0; }
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
padding: 2px 0;
|
||||
|
||||
code {
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-kv-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meta-kv-key {
|
||||
color: $text-muted;
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-kv-val {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.artifact-link {
|
||||
cursor: pointer;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover { color: $accent-primary; }
|
||||
}
|
||||
|
||||
.artifact-modal-body,
|
||||
.messages-modal-body {
|
||||
max-height: 65vh;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
|
||||
:deep(.message-list) {
|
||||
max-height: 65vh;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid $border-light;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.triage {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
&.todo {
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
&.ready {
|
||||
background: rgba(var(--warning-rgb), 0.12);
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.blocked {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.done {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.archived {
|
||||
background: rgba(100, 116, 139, 0.14);
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.block-input,
|
||||
.complete-input {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.assign-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-item,
|
||||
.comment-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid $border-light;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.run-header,
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.run-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.running {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.done, &.completed {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.crashed, &.failed {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.run-profile,
|
||||
.comment-author {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.run-time,
|
||||
.comment-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.run-summary,
|
||||
.run-error,
|
||||
.comment-body {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.run-error {
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.event-kind {
|
||||
font-size: 11px;
|
||||
font-family: $font-code;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-light;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover { border-color: rgba(var(--accent-primary-rgb), 0.3); }
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.session-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-msg {
|
||||
padding: 10px 12px;
|
||||
border-radius: $radius-sm;
|
||||
border: 1px solid $border-light;
|
||||
|
||||
&.user {
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.session-msg-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.session-msg-content {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.5;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 8px;
|
||||
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NSwitch, NPopconfirm } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { McpServerInfo } from '@/api/hermes/mcp'
|
||||
|
||||
const props = defineProps<{
|
||||
server: McpServerInfo
|
||||
toolsByServer: Record<string, Array<{ name: string; description?: string }>>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [server: McpServerInfo]
|
||||
test: [server: McpServerInfo]
|
||||
reload: [name: string]
|
||||
remove: [server: McpServerInfo]
|
||||
toggleEnabled: [server: McpServerInfo]
|
||||
manageTools: [server: McpServerInfo]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function statusClass(server: McpServerInfo) {
|
||||
if (server.raw_config.enabled === false) return 'disabled'
|
||||
return server.connected ? 'connected' : 'disconnected'
|
||||
}
|
||||
|
||||
function statusLabel(server: McpServerInfo) {
|
||||
if (server.raw_config.enabled === false) return t('mcp.disabledStatus')
|
||||
return server.connected ? t('mcp.connectedStatus') : t('mcp.disconnectedStatus')
|
||||
}
|
||||
|
||||
const tools = computed(() => props.toolsByServer[props.server.name] || [])
|
||||
const MAX_VISIBLE_TOOLS = 20
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-card" :class="{ disconnected: !server.connected, disabled: server.raw_config.enabled === false }">
|
||||
<!-- 第一行:标题 + 标签 -->
|
||||
<div class="card-header">
|
||||
<h3 class="server-name">{{ server.name }}</h3>
|
||||
<div class="server-badges">
|
||||
<span class="type-badge transport">{{ server.transport }}</span>
|
||||
<span class="type-badge" :class="statusClass(server)">{{ statusLabel(server) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:工具列表 + 数量 -->
|
||||
<div class="card-body">
|
||||
<div v-if="server.error" class="error-row">
|
||||
<span class="error-text">{{ server.error }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('mcp.toolList') }}</span>
|
||||
<span class="info-value">
|
||||
{{ server.tools_registered }}/{{ server.tools }}{{ t('mcp.count') }}{{ t('mcp.tools') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 工具标签列表 -->
|
||||
<div v-if="server.tools > 0" class="tools-list">
|
||||
<span
|
||||
v-for="tool in tools.slice(0, MAX_VISIBLE_TOOLS)"
|
||||
:key="tool.name"
|
||||
class="tool-tag"
|
||||
:title="tool.description"
|
||||
>
|
||||
{{ tool.name }}
|
||||
</span>
|
||||
<span v-if="tools.length > MAX_VISIBLE_TOOLS" class="tool-tag tool-tag-more">
|
||||
+{{ tools.length - MAX_VISIBLE_TOOLS }} {{ t('mcp.more') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="tools-empty">
|
||||
<span class="muted">{{ t('mcp.zeroTools') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:按钮 + 开关 -->
|
||||
<div class="card-footer">
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary @click="emit('edit', server)">{{ t('mcp.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!server.connected" @click="emit('manageTools', server)">{{ t('mcp.manageTools') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="emit('test', server)">{{ t('mcp.test') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="emit('reload', server.name)">{{ t('mcp.reload') }}</NButton>
|
||||
<NPopconfirm @positive-click="emit('remove', server)">
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary type="error">{{ t('mcp.remove') }}</NButton>
|
||||
</template>
|
||||
{{ t('mcp.confirmRemove', { name: server.name }) }}
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<NSwitch
|
||||
:value="server.raw_config.enabled !== false"
|
||||
size="small"
|
||||
@update:value="() => emit('toggleEnabled', server)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.mcp-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
border-color: rgba(var(--error-rgb), 0.3);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.server-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.transport {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.connected {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: rgba(var(--text-muted-rgb, 128,128,128), 0.12);
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: $error;
|
||||
font-size: 11px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
height: 88px;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.tool-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
font-size: 10px;
|
||||
font-family: $font-code;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.16);
|
||||
}
|
||||
|
||||
&-more {
|
||||
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tools-empty {
|
||||
height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startCodexLogin, pollCodexLogin } from '@/api/hermes/codex-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
|
||||
const userCode = ref('')
|
||||
const verificationUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const errorMessage = ref('')
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function startLogin() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const data = await startCodexLogin()
|
||||
userCode.value = data.user_code
|
||||
verificationUrl.value = data.verification_url
|
||||
sessionId.value = data.session_id
|
||||
status.value = 'waiting'
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
status.value = 'error'
|
||||
const msg = err.message || ''
|
||||
// Try to extract friendly error from response
|
||||
try {
|
||||
const match = msg.match(/\{[\s\S]*\}$/)
|
||||
if (match) {
|
||||
const body = JSON.parse(match[0])
|
||||
errorMessage.value = body.error || msg
|
||||
} else {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
} catch {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
message.error(errorMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await pollCodexLogin(sessionId.value)
|
||||
if (result.status === 'pending') {
|
||||
startPolling()
|
||||
} else if (result.status === 'approved') {
|
||||
status.value = 'approved'
|
||||
message.success(t('models.codexApproved'))
|
||||
setTimeout(() => {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('success'), 200)
|
||||
}, 1000)
|
||||
} else if (result.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
} else if (result.status === 'error') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = result.error || 'Unknown error'
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling()
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await copyToClipboard(userCode.value)
|
||||
if (ok) message.success(t('models.codexCopyCode'))
|
||||
else message.error(t('models.codexCopyCode') + ' ✗')
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(verificationUrl.value, '_blank')
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'idle'
|
||||
userCode.value = ''
|
||||
verificationUrl.value = ''
|
||||
sessionId.value = ''
|
||||
errorMessage.value = ''
|
||||
startLogin()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// Auto-start when modal opens
|
||||
startLogin()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.codexLoginTitle')"
|
||||
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||
:mask-closable="status !== 'waiting'"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<div class="codex-login">
|
||||
<!-- Idle / Loading -->
|
||||
<div v-if="status === 'idle' || status === 'loading'" class="codex-login__state">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<!-- Waiting for authorization -->
|
||||
<div v-else-if="status === 'waiting'" class="codex-login__state">
|
||||
<p class="codex-login__hint">{{ t('models.codexWaiting') }}</p>
|
||||
<div class="codex-login__code" @click="copyCode">
|
||||
<span class="codex-login__code-text">{{ userCode }}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</div>
|
||||
<NButton type="primary" block @click="openLink">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</template>
|
||||
{{ t('models.codexOpenLink') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Approved -->
|
||||
<div v-else-if="status === 'approved'" class="codex-login__state codex-login__state--success">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<p>{{ t('models.codexApproved') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Expired -->
|
||||
<div v-else-if="status === 'expired'" class="codex-login__state">
|
||||
<p class="codex-login__error">{{ t('models.codexExpired') }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="status === 'error'" class="codex-login__state">
|
||||
<p class="codex-login__error">{{ errorMessage }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.codex-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.codex-login__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codex-login__hint {
|
||||
font-size: 14px;
|
||||
color: var(--n-text-color, inherit);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.codex-login__code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
background: var(--n-color, #fafafa);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--n-primary-color, #18a058);
|
||||
}
|
||||
}
|
||||
|
||||
.codex-login__code-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
letter-spacing: 4px;
|
||||
color: var(--n-text-color, inherit);
|
||||
}
|
||||
|
||||
.codex-login__state--success {
|
||||
color: #18a058;
|
||||
|
||||
svg {
|
||||
stroke: #18a058;
|
||||
}
|
||||
}
|
||||
|
||||
.codex-login__error {
|
||||
color: #d03050;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startCopilotLogin, pollCopilotLogin } from '@/api/hermes/copilot-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
|
||||
const userCode = ref('')
|
||||
const verificationUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const errorMessage = ref('')
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function startLogin() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const data = await startCopilotLogin()
|
||||
userCode.value = data.user_code
|
||||
verificationUrl.value = data.verification_url
|
||||
sessionId.value = data.session_id
|
||||
status.value = 'waiting'
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
status.value = 'error'
|
||||
const msg = err?.message || ''
|
||||
try {
|
||||
const match = msg.match(/\{[\s\S]*\}$/)
|
||||
if (match) {
|
||||
const body = JSON.parse(match[0])
|
||||
errorMessage.value = body.error || msg
|
||||
} else {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
} catch {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
message.error(errorMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await pollCopilotLogin(sessionId.value)
|
||||
if (result.status === 'pending') {
|
||||
startPolling()
|
||||
} else if (result.status === 'approved') {
|
||||
status.value = 'approved'
|
||||
message.success(t('models.copilotApproved'))
|
||||
setTimeout(() => {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('success'), 200)
|
||||
}, 1000)
|
||||
} else if (result.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
} else if (result.status === 'denied') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = t('models.copilotDenied')
|
||||
} else if (result.status === 'error') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = result.error || 'Unknown error'
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling()
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await copyToClipboard(userCode.value)
|
||||
if (ok) message.success(t('models.copilotCopyCode'))
|
||||
else message.error(t('models.copilotCopyCode') + ' ✗')
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(verificationUrl.value, '_blank')
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'idle'
|
||||
userCode.value = ''
|
||||
verificationUrl.value = ''
|
||||
sessionId.value = ''
|
||||
errorMessage.value = ''
|
||||
startLogin()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// Auto-start when modal opens
|
||||
startLogin()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.copilotLoginTitle')"
|
||||
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||
:mask-closable="status !== 'waiting'"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<div class="copilot-login">
|
||||
<!-- Idle / Loading -->
|
||||
<div v-if="status === 'idle' || status === 'loading'" class="copilot-login__state">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<!-- Waiting for authorization -->
|
||||
<div v-else-if="status === 'waiting'" class="copilot-login__state">
|
||||
<p class="copilot-login__hint">{{ t('models.copilotWaiting') }}</p>
|
||||
<div class="copilot-login__code" @click="copyCode">
|
||||
<span class="copilot-login__code-text">{{ userCode }}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</div>
|
||||
<NButton type="primary" block @click="openLink">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</template>
|
||||
{{ t('models.copilotOpenLink') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Approved -->
|
||||
<div v-else-if="status === 'approved'" class="copilot-login__state copilot-login__state--success">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<p>{{ t('models.copilotApproved') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Expired -->
|
||||
<div v-else-if="status === 'expired'" class="copilot-login__state">
|
||||
<p class="copilot-login__error">{{ t('models.copilotExpired') }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="status === 'error'" class="copilot-login__state">
|
||||
<p class="copilot-login__error">{{ errorMessage }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.copilot-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.copilot-login__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copilot-login__hint {
|
||||
font-size: 14px;
|
||||
color: var(--n-text-color, inherit);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.copilot-login__code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
background: var(--n-color, #fafafa);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--n-primary-color, #18a058);
|
||||
}
|
||||
}
|
||||
|
||||
.copilot-login__code-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
letter-spacing: 4px;
|
||||
color: var(--n-text-color, inherit);
|
||||
}
|
||||
|
||||
.copilot-login__state--success {
|
||||
color: #18a058;
|
||||
|
||||
svg {
|
||||
stroke: #18a058;
|
||||
}
|
||||
}
|
||||
|
||||
.copilot-login__error {
|
||||
color: #d03050;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startNousLogin, pollNousLogin } from '@/api/hermes/nous-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
|
||||
const userCode = ref('')
|
||||
const verificationUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const errorMessage = ref('')
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function startLogin() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const data = await startNousLogin()
|
||||
userCode.value = data.user_code
|
||||
verificationUrl.value = data.verification_url
|
||||
sessionId.value = data.session_id
|
||||
status.value = 'waiting'
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
status.value = 'error'
|
||||
const msg = err.message || ''
|
||||
try {
|
||||
const match = msg.match(/\{[\s\S]*\}$/)
|
||||
if (match) {
|
||||
const body = JSON.parse(match[0])
|
||||
errorMessage.value = body.error || msg
|
||||
} else {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
} catch {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
message.error(errorMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await pollNousLogin(sessionId.value)
|
||||
if (result.status === 'pending') {
|
||||
startPolling()
|
||||
} else if (result.status === 'approved') {
|
||||
status.value = 'approved'
|
||||
message.success(t('models.nousApproved'))
|
||||
setTimeout(() => {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('success'), 200)
|
||||
}, 1000)
|
||||
} else if (result.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
} else if (result.status === 'denied') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = t('models.nousDenied')
|
||||
} else if (result.status === 'error') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = result.error || 'Unknown error'
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling()
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
const ok = await copyToClipboard(userCode.value)
|
||||
if (ok) message.success(t('models.nousCopyCode'))
|
||||
else message.error(t('models.nousCopyCode') + ' ✗')
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(verificationUrl.value, '_blank')
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'idle'
|
||||
userCode.value = ''
|
||||
verificationUrl.value = ''
|
||||
sessionId.value = ''
|
||||
errorMessage.value = ''
|
||||
startLogin()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// Auto-start when modal opens
|
||||
startLogin()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.nousLoginTitle')"
|
||||
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||
:mask-closable="status !== 'waiting'"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<div class="nous-login">
|
||||
<!-- Idle / Loading -->
|
||||
<div v-if="status === 'idle' || status === 'loading'" class="nous-login__state">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<!-- Waiting for authorization -->
|
||||
<div v-else-if="status === 'waiting'" class="nous-login__state">
|
||||
<p class="nous-login__hint">{{ t('models.nousWaiting') }}</p>
|
||||
<div class="nous-login__code" @click="copyCode">
|
||||
<span class="nous-login__code-text">{{ userCode }}</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
</div>
|
||||
<NButton type="primary" block @click="openLink">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</template>
|
||||
{{ t('models.nousOpenLink') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Approved -->
|
||||
<div v-else-if="status === 'approved'" class="nous-login__state nous-login__state--success">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<p>{{ t('models.nousApproved') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Expired -->
|
||||
<div v-else-if="status === 'expired'" class="nous-login__state">
|
||||
<p class="nous-login__error">{{ t('models.nousExpired') }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="status === 'error'" class="nous-login__state">
|
||||
<p class="nous-login__error">{{ errorMessage }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nous-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nous-login__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nous-login__hint {
|
||||
font-size: 14px;
|
||||
color: var(--n-text-color, inherit);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nous-login__code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
background: var(--n-color, #fafafa);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--n-primary-color, #18a058);
|
||||
}
|
||||
}
|
||||
|
||||
.nous-login__code-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
letter-spacing: 4px;
|
||||
color: var(--n-text-color, inherit);
|
||||
}
|
||||
|
||||
.nous-login__state--success {
|
||||
color: #18a058;
|
||||
|
||||
svg {
|
||||
stroke: #18a058;
|
||||
}
|
||||
}
|
||||
|
||||
.nous-login__error {
|
||||
color: #d03050;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,603 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NButton, NCheckbox, NCheckboxGroup, NModal, NInput, useMessage, useDialog } from 'naive-ui'
|
||||
import type { AvailableModelGroup } from '@/api/hermes/system'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import { checkCopilotToken, disableCopilot } from '@/api/hermes/copilot-auth'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ provider: AvailableModelGroup }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const isCustom = computed(() => !props.provider.builtin && props.provider.provider.startsWith('custom:'))
|
||||
const isCopilot = computed(() => props.provider.provider === 'copilot')
|
||||
const displayName = computed(() => props.provider.label)
|
||||
const deleting = ref(false)
|
||||
|
||||
const showAliasListModal = ref(false)
|
||||
const showAliasModal = ref(false)
|
||||
const aliasProvider = ref('')
|
||||
const aliasModel = ref('')
|
||||
const aliasInput = ref('')
|
||||
|
||||
const showVisibilityModal = ref(false)
|
||||
const visibilitySaving = ref(false)
|
||||
const selectedVisibleModels = ref<string[]>([])
|
||||
|
||||
const sourceProvider = computed(() => modelsStore.allProviders.find(g => g.provider === props.provider.provider))
|
||||
const allModels = computed(() => props.provider.available_models?.length ? props.provider.available_models : (sourceProvider.value?.models?.length ? sourceProvider.value.models : props.provider.models))
|
||||
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
|
||||
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
|
||||
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
|
||||
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
|
||||
|
||||
function isDefaultModel(model: string) {
|
||||
return isDefaultProvider.value && modelsStore.defaultModel === model
|
||||
}
|
||||
|
||||
function modelAlias(model: string) {
|
||||
return appStore.getModelAlias(model, props.provider.provider)
|
||||
}
|
||||
|
||||
function modelDisplayName(model: string) {
|
||||
return appStore.displayModelName(model, props.provider.provider)
|
||||
}
|
||||
|
||||
function openAliasEditor(model: string) {
|
||||
aliasProvider.value = props.provider.provider
|
||||
aliasModel.value = model
|
||||
aliasInput.value = appStore.getModelAlias(model, props.provider.provider)
|
||||
showAliasModal.value = true
|
||||
}
|
||||
|
||||
async function saveAlias() {
|
||||
if (!aliasModel.value || !aliasProvider.value) return
|
||||
try {
|
||||
await appStore.setModelAlias(aliasModel.value, aliasProvider.value, aliasInput.value)
|
||||
showAliasModal.value = false
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('models.aliasSaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAlias() {
|
||||
aliasInput.value = ''
|
||||
await saveAlias()
|
||||
}
|
||||
|
||||
function openVisibilityModal() {
|
||||
const rule = appStore.getProviderVisibility(props.provider.provider)
|
||||
selectedVisibleModels.value = rule.mode === 'include' ? allModels.value.filter(m => rule.models.includes(m)) : [...allModels.value]
|
||||
showVisibilityModal.value = true
|
||||
}
|
||||
|
||||
async function handleVisibilitySave() {
|
||||
if (selectedVisibleModels.value.length === 0) {
|
||||
message.error(t('models.visibilitySelectOne'))
|
||||
return
|
||||
}
|
||||
visibilitySaving.value = true
|
||||
try {
|
||||
const selected = selectedVisibleModels.value.filter(m => allModels.value.includes(m))
|
||||
const mode = selected.length === allModels.value.length ? 'all' : 'include'
|
||||
await appStore.setModelVisibility(props.provider.provider, { mode, models: selected })
|
||||
await modelsStore.fetchProviders()
|
||||
showVisibilityModal.value = false
|
||||
message.success(t('models.visibilitySaved'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('models.visibilitySaveFailed'))
|
||||
} finally {
|
||||
visibilitySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetVisibility() {
|
||||
selectedVisibleModels.value = [...allModels.value]
|
||||
}
|
||||
|
||||
function clearVisibility() {
|
||||
selectedVisibleModels.value = []
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
let copilotMsg = ''
|
||||
if (isCopilot.value) {
|
||||
// 提前查 source,让用户清楚移除会不会影响 VS Code/gh CLI 等其他工具的登录态
|
||||
try {
|
||||
const status = await checkCopilotToken()
|
||||
if (status.source === 'env') copilotMsg = t('models.copilotDeleteHintEnv')
|
||||
else if (status.source === 'gh-cli') copilotMsg = t('models.copilotDeleteHintGhCli')
|
||||
else if (status.source === 'apps-json') copilotMsg = t('models.copilotDeleteHintAppsJson')
|
||||
} catch { /* ignore — fall back to generic confirm copy */ }
|
||||
}
|
||||
dialog.warning({
|
||||
title: t('models.deleteProvider'),
|
||||
content: isCopilot.value && copilotMsg
|
||||
? `${t('models.deleteConfirm', { name: displayName.value })}\n\n${copilotMsg}`
|
||||
: t('models.deleteConfirm', { name: displayName.value }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
deleting.value = true
|
||||
try {
|
||||
if (isCopilot.value) {
|
||||
// Copilot 走显式 opt-in 模型:disable 把 enabled 置 false,
|
||||
// 仅当 token 来自 ~/.hermes/.env 时才清掉,gh-cli / apps.json 不动。
|
||||
await disableCopilot()
|
||||
// 服务端会在默认模型属于 copilot 时清掉 model.default,这里再清理本地
|
||||
// 会话级 model/provider,避免 Chat 页继续显示已下架的 copilot 模型。
|
||||
chatStore.clearProviderFromSessions('copilot')
|
||||
await modelsStore.fetchProviders()
|
||||
} else {
|
||||
await modelsStore.removeProvider(props.provider.provider)
|
||||
}
|
||||
// 删完之后若已没有默认模型,自动从剩余 provider 里挑一个,避免 chat 页
|
||||
// "无默认模型"的尴尬态。与 hermes CLI `model` 子命令的隐含行为对齐。
|
||||
if (!appStore.selectedModel && appStore.modelGroups.length > 0) {
|
||||
const first = appStore.modelGroups.find(g => g.models.length > 0)
|
||||
if (first) {
|
||||
await appStore.switchModel(first.models[0], first.provider)
|
||||
}
|
||||
}
|
||||
message.success(t('models.providerDeleted'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="provider-card">
|
||||
<div class="card-header">
|
||||
<h3 class="provider-name">{{ displayName }}</h3>
|
||||
<div class="provider-badges">
|
||||
<span v-if="isDefaultProvider" class="type-badge default">{{ t('models.currentDefault') }}</span>
|
||||
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
|
||||
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('models.provider') }}</span>
|
||||
<code class="info-value mono">{{ provider.provider }}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('models.baseUrl') }}</span>
|
||||
<code class="info-value mono">{{ provider.base_url }}</code>
|
||||
</div>
|
||||
<div class="info-row models-row">
|
||||
<span class="info-label">{{ t('models.models') }}</span>
|
||||
<span class="info-value models-count">
|
||||
{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="models-list">
|
||||
<button
|
||||
v-for="model in provider.models.slice(0, 20)"
|
||||
:key="model"
|
||||
class="model-tag model-tag-button"
|
||||
:class="{ default: isDefaultModel(model) }"
|
||||
type="button"
|
||||
:title="t('models.aliasTitleFor', { model })"
|
||||
@click="openAliasEditor(model)"
|
||||
>
|
||||
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
|
||||
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
|
||||
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
|
||||
</button>
|
||||
<span v-if="provider.models.length > 20" class="model-tag model-tag-more">
|
||||
+{{ provider.models.length - 20 }} {{ t('models.more') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary @click="showAliasListModal = true">{{ t('models.aliasManage') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
|
||||
<NModal
|
||||
v-model:show="showAliasListModal"
|
||||
preset="card"
|
||||
:title="t('models.aliasManageFor', { provider: displayName })"
|
||||
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
|
||||
:mask-closable="true"
|
||||
>
|
||||
<div class="alias-list-hint">{{ t('models.aliasHint') }}</div>
|
||||
<div class="alias-list">
|
||||
<div v-for="model in provider.models" :key="model" class="alias-row">
|
||||
<div class="alias-row-text">
|
||||
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
|
||||
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
|
||||
<code class="alias-row-id">{{ model }}</code>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
|
||||
<NModal
|
||||
v-model:show="showAliasModal"
|
||||
preset="card"
|
||||
:title="aliasModel ? t('models.aliasTitleFor', { model: aliasModel }) : t('models.aliasTitle')"
|
||||
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
|
||||
:mask-closable="true"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="aliasInput"
|
||||
:placeholder="t('models.aliasPlaceholder')"
|
||||
clearable
|
||||
@keydown.enter="saveAlias"
|
||||
/>
|
||||
<div v-if="aliasModel" class="model-alias-canonical">
|
||||
{{ t('models.aliasCanonical', { model: aliasModel }) }}
|
||||
</div>
|
||||
<div class="model-alias-hint">{{ t('models.aliasHint') }}</div>
|
||||
<template #footer>
|
||||
<div class="model-alias-actions">
|
||||
<NButton quaternary :disabled="!appStore.getModelAlias(aliasModel, aliasProvider)" @click="clearAlias">
|
||||
{{ t('models.aliasUseOriginal') }}
|
||||
</NButton>
|
||||
<div class="model-alias-spacer" />
|
||||
<NButton @click="showAliasModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" @click="saveAlias">{{ t('common.save') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<NModal
|
||||
v-model:show="showVisibilityModal"
|
||||
preset="card"
|
||||
:title="t('models.manageVisibleModelsFor', { name: displayName })"
|
||||
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!visibilitySaving"
|
||||
>
|
||||
<p class="visibility-hint">{{ t('models.visibilityHint') }}</p>
|
||||
<div class="visibility-count">
|
||||
{{ selectedVisibleModels.length }}/{{ allModels.length }} {{ t('models.count') }}
|
||||
</div>
|
||||
<div class="visibility-list">
|
||||
<NCheckboxGroup v-model:value="selectedVisibleModels">
|
||||
<NCheckbox
|
||||
v-for="model in allModels"
|
||||
:key="model"
|
||||
:value="model"
|
||||
class="visibility-model"
|
||||
>
|
||||
<code>{{ modelDisplayName(model) }}</code>
|
||||
<code v-if="modelAlias(model)" class="visibility-model-id">{{ model }}</code>
|
||||
</NCheckbox>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
<div class="visibility-actions">
|
||||
<NButton size="small" quaternary :disabled="visibilitySaving" @click="resetVisibility">
|
||||
{{ t('models.showAllModels') }}
|
||||
</NButton>
|
||||
<NButton size="small" quaternary :disabled="visibilitySaving" @click="clearVisibility">
|
||||
{{ t('models.clearVisibleModels') }}
|
||||
</NButton>
|
||||
<div class="visibility-action-spacer" />
|
||||
<NButton size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
|
||||
{{ t('common.cancel') }}
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
|
||||
{{ t('common.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.provider-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.provider-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.builtin {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.default {
|
||||
background: rgba(var(--warning-rgb), 0.14);
|
||||
color: $warning;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.models-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.models-count {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.models-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
margin-top: 6px;
|
||||
height: 100px;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 22px;
|
||||
font-size: 10px;
|
||||
font-family: $font-code;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&-more {
|
||||
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.default {
|
||||
background: rgba(var(--warning-rgb), 0.14);
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.model-tag-button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.16);
|
||||
color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.model-tag-name,
|
||||
.model-tag-id {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.model-tag-id {
|
||||
color: $text-muted;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.model-tag-default,
|
||||
.alias-row-default {
|
||||
color: $warning;
|
||||
font-family: $font-ui;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.alias-list-hint,
|
||||
.model-alias-hint {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alias-list-hint {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alias-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.alias-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.alias-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.alias-row-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: $text-primary;
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alias-row-id,
|
||||
.model-alias-canonical {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: $text-muted;
|
||||
font-family: $font-code;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.model-alias-canonical {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.model-alias-hint {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.model-alias-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-alias-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.visibility-hint {
|
||||
margin: 0 0 10px;
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visibility-count {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.visibility-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid $border-light;
|
||||
border-radius: $radius-sm;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.visibility-model {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
|
||||
code {
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-model-id {
|
||||
margin-left: 6px;
|
||||
color: $text-muted !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.visibility-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.visibility-action-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,492 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NInputNumber, NButton, NSelect, NRadioGroup, NRadioButton, useMessage, useDialog } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CodexLoginModal from './CodexLoginModal.vue'
|
||||
import NousLoginModal from './NousLoginModal.vue'
|
||||
import CopilotLoginModal from './CopilotLoginModal.vue'
|
||||
import XaiOAuthLoginModal from './XaiOAuthLoginModal.vue'
|
||||
import { checkCopilotToken, enableCopilot, type CopilotTokenSource } from '@/api/hermes/copilot-auth'
|
||||
import { fetchProviderModels } from '@/api/hermes/system'
|
||||
import { normalizeCustomProviderBaseUrl } from '@/utils/providerBaseUrl'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const fetchingModels = ref(false)
|
||||
const showCodexLogin = ref(false)
|
||||
const showNousLogin = ref(false)
|
||||
const showCopilotLogin = ref(false)
|
||||
const showXaiLogin = ref(false)
|
||||
const copilotChecking = ref(false)
|
||||
|
||||
const providerType = ref<'preset' | 'custom'>('preset')
|
||||
const selectedPreset = ref<string | null>(null)
|
||||
const formData = ref({
|
||||
name: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
model: '',
|
||||
context_length: null as number | null,
|
||||
})
|
||||
|
||||
const modelOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const CODEX_KEY = 'openai-codex'
|
||||
const NOUS_KEY = 'nous'
|
||||
const COPILOT_KEY = 'copilot'
|
||||
const CLIPROXYAPI_KEY = 'cliproxyapi'
|
||||
const XAI_OAUTH_KEY = 'xai-oauth'
|
||||
const ALIBABA_CODING_KEY = 'alibaba-coding-plan'
|
||||
const ALIBABA_CODING_REGIONS = {
|
||||
intl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
cn: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
} as const
|
||||
|
||||
const isCodex = computed(() => selectedPreset.value === CODEX_KEY)
|
||||
const isNous = computed(() => selectedPreset.value === NOUS_KEY)
|
||||
const isCopilot = computed(() => selectedPreset.value === COPILOT_KEY)
|
||||
const isCliproxyApi = computed(() => selectedPreset.value === CLIPROXYAPI_KEY)
|
||||
const isXaiOAuth = computed(() => selectedPreset.value === XAI_OAUTH_KEY)
|
||||
const isAlibabaCoding = computed(() => selectedPreset.value === ALIBABA_CODING_KEY)
|
||||
const alibabaCodingRegion = ref<'intl' | 'cn'>('intl')
|
||||
|
||||
const presetOptions = computed(() =>
|
||||
modelsStore.allProviders.map(g => ({ label: g.label, value: g.provider })),
|
||||
)
|
||||
const selectedPresetProvider = computed(() =>
|
||||
selectedPreset.value ? modelsStore.allProviders.find(g => g.provider === selectedPreset.value) : null,
|
||||
)
|
||||
const canEditPresetBaseUrl = computed(() => !!selectedPresetProvider.value?.base_url_env)
|
||||
|
||||
const FUN_LINK_MAP: Record<string, string> = {
|
||||
'fun-codex': 'https://apikey.fun/register?aff=LIBAPI',
|
||||
'fun-claude': 'https://apikey.fun/register?aff=LIBAPI',
|
||||
}
|
||||
|
||||
const funProviderLink = computed(() => selectedPreset.value ? FUN_LINK_MAP[selectedPreset.value] || '' : '')
|
||||
|
||||
function autoGenerateName(url: string): string {
|
||||
const clean = url.replace(/^https?:\/\//, '').replace(/\/v1\/?$/, '')
|
||||
const host = clean.split('/')[0]
|
||||
if (host.includes('localhost') || host.includes('127.0.0.1')) {
|
||||
return t('models.local', { host })
|
||||
}
|
||||
return host.charAt(0).toUpperCase() + host.slice(1)
|
||||
}
|
||||
|
||||
watch(selectedPreset, (val) => {
|
||||
formData.value.model = ''
|
||||
alibabaCodingRegion.value = 'intl'
|
||||
if (val) {
|
||||
const group = selectedPresetProvider.value
|
||||
if (group) {
|
||||
formData.value.name = group.label
|
||||
formData.value.base_url = group.base_url
|
||||
modelOptions.value = group.models.map((m: string) => ({ label: m, value: m }))
|
||||
if (group.models.length > 0) {
|
||||
formData.value.model = group.models[0]
|
||||
}
|
||||
}
|
||||
if (val === COPILOT_KEY) {
|
||||
// 判断是否已能解析到 token:有 → 弹简单确认;无 → 走 in-app device flow
|
||||
void triggerCopilotAdd()
|
||||
} else if (val === XAI_OAUTH_KEY) {
|
||||
showXaiLogin.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(alibabaCodingRegion, (region) => {
|
||||
if (isAlibabaCoding.value) {
|
||||
formData.value.base_url = ALIBABA_CODING_REGIONS[region]
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => formData.value.base_url, (url) => {
|
||||
if (providerType.value === 'custom' && url.trim() && !formData.value.name) {
|
||||
formData.value.name = autoGenerateName(url.trim())
|
||||
}
|
||||
})
|
||||
|
||||
watch(providerType, () => {
|
||||
modelOptions.value = []
|
||||
formData.value = { name: '', base_url: '', api_key: '', model: '', context_length: null }
|
||||
selectedPreset.value = null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (modelsStore.providers.length === 0) {
|
||||
modelsStore.fetchProviders()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchModels() {
|
||||
const { base_url } = formData.value
|
||||
if (!base_url.trim()) {
|
||||
message.warning(t('models.enterBaseUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
fetchingModels.value = true
|
||||
try {
|
||||
const data = await fetchProviderModels({
|
||||
base_url: base_url.trim(),
|
||||
api_key: formData.value.api_key.trim(),
|
||||
})
|
||||
modelOptions.value = data.models.map(m => ({ label: m, value: m }))
|
||||
if (modelOptions.value.length > 0 && !formData.value.model) {
|
||||
formData.value.model = modelOptions.value[0].value
|
||||
}
|
||||
message.success(t('models.foundModels', { count: modelOptions.value.length }))
|
||||
} catch (e: any) {
|
||||
message.error(t('models.fetchFailed') + ': ' + e.message)
|
||||
} finally {
|
||||
fetchingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (providerType.value === 'preset' && !selectedPreset.value) {
|
||||
message.warning(t('models.selectProviderRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// Codex: 弹出授权码弹窗
|
||||
if (isCodex.value) {
|
||||
showCodexLogin.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Nous: 弹出 OAuth 设备码弹窗
|
||||
if (isNous.value) {
|
||||
showNousLogin.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Copilot: 走 token-aware 的添加流程(已有 token → 确认窗;否则 device flow)
|
||||
if (isCopilot.value) {
|
||||
void triggerCopilotAdd()
|
||||
return
|
||||
}
|
||||
|
||||
if (isXaiOAuth.value) {
|
||||
showXaiLogin.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.base_url.trim()) {
|
||||
message.warning(t('models.baseUrlRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.api_key.trim() && !isCliproxyApi.value && !isXaiOAuth.value) {
|
||||
message.warning(t('models.apiKeyRequired'))
|
||||
return
|
||||
}
|
||||
if (!formData.value.model) {
|
||||
message.warning(t('models.modelRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const providerKey = providerType.value === 'preset'
|
||||
? selectedPreset.value
|
||||
: null
|
||||
|
||||
const contextLength = formData.value.context_length ?? undefined
|
||||
const baseUrl = providerType.value === 'custom'
|
||||
? normalizeCustomProviderBaseUrl(formData.value.base_url)
|
||||
: formData.value.base_url.trim()
|
||||
|
||||
await modelsStore.addProvider({
|
||||
name: formData.value.name.trim(),
|
||||
base_url: baseUrl,
|
||||
api_key: formData.value.api_key.trim(),
|
||||
model: formData.value.model,
|
||||
context_length: contextLength,
|
||||
providerKey,
|
||||
})
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodexSuccess() {
|
||||
showCodexLogin.value = false
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
async function handleNousSuccess() {
|
||||
showNousLogin.value = false
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
async function handleCopilotSuccess() {
|
||||
showCopilotLogin.value = false
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
async function handleXaiSuccess() {
|
||||
showXaiLogin.value = false
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
function copilotSourceLabel(source: CopilotTokenSource): string {
|
||||
if (source === 'env') return t('models.copilotAddSourceEnv')
|
||||
if (source === 'gh-cli') return t('models.copilotAddSourceGhCli')
|
||||
if (source === 'apps-json') return t('models.copilotAddSourceAppsJson')
|
||||
return ''
|
||||
}
|
||||
|
||||
async function triggerCopilotAdd() {
|
||||
if (copilotChecking.value) return
|
||||
copilotChecking.value = true
|
||||
try {
|
||||
const status = await checkCopilotToken()
|
||||
if (status.has_token) {
|
||||
// 已能解析到 token:弹确认窗,用户点 [添加] → enable + saved
|
||||
const sourceText = copilotSourceLabel(status.source)
|
||||
dialog.success({
|
||||
title: t('models.copilotAddDetectedTitle'),
|
||||
content: sourceText
|
||||
? `${t('models.copilotAddDetected')}\n\n${sourceText}`
|
||||
: t('models.copilotAddDetected'),
|
||||
positiveText: t('common.add'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await enableCopilot()
|
||||
message.success(t('models.providerAdded'))
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
message.error(e?.message ?? String(e))
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
selectedPreset.value = null
|
||||
},
|
||||
onClose: () => {
|
||||
selectedPreset.value = null
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 无 token:device flow
|
||||
showCopilotLogin.value = true
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message ?? String(e))
|
||||
selectedPreset.value = null
|
||||
} finally {
|
||||
copilotChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopilotClose() {
|
||||
showCopilotLogin.value = false
|
||||
// 用户取消 Copilot 引导时,清空选择避免卡在无 api_key 状态
|
||||
selectedPreset.value = null
|
||||
}
|
||||
|
||||
function handleXaiClose() {
|
||||
showXaiLogin.value = false
|
||||
selectedPreset.value = null
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.addProvider')"
|
||||
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading && !showCodexLogin && !showNousLogin && !showCopilotLogin && !showXaiLogin"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('models.providerType')">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<NButton
|
||||
:type="providerType === 'preset' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="providerType = 'preset'"
|
||||
>
|
||||
{{ t('models.preset') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
:type="providerType === 'custom' ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="providerType = 'custom'"
|
||||
>
|
||||
{{ t('models.custom') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'preset'" :label="t('models.selectProvider')" required>
|
||||
<NSelect
|
||||
v-model:value="selectedPreset"
|
||||
:options="presetOptions"
|
||||
:placeholder="t('models.chooseProvider')"
|
||||
filterable
|
||||
/>
|
||||
<div v-if="selectedPreset && funProviderLink" class="fun-provider-hint">
|
||||
<a :href="funProviderLink" target="_blank" rel="noopener noreferrer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
{{ t('models.getApiKey') }}
|
||||
</a>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'custom'" :label="t('models.name')">
|
||||
<NInput
|
||||
v-model:value="formData.name"
|
||||
:placeholder="t('models.autoGeneratedName')"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="isAlibabaCoding" :label="t('models.region')">
|
||||
<NRadioGroup v-model:value="alibabaCodingRegion">
|
||||
<NRadioButton value="intl">{{ t('models.regionIntl') }}</NRadioButton>
|
||||
<NRadioButton value="cn">{{ t('models.regionCn') }}</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="!isCodex && !isNous" :label="t('models.baseUrl')" required>
|
||||
<NInput
|
||||
v-model:value="formData.base_url"
|
||||
:placeholder="t('models.baseUrlPlaceholder')"
|
||||
:disabled="providerType === 'preset' && !canEditPresetBaseUrl"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="!isCodex && !isNous" :label="t('models.apiKey')" :required="!isCliproxyApi && !isXaiOAuth">
|
||||
<NInput
|
||||
v-model:value="formData.api_key"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('models.apiKeyPlaceholder')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem :label="t('models.defaultModel')" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<NSelect
|
||||
v-model:value="formData.model"
|
||||
:options="modelOptions"
|
||||
filterable
|
||||
tag
|
||||
:placeholder="t('models.selectOrInput')"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<NButton
|
||||
v-if="providerType === 'custom' || (providerType === 'preset' && modelOptions.length === 0)"
|
||||
:loading="fetchingModels"
|
||||
@click="fetchModels"
|
||||
>
|
||||
{{ t('common.fetch') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem v-if="providerType === 'custom'" :label="t('models.contextLength')">
|
||||
<NInputNumber
|
||||
v-model:value="formData.context_length as number | null"
|
||||
:placeholder="t('models.contextLengthPlaceholder')"
|
||||
:min="0"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleSave">
|
||||
{{ t('common.add') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CodexLoginModal
|
||||
v-if="showCodexLogin"
|
||||
@close="showCodexLogin = false"
|
||||
@success="handleCodexSuccess"
|
||||
/>
|
||||
|
||||
<NousLoginModal
|
||||
v-if="showNousLogin"
|
||||
@close="showNousLogin = false"
|
||||
@success="handleNousSuccess"
|
||||
/>
|
||||
|
||||
<CopilotLoginModal
|
||||
v-if="showCopilotLogin"
|
||||
@close="handleCopilotClose"
|
||||
@success="handleCopilotSuccess"
|
||||
/>
|
||||
|
||||
<XaiOAuthLoginModal
|
||||
v-if="showXaiLogin"
|
||||
@close="handleXaiClose"
|
||||
@success="handleXaiSuccess"
|
||||
/>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fun-provider-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import ProviderCard from './ProviderCard.vue'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="modelsStore.providers.length === 0" class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<p>{{ t('models.noProviders') }}</p>
|
||||
</div>
|
||||
<div v-else class="providers-grid">
|
||||
<ProviderCard
|
||||
v-for="g in modelsStore.providers"
|
||||
:key="g.provider"
|
||||
:provider="g"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
gap: 12px;
|
||||
|
||||
.empty-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.providers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{ close: []; success: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const status = ref<'idle' | 'loading' | 'waiting' | 'approved' | 'expired' | 'error'>('idle')
|
||||
const authorizationUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const errorMessage = ref('')
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function startLogin() {
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const data = await startXaiLogin()
|
||||
authorizationUrl.value = data.authorization_url
|
||||
sessionId.value = data.session_id
|
||||
status.value = 'waiting'
|
||||
window.open(authorizationUrl.value, '_blank')
|
||||
startPolling()
|
||||
} catch (err: any) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = err?.message || String(err)
|
||||
message.error(errorMessage.value)
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await pollXaiLogin(sessionId.value)
|
||||
if (result.status === 'pending') {
|
||||
startPolling()
|
||||
} else if (result.status === 'approved') {
|
||||
status.value = 'approved'
|
||||
message.success(t('models.xaiApproved'))
|
||||
setTimeout(() => {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('success'), 200)
|
||||
}, 1000)
|
||||
} else if (result.status === 'expired') {
|
||||
status.value = 'expired'
|
||||
} else if (result.status === 'error') {
|
||||
status.value = 'error'
|
||||
errorMessage.value = result.error || 'Unknown error'
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
stopPolling()
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(authorizationUrl.value, '_blank')
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const ok = await copyToClipboard(authorizationUrl.value)
|
||||
if (ok) message.success(t('common.copied'))
|
||||
else message.error(t('chat.copyFailed'))
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'idle'
|
||||
authorizationUrl.value = ''
|
||||
sessionId.value = ''
|
||||
errorMessage.value = ''
|
||||
startLogin()
|
||||
}
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
startLogin()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('models.xaiLoginTitle')"
|
||||
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
|
||||
:mask-closable="status !== 'waiting'"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<div class="xai-login">
|
||||
<div v-if="status === 'idle' || status === 'loading'" class="xai-login__state">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'waiting'" class="xai-login__state">
|
||||
<p class="xai-login__hint">{{ t('models.xaiWaiting') }}</p>
|
||||
<NButton type="primary" block @click="openLink">
|
||||
{{ t('models.xaiOpenLink') }}
|
||||
</NButton>
|
||||
<NButton block @click="copyLink">
|
||||
{{ t('models.xaiCopyLink') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<p>{{ t('models.xaiApproved') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'expired'" class="xai-login__state">
|
||||
<p class="xai-login__error">{{ t('models.xaiExpired') }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="status === 'error'" class="xai-login__state">
|
||||
<p class="xai-login__error">{{ errorMessage }}</p>
|
||||
<NButton size="small" @click="retry">{{ t('common.retry') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton :disabled="status === 'waiting'" @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.xai-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.xai-login__state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 120px;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xai-login__hint {
|
||||
font-size: 14px;
|
||||
color: var(--n-text-color, inherit);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.xai-login__state--success {
|
||||
color: #18a058;
|
||||
|
||||
svg {
|
||||
stroke: #18a058;
|
||||
}
|
||||
}
|
||||
|
||||
.xai-login__error {
|
||||
color: #d03050;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import multiavatar from '@multiavatar/multiavatar'
|
||||
import type { ProfileAvatar } from '@/api/hermes/profiles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
avatar?: ProfileAvatar | null
|
||||
size?: number
|
||||
}>(), {
|
||||
size: 24,
|
||||
})
|
||||
|
||||
const fallbackSeed = computed(() => props.name || 'default')
|
||||
const generatedSvg = computed(() => multiavatar(props.avatar?.seed || fallbackSeed.value))
|
||||
const style = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
flexBasis: `${props.size}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="profile-avatar-view" :style="style">
|
||||
<img
|
||||
v-if="avatar?.type === 'image' && avatar.dataUrl"
|
||||
class="profile-avatar-image"
|
||||
:src="avatar.dataUrl"
|
||||
alt=""
|
||||
draggable="false"
|
||||
>
|
||||
<span v-else class="profile-avatar-svg" v-html="generatedSvg" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-avatar-view {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.profile-avatar-image,
|
||||
.profile-avatar-svg,
|
||||
.profile-avatar-svg :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-avatar-image {
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NButton, NTag, NSpin, useMessage, useDialog } from 'naive-ui'
|
||||
import type { HermesProfile, HermesProfileDetail } from '@/api/hermes/profiles'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProfileAvatar from './ProfileAvatar.vue'
|
||||
|
||||
const props = defineProps<{ profile: HermesProfile }>()
|
||||
const emit = defineEmits<{}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const profilesStore = useProfilesStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const expanded = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const switching = ref(false)
|
||||
const detail = ref<HermesProfileDetail | null>(null)
|
||||
|
||||
const isDefault = computed(() => props.profile.name === 'default')
|
||||
|
||||
async function toggleDetail() {
|
||||
if (expanded.value) {
|
||||
expanded.value = false
|
||||
return
|
||||
}
|
||||
expanded.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
detail.value = await profilesStore.fetchProfileDetail(props.profile.name)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitch() {
|
||||
dialog.warning({
|
||||
title: t('profiles.switchTo'),
|
||||
content: t('profiles.switchConfirm', { name: props.profile.name }),
|
||||
positiveText: t('profiles.switchTo'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: performHermesSwitch,
|
||||
})
|
||||
}
|
||||
|
||||
async function performHermesSwitch() {
|
||||
switching.value = true
|
||||
try {
|
||||
const ok = await profilesStore.switchHermesProfile(props.profile.name)
|
||||
if (ok) {
|
||||
message.success(t('profiles.switchSuccess', { name: props.profile.name }))
|
||||
// Reload to refresh all profile-dependent data
|
||||
setTimeout(() => window.location.reload(), 500)
|
||||
} else {
|
||||
message.error(t('profiles.switchFailed'))
|
||||
}
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dialog.warning({
|
||||
title: t('profiles.delete'),
|
||||
content: t('profiles.deleteConfirm', { name: props.profile.name }),
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
const ok = await profilesStore.deleteProfile(props.profile.name)
|
||||
if (ok) {
|
||||
message.success(t('profiles.deleteSuccess'))
|
||||
} else {
|
||||
message.error(t('profiles.deleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const ok = await profilesStore.exportProfile(props.profile.name)
|
||||
if (ok) {
|
||||
message.success(t('profiles.exportSuccess'))
|
||||
} else {
|
||||
message.error(t('profiles.exportFailed'))
|
||||
}
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-card" :class="{ active: profile.active }">
|
||||
<div class="card-header">
|
||||
<div class="profile-title">
|
||||
<ProfileAvatar :name="profile.name" :avatar="profile.avatar" :size="28" />
|
||||
<h3 class="profile-name">{{ profile.name }}</h3>
|
||||
</div>
|
||||
<NTag v-if="profile.active" size="tiny" type="success" :bordered="false">
|
||||
{{ t('profiles.active') }}
|
||||
</NTag>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.model') }}</span>
|
||||
<code class="info-value mono">{{ profile.model }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-detail-toggle" @click="toggleDetail">
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="toggle-icon"
|
||||
:class="{ expanded }"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
<span class="toggle-text">{{ expanded ? t('common.collapse') : t('common.expand') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" class="card-detail">
|
||||
<NSpin :show="detailLoading" size="small">
|
||||
<template v-if="detail">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.provider') }}</span>
|
||||
<span class="info-value">{{ detail.provider }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.path') }}</span>
|
||||
<code class="info-value mono detail-path">{{ detail.path }}</code>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.skills') }}</span>
|
||||
<span class="info-value">{{ detail.skills }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.hasEnv') }}</span>
|
||||
<span class="info-value">{{ detail.hasEnv ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('profiles.hasSoulMd') }}</span>
|
||||
<span class="info-value">{{ detail.hasSoulMd ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<NButton
|
||||
v-if="!profile.active"
|
||||
size="tiny"
|
||||
:loading="switching"
|
||||
quaternary
|
||||
type="primary"
|
||||
@click="handleSwitch"
|
||||
>
|
||||
{{ t('profiles.switchTo') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="isDefault || profile.active"
|
||||
@click="handleDelete"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary :loading="exporting" @click="handleExport">
|
||||
{{ t('profiles.export') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.profile-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(var(--success-rgb), 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-detail-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card-detail {
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid $border-light;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: $font-code;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-path {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NSwitch, NText, useMessage } from 'naive-ui'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const profilesStore = useProfilesStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const name = ref('')
|
||||
const clone = ref(false)
|
||||
const nameValidationMessage = ref('')
|
||||
|
||||
function handleNameInput(value: string) {
|
||||
// 过滤掉不符合规则的字符,只保留小写字母、数字、下划线和连字符
|
||||
const filtered = value.toLowerCase().replace(/[^a-z0-9_-]/g, '')
|
||||
if (filtered !== value) {
|
||||
nameValidationMessage.value = t('profiles.nameValidation')
|
||||
} else {
|
||||
nameValidationMessage.value = ''
|
||||
}
|
||||
name.value = filtered
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.value) {
|
||||
message.warning(t('profiles.namePlaceholder'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(name.value)) {
|
||||
message.error(t('profiles.nameValidation'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await profilesStore.createProfile(name.value.trim(), clone.value)
|
||||
if (res.success) {
|
||||
const stripped = res.strippedCredentials ?? []
|
||||
const disabled = res.disabledPlatforms ?? []
|
||||
const cfgStripped = res.strippedConfigCredentials ?? []
|
||||
if (clone.value && (stripped.length > 0 || disabled.length > 0 || cfgStripped.length > 0)) {
|
||||
const parts: string[] = []
|
||||
if (stripped.length > 0) parts.push(t('profiles.cloneStrippedCredentials', { count: stripped.length, list: stripped.join(', ') }))
|
||||
if (disabled.length > 0) parts.push(t('profiles.cloneDisabledPlatforms', { count: disabled.length, list: disabled.join(', ') }))
|
||||
if (cfgStripped.length > 0) parts.push(t('profiles.cloneStrippedConfigCredentials', { count: cfgStripped.length, list: cfgStripped.join(', ') }))
|
||||
message.info(`${t('profiles.createSuccess', { name: name.value.trim() })}\n${parts.join('\n')}`, { duration: 6000 })
|
||||
} else {
|
||||
message.success(t('profiles.createSuccess', { name: name.value.trim() }))
|
||||
}
|
||||
emit('saved')
|
||||
} else {
|
||||
const errorMsg = res.error || t('profiles.createFailed')
|
||||
message.error(errorMsg)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('profiles.create')"
|
||||
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('profiles.name')" required>
|
||||
<NInput
|
||||
v-model:value="name"
|
||||
:placeholder="t('profiles.namePlaceholder')"
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NText v-if="nameValidationMessage" depth="3" type="warning" style="font-size: 12px;">
|
||||
{{ nameValidationMessage }}
|
||||
</NText>
|
||||
|
||||
<NFormItem :label="t('profiles.cloneFromCurrent')">
|
||||
<NSwitch v-model:value="clone" />
|
||||
</NFormItem>
|
||||
<NText v-if="clone" depth="3" style="font-size: 12px;">
|
||||
{{ t('profiles.cloneCleanupNotice') }}
|
||||
</NText>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleSave">
|
||||
{{ t('common.create') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NUpload, NButton, useMessage } from 'naive-ui'
|
||||
import type { UploadFileInfo } from 'naive-ui'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const profilesStore = useProfilesStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const fileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
const ACCEPT_TYPES = [
|
||||
'.tar.gz',
|
||||
'.tgz',
|
||||
'.gz',
|
||||
'.zip',
|
||||
]
|
||||
|
||||
function beforeUpload({ file }: { file: UploadFileInfo }) {
|
||||
const name = file.name?.toLowerCase() || ''
|
||||
const valid = ACCEPT_TYPES.some(ext => name.endsWith(ext))
|
||||
if (!valid) {
|
||||
message.warning(t('profiles.importInvalidFile'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!fileList.value.length) {
|
||||
message.warning(t('profiles.importSelectFile'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const file = fileList.value[0].file
|
||||
if (!file) {
|
||||
message.error(t('profiles.importFailed'))
|
||||
return
|
||||
}
|
||||
const ok = await profilesStore.importProfile(file)
|
||||
if (ok) {
|
||||
message.success(t('profiles.importSuccess'))
|
||||
emit('saved')
|
||||
} else {
|
||||
message.error(t('profiles.importFailed'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('profiles.import')"
|
||||
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NUpload
|
||||
v-model:file-list="fileList"
|
||||
:max="1"
|
||||
:accept="ACCEPT_TYPES.join(',')"
|
||||
:disabled="loading"
|
||||
@before-upload="beforeUpload"
|
||||
>
|
||||
<NButton>{{ t('profiles.importSelectFile') }}</NButton>
|
||||
</NUpload>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="loading" :disabled="!fileList.length" @click="handleSave">
|
||||
{{ t('common.confirm') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ profileName: string }>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const profilesStore = useProfilesStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showModal = ref(true)
|
||||
const loading = ref(false)
|
||||
const newName = ref('')
|
||||
const nameValidationMessage = ref('')
|
||||
|
||||
function handleNameInput(value: string) {
|
||||
// 过滤掉不符合规则的字符,只保留小写字母、数字、下划线和连字符
|
||||
const filtered = value.toLowerCase().replace(/[^a-z0-9_-]/g, '')
|
||||
if (filtered !== value) {
|
||||
nameValidationMessage.value = t('profiles.nameValidation')
|
||||
} else {
|
||||
nameValidationMessage.value = ''
|
||||
}
|
||||
newName.value = filtered
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!newName.value) {
|
||||
message.warning(t('profiles.newNamePlaceholder'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(newName.value)) {
|
||||
message.error(t('profiles.nameValidation'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const ok = await profilesStore.renameProfile(props.profileName, newName.value.trim())
|
||||
if (ok) {
|
||||
message.success(t('profiles.renameSuccess'))
|
||||
emit('saved')
|
||||
} else {
|
||||
message.error(t('profiles.renameFailed'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
showModal.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="t('profiles.rename')"
|
||||
:style="{ width: 'min(420px, calc(100vw - 32px))' }"
|
||||
:mask-closable="!loading"
|
||||
@after-leave="emit('close')"
|
||||
>
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('profiles.newName')" required>
|
||||
<NInput
|
||||
v-model:value="newName"
|
||||
:placeholder="t('profiles.newNamePlaceholder')"
|
||||
@input="handleNameInput"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NText v-if="nameValidationMessage" depth="3" type="warning" style="font-size: 12px;">
|
||||
{{ nameValidationMessage }}
|
||||
</NText>
|
||||
</NForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleSave">
|
||||
{{ t('common.confirm') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import ProfileCard from './ProfileCard.vue'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineEmits<{ rename: [name: string] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const profilesStore = useProfilesStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="profilesStore.profiles.length === 0" class="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<p>{{ t('profiles.noProfiles') }}</p>
|
||||
</div>
|
||||
<div v-else class="profiles-grid">
|
||||
<ProfileCard
|
||||
v-for="p in profilesStore.profiles"
|
||||
:key="p.name"
|
||||
:profile="p"
|
||||
@rename="$emit('rename', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $text-muted;
|
||||
gap: 12px;
|
||||
|
||||
.empty-icon {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.profiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { NButton, NInput, NModal, NForm, NFormItem, NPopconfirm, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { changePassword, changeUsername, fetchCurrentUser, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth";
|
||||
import type { LockedIp } from "@/api/auth";
|
||||
|
||||
const { t } = useI18n();
|
||||
const message = useMessage();
|
||||
|
||||
const username = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// Change password form
|
||||
const showChangePasswordModal = ref(false);
|
||||
const currentPasswordForPwd = ref("");
|
||||
const newPasswordVal = ref("");
|
||||
const newPasswordConfirm = ref("");
|
||||
|
||||
// Change username form
|
||||
const showChangeUsernameModal = ref(false);
|
||||
const currentPasswordForName = ref("");
|
||||
const newUsernameVal = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const user = await fetchCurrentUser();
|
||||
username.value = user.username;
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
async function handleChangePassword() {
|
||||
if (newPasswordVal.value !== newPasswordConfirm.value) {
|
||||
message.error(t("login.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
if (newPasswordVal.value.length < 6) {
|
||||
message.error(t("login.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await changePassword(currentPasswordForPwd.value, newPasswordVal.value);
|
||||
showChangePasswordModal.value = false;
|
||||
currentPasswordForPwd.value = "";
|
||||
newPasswordVal.value = "";
|
||||
newPasswordConfirm.value = "";
|
||||
message.success(t("login.passwordChanged"));
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t("common.saveFailed"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeUsername() {
|
||||
if (newUsernameVal.value.trim().length < 2) {
|
||||
message.error(t("login.usernameTooShort"));
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await changeUsername(currentPasswordForName.value, newUsernameVal.value.trim());
|
||||
username.value = newUsernameVal.value.trim();
|
||||
showChangeUsernameModal.value = false;
|
||||
currentPasswordForName.value = "";
|
||||
newUsernameVal.value = "";
|
||||
message.success(t("login.usernameChanged"));
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t("common.saveFailed"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openChangePasswordModal() {
|
||||
currentPasswordForPwd.value = "";
|
||||
newPasswordVal.value = "";
|
||||
newPasswordConfirm.value = "";
|
||||
showChangePasswordModal.value = true;
|
||||
}
|
||||
|
||||
function openChangeUsernameModal() {
|
||||
currentPasswordForName.value = "";
|
||||
newUsernameVal.value = "";
|
||||
showChangeUsernameModal.value = true;
|
||||
}
|
||||
|
||||
// Locked IPs management
|
||||
const lockedIps = ref<LockedIp[]>([]);
|
||||
const loadingLocks = ref(false);
|
||||
|
||||
async function loadLockedIps() {
|
||||
loadingLocks.value = true;
|
||||
try {
|
||||
lockedIps.value = await fetchLockedIps();
|
||||
} catch { /* ignore */ }
|
||||
finally {
|
||||
loadingLocks.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlockIp(ip: string) {
|
||||
try {
|
||||
await unlockSpecificIp(ip);
|
||||
message.success(t("settings.lockedIps.unlocked"));
|
||||
await loadLockedIps();
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t("common.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlockAll() {
|
||||
try {
|
||||
const count = await unlockAllIps();
|
||||
message.success(t("settings.lockedIps.allUnlocked", { count }));
|
||||
await loadLockedIps();
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t("common.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const remaining = Math.max(0, Math.round((ts - Date.now()) / 60000));
|
||||
return remaining > 0 ? `${remaining} min` : t("common.expired");
|
||||
}
|
||||
|
||||
onMounted(() => { loadLockedIps(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="account-settings">
|
||||
<p class="section-desc">{{ t("login.setupDescription") }}</p>
|
||||
|
||||
<div class="configured-section">
|
||||
<div class="action-row">
|
||||
<span class="action-label">{{ t("login.passwordLoginConfigured", { username }) }}</span>
|
||||
<div class="action-buttons">
|
||||
<NButton @click="openChangePasswordModal">{{ t("login.changePassword") }}</NButton>
|
||||
<NButton @click="openChangeUsernameModal">{{ t("login.changeUsername") }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locked IPs management -->
|
||||
<div class="locked-ips-section">
|
||||
<h3 class="section-title">{{ t("settings.lockedIps.title") }}</h3>
|
||||
<div class="action-row" style="margin-bottom: 12px;">
|
||||
<span class="action-label">{{ t("settings.lockedIps.count", { count: lockedIps.length }) }}</span>
|
||||
<div class="action-buttons">
|
||||
<NButton size="small" :loading="loadingLocks" @click="loadLockedIps">{{ t("common.retry") }}</NButton>
|
||||
<NPopconfirm v-if="lockedIps.length > 0" @positive-click="handleUnlockAll">
|
||||
<template #trigger>
|
||||
<NButton size="small" type="warning">{{ t("settings.lockedIps.unlockAll") }}</NButton>
|
||||
</template>
|
||||
{{ t("settings.lockedIps.unlockAllConfirm") }}
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="lockedIps.length > 0" class="locked-list">
|
||||
<div v-for="lock in lockedIps" :key="lock.ip + lock.type" class="locked-item">
|
||||
<div class="locked-info">
|
||||
<span class="locked-ip">{{ lock.ip }}</span>
|
||||
<span class="locked-badge">{{ lock.type }}</span>
|
||||
<span class="locked-ttl">{{ formatTime(lock.lockedUntil) }}</span>
|
||||
</div>
|
||||
<NButton size="tiny" type="error" ghost @click="handleUnlockIp(lock.ip)">{{ t("settings.lockedIps.unlock") }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-hint">{{ t("settings.lockedIps.empty") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Change password modal -->
|
||||
<NModal v-model:show="showChangePasswordModal" preset="dialog" :title="t('login.changePassword')">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('login.currentPassword')">
|
||||
<NInput v-model:value="currentPasswordForPwd" type="password" show-password-on="click" :placeholder="t('login.currentPassword')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('login.newPassword')">
|
||||
<NInput v-model:value="newPasswordVal" type="password" show-password-on="click" :placeholder="t('login.newPassword')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('login.confirmPassword')">
|
||||
<NInput v-model:value="newPasswordConfirm" type="password" show-password-on="click" :placeholder="t('login.confirmPassword')" @keyup.enter="handleChangePassword" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #action>
|
||||
<NButton @click="showChangePasswordModal = false">{{ t("common.cancel") }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleChangePassword">{{ t("common.save") }}</NButton>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<!-- Change username modal -->
|
||||
<NModal v-model:show="showChangeUsernameModal" preset="dialog" :title="t('login.changeUsername')">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('login.currentPassword')">
|
||||
<NInput v-model:value="currentPasswordForName" type="password" show-password-on="click" :placeholder="t('login.currentPassword')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('login.newUsername')">
|
||||
<NInput v-model:value="newUsernameVal" :placeholder="t('login.usernamePlaceholder')" @keyup.enter="handleChangeUsername" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #action>
|
||||
<NButton @click="showChangeUsernameModal = false">{{ t("common.cancel") }}</NButton>
|
||||
<NButton type="primary" :loading="loading" @click="handleChangeUsername">{{ t("common.save") }}</NButton>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.account-settings {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.locked-ips-section {
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.locked-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.locked-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-input;
|
||||
}
|
||||
|
||||
.locked-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.locked-ip {
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.locked-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba($error, 0.1);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
.locked-ttl {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { NInputNumber, NSelect, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 防抖保存:每个字段独立定时器,300ms 内只发最后一次 HTTP 请求
|
||||
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
function save(values: Record<string, any>) {
|
||||
// NSelect 等一次性操作,直接保存,不需要防抖
|
||||
settingsStore.updateLocal('agent', values)
|
||||
settingsStore.saveSection('agent', values).then(() => {
|
||||
message.success(t('settings.saved'))
|
||||
}).catch(() => {
|
||||
message.error(t('settings.saveFailed'))
|
||||
})
|
||||
}
|
||||
|
||||
function debouncedSave(key: string, value: any) {
|
||||
// 先立即更新本地 store(UI 即时响应)
|
||||
settingsStore.updateLocal('agent', { [key]: value })
|
||||
// 再防抖发 HTTP 保存
|
||||
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
|
||||
debounceTimers[key] = setTimeout(async () => {
|
||||
try {
|
||||
await settingsStore.saveSection('agent', { [key]: value })
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.agent.maxTurns')" :hint="t('settings.agent.maxTurnsHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.agent.max_turns"
|
||||
:min="1" :max="200" :step="5"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('max_turns', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.agent.gatewayTimeout')" :hint="t('settings.agent.gatewayTimeoutHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.agent.gateway_timeout"
|
||||
:min="60" :max="7200" :step="60"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('gateway_timeout', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.agent.restartDrainTimeout')" :hint="t('settings.agent.restartDrainTimeoutHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.agent.restart_drain_timeout"
|
||||
:min="10" :max="300" :step="10"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('restart_drain_timeout', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.agent.toolEnforcement')" :hint="t('settings.agent.toolEnforcementHint')">
|
||||
<NSelect
|
||||
:value="settingsStore.agent.tool_use_enforcement || 'auto'"
|
||||
:options="[
|
||||
{ label: t('settings.agent.auto'), value: 'auto' },
|
||||
{ label: t('settings.agent.always'), value: 'always' },
|
||||
{ label: t('settings.agent.never'), value: 'never' },
|
||||
]"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => save({ tool_use_enforcement: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { NInputNumber, NSwitch, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const defaults = {
|
||||
enabled: true,
|
||||
threshold: 0.5,
|
||||
target_ratio: 0.2,
|
||||
protect_last_n: 20,
|
||||
protect_first_n: 3,
|
||||
}
|
||||
|
||||
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
function save(values: Record<string, any>) {
|
||||
settingsStore.updateLocal('compression', values)
|
||||
settingsStore.saveSection('compression', values).then(() => {
|
||||
message.success(t('settings.saved'))
|
||||
}).catch(() => {
|
||||
message.error(t('settings.saveFailed'))
|
||||
})
|
||||
}
|
||||
|
||||
function debouncedSave(key: string, value: any) {
|
||||
settingsStore.updateLocal('compression', { [key]: value })
|
||||
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
|
||||
debounceTimers[key] = setTimeout(async () => {
|
||||
try {
|
||||
await settingsStore.saveSection('compression', { [key]: value })
|
||||
message.success(t('settings.saved'))
|
||||
} catch {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.compression.enabled')" :hint="t('settings.compression.enabledHint')">
|
||||
<NSwitch
|
||||
:value="settingsStore.compression.enabled ?? defaults.enabled"
|
||||
size="small"
|
||||
@update:value="v => save({ enabled: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.compression.threshold')" :hint="t('settings.compression.thresholdHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.compression.threshold ?? defaults.threshold"
|
||||
:min="0.1"
|
||||
:max="0.95"
|
||||
:step="0.05"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('threshold', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.compression.targetRatio')" :hint="t('settings.compression.targetRatioHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.compression.target_ratio ?? defaults.target_ratio"
|
||||
:min="0.05"
|
||||
:max="0.8"
|
||||
:step="0.05"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('target_ratio', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.compression.protectLastN')" :hint="t('settings.compression.protectLastNHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.compression.protect_last_n ?? defaults.protect_last_n"
|
||||
:min="0"
|
||||
:max="200"
|
||||
:step="1"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('protect_last_n', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.compression.protectFirstN')" :hint="t('settings.compression.protectFirstNHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.compression.protect_first_n ?? defaults.protect_first_n"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="1"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('protect_first_n', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, NSelect, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import { useTheme, type BrightnessMode } from '@/composables/useTheme'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
const { brightness, setBrightness } = useTheme()
|
||||
|
||||
const themeOptions = [
|
||||
{ label: t('settings.display.themeLight'), value: 'light' },
|
||||
{ label: t('settings.display.themeDark'), value: 'dark' },
|
||||
{ label: t('settings.display.themeSystem'), value: 'system' },
|
||||
]
|
||||
|
||||
async function save(values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection('display', values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleThemeChange(val: string) {
|
||||
const m = val as BrightnessMode
|
||||
setBrightness(m)
|
||||
save({ skin: m })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.display.theme')" :hint="t('settings.display.themeHint')">
|
||||
<NSelect :value="brightness" :options="themeOptions" size="small" :consistent-menu-width="false" class="input-sm" @update:value="handleThemeChange" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
|
||||
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.compact')" :hint="t('settings.display.compactHint')">
|
||||
<NSwitch :value="settingsStore.display.compact" @update:value="v => save({ compact: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.showReasoning')" :hint="t('settings.display.showReasoningHint')">
|
||||
<NSwitch :value="settingsStore.display.show_reasoning" @update:value="v => save({ show_reasoning: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.showCost')" :hint="t('settings.display.showCostHint')">
|
||||
<NSwitch :value="settingsStore.display.show_cost" @update:value="v => save({ show_cost: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.inlineDiffs')" :hint="t('settings.display.inlineDiffsHint')">
|
||||
<NSwitch :value="settingsStore.display.inline_diffs" @update:value="v => save({ inline_diffs: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.bellOnComplete')" :hint="t('settings.display.bellOnCompleteHint')">
|
||||
<NSwitch :value="settingsStore.display.bell_on_complete" @update:value="v => save({ bell_on_complete: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.display.busyInputMode')" :hint="t('settings.display.busyInputModeHint')">
|
||||
<NSwitch :value="settingsStore.display.busy_input_mode === 'interrupt'" @update:value="v => save({ busy_input_mode: v ? 'interrupt' : 'off' })" />
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NDescriptions, NDescriptionsItem, NSelect, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
fetchPreviewStatus,
|
||||
fetchPreviewTags,
|
||||
installPreview,
|
||||
preparePreview,
|
||||
startPreview,
|
||||
stopPreview,
|
||||
type PreviewActionResponse,
|
||||
type PreviewStatus,
|
||||
type PreviewTag,
|
||||
} from '@/api/hermes/system'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(false)
|
||||
const tagsLoading = ref(false)
|
||||
const actionLoading = ref('')
|
||||
const tags = ref<PreviewTag[]>([])
|
||||
const selectedTag = ref('')
|
||||
const status = ref<PreviewStatus | null>(null)
|
||||
const lastHandledCompletion = ref('')
|
||||
const completionNotificationsReady = ref(false)
|
||||
let pollTimer: number | null = null
|
||||
|
||||
const tagOptions = computed(() => tags.value.map(tag => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
})))
|
||||
const actionLog = computed(() => status.value?.action_log || '')
|
||||
const devLog = computed(() => status.value?.dev_log || '')
|
||||
const activeAction = computed(() => actionLoading.value || status.value?.active_action || '')
|
||||
const hasActiveAction = computed(() => Boolean(activeAction.value))
|
||||
const actionSuccessKeys: Record<string, string> = {
|
||||
prepare: 'githubPreview.prepareSuccess',
|
||||
install: 'githubPreview.installSuccess',
|
||||
start: 'githubPreview.startSuccess',
|
||||
stop: 'githubPreview.stopSuccess',
|
||||
}
|
||||
|
||||
function applyErrorStatus(err: any) {
|
||||
const messageText = String(err?.message || '')
|
||||
const jsonStart = messageText.indexOf('{')
|
||||
if (jsonStart < 0) return
|
||||
try {
|
||||
const parsed = JSON.parse(messageText.slice(jsonStart))
|
||||
if (parsed && typeof parsed === 'object' && 'preview_dir' in parsed) {
|
||||
status.value = parsed as PreviewStatus
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function errorCodeMessage(code?: string, fallback?: string): string {
|
||||
if (code === 'node_environment_missing') return t('githubPreview.nodeEnvironmentMissing')
|
||||
return fallback || t('githubPreview.actionFailed')
|
||||
}
|
||||
|
||||
function parseErrorPayload(err: any): { message?: string; code?: string } | null {
|
||||
const messageText = String(err?.message || '')
|
||||
const jsonStart = messageText.indexOf('{')
|
||||
if (jsonStart < 0) return null
|
||||
try {
|
||||
const parsed = JSON.parse(messageText.slice(jsonStart))
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
status.value = await fetchPreviewStatus()
|
||||
if (!selectedTag.value && status.value.current_tag) {
|
||||
selectedTag.value = status.value.current_tag
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
tagsLoading.value = true
|
||||
try {
|
||||
const res = await fetchPreviewTags()
|
||||
tags.value = res.tags
|
||||
if (!selectedTag.value && tags.value[0]) {
|
||||
selectedTag.value = tags.value[0].name
|
||||
}
|
||||
} finally {
|
||||
tagsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([loadStatus(), loadTags()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(action: string, fn: () => Promise<PreviewActionResponse>, successKey: string) {
|
||||
actionLoading.value = action
|
||||
try {
|
||||
const res = await fn()
|
||||
status.value = res
|
||||
if (res.success === false) {
|
||||
message.warning(errorCodeMessage(res.code, res.message))
|
||||
return
|
||||
}
|
||||
if (!res.accepted && !res.active_action) {
|
||||
message.success(t(successKey))
|
||||
}
|
||||
} catch (err: any) {
|
||||
applyErrorStatus(err)
|
||||
const payload = parseErrorPayload(err)
|
||||
message.error(errorCodeMessage(payload?.code, payload?.message || err?.message))
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
await loadStatus()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
pollTimer = window.setInterval(() => {
|
||||
void pollStatus()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (!pollTimer) return
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
function requireTag(): string | null {
|
||||
if (!selectedTag.value) {
|
||||
message.warning(t('githubPreview.selectTag'))
|
||||
return null
|
||||
}
|
||||
return selectedTag.value
|
||||
}
|
||||
|
||||
async function handlePrepare() {
|
||||
const tag = requireTag()
|
||||
if (!tag) return
|
||||
await runAction('prepare', () => preparePreview(tag), 'githubPreview.prepareSuccess')
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
await runAction('install', async () => {
|
||||
const res = await installPreview()
|
||||
if (res.success !== false && !res.accepted && !res.active_action && !res.installed) {
|
||||
return {
|
||||
...res,
|
||||
success: false,
|
||||
message: res.message || t('githubPreview.actionFailed'),
|
||||
}
|
||||
}
|
||||
return res
|
||||
}, 'githubPreview.installSuccess')
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
await runAction('start', () => startPreview(selectedTag.value || undefined), 'githubPreview.startSuccess')
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
await runAction('stop', stopPreview, 'githubPreview.stopSuccess')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await handleRefresh()
|
||||
lastHandledCompletion.value = status.value?.last_action_completed_at || ''
|
||||
completionNotificationsReady.value = true
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => status.value?.active_action || '',
|
||||
(action) => {
|
||||
if (action) startPolling()
|
||||
else stopPolling()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => status.value?.last_action_completed_at || '',
|
||||
(completedAt) => {
|
||||
if (!completedAt) return
|
||||
if (!completionNotificationsReady.value) {
|
||||
lastHandledCompletion.value = completedAt
|
||||
return
|
||||
}
|
||||
if (completedAt === lastHandledCompletion.value || actionLoading.value) return
|
||||
lastHandledCompletion.value = completedAt
|
||||
const completedAction = status.value?.last_action || ''
|
||||
if (status.value?.last_action_success === false) {
|
||||
message.error(errorCodeMessage(status.value.last_action_code, status.value.last_action_message))
|
||||
return
|
||||
}
|
||||
const successKey = actionSuccessKeys[completedAction]
|
||||
if (successKey) message.success(t(successKey))
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="github-preview-settings">
|
||||
<div class="settings-section">
|
||||
<div class="control-row">
|
||||
<NSelect
|
||||
v-model:value="selectedTag"
|
||||
class="tag-select"
|
||||
filterable
|
||||
:loading="tagsLoading"
|
||||
:options="tagOptions"
|
||||
:placeholder="t('githubPreview.selectTag')"
|
||||
/>
|
||||
<NSpace>
|
||||
<NButton type="primary" :loading="activeAction === 'prepare'" :disabled="hasActiveAction || !selectedTag" @click="handlePrepare">
|
||||
{{ t('githubPreview.prepare') }}
|
||||
</NButton>
|
||||
<NButton :loading="activeAction === 'install'" :disabled="hasActiveAction || !status?.has_package" @click="handleInstall">
|
||||
{{ t('githubPreview.install') }}
|
||||
</NButton>
|
||||
<NButton type="success" :loading="activeAction === 'start'" :disabled="hasActiveAction || !status?.installed" @click="handleStart">
|
||||
{{ t('githubPreview.start') }}
|
||||
</NButton>
|
||||
<NButton :loading="activeAction === 'stop'" :disabled="hasActiveAction || !status?.running" @click="handleStop">
|
||||
{{ t('githubPreview.stop') }}
|
||||
</NButton>
|
||||
<NButton :loading="loading || tagsLoading" @click="handleRefresh">
|
||||
{{ t('githubPreview.refresh') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</div>
|
||||
|
||||
<p class="section-description">{{ t('githubPreview.description') }}</p>
|
||||
|
||||
<NAlert type="info" :bordered="false" class="preview-note">
|
||||
{{ t('githubPreview.note') }}
|
||||
</NAlert>
|
||||
|
||||
<NDescriptions v-if="status" :column="1" bordered size="small" class="status-table">
|
||||
<NDescriptionsItem :label="t('githubPreview.path')">
|
||||
<code>{{ status.preview_dir }}</code>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.webuiHome')">
|
||||
<code>{{ status.webui_home }}</code>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.currentTag')">
|
||||
{{ status.current_tag || '-' }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.repoReady')">
|
||||
<NTag size="small" :type="status.has_package ? 'success' : 'default'">
|
||||
{{ status.has_package ? t('githubPreview.yes') : t('githubPreview.no') }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.dependencies')">
|
||||
<NTag size="small" :type="status.installed ? 'success' : 'warning'">
|
||||
{{ status.installed ? t('githubPreview.yes') : t('githubPreview.no') }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.running')">
|
||||
<NTag size="small" :type="status.running ? 'success' : 'default'">
|
||||
{{ status.running ? `PID ${status.pid}` : t('githubPreview.notRunning') }}
|
||||
</NTag>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.open')">
|
||||
<a :href="status.frontend_url" target="_blank" rel="noopener noreferrer">{{ status.frontend_url }}</a>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.log')">
|
||||
<code>{{ status.action_log_path }}</code>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem :label="t('githubPreview.devLog')">
|
||||
<code>{{ status.dev_log_path }}</code>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<div class="log-output">
|
||||
<div class="log-output-header">{{ t('githubPreview.logOutput') }}</div>
|
||||
<div class="log-box">
|
||||
<div class="log-title">{{ t('githubPreview.actionLog') }}</div>
|
||||
<pre>{{ actionLog || '-' }}</pre>
|
||||
</div>
|
||||
<div class="log-box">
|
||||
<div class="log-title">{{ t('githubPreview.devLog') }}</div>
|
||||
<pre>{{ devLog || '-' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.github-preview-settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0;
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-select {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
width: 100%;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
background: $bg-card;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-output-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.log-box {
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.log-title {
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
background: $bg-secondary;
|
||||
}
|
||||
|
||||
pre {
|
||||
min-height: 180px;
|
||||
max-height: 320px;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.control-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, NInputNumber, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 防抖保存:每个字段独立定时器,300ms 内只发最后一次 HTTP 请求
|
||||
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {}
|
||||
|
||||
function save(values: Record<string, any>) {
|
||||
// Switch 等一次性操作,直接保存,不需要防抖
|
||||
settingsStore.updateLocal('memory', values)
|
||||
settingsStore.saveSection('memory', values).then(() => {
|
||||
message.success(t('settings.saved'))
|
||||
}).catch(() => {
|
||||
message.error(t('settings.saveFailed'))
|
||||
})
|
||||
}
|
||||
|
||||
function debouncedSave(key: string, value: any) {
|
||||
// 先立即更新本地 store(UI 即时响应)
|
||||
settingsStore.updateLocal('memory', { [key]: value })
|
||||
// 再防抖发 HTTP 保存
|
||||
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
|
||||
debounceTimers[key] = setTimeout(async () => {
|
||||
try {
|
||||
await settingsStore.saveSection('memory', { [key]: value })
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.memory.enabled')" :hint="t('settings.memory.enabledHint')">
|
||||
<NSwitch :value="settingsStore.memory.memory_enabled" @update:value="v => save({ memory_enabled: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.memory.userProfile')" :hint="t('settings.memory.userProfileHint')">
|
||||
<NSwitch :value="settingsStore.memory.user_profile_enabled" @update:value="v => save({ user_profile_enabled: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.memory.charLimit')" :hint="t('settings.memory.charLimitHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.memory.memory_char_limit"
|
||||
:min="100" :max="10000" :step="100"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('memory_char_limit', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.memory.userCharLimit')" :hint="t('settings.memory.userCharLimitHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.memory.user_char_limit"
|
||||
:min="100" :max="10000" :step="100"
|
||||
size="small" class="input-sm"
|
||||
@update:value="v => v != null && debouncedSave('user_char_limit', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NInput, NButton, NSpin, NEmpty, useMessage } from 'naive-ui'
|
||||
import { useModelsStore } from '@/stores/hermes/models'
|
||||
import { updateProvider } from '@/api/hermes/system'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const savingKey = ref<string | null>(null)
|
||||
const editKeys = ref<Record<string, string>>({})
|
||||
|
||||
onMounted(() => {
|
||||
if (modelsStore.providers.length === 0) {
|
||||
modelsStore.fetchProviders()
|
||||
}
|
||||
})
|
||||
|
||||
const isCustom = (provider: string) => {
|
||||
const g = modelsStore.providers.find(p => p.provider === provider)
|
||||
return !g?.builtin && provider.startsWith('custom:')
|
||||
}
|
||||
|
||||
function getEditKey(provider: string): string {
|
||||
if (!(provider in editKeys.value)) {
|
||||
const g = modelsStore.providers.find(p => p.provider === provider)
|
||||
editKeys.value[provider] = g?.api_key || ''
|
||||
}
|
||||
return editKeys.value[provider]
|
||||
}
|
||||
|
||||
async function handleSaveApiKey(providerKey: string) {
|
||||
const key = getEditKey(providerKey)
|
||||
if (!key.trim()) {
|
||||
message.warning(t('settings.models.apiKeyPlaceholder'))
|
||||
return
|
||||
}
|
||||
savingKey.value = providerKey
|
||||
try {
|
||||
await updateProvider(providerKey, { api_key: key.trim() })
|
||||
message.success(t('settings.models.saved'))
|
||||
await modelsStore.fetchProviders()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('settings.models.saveFailed'))
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCustom(providerKey: string) {
|
||||
const key = getEditKey(providerKey)
|
||||
savingKey.value = providerKey
|
||||
try {
|
||||
await updateProvider(providerKey, { api_key: key.trim() })
|
||||
message.success(t('settings.models.saved'))
|
||||
await modelsStore.fetchProviders()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('settings.models.saveFailed'))
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<NSpin :show="modelsStore.loading">
|
||||
<div v-if="modelsStore.providers.length === 0" class="empty-hint">
|
||||
<NEmpty :description="t('settings.models.noProviders')" />
|
||||
</div>
|
||||
|
||||
<div v-for="g in modelsStore.providers" :key="g.provider" class="provider-section">
|
||||
<div class="provider-header">
|
||||
<h4 class="provider-name">{{ g.label }}</h4>
|
||||
<span class="type-badge" :class="isCustom(g.provider) ? 'custom' : 'builtin'">
|
||||
{{ isCustom(g.provider) ? t('models.customType') : t('models.builtIn') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Built-in provider: only API key -->
|
||||
<div v-if="!isCustom(g.provider)" class="provider-fields">
|
||||
<div class="field-row">
|
||||
<NInput
|
||||
:value="getEditKey(g.provider)"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('settings.models.apiKeyPlaceholder')"
|
||||
autocomplete="off"
|
||||
@update:value="v => editKeys[g.provider] = v"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingKey === g.provider"
|
||||
@click="handleSaveApiKey(g.provider)"
|
||||
>
|
||||
{{ t('settings.models.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom provider: API key -->
|
||||
<div v-else class="provider-fields">
|
||||
<div class="field-row">
|
||||
<NInput
|
||||
:value="getEditKey(g.provider)"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="t('settings.models.apiKeyPlaceholder')"
|
||||
autocomplete="off"
|
||||
@update:value="v => editKeys[g.provider] = v"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="savingKey === g.provider"
|
||||
@click="handleSaveCustom(g.provider)"
|
||||
>
|
||||
{{ t('settings.models.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
background: $bg-card;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.builtin {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.n-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NTag, NAlert } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
icon: string
|
||||
config: Record<string, any>
|
||||
credentials?: Record<string, any>
|
||||
exclusive?: boolean
|
||||
}>()
|
||||
|
||||
const expanded = ref(true)
|
||||
const { t } = useI18n()
|
||||
|
||||
const configured = computed(() => {
|
||||
const creds = props.credentials
|
||||
if (!creds) return false
|
||||
const keys = ['token', 'api_key', 'app_id', 'client_id', 'secret', 'app_secret', 'client_secret', 'access_token', 'bot_id', 'account_id', 'enabled']
|
||||
// Check top-level and nested extra.*
|
||||
const targets = [creds, creds.extra].filter(Boolean)
|
||||
return targets.some(obj =>
|
||||
keys.some(key => {
|
||||
const val = (obj as Record<string, any>)[key]
|
||||
return val !== undefined && val !== null && val !== '' && val !== false
|
||||
})
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-card" :class="{ configured }">
|
||||
<div class="platform-card-header" @click="expanded = !expanded">
|
||||
<div class="platform-info">
|
||||
<span class="platform-icon" v-html="icon" />
|
||||
<span class="platform-name">{{ name }}</span>
|
||||
<NTag :type="configured ? 'success' : 'default'" size="small" round>
|
||||
{{ configured ? t('common.configured') : t('common.notConfigured') }}
|
||||
</NTag>
|
||||
</div>
|
||||
<span class="expand-icon" :class="{ expanded }">▾</span>
|
||||
</div>
|
||||
<div v-if="expanded" class="platform-card-body">
|
||||
<NAlert v-if="exclusive" type="warning" :show-icon="true" class="exclusive-alert">
|
||||
{{ t('platform.exclusiveTokenWarning') }}
|
||||
</NAlert>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.platform-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
&.configured {
|
||||
border-color: rgba(var(--success-rgb), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.platform-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--text-primary-rgb), 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $text-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
&:not(.expanded) {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.platform-card-body {
|
||||
padding: 0 16px 12px;
|
||||
border-top: 1px solid $border-light;
|
||||
}
|
||||
|
||||
.exclusive-alert {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,510 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onUnmounted, watch } from 'vue'
|
||||
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/hermes/config'
|
||||
import PlatformCard from './PlatformCard.vue'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const saving = reactive<Record<string, boolean>>({})
|
||||
const configDrafts = reactive<Record<string, Record<string, any>>>({})
|
||||
const credentialDrafts = reactive<Record<string, Record<string, any>>>({})
|
||||
const touchedConfig = reactive<Record<string, boolean>>({})
|
||||
const touchedCredentials = reactive<Record<string, boolean>>({})
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value || {}))
|
||||
}
|
||||
|
||||
function mergeDeep(target: Record<string, any>, values: Record<string, any>) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
target[key] = mergeDeep({ ...(target[key] || {}) }, value as Record<string, any>)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
function configDraft(platform: string) {
|
||||
if (!configDrafts[platform]) {
|
||||
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
|
||||
}
|
||||
return configDrafts[platform]
|
||||
}
|
||||
|
||||
function credentialDraft(platform: string) {
|
||||
if (!credentialDrafts[platform]) credentialDrafts[platform] = cloneValue(getCreds(platform))
|
||||
return credentialDrafts[platform]
|
||||
}
|
||||
|
||||
function setConfigDraft(platform: string, values: Record<string, any>) {
|
||||
configDrafts[platform] = mergeDeep({ ...configDraft(platform) }, values)
|
||||
touchedConfig[platform] = true
|
||||
}
|
||||
|
||||
function setCredentialDraft(platform: string, values: Record<string, any>) {
|
||||
credentialDrafts[platform] = mergeDeep({ ...credentialDraft(platform) }, values)
|
||||
touchedCredentials[platform] = true
|
||||
}
|
||||
|
||||
function sameJson(a: unknown, b: unknown) {
|
||||
return JSON.stringify(a || {}) === JSON.stringify(b || {})
|
||||
}
|
||||
|
||||
function hasConfigChanges(platform: string) {
|
||||
return !!touchedConfig[platform] && !!configDrafts[platform] && !sameJson(configDrafts[platform], settingsStore[platform as keyof typeof settingsStore])
|
||||
}
|
||||
|
||||
function hasCredentialChanges(platform: string) {
|
||||
return !!touchedCredentials[platform] && !!credentialDrafts[platform] && !sameJson(credentialDrafts[platform], getCreds(platform))
|
||||
}
|
||||
|
||||
function hasUnsavedChanges(platform: string) {
|
||||
return hasConfigChanges(platform) || hasCredentialChanges(platform)
|
||||
}
|
||||
|
||||
function isSavingPlatform(platform: string) {
|
||||
return !!saving[platform]
|
||||
}
|
||||
|
||||
async function savePlatform(platform: string) {
|
||||
saving[platform] = true
|
||||
try {
|
||||
const configChanged = hasConfigChanges(platform)
|
||||
const credentialsChanged = hasCredentialChanges(platform)
|
||||
if (configChanged) {
|
||||
await settingsStore.saveSection(platform, configDraft(platform), { restart: !credentialsChanged })
|
||||
}
|
||||
if (credentialsChanged) {
|
||||
await saveCredsApi(platform, credentialDraft(platform))
|
||||
await settingsStore.fetchSettings()
|
||||
}
|
||||
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
|
||||
credentialDrafts[platform] = cloneValue(getCreds(platform))
|
||||
touchedConfig[platform] = false
|
||||
touchedCredentials[platform] = false
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('settings.saveFailed'))
|
||||
} finally {
|
||||
saving[platform] = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCreds(key: string) {
|
||||
return (settingsStore.platforms[key] || {}) as Record<string, any>
|
||||
}
|
||||
|
||||
function boolValue(value: unknown) {
|
||||
return value === true || value === 'true'
|
||||
}
|
||||
|
||||
// Weixin QR code login state
|
||||
const wxQrUrl = ref('')
|
||||
const wxQrId = ref('')
|
||||
const wxQrStatus = ref<'idle' | 'loading' | 'waiting' | 'scaned' | 'confirmed' | 'error' | 'expired'>('idle')
|
||||
let wxPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function startWeixinQrLogin() {
|
||||
wxQrStatus.value = 'loading'
|
||||
wxQrUrl.value = ''
|
||||
wxQrId.value = ''
|
||||
stopWeixinPoll()
|
||||
|
||||
try {
|
||||
const data = await fetchWeixinQrCode()
|
||||
wxQrId.value = data.qrcode
|
||||
wxQrUrl.value = data.qrcode_url
|
||||
window.open(data.qrcode_url, '_blank')
|
||||
wxQrStatus.value = 'waiting'
|
||||
pollWeixinStatus()
|
||||
} catch (err: any) {
|
||||
wxQrStatus.value = 'error'
|
||||
message.error(err.message || t('platform.qrFetching'))
|
||||
}
|
||||
}
|
||||
|
||||
function pollWeixinStatus() {
|
||||
if (!wxQrId.value) return
|
||||
wxPollTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await pollWeixinQrStatus(wxQrId.value)
|
||||
if (data.status === 'wait') {
|
||||
pollWeixinStatus()
|
||||
} else if (data.status === 'scaned') {
|
||||
wxQrStatus.value = 'scaned'
|
||||
pollWeixinStatus()
|
||||
} else if (data.status === 'expired') {
|
||||
wxQrStatus.value = 'expired'
|
||||
} else if (data.status === 'confirmed') {
|
||||
wxQrStatus.value = 'confirmed'
|
||||
await saveWeixinCredentials({
|
||||
account_id: data.account_id!,
|
||||
token: data.token!,
|
||||
base_url: data.base_url,
|
||||
})
|
||||
await settingsStore.fetchSettings()
|
||||
message.success(t('settings.saved'))
|
||||
}
|
||||
} catch {
|
||||
pollWeixinStatus()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopWeixinPoll() {
|
||||
if (wxPollTimer) {
|
||||
clearTimeout(wxPollTimer)
|
||||
wxPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWeixinPoll()
|
||||
})
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
key: 'telegram',
|
||||
name: 'Telegram',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'discord',
|
||||
name: 'Discord',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'slack',
|
||||
name: 'Slack',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 0a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V5.042zm-1.27 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1 2.523-2.52h6.313A2.528 2.528 0 0 1 24 18.956a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'whatsapp',
|
||||
name: 'WhatsApp',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'matrix',
|
||||
name: 'Matrix',
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.48.324.448.217.786.619 1.017 1.205.24-.376.558-.702.956-.98.398-.277.872-.414 1.424-.414.41 0 .784.065 1.122.194.34.13.629.325.87.588.241.263.428.59.56.984.132.393.198.85.198 1.368v5.89h-2.49v-4.893c0-.268-.016-.525-.048-.77a1.627 1.627 0 00-.2-.63 1.028 1.028 0 00-.392-.426 1.294 1.294 0 00-.616-.134c-.277 0-.508.05-.693.15a1.043 1.043 0 00-.43.41 1.768 1.768 0 00-.214.616 4.15 4.15 0 00-.06.74v4.937H9.29v-4.937c0-.25-.01-.498-.032-.742a1.84 1.84 0 00-.166-.638.998.998 0 00-.363-.448 1.206 1.206 0 00-.624-.154c-.26 0-.483.048-.67.144a1.055 1.055 0 00-.436.402 1.744 1.744 0 00-.227.616 4.108 4.108 0 00-.063.74v4.937H5.21V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
name: 'Feishu',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.59 3.41a2.25 2.25 0 0 1 3.182 0L13.5 7.14l-3.182 3.182L6.59 7.59a2.25 2.25 0 0 1 0-3.182zm5.303 5.303L15.075 5.53a2.25 2.25 0 0 1 3.182 3.182L15.075 11.894 11.893 8.713zM3.41 6.59a2.25 2.25 0 0 1 3.182 0l3.182 3.182-3.182 3.182a2.25 2.25 0 0 1-3.182-3.182L3.41 6.59zm5.303 5.303L11.894 15.075a2.25 2.25 0 0 1-3.182 3.182L5.53 15.075 8.713 11.893zm5.303-5.303L17.478 9.778a2.25 2.25 0 0 1-3.182 3.182L10.53 10.075l3.182-3.182 0 .023z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'dingtalk',
|
||||
name: 'DingTalk',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.76 7.05c-.23-.52-.7-.9-1.26-1.02L5.35 3.2c-.77-.16-1.51.38-1.58 1.16-.22 2.55.17 5.4 1.13 7.66.97 2.29 2.52 4.11 4.45 4.82l-1.28 3.03c-.17.4.24.79.63.59l9.47-4.83c.34-.17.55-.52.55-.9v-3.12c.73-.4 1.22-1.17 1.22-2.06 0-.87-.08-1.73-.18-2.5zm-3.66 5.95-5.19 2.65.76-1.8c.12-.29-.03-.62-.33-.72-2.1-.73-3.56-3.54-3.95-6.73l9.27 2c.04.38.07.76.07 1.15 0 .45-.36.81-.81.81h-2.79c-.35 0-.63.28-.63.63s.28.63.63.63h2.97V13z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'qqbot',
|
||||
name: 'QQBot',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C7.58 2 4 5.27 4 9.31c0 2.3 1.15 4.34 2.95 5.68-.13.58-.48 1.62-1.26 2.53-.24.28-.05.72.32.73 1.72.05 3.02-.68 3.69-1.15.72.16 1.49.25 2.3.25 4.42 0 8-3.27 8-7.31S16.42 2 12 2zm-3.2 7.63c-.63 0-1.14-.55-1.14-1.23s.51-1.23 1.14-1.23 1.14.55 1.14 1.23-.51 1.23-1.14 1.23zm6.4 0c-.63 0-1.14-.55-1.14-1.23s.51-1.23 1.14-1.23 1.14.55 1.14 1.23-.51 1.23-1.14 1.23zM5.5 20.5a.5.5 0 0 1 .5-.5h12a.5.5 0 0 1 0 1H6a.5.5 0 0 1-.5-.5z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'weixin',
|
||||
name: 'Weixin',
|
||||
exclusive: true,
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
|
||||
},
|
||||
{
|
||||
key: 'wecom',
|
||||
name: 'WeCom',
|
||||
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
|
||||
},
|
||||
]
|
||||
|
||||
watch(
|
||||
() => platforms.map((platform) => ({
|
||||
key: platform.key,
|
||||
config: settingsStore[platform.key as keyof typeof settingsStore],
|
||||
credentials: getCreds(platform.key),
|
||||
})),
|
||||
(items) => {
|
||||
for (const item of items) {
|
||||
if (!touchedConfig[item.key]) {
|
||||
configDrafts[item.key] = cloneValue(item.config as Record<string, any>)
|
||||
}
|
||||
if (!touchedCredentials[item.key]) {
|
||||
credentialDrafts[item.key] = cloneValue(item.credentials)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<PlatformCard
|
||||
v-for="p in platforms"
|
||||
:key="p.key"
|
||||
:name="p.name"
|
||||
:icon="p.icon"
|
||||
:exclusive="p.exclusive"
|
||||
:config="settingsStore[p.key as keyof typeof settingsStore] as Record<string, any>"
|
||||
:credentials="getCreds(p.key)"
|
||||
>
|
||||
<!-- Telegram -->
|
||||
<template v-if="p.key === 'telegram'">
|
||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||
<NInput :value="credentialDraft('telegram').token || ''" :loading="isSavingPlatform('telegram')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => setCredentialDraft('telegram', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="configDraft('telegram').require_mention" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
|
||||
<NSwitch :value="configDraft('telegram').reactions" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { reactions: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="configDraft('telegram').free_response_chats || ''" :loading="isSavingPlatform('telegram')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('telegram', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||
<NInput :value="(configDraft('telegram').mention_patterns || []).join(', ')" :loading="isSavingPlatform('telegram')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Discord -->
|
||||
<template v-if="p.key === 'discord'">
|
||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||
<NInput :value="credentialDraft('discord').token || ''" :loading="isSavingPlatform('discord')" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => setCredentialDraft('discord', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||
<NSwitch :value="configDraft('discord').require_mention" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
|
||||
<NSwitch :value="configDraft('discord').auto_thread" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { auto_thread: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
|
||||
<NSwitch :value="configDraft('discord').reactions" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { reactions: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||
<NInput :value="configDraft('discord').free_response_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { free_response_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
|
||||
<NInput :value="configDraft('discord').allowed_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { allowed_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
|
||||
<NInput :value="configDraft('discord').ignored_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { ignored_channels: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
|
||||
<NInput :value="configDraft('discord').no_thread_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { no_thread_channels: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Slack -->
|
||||
<template v-if="p.key === 'slack'">
|
||||
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
|
||||
<NInput :value="credentialDraft('slack').token || ''" :loading="isSavingPlatform('slack')" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => setCredentialDraft('slack', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
|
||||
<NSwitch :value="configDraft('slack').require_mention" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
|
||||
<NSwitch :value="configDraft('slack').allow_bots" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { allow_bots: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
|
||||
<NInput :value="configDraft('slack').free_response_channels || ''" :loading="isSavingPlatform('slack')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('slack', { free_response_channels: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- WhatsApp -->
|
||||
<template v-if="p.key === 'whatsapp'">
|
||||
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
|
||||
<NSwitch :value="credentialDraft('whatsapp').enabled" :loading="isSavingPlatform('whatsapp')" @update:value="v => setCredentialDraft('whatsapp', { enabled: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="configDraft('whatsapp').require_mention" :loading="isSavingPlatform('whatsapp')" @update:value="v => setConfigDraft('whatsapp', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="configDraft('whatsapp').free_response_chats || ''" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('whatsapp', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
|
||||
<NInput :value="(configDraft('whatsapp').mention_patterns || []).join(', ')" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Matrix -->
|
||||
<template v-if="p.key === 'matrix'">
|
||||
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
|
||||
<NInput :value="credentialDraft('matrix').token || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => setCredentialDraft('matrix', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
|
||||
<NInput :value="credentialDraft('matrix').extra?.homeserver || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => setCredentialDraft('matrix', { extra: { ...credentialDraft('matrix').extra, homeserver: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
|
||||
<NSwitch :value="configDraft('matrix').require_mention" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
|
||||
<NSwitch :value="configDraft('matrix').auto_thread" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { auto_thread: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
|
||||
<NSwitch :value="configDraft('matrix').dm_mention_threads" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { dm_mention_threads: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
|
||||
<NInput :value="configDraft('matrix').free_response_rooms || ''" :loading="isSavingPlatform('matrix')" size="small" placeholder="room_id1,room_id2" @update:value="v => setConfigDraft('matrix', { free_response_rooms: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Feishu -->
|
||||
<template v-if="p.key === 'feishu'">
|
||||
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
|
||||
<NInput :value="credentialDraft('feishu').extra?.app_id || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
|
||||
<NInput :value="credentialDraft('feishu').extra?.app_secret || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="configDraft('feishu').require_mention" :loading="isSavingPlatform('feishu')" @update:value="v => setConfigDraft('feishu', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="configDraft('feishu').free_response_chats || ''" :loading="isSavingPlatform('feishu')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('feishu', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- DingTalk -->
|
||||
<template v-if="p.key === 'dingtalk'">
|
||||
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
|
||||
<NInput :value="credentialDraft('dingtalk').extra?.client_id || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
|
||||
<NInput :value="credentialDraft('dingtalk').extra?.client_secret || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.cardTemplateId')" :hint="t('platform.cardTemplateIdHint')">
|
||||
<NInput :value="credentialDraft('dingtalk').extra?.card_template_id || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="AI Card Template ID" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, card_template_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
|
||||
<NSwitch :value="boolValue(credentialDraft('dingtalk').allow_all_users)" :loading="isSavingPlatform('dingtalk')" @update:value="v => setCredentialDraft('dingtalk', { allow_all_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
|
||||
<NInput :value="credentialDraft('dingtalk').allowed_users || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @update:value="v => setCredentialDraft('dingtalk', { allowed_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
|
||||
<NSwitch :value="configDraft('dingtalk').require_mention" :loading="isSavingPlatform('dingtalk')" @update:value="v => setConfigDraft('dingtalk', { require_mention: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
|
||||
<NInput :value="configDraft('dingtalk').free_response_chats || ''" :loading="isSavingPlatform('dingtalk')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('dingtalk', { free_response_chats: v })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- QQBot -->
|
||||
<template v-if="p.key === 'qqbot'">
|
||||
<SettingRow :label="t('platform.qqAppId')" :hint="t('platform.qqAppIdHint')">
|
||||
<NInput :value="credentialDraft('qqbot').extra?.app_id || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App ID" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, app_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.qqAppSecret')" :hint="t('platform.qqAppSecretHint')">
|
||||
<NInput :value="credentialDraft('qqbot').extra?.client_secret || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, client_secret: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
|
||||
<NInput :value="credentialDraft('qqbot').allowed_users || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @update:value="v => setCredentialDraft('qqbot', { allowed_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
|
||||
<NSwitch :value="boolValue(credentialDraft('qqbot').allow_all_users)" :loading="isSavingPlatform('qqbot')" @update:value="v => setCredentialDraft('qqbot', { allow_all_users: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.qqMarkdown')" :hint="t('platform.qqMarkdownHint')">
|
||||
<NSwitch :value="configDraft('qqbot').extra?.markdown_support ?? true" :loading="isSavingPlatform('qqbot')" @update:value="v => setConfigDraft('qqbot', { extra: { ...configDraft('qqbot').extra, markdown_support: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- Weixin -->
|
||||
<template v-if="p.key === 'weixin'">
|
||||
<div class="weixin-qr-section">
|
||||
<NButton
|
||||
v-if="wxQrStatus === 'idle' || wxQrStatus === 'error' || wxQrStatus === 'expired' || wxQrStatus === 'confirmed'"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="startWeixinQrLogin"
|
||||
>
|
||||
{{ wxQrStatus === 'confirmed' ? t('platform.qrRelogin') : t('platform.qrLogin') }}
|
||||
</NButton>
|
||||
<div v-if="wxQrStatus === 'loading'" class="weixin-qr-loading">
|
||||
<NSpin size="small" />
|
||||
<span>{{ t('platform.qrFetching') }}</span>
|
||||
</div>
|
||||
<div v-if="wxQrStatus === 'waiting' || wxQrStatus === 'scaned'" class="weixin-qr-hint">
|
||||
{{ wxQrStatus === 'scaned' ? t('platform.qrScanedHint') : t('platform.qrScanHint') }}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
|
||||
<NInput :value="credentialDraft('weixin').token || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => setCredentialDraft('weixin', { token: v })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
|
||||
<NInput :value="credentialDraft('weixin').extra?.account_id || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => setCredentialDraft('weixin', { extra: { ...credentialDraft('weixin').extra, account_id: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- WeCom -->
|
||||
<template v-if="p.key === 'wecom'">
|
||||
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
|
||||
<NInput :value="credentialDraft('wecom').extra?.bot_id || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, bot_id: v } })" />
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
|
||||
<NInput :value="credentialDraft('wecom').extra?.secret || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, secret: v } })" />
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<div class="platform-actions">
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="isSavingPlatform(p.key)"
|
||||
:disabled="!hasUnsavedChanges(p.key)"
|
||||
@click="savePlatform(p.key)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</PlatformCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.weixin-qr-section {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.weixin-qr-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.weixin-qr-hint {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.platform-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid $border-light;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
async function save(values: Record<string, any>) {
|
||||
try {
|
||||
await settingsStore.saveSection('privacy', values)
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.privacy.redactPii')" :hint="t('settings.privacy.redactPiiHint')">
|
||||
<NSwitch :value="settingsStore.privacy.redact_pii" @update:value="v => save({ redact_pii: v })" />
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { NInputNumber, NSelect, NSwitch, useMessage } from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useSettingsStore } from "@/stores/hermes/settings";
|
||||
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
|
||||
import SettingRow from "./SettingRow.vue";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
|
||||
const message = useMessage();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 防抖保存:每个字段独立定时器,300ms 内只发最后一次 HTTP 请求
|
||||
const debounceTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
function save(values: Record<string, any>) {
|
||||
// NSelect/NSwitch 等一次性操作,直接保存,不需要防抖
|
||||
settingsStore.updateLocal('session_reset', values)
|
||||
settingsStore.saveSection('session_reset', values).then(() => {
|
||||
message.success(t("settings.saved"));
|
||||
}).catch(() => {
|
||||
message.error(t("settings.saveFailed"));
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedSave(key: string, value: any) {
|
||||
// 先立即更新本地 store(UI 即时响应)
|
||||
settingsStore.updateLocal('session_reset', { [key]: value });
|
||||
// 再防抖发 HTTP 保存
|
||||
if (debounceTimers[key]) clearTimeout(debounceTimers[key])
|
||||
debounceTimers[key] = setTimeout(async () => {
|
||||
try {
|
||||
await settingsStore.saveSection('session_reset', { [key]: value });
|
||||
message.success(t("settings.saved"));
|
||||
} catch (err: any) {
|
||||
message.error(t("settings.saveFailed"));
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function toggleRequireAuth(value: boolean) {
|
||||
try {
|
||||
await settingsStore.saveSection("approvals", { mode: value ? "manual" : "off" });
|
||||
message.success(t("settings.saved"));
|
||||
} catch (err: any) {
|
||||
message.error(t("settings.saveFailed"));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<SettingRow
|
||||
:label="t('settings.session.requireAuth')"
|
||||
:hint="t('settings.session.requireAuthHint')"
|
||||
>
|
||||
<NSwitch :value="settingsStore.approvals.mode === 'manual'" @update:value="toggleRequireAuth" />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.session.mode')"
|
||||
:hint="t('settings.session.modeHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="settingsStore.sessionReset.mode || 'both'"
|
||||
:options="[
|
||||
{ label: t('settings.session.modeBoth'), value: 'both' },
|
||||
{ label: t('settings.session.modeIdle'), value: 'idle' },
|
||||
{ label: t('settings.session.modeDaily'), value: 'daily' },
|
||||
{ label: t('settings.session.modeNone'), value: 'none' },
|
||||
]"
|
||||
size="small"
|
||||
class="input-md"
|
||||
@update:value="(v) => save({ mode: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.session.idleMinutes')"
|
||||
:hint="t('settings.session.idleMinutesHint')"
|
||||
>
|
||||
<NInputNumber
|
||||
:value="settingsStore.sessionReset.idle_minutes"
|
||||
:min="10"
|
||||
:max="10080"
|
||||
:step="30"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="(v) => v != null && debouncedSave('idle_minutes', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.session.atHour')"
|
||||
:hint="t('settings.session.atHourHint')"
|
||||
>
|
||||
<NInputNumber
|
||||
:value="settingsStore.sessionReset.at_hour"
|
||||
:min="0"
|
||||
:max="23"
|
||||
:step="1"
|
||||
size="small"
|
||||
class="input-sm"
|
||||
@update:value="(v) => v != null && debouncedSave('at_hour', v)"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
:label="t('settings.session.liveMonitorHumanOnly')"
|
||||
:hint="t('settings.session.liveMonitorHumanOnlyHint')"
|
||||
>
|
||||
<NSwitch
|
||||
:value="sessionBrowserPrefsStore.humanOnly"
|
||||
@update:value="(value) => sessionBrowserPrefsStore.setHumanOnly(value)"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.settings-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
hint?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label class="setting-label">{{ label }}</label>
|
||||
<p v-if="hint" class="setting-hint">{{ hint }}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $border-light;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 13px;
|
||||
color: $text-primary;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.setting-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSelect, NSpace, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
createManagedUser,
|
||||
deleteManagedUser,
|
||||
fetchManagedUsers,
|
||||
updateManagedUser,
|
||||
type ManagedUser,
|
||||
type UserRole,
|
||||
type UserStatus,
|
||||
} from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const users = ref<ManagedUser[]>([])
|
||||
const profiles = ref<string[]>([])
|
||||
const showModal = ref(false)
|
||||
const editingUser = ref<ManagedUser | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'admin' as UserRole,
|
||||
status: 'active' as UserStatus,
|
||||
profiles: [] as string[],
|
||||
})
|
||||
|
||||
const roleOptions = computed(() => [
|
||||
{ label: t('users.roles.admin'), value: 'admin' },
|
||||
{ label: t('users.roles.superAdmin'), value: 'super_admin' },
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: t('users.status.active'), value: 'active' },
|
||||
{ label: t('users.status.disabled'), value: 'disabled' },
|
||||
])
|
||||
|
||||
const profileOptions = computed(() => profiles.value.map(profile => ({ label: profile, value: profile })))
|
||||
|
||||
function resetForm() {
|
||||
editingUser.value = null
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.role = 'admin'
|
||||
form.status = 'active'
|
||||
form.profiles = []
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchManagedUsers()
|
||||
users.value = res.users
|
||||
profiles.value = res.profiles
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('users.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(user: ManagedUser) {
|
||||
editingUser.value = user
|
||||
form.username = user.username
|
||||
form.password = ''
|
||||
form.role = user.role
|
||||
form.status = user.status
|
||||
form.profiles = [...user.profiles]
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (form.username.trim().length < 2) {
|
||||
message.error(t('login.usernameTooShort'))
|
||||
return
|
||||
}
|
||||
if (!editingUser.value && form.password.length < 6) {
|
||||
message.error(t('login.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
if (form.password && form.password.length < 6) {
|
||||
message.error(t('login.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
username: form.username.trim(),
|
||||
password: form.password || undefined,
|
||||
role: form.role,
|
||||
status: form.status,
|
||||
profiles: form.role === 'super_admin' ? [] : form.profiles,
|
||||
defaultProfile: form.profiles[0] || null,
|
||||
}
|
||||
const res = editingUser.value
|
||||
? await updateManagedUser(editingUser.value.id, payload)
|
||||
: await createManagedUser({ ...payload, password: form.password })
|
||||
users.value = res.users
|
||||
profiles.value = res.profiles
|
||||
showModal.value = false
|
||||
resetForm()
|
||||
message.success(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('common.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(user: ManagedUser, status: UserStatus) {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await updateManagedUser(user.id, { status })
|
||||
users.value = res.users
|
||||
profiles.value = res.profiles
|
||||
message.success(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('common.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(user: ManagedUser) {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await deleteManagedUser(user.id)
|
||||
users.value = res.users
|
||||
profiles.value = res.profiles
|
||||
message.success(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('common.deleteFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: number | null): string {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<ManagedUser>>(() => [
|
||||
{
|
||||
title: t('users.username'),
|
||||
key: 'username',
|
||||
minWidth: 140,
|
||||
},
|
||||
{
|
||||
title: t('users.role'),
|
||||
key: 'role',
|
||||
width: 130,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.role === 'super_admin' ? 'warning' : 'default' }, {
|
||||
default: () => row.role === 'super_admin' ? t('users.roles.superAdmin') : t('users.roles.admin'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('users.statusLabel'),
|
||||
key: 'status',
|
||||
width: 110,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'error' }, {
|
||||
default: () => row.status === 'active' ? t('users.status.active') : t('users.status.disabled'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('users.profiles'),
|
||||
key: 'profiles',
|
||||
minWidth: 200,
|
||||
render: (row) => row.role === 'super_admin'
|
||||
? h('span', { class: 'muted' }, t('users.allProfiles'))
|
||||
: h(NSpace, { size: 4 }, {
|
||||
default: () => row.profiles.length
|
||||
? row.profiles.map(profile => h(NTag, { size: 'small', bordered: false }, { default: () => profile }))
|
||||
: h('span', { class: 'muted' }, t('users.noProfiles')),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('users.lastLogin'),
|
||||
key: 'last_login_at',
|
||||
minWidth: 170,
|
||||
render: (row) => formatTime(row.last_login_at),
|
||||
},
|
||||
{
|
||||
title: t('common.edit'),
|
||||
key: 'actions',
|
||||
width: 280,
|
||||
render: (row) => h(NSpace, { size: 8 }, {
|
||||
default: () => [
|
||||
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => t('common.edit') }),
|
||||
h(NButton, {
|
||||
size: 'small',
|
||||
type: row.status === 'active' ? 'warning' : 'primary',
|
||||
ghost: true,
|
||||
loading: saving.value,
|
||||
onClick: () => setStatus(row, row.status === 'active' ? 'disabled' : 'active'),
|
||||
}, { default: () => row.status === 'active' ? t('users.disable') : t('users.enable') }),
|
||||
h(NPopconfirm, { onPositiveClick: () => removeUser(row) }, {
|
||||
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true, loading: saving.value }, { default: () => t('common.delete') }),
|
||||
default: () => t('users.deleteConfirm'),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-management">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h3 class="section-title">{{ t('users.title') }}</h3>
|
||||
<p class="section-desc">{{ t('users.description') }}</p>
|
||||
</div>
|
||||
<NButton type="primary" @click="openCreate">{{ t('users.create') }}</NButton>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="users"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<NModal v-model:show="showModal" preset="dialog" :title="editingUser ? t('users.edit') : t('users.create')">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem :label="t('users.username')">
|
||||
<NInput v-model:value="form.username" :placeholder="t('login.usernamePlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="editingUser ? t('users.newPasswordOptional') : t('login.newPassword')">
|
||||
<NInput v-model:value="form.password" type="password" show-password-on="click" :placeholder="t('login.passwordPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('users.role')">
|
||||
<NSelect v-model:value="form.role" :options="roleOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('users.statusLabel')">
|
||||
<NSelect v-model:value="form.status" :options="statusOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem v-if="form.role !== 'super_admin'" :label="t('users.profiles')">
|
||||
<NSelect
|
||||
v-model:value="form.profiles"
|
||||
multiple
|
||||
filterable
|
||||
:options="profileOptions"
|
||||
:placeholder="t('users.profilesPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #action>
|
||||
<NButton @click="showModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="submit">{{ t('common.save') }}</NButton>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.user-management {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
:deep(.muted) {
|
||||
color: $text-muted;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,517 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NSelect, NInput, NButton, NSlider } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useVoiceSettings } from '@/composables/useVoiceSettings'
|
||||
import { useSpeech } from '@/composables/useSpeech'
|
||||
import { speedToEdgeRate, hzToEdgePitch } from '@/utils/ttsHelpers'
|
||||
import SettingRow from './SettingRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const vs = useVoiceSettings()
|
||||
const speech = useSpeech()
|
||||
|
||||
const testText = ref(t('settings.voice.testTextDefault'))
|
||||
const testPlaying = ref(false)
|
||||
|
||||
const providerOptions = [
|
||||
{ label: t('settings.voice.providerWebSpeech'), value: 'webspeech' },
|
||||
{ label: t('settings.voice.providerOpenai'), value: 'openai' },
|
||||
{ label: t('settings.voice.providerCustom'), value: 'custom' },
|
||||
{ label: t('settings.voice.providerEdge'), value: 'edge' },
|
||||
{ label: t('settings.voice.providerMimo'), value: 'mimo' },
|
||||
]
|
||||
|
||||
const openaiModelOptions = [
|
||||
{ label: 'tts-1', value: 'tts-1' },
|
||||
{ label: 'tts-1-hd', value: 'tts-1-hd' },
|
||||
]
|
||||
|
||||
const openaiVoiceOptions = [
|
||||
{ label: 'Alloy', value: 'alloy' },
|
||||
{ label: 'Echo', value: 'echo' },
|
||||
{ label: 'Fable', value: 'fable' },
|
||||
{ label: 'Nova', value: 'nova' },
|
||||
{ label: 'Onyx', value: 'onyx' },
|
||||
{ label: 'Shimmer', value: 'shimmer' },
|
||||
]
|
||||
|
||||
const edgeVoiceOptions = [
|
||||
{ label: '晓晓 (zh-CN-XiaoxiaoNeural)', value: 'zh-CN-XiaoxiaoNeural' },
|
||||
{ label: '晓萱 (zh-CN-XiaoxuanNeural)', value: 'zh-CN-XiaoxuanNeural' },
|
||||
{ label: '云希 (zh-CN-YunxiNeural)', value: 'zh-CN-YunxiNeural' },
|
||||
{ label: '云健 (zh-CN-YunjianNeural)', value: 'zh-CN-YunjianNeural' },
|
||||
{ label: '云扬 (zh-CN-YunyangNeural)', value: 'zh-CN-YunyangNeural' },
|
||||
{ label: '小晨 (zh-TW-HsiaoChenNeural)', value: 'zh-TW-HsiaoChenNeural' },
|
||||
{ label: '小宇 (zh-TW-HsiaoYuNeural)', value: 'zh-TW-HsiaoYuNeural' },
|
||||
{ label: '云哲 (zh-TW-YunJheNeural)', value: 'zh-TW-YunJheNeural' },
|
||||
{ label: '希雅 (zh-HK-HiuGaaiNeural)', value: 'zh-HK-HiuGaaiNeural' },
|
||||
{ label: '希文 (zh-HK-HiuMaanNeural)', value: 'zh-HK-HiuMaanNeural' },
|
||||
{ label: '文龙 (zh-HK-WanLungNeural)', value: 'zh-HK-WanLungNeural' },
|
||||
{ label: 'Jenny (en-US-JennyNeural)', value: 'en-US-JennyNeural' },
|
||||
{ label: 'Aria (en-US-AriaNeural)', value: 'en-US-AriaNeural' },
|
||||
{ label: 'Guy (en-US-GuyNeural)', value: 'en-US-GuyNeural' },
|
||||
{ label: 'Sonia (en-GB-SoniaNeural)', value: 'en-GB-SoniaNeural' },
|
||||
{ label: 'Ryan (en-GB-RyanNeural)', value: 'en-GB-RyanNeural' },
|
||||
{ label: 'Nanami (ja-JP-NanamiNeural)', value: 'ja-JP-NanamiNeural' },
|
||||
{ label: 'Keita (ja-JP-KeitaNeural)', value: 'ja-JP-KeitaNeural' },
|
||||
{ label: 'Sun-Hi (ko-KR-SunHiNeural)', value: 'ko-KR-SunHiNeural' },
|
||||
{ label: 'InJoon (ko-KR-InJoonNeural)', value: 'ko-KR-InJoonNeural' },
|
||||
{ label: 'Denise (fr-FR-DeniseNeural)', value: 'fr-FR-DeniseNeural' },
|
||||
{ label: 'Henri (fr-FR-HenriNeural)', value: 'fr-FR-HenriNeural' },
|
||||
{ label: 'Katja (de-DE-KatjaNeural)', value: 'de-DE-KatjaNeural' },
|
||||
{ label: 'Conrad (de-DE-ConradNeural)', value: 'de-DE-ConradNeural' },
|
||||
]
|
||||
|
||||
// Get WebSpeech voices list on mount
|
||||
const webspeechVoices = ref<SpeechSynthesisVoice[]>([])
|
||||
onMounted(() => {
|
||||
if ('speechSynthesis' in window) {
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
if (voices.length) {
|
||||
webspeechVoices.value = voices
|
||||
}
|
||||
window.speechSynthesis.onvoiceschanged = () => {
|
||||
webspeechVoices.value = window.speechSynthesis.getVoices()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── MiMo TTS options ──
|
||||
const mimoBaseUrlOptions = [
|
||||
{ label: 'https://api.xiaomimimo.com/v1', value: 'https://api.xiaomimimo.com/v1' },
|
||||
{ label: 'https://token-plan-cn.xiaomimimo.com/v1', value: 'https://token-plan-cn.xiaomimimo.com/v1' },
|
||||
]
|
||||
|
||||
const mimoModelOptions = [
|
||||
{ label: t('settings.voice.mimoModelPreset'), value: 'mimo-v2.5-tts' },
|
||||
{ label: t('settings.voice.mimoModelVoiceDesign'), value: 'mimo-v2.5-tts-voicedesign' },
|
||||
]
|
||||
|
||||
const mimoVoiceOptions = [
|
||||
{ label: '冰糖 (中文·女)', value: '冰糖' },
|
||||
{ label: '茉莉 (中文·女)', value: '茉莉' },
|
||||
{ label: '苏打 (中文·男)', value: '苏打' },
|
||||
{ label: '白桦 (中文·男)', value: '白桦' },
|
||||
{ label: 'Mia (English·Female)', value: 'Mia' },
|
||||
{ label: 'Chloe (English·Female)', value: 'Chloe' },
|
||||
{ label: 'Milo (English·Male)', value: 'Milo' },
|
||||
{ label: 'Dean (English·Male)', value: 'Dean' },
|
||||
]
|
||||
|
||||
async function handleTest() {
|
||||
const text = testText.value.trim()
|
||||
if (!text) return
|
||||
testPlaying.value = true
|
||||
try {
|
||||
if (vs.provider.value === 'webspeech') {
|
||||
speech.stop(false)
|
||||
speech.speakViaBrowser('__test__', text, {
|
||||
voiceName: vs.webspeechVoice.value || undefined,
|
||||
})
|
||||
} else if (vs.provider.value === 'openai') {
|
||||
if (!vs.openaiBaseUrl.value) {
|
||||
console.warn('[VoiceSettings] OpenAI base URL empty')
|
||||
return
|
||||
}
|
||||
await speech.openaiPlay('__test__', text, {
|
||||
baseUrl: vs.openaiBaseUrl.value,
|
||||
apiKey: vs.openaiApiKey.value || undefined,
|
||||
model: vs.openaiModel.value,
|
||||
voice: vs.openaiVoice.value,
|
||||
})
|
||||
} else if (vs.provider.value === 'custom') {
|
||||
if (!vs.customUrl.value) {
|
||||
console.warn('[VoiceSettings] Custom URL empty')
|
||||
return
|
||||
}
|
||||
await speech.openaiPlay('__test__', text, {
|
||||
baseUrl: vs.customUrl.value,
|
||||
apiKey: vs.customApiKey.value || undefined,
|
||||
})
|
||||
} else if (vs.provider.value === 'edge') {
|
||||
await speech.openaiPlay('__test__', text, {
|
||||
baseUrl: '/api/tts/proxy',
|
||||
voice: vs.edgeVoice.value,
|
||||
rate: speedToEdgeRate(vs.edgeRate.value),
|
||||
pitch: hzToEdgePitch(vs.edgePitchHz.value),
|
||||
})
|
||||
} else if (vs.provider.value === 'mimo') {
|
||||
if (!vs.mimoApiKey.value) {
|
||||
console.warn('[VoiceSettings] MiMo API Key empty')
|
||||
return
|
||||
}
|
||||
await speech.mimoPlay('__test__', text, {
|
||||
baseUrl: vs.mimoBaseUrl.value,
|
||||
apiKey: vs.mimoApiKey.value,
|
||||
model: vs.mimoModel.value,
|
||||
voice: vs.mimoVoice.value,
|
||||
voiceDesignDesc: vs.mimoVoiceDesignDesc.value || undefined,
|
||||
stylePrompt: vs.mimoStylePrompt.value || undefined,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VoiceSettings] Test failed:', err)
|
||||
} finally {
|
||||
testPlaying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-settings">
|
||||
<SettingRow
|
||||
:label="t('settings.voice.ttsProvider')"
|
||||
:hint="t('settings.voice.ttsProviderHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.provider.value"
|
||||
:options="providerOptions"
|
||||
size="small"
|
||||
style="width: 300px"
|
||||
@update:value="vs.setProvider"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<!-- ════ WebSpeech API ════ -->
|
||||
<template v-if="vs.provider.value === 'webspeech'">
|
||||
<SettingRow
|
||||
:label="t('settings.voice.webspeechVoice')"
|
||||
:hint="t('settings.voice.webspeechVoiceHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.webspeechVoice.value"
|
||||
size="small"
|
||||
filterable
|
||||
style="width: 320px"
|
||||
:placeholder="t('settings.voice.webspeechVoicePlaceholder')"
|
||||
:consistent-menu-width="false"
|
||||
:options="webspeechVoices.map(v => ({
|
||||
label: `${v.name} (${v.lang})`,
|
||||
value: v.name,
|
||||
}))"
|
||||
@update:value="vs.setWebSpeechVoice"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- ════ OpenAI TTS ════ -->
|
||||
<template v-if="vs.provider.value === 'openai'">
|
||||
<SettingRow
|
||||
:label="t('settings.voice.openaiKey')"
|
||||
:hint="t('settings.voice.openaiKeyHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.openaiApiKey.value"
|
||||
type="password"
|
||||
size="small"
|
||||
show-password-on="click"
|
||||
style="width: 360px"
|
||||
placeholder="sk-..."
|
||||
@update:value="vs.setOpenaiApiKey"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.openaiUrl')"
|
||||
:hint="t('settings.voice.openaiUrlHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.openaiBaseUrl.value"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
placeholder="https://api.openai.com/v1/audio/speech"
|
||||
@update:value="vs.setOpenaiBaseUrl"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.openaiModel')"
|
||||
:hint="t('settings.voice.openaiModelHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.openaiModel.value"
|
||||
:options="openaiModelOptions"
|
||||
size="small"
|
||||
style="width: 200px"
|
||||
@update:value="vs.setOpenaiModel"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.openaiVoice')"
|
||||
:hint="t('settings.voice.openaiVoiceHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.openaiVoice.value"
|
||||
:options="openaiVoiceOptions"
|
||||
size="small"
|
||||
style="width: 200px"
|
||||
@update:value="vs.setOpenaiVoice"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- ════ Custom Endpoint ════ -->
|
||||
<template v-if="vs.provider.value === 'custom'">
|
||||
<div class="provider-hint">
|
||||
{{ t('settings.voice.customHint') }}
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.customUrl')"
|
||||
:hint="t('settings.voice.customUrlHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.customUrl.value"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:placeholder="t('settings.voice.customUrlPlaceholder')"
|
||||
@update:value="vs.setCustomUrl"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.customApiKey')"
|
||||
:hint="t('settings.voice.customApiKeyHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.customApiKey.value"
|
||||
type="password"
|
||||
size="small"
|
||||
show-password-on="click"
|
||||
style="width: 360px"
|
||||
:placeholder="t('settings.voice.customApiKeyPlaceholder')"
|
||||
@update:value="vs.setCustomApiKey"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<!-- ════ Edge TTS ════ -->
|
||||
<template v-if="vs.provider.value === 'edge'">
|
||||
<div class="provider-hint">
|
||||
{{ t('settings.voice.edgeHint') }}
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.edgeVoice')"
|
||||
:hint="t('settings.voice.edgeVoiceHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.edgeVoice.value"
|
||||
:options="edgeVoiceOptions"
|
||||
size="small"
|
||||
filterable
|
||||
style="width: 320px"
|
||||
:consistent-menu-width="false"
|
||||
@update:value="vs.setEdgeVoice"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.edgeRate')"
|
||||
:hint="t('settings.voice.edgeRateHint')"
|
||||
>
|
||||
<div class="slider-row">
|
||||
<NSlider
|
||||
:value="vs.edgeRate.value"
|
||||
:min="0.5"
|
||||
:max="2.0"
|
||||
:step="0.05"
|
||||
style="width: 200px"
|
||||
@update:value="vs.setEdgeRate"
|
||||
/>
|
||||
<span class="slider-value">{{ vs.edgeRate.value.toFixed(2) }}x ({{ speedToEdgeRate(vs.edgeRate.value) }})</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.edgePitch')"
|
||||
:hint="t('settings.voice.edgePitchHint')"
|
||||
>
|
||||
<div class="slider-row">
|
||||
<NSlider
|
||||
:value="vs.edgePitchHz.value"
|
||||
:min="-20"
|
||||
:max="20"
|
||||
:step="1"
|
||||
style="width: 200px"
|
||||
@update:value="vs.setEdgePitchHz"
|
||||
/>
|
||||
<span class="slider-value">{{ vs.edgePitchHz.value > 0 ? '+' : '' }}{{ vs.edgePitchHz.value }} Hz ({{ hzToEdgePitch(vs.edgePitchHz.value) }})</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- ════ MiMo TTS ════ -->
|
||||
<template v-if="vs.provider.value === 'mimo'">
|
||||
<div class="provider-hint">
|
||||
{{ t('settings.voice.mimoHint') }}
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.mimoApiKey')"
|
||||
:hint="t('settings.voice.mimoApiKeyHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.mimoApiKey.value"
|
||||
type="password"
|
||||
size="small"
|
||||
show-password-on="click"
|
||||
style="width: 360px"
|
||||
:placeholder="t('settings.voice.mimoApiKeyPlaceholder')"
|
||||
@update:value="vs.setMimoApiKey"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.mimoBaseUrl')"
|
||||
:hint="t('settings.voice.mimoBaseUrlHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.mimoBaseUrl.value"
|
||||
:options="mimoBaseUrlOptions"
|
||||
size="small"
|
||||
filterable
|
||||
tag
|
||||
style="width: 360px"
|
||||
@update:value="vs.setMimoBaseUrl"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
:label="t('settings.voice.mimoModel')"
|
||||
:hint="t('settings.voice.mimoModelHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.mimoModel.value"
|
||||
:options="mimoModelOptions"
|
||||
size="small"
|
||||
style="width: 320px"
|
||||
@update:value="vs.setMimoModel"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<!-- Preset voice mode -->
|
||||
<SettingRow
|
||||
v-if="vs.mimoModel.value === 'mimo-v2.5-tts'"
|
||||
:label="t('settings.voice.mimoVoice')"
|
||||
:hint="t('settings.voice.mimoVoiceHint')"
|
||||
>
|
||||
<NSelect
|
||||
:value="vs.mimoVoice.value"
|
||||
:options="mimoVoiceOptions"
|
||||
size="small"
|
||||
style="width: 200px"
|
||||
@update:value="vs.setMimoVoice"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<!-- Voice design mode -->
|
||||
<SettingRow
|
||||
v-if="vs.mimoModel.value === 'mimo-v2.5-tts-voicedesign'"
|
||||
:label="t('settings.voice.mimoVoiceDesignPrompt')"
|
||||
:hint="t('settings.voice.mimoVoiceDesignPromptHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.mimoVoiceDesignDesc.value"
|
||||
type="textarea"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:rows="3"
|
||||
:placeholder="t('settings.voice.mimoVoiceDesignPromptPlaceholder')"
|
||||
@update:value="vs.setMimoVoiceDesignDesc"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<!-- Style prompt (available for all models) -->
|
||||
<SettingRow
|
||||
:label="t('settings.voice.mimoStylePrompt')"
|
||||
:hint="t('settings.voice.mimoStylePromptHint')"
|
||||
>
|
||||
<NInput
|
||||
:value="vs.mimoStylePrompt.value"
|
||||
type="textarea"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:rows="2"
|
||||
:placeholder="t('settings.voice.mimoStylePromptPlaceholder')"
|
||||
@update:value="vs.setMimoStylePrompt"
|
||||
/>
|
||||
</SettingRow>
|
||||
</template>
|
||||
|
||||
<!-- ─── Test / Audition ─── -->
|
||||
<div class="test-section">
|
||||
<h4 class="test-title">{{ t('settings.voice.testTitle') }}</h4>
|
||||
<div class="test-row">
|
||||
<NInput
|
||||
v-model:value="testText"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:placeholder="t('settings.voice.testTextPlaceholder')"
|
||||
:disabled="testPlaying"
|
||||
@keyup.enter="handleTest"
|
||||
/>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="testPlaying"
|
||||
:disabled="testPlaying"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ testPlaying ? t('settings.voice.testButtonPlaying') : t('settings.voice.testButton') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.voice-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.provider-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
padding: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding-top: 16px;
|
||||
|
||||
.test-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.test-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
import { fetchSkillContent, fetchSkillFiles, pinSkillApi, type SkillFileEntry } from '@/api/hermes/skills'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const props = defineProps<{
|
||||
category: string
|
||||
skill: string
|
||||
skillName: string
|
||||
patchCount?: number
|
||||
useCount?: number
|
||||
viewCount?: number
|
||||
pinned?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pinToggled: [name: string, pinned: boolean]
|
||||
}>()
|
||||
|
||||
const content = ref('')
|
||||
const files = ref<SkillFileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const fileContent = ref('')
|
||||
const viewingFile = ref<string | null>(null)
|
||||
const fileLoading = ref(false)
|
||||
|
||||
async function loadSkill() {
|
||||
loading.value = true
|
||||
viewingFile.value = null
|
||||
fileContent.value = ''
|
||||
files.value = []
|
||||
content.value = ''
|
||||
try {
|
||||
const skillPath = `${props.category}/${props.skill}/SKILL.md`
|
||||
const [skillContent, skillFiles] = await Promise.all([
|
||||
fetchSkillContent(skillPath),
|
||||
fetchSkillFiles(props.category, props.skill),
|
||||
])
|
||||
content.value = skillContent
|
||||
files.value = skillFiles.filter(f => !f.isDir && f.path !== 'SKILL.md')
|
||||
} catch (err: any) {
|
||||
content.value = t('skills.loadFailed') + `: ${err.message}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewFile(filePath: string) {
|
||||
fileLoading.value = true
|
||||
viewingFile.value = filePath
|
||||
try {
|
||||
// filePath might be absolute or relative; normalize to relative under category/skill/
|
||||
const base = `${props.category}/${props.skill}/`
|
||||
let relPath = filePath
|
||||
if (filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) {
|
||||
// Strip absolute prefix to get relative path
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
const segments = normalizedPath.split(/(?:^|\/)(?:\.hermes|hermes)\/skills\//)[1]
|
||||
if (segments) {
|
||||
const afterSkillDir = segments.split('/').slice(2).join('/')
|
||||
relPath = afterSkillDir
|
||||
}
|
||||
}
|
||||
fileContent.value = await fetchSkillContent(`${base}${relPath}`)
|
||||
} catch (err: any) {
|
||||
fileContent.value = t('skills.fileLoadFailed') + `: ${err.message}`
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToSkill() {
|
||||
viewingFile.value = null
|
||||
fileContent.value = ''
|
||||
}
|
||||
|
||||
const pinLoading = ref(false)
|
||||
|
||||
async function handlePinToggle() {
|
||||
if (pinLoading.value) return
|
||||
pinLoading.value = true
|
||||
try {
|
||||
const newPinned = !props.pinned
|
||||
await pinSkillApi(props.skillName, newPinned)
|
||||
emit('pinToggled', props.skillName, newPinned)
|
||||
} catch (err: any) {
|
||||
message.error(t('skills.pinFailed') + `: ${err.message}`)
|
||||
} finally {
|
||||
pinLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-detail">
|
||||
<!-- Skill title -->
|
||||
<div class="detail-title">
|
||||
<span class="detail-category">{{ category }}</span>
|
||||
<span class="detail-separator">/</span>
|
||||
<span class="detail-name">{{ skill }}</span>
|
||||
<div class="usage-stats">
|
||||
<button class="pin-toggle" :class="{ active: pinned }" :disabled="pinLoading" :title="pinned ? t('skills.unpin') : t('skills.pin')" @click="handlePinToggle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" :fill="pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>
|
||||
</button>
|
||||
<span v-if="viewCount != null" class="usage-stat" title="Views">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{{ viewCount }}
|
||||
</span>
|
||||
<span v-if="useCount != null" class="usage-stat" title="Uses">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
{{ useCount }}
|
||||
</span>
|
||||
<span v-if="patchCount != null" class="usage-stat" title="Patches">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
{{ patchCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !content" class="detail-loading">{{ t('common.loading') }}</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Breadcrumb for file view -->
|
||||
<div v-if="viewingFile" class="detail-breadcrumb">
|
||||
<button class="back-btn" @click="backToSkill">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
{{ t('skills.backTo') }} {{ skill }}
|
||||
</button>
|
||||
<span class="breadcrumb-path">{{ viewingFile }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skill content -->
|
||||
<div class="detail-content">
|
||||
<MarkdownRenderer v-if="viewingFile" :content="fileContent" />
|
||||
<MarkdownRenderer v-else :content="content" />
|
||||
</div>
|
||||
|
||||
<!-- Attached files -->
|
||||
<div v-if="!viewingFile && files.length > 0" class="detail-files">
|
||||
<div class="files-header">{{ t('skills.attachedFiles') }}</div>
|
||||
<div class="files-list">
|
||||
<button
|
||||
v-for="f in files"
|
||||
:key="f.path"
|
||||
class="file-item"
|
||||
@click="viewFile(f.path)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span>{{ f.path }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skill-detail {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-category {
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-separator {
|
||||
color: $text-muted;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.usage-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
opacity: 0.5;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: $accent-primary;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.15);
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 12px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $accent-primary;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-path {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 12px;
|
||||
|
||||
:deep(hr) {
|
||||
border: none;
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-files {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid $border-color;
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.files-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-secondary;
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent-primary;
|
||||
color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { NSwitch, useMessage } from 'naive-ui'
|
||||
import type { SkillCategory, SkillSource, SkillInfo } from '@/api/hermes/skills'
|
||||
import { toggleSkill } from '@/api/hermes/skills'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type SourceFilter = SkillSource | 'modified'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const props = defineProps<{
|
||||
categories: SkillCategory[]
|
||||
archived: SkillInfo[]
|
||||
selectedSkill: string | null
|
||||
searchQuery: string
|
||||
sourceFilter: SourceFilter | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [category: string, skill: string]
|
||||
}>()
|
||||
|
||||
const collapsedCategories = ref<Set<string>>(new Set())
|
||||
const archiveCollapsed = ref(true)
|
||||
const togglingSkills = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredArchived = computed(() => {
|
||||
let result = props.archived
|
||||
if (props.sourceFilter && props.sourceFilter !== 'modified') {
|
||||
result = result.filter(s => (s.source || 'local') === props.sourceFilter)
|
||||
}
|
||||
if (props.searchQuery) {
|
||||
const q = props.searchQuery.toLowerCase()
|
||||
result = result.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
let result = props.categories
|
||||
|
||||
// Filter by source
|
||||
if (props.sourceFilter) {
|
||||
result = result
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
skills: cat.skills.filter(s => {
|
||||
if (props.sourceFilter === 'modified') return s.modified
|
||||
return (s.source || 'local') === props.sourceFilter
|
||||
}),
|
||||
}))
|
||||
.filter(cat => cat.skills.length > 0)
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (props.searchQuery) {
|
||||
const q = props.searchQuery.toLowerCase()
|
||||
result = result
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
skills: cat.skills.filter(
|
||||
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter(cat => cat.skills.length > 0 || cat.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
if (collapsedCategories.value.has(name)) {
|
||||
collapsedCategories.value.delete(name)
|
||||
} else {
|
||||
collapsedCategories.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(category: string, skillName: string) {
|
||||
emit('select', category, skillName)
|
||||
}
|
||||
|
||||
/** Unique key for selection tracking */
|
||||
function skillKey(catName: string, skill: { name: string }): string {
|
||||
return `${catName}/${skill.name}`
|
||||
}
|
||||
|
||||
async function handleToggle(category: string, skillName: string, newEnabled: boolean) {
|
||||
if (togglingSkills.value.has(skillName)) return
|
||||
togglingSkills.value.add(skillName)
|
||||
|
||||
try {
|
||||
await toggleSkill(skillName, newEnabled)
|
||||
// Update local state
|
||||
const cat = props.categories.find(c => c.name === category)
|
||||
const skill = cat?.skills.find(s => s.name === skillName)
|
||||
if (skill) skill.enabled = newEnabled
|
||||
} catch (err: any) {
|
||||
message.error(t('skills.toggleFailed') + `: ${err.message}`)
|
||||
} finally {
|
||||
togglingSkills.value.delete(skillName)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skill-list">
|
||||
<div v-if="filteredCategories.length === 0" class="skill-empty">
|
||||
{{ searchQuery ? t('skills.noMatch') : t('skills.noSkills') }}
|
||||
</div>
|
||||
<div v-for="cat in filteredCategories" :key="cat.name" class="skill-category">
|
||||
<button class="category-header" @click="toggleCategory(cat.name)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
class="category-arrow" :class="{ collapsed: collapsedCategories.has(cat.name) }">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
<span class="category-name">{{ cat.name }}</span>
|
||||
<span class="category-count">{{ cat.skills.length }}</span>
|
||||
</button>
|
||||
<div v-if="!collapsedCategories.has(cat.name)" class="category-skills">
|
||||
<button v-for="skill in cat.skills" :key="skillKey(cat.name, skill)" class="skill-item" :class="[
|
||||
{ active: selectedSkill === skillKey(cat.name, skill) },
|
||||
`source-${skill.source || 'local'}`,
|
||||
]" @click="handleSelect(cat.name, skill.name)">
|
||||
<div class="skill-info">
|
||||
<span class="skill-name">
|
||||
<span class="source-dot" :class="`dot-${skill.source || 'local'}`"
|
||||
:title="t(`skills.source.${skill.source || 'local'}`)" />
|
||||
{{ skill.name }}
|
||||
<span v-if="skill.modified" class="modified-badge"
|
||||
:title="t('skills.modified')">✎</span>
|
||||
</span>
|
||||
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
|
||||
</div>
|
||||
<NSwitch size="small" :value="skill.enabled !== false" :loading="togglingSkills.has(skill.name)"
|
||||
@update:value="handleToggle(cat.name, skill.name, $event)" @click.stop />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archived skills (separate section) -->
|
||||
<div v-if="filteredArchived.length > 0 || archived.length > 0" class="skill-category archive-section">
|
||||
<button class="category-header archive-header" @click="archiveCollapsed = !archiveCollapsed">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
class="category-arrow" :class="{ collapsed: archiveCollapsed }">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
<span class="category-name">{{ t('skills.archived') }}</span>
|
||||
<span class="category-count">{{ archived.length }}</span>
|
||||
</button>
|
||||
<div v-if="!archiveCollapsed" class="category-skills">
|
||||
<button v-for="skill in filteredArchived" :key="skillKey('.archive', skill)" class="skill-item skill-archived"
|
||||
:class="{ active: selectedSkill === skillKey('.archive', skill) }"
|
||||
@click="handleSelect('.archive', skill.name)">
|
||||
<div class="skill-info">
|
||||
<span class="skill-name">
|
||||
<span class="source-dot" :class="`dot-${skill.source || 'local'}`"
|
||||
:title="t(`skills.source.${skill.source || 'local'}`)" />
|
||||
{{ skill.name }}
|
||||
</span>
|
||||
<span v-if="skill.description" class="skill-desc">{{ skill.description }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skill-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.skill-empty {
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skill-category {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.category-arrow {
|
||||
flex-shrink: 0;
|
||||
transition: transform $transition-fast;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-skills {
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
transition: all $transition-fast;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.06);
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-primary-rgb), 0.1);
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Source indicator dot
|
||||
.source-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dot-builtin {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
.dot-hub {
|
||||
background: #4a90d9;
|
||||
}
|
||||
|
||||
.dot-local {
|
||||
background: #66bb6a;
|
||||
}
|
||||
|
||||
.dot-external {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.skill-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.modified-badge {
|
||||
font-size: 11px;
|
||||
color: $warning;
|
||||
margin-left: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.archive-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.skill-archived {
|
||||
opacity: 0.6;
|
||||
padding-left: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUsageStore } from '@/stores/hermes/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const usageStore = useUsageStore()
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
if (n === 0) return '$0.00'
|
||||
if (n < 0.01) return '<$0.01'
|
||||
return '$' + n.toFixed(2)
|
||||
}
|
||||
|
||||
function cacheHitRate(d: { input_tokens: number; cache_read_tokens: number }): string {
|
||||
const total = d.input_tokens + d.cache_read_tokens
|
||||
if (total === 0) return '--'
|
||||
return ((d.cache_read_tokens / total) * 100).toFixed(1) + '%'
|
||||
}
|
||||
|
||||
const maxTokens = computed(() =>
|
||||
Math.max(...usageStore.dailyUsage.map(d => d.visualTokens), 1),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="daily-trend">
|
||||
<h3 class="section-title">{{ t('usage.dailyTrend') }}</h3>
|
||||
|
||||
<div class="bar-chart">
|
||||
<div
|
||||
v-for="d in usageStore.dailyUsage"
|
||||
:key="d.date"
|
||||
class="bar-col"
|
||||
>
|
||||
<div class="bar-track">
|
||||
<div
|
||||
class="bar-stack"
|
||||
:style="{ height: (d.visualTokens / maxTokens * 100) + '%' }"
|
||||
>
|
||||
<div
|
||||
v-if="d.output_tokens > 0"
|
||||
class="bar-segment output"
|
||||
:style="{ height: d.outputPercent + '%' }"
|
||||
/>
|
||||
<div
|
||||
v-if="d.input_tokens > 0"
|
||||
class="bar-segment input"
|
||||
:style="{ height: d.inputPercent + '%' }"
|
||||
/>
|
||||
<div
|
||||
v-if="d.cache_read_tokens > 0"
|
||||
class="bar-segment cache"
|
||||
:style="{ height: d.cachePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-tooltip">
|
||||
<div class="tooltip-date">{{ d.date }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.inputTokens') }}: {{ formatTokens(d.input_tokens) }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.outputTokens') }}: {{ formatTokens(d.output_tokens) }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.cacheRead') }}: {{ formatTokens(d.cache_read_tokens) }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.cacheWrite') }}: {{ formatTokens(d.cache_write_tokens) }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.cacheHitRate') }}: {{ cacheHitRate(d) }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.sessions') }}: {{ d.sessions }}</div>
|
||||
<div class="tooltip-row">{{ t('usage.cost') }}: {{ formatCost(d.cost) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-dates">
|
||||
<span>{{ usageStore.dailyUsage[0]?.date.slice(5) }}</span>
|
||||
<span>{{ usageStore.dailyUsage[usageStore.dailyUsage.length - 1]?.date.slice(5) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="chart-legend" aria-label="Token type legend">
|
||||
<div class="legend-item"><span class="legend-swatch input" />{{ t('usage.inputTokens') }}</div>
|
||||
<div class="legend-item"><span class="legend-swatch output" />{{ t('usage.outputTokens') }}</div>
|
||||
<div class="legend-item"><span class="legend-swatch cache" />{{ t('usage.cacheRead') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('usage.date') }}</th>
|
||||
<th>{{ t('usage.inputTokens') }}</th>
|
||||
<th>{{ t('usage.outputTokens') }}</th>
|
||||
<th>{{ t('usage.cacheRead') }}</th>
|
||||
<th>{{ t('usage.cacheWrite') }}</th>
|
||||
<th>{{ t('usage.cacheHitRate') }}</th>
|
||||
<th>{{ t('usage.sessions') }}</th>
|
||||
<th>{{ t('usage.cost') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in [...usageStore.dailyUsage].reverse()" :key="d.date">
|
||||
<td>{{ d.date }}</td>
|
||||
<td>{{ formatTokens(d.input_tokens) }}</td>
|
||||
<td>{{ formatTokens(d.output_tokens) }}</td>
|
||||
<td>{{ formatTokens(d.cache_read_tokens) }}</td>
|
||||
<td>{{ formatTokens(d.cache_write_tokens) }}</td>
|
||||
<td>{{ cacheHitRate(d) }}</td>
|
||||
<td>{{ d.sessions }}</td>
|
||||
<td>{{ formatCost(d.cost) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.daily-trend {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-secondary;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: $bg-secondary;
|
||||
border-radius: 2px 2px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-stack {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-segment {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-segment.output,
|
||||
.legend-swatch.output {
|
||||
background: #26a69a;
|
||||
}
|
||||
|
||||
.bar-segment.input,
|
||||
.legend-swatch.input {
|
||||
background: #5c6bc0;
|
||||
}
|
||||
|
||||
.bar-segment.cache,
|
||||
.legend-swatch.cache {
|
||||
background: #f6ad55;
|
||||
}
|
||||
|
||||
.bar-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: $text-primary;
|
||||
color: var(--text-on-accent);
|
||||
padding: 6px 10px;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: $text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-col:hover .bar-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bar-dates {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin: 0 0 16px;
|
||||
color: $text-muted;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trend-table {
|
||||
overflow-x: auto;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: $text-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUsageStore } from '@/stores/hermes/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const usageStore = useUsageStore()
|
||||
const maxModelTokens = computed(() => Math.max(...usageStore.modelUsage.map(m => m.visualTokens), 1))
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function cacheHitRate(m: { inputTokens: number; cacheTokens: number }): string {
|
||||
const total = m.inputTokens + m.cacheTokens
|
||||
if (total === 0) return '--'
|
||||
return ((m.cacheTokens / total) * 100).toFixed(1) + '%'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-breakdown">
|
||||
<h3 class="section-title">{{ t('usage.modelBreakdown') }}</h3>
|
||||
|
||||
<div class="model-legend" aria-label="Token type legend">
|
||||
<div class="legend-item"><span class="legend-swatch input" />{{ t('usage.inputTokens') }}</div>
|
||||
<div class="legend-item"><span class="legend-swatch output" />{{ t('usage.outputTokens') }}</div>
|
||||
<div class="legend-item"><span class="legend-swatch cache" />{{ t('usage.cacheRead') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="model-list">
|
||||
<div v-for="m in usageStore.modelUsage" :key="m.model" class="model-row">
|
||||
<span class="model-swatch" :style="{ background: m.color }" />
|
||||
<span class="model-name" :title="m.model">{{ m.model }}</span>
|
||||
<div class="model-bar-wrap">
|
||||
<div
|
||||
class="model-bar"
|
||||
:style="{ width: (m.visualTokens / maxModelTokens * 100) + '%' }"
|
||||
>
|
||||
<div
|
||||
v-if="m.inputTokens > 0"
|
||||
class="model-bar-segment input"
|
||||
:style="{ width: m.inputPercent + '%' }"
|
||||
/>
|
||||
<div
|
||||
v-if="m.outputTokens > 0"
|
||||
class="model-bar-segment output"
|
||||
:style="{ width: m.outputPercent + '%' }"
|
||||
/>
|
||||
<div
|
||||
v-if="m.cacheTokens > 0"
|
||||
class="model-bar-segment cache"
|
||||
:style="{ width: m.cachePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span class="model-tokens" :title="`${t('usage.inputTokens')}: ${formatTokens(m.inputTokens)} · ${t('usage.outputTokens')}: ${formatTokens(m.outputTokens)} · ${t('usage.cacheRead')}: ${formatTokens(m.cacheTokens)} · ${t('usage.cacheHitRate')}: ${cacheHitRate(m)}`">
|
||||
{{ formatTokens(m.totalTokens) }}
|
||||
<small v-if="m.cacheTokens > 0">+{{ formatTokens(m.cacheTokens) }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.model-breakdown {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-secondary;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.model-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin: 0 0 12px;
|
||||
color: $text-muted;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.legend-swatch,
|
||||
.model-swatch {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-swatch.input,
|
||||
.model-bar-segment.input {
|
||||
background: #5c6bc0;
|
||||
}
|
||||
|
||||
.legend-swatch.output,
|
||||
.model-bar-segment.output {
|
||||
background: #26a69a;
|
||||
}
|
||||
|
||||
.legend-swatch.cache,
|
||||
.model-bar-segment.cache {
|
||||
background: #f6ad55;
|
||||
}
|
||||
|
||||
.model-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 12px;
|
||||
font-family: $font-code;
|
||||
color: $text-secondary;
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-bar-wrap {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: $bg-secondary;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
min-width: 2px;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-bar-segment {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-tokens {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
width: 86px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
|
||||
small {
|
||||
color: #f6ad55;
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUsageStore } from '@/stores/hermes/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const usageStore = useUsageStore()
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
if (n === 0) return '$0.00'
|
||||
if (n < 0.01) return '<$0.01'
|
||||
return '$' + n.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('usage.totalTokens') }}</div>
|
||||
<div class="stat-value">{{ formatTokens(usageStore.totalTokens) }}</div>
|
||||
<div class="stat-sub">
|
||||
{{ formatTokens(usageStore.totalInputTokens) }} {{ t('usage.inputTokens') }} /
|
||||
{{ formatTokens(usageStore.totalOutputTokens) }} {{ t('usage.outputTokens') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('usage.totalSessions') }}</div>
|
||||
<div class="stat-value">{{ usageStore.totalSessions }}</div>
|
||||
<div class="stat-sub">{{ t('usage.avgPerDay', { n: usageStore.avgSessionsPerDay.toFixed(1) }) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('usage.estimatedCost') }}</div>
|
||||
<div class="stat-value">{{ formatCost(usageStore.estimatedCost) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ t('usage.cacheHitRate') }}</div>
|
||||
<div class="stat-value">{{ usageStore.cacheHitRate !== null ? usageStore.cacheHitRate.toFixed(1) + '%' : '--' }}</div>
|
||||
<div class="stat-sub" v-if="usageStore.cacheHitRate !== null">
|
||||
{{ formatTokens(usageStore.totalCacheTokens) }} {{ t('usage.tokens') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stat-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user