mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(web): add Activity Bar and right sidebar panel to chat page
Replace the old file manager panel with a multi-tab right sidebar system: - Activity Bar with Terminal, Files, and Info tabs - Resizable right panel with tab switching - Extract shared Terminal component from bot-terminal.vue - Add bottom preview layout mode to FileManager - Delete session button with confirmation dialog - Fix FileManager scroll in flex column layout (min-h-0)
This commit is contained in:
@@ -32,8 +32,10 @@ import FileViewer from './file-viewer.vue'
|
||||
const props = withDefaults(defineProps<{
|
||||
botId: string
|
||||
syncUrl?: boolean
|
||||
previewLayout?: 'side' | 'bottom'
|
||||
}>(), {
|
||||
syncUrl: true,
|
||||
previewLayout: 'side',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -345,11 +347,16 @@ defineExpose({ navigateTo, openFileByPath })
|
||||
</div>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex flex-1 min-h-0 overflow-hidden h-full ">
|
||||
<div
|
||||
class="flex flex-1 min-h-0 overflow-hidden h-full"
|
||||
:class="previewLayout === 'bottom' ? 'flex-col' : ''"
|
||||
>
|
||||
<!-- File list -->
|
||||
<ScrollArea
|
||||
class=" border-border transition-colors "
|
||||
:class="openFile ? 'w-80 shrink-0 border-r' : 'w-full'"
|
||||
class="border-border transition-colors min-h-0"
|
||||
:class="openFile
|
||||
? (previewLayout === 'bottom' ? 'h-1/2 shrink-0 border-b' : 'w-80 shrink-0 border-r')
|
||||
: (previewLayout === 'bottom' ? 'flex-1' : 'w-full')"
|
||||
>
|
||||
<FileList
|
||||
:entries="entries"
|
||||
@@ -365,7 +372,8 @@ defineExpose({ navigateTo, openFileByPath })
|
||||
<!-- File viewer -->
|
||||
<div
|
||||
v-if="openFile"
|
||||
class="flex-1 overflow-hidden max-h-full"
|
||||
class="flex-1 overflow-hidden min-h-0"
|
||||
:class="previewLayout === 'bottom' ? '' : 'max-h-full'"
|
||||
>
|
||||
<FileViewer
|
||||
:bot-id="botId"
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, shallowReactive, onMounted, onBeforeUnmount, watch, computed, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { client } from '@memohai/sdk/client'
|
||||
import { Button } from '@memohai/ui'
|
||||
import { useTerminalCache } from '@/composables/useTerminalCache'
|
||||
import type { TerminalCacheState } from '@/composables/useTerminalCache'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
botId: string
|
||||
visible?: boolean
|
||||
}>(), {
|
||||
visible: true,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { loadCache, saveCache } = useTerminalCache()
|
||||
|
||||
const TERMINAL_OPTIONS = {
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#c0caf5',
|
||||
selectionBackground: '#33467c',
|
||||
},
|
||||
} as const
|
||||
|
||||
interface TerminalTab {
|
||||
id: string
|
||||
label: string
|
||||
terminal: Terminal | null
|
||||
fitAddon: FitAddon | null
|
||||
serializeAddon: SerializeAddon | null
|
||||
ws: WebSocket | null
|
||||
status: 'idle' | 'connecting' | 'connected' | 'disconnected'
|
||||
containerEl: HTMLDivElement | null
|
||||
wsDisposables: Array<{ dispose(): void }>
|
||||
}
|
||||
|
||||
function makeTab(id: string, label: string): TerminalTab {
|
||||
return shallowReactive<TerminalTab>({
|
||||
id,
|
||||
label,
|
||||
terminal: null,
|
||||
fitAddon: null,
|
||||
serializeAddon: null,
|
||||
ws: null,
|
||||
status: 'idle',
|
||||
containerEl: null,
|
||||
wsDisposables: [],
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = reactive<TerminalTab[]>([])
|
||||
const activeTabId = ref('')
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null)
|
||||
let tabCounter = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let fitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let cacheTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CACHE_DEBOUNCE_MS = 2000
|
||||
|
||||
const activeTermTab = computed(() => tabs.find((t) => t.id === activeTabId.value))
|
||||
|
||||
function resolveTerminalWsUrl(botIdValue: string, cols: number, rows: number): string {
|
||||
const baseUrl = String(client.getConfig().baseUrl || '').trim()
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
const path = `/bots/${encodeURIComponent(botIdValue)}/container/terminal/ws`
|
||||
const query = `?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`
|
||||
|
||||
if (!baseUrl || baseUrl.startsWith('/')) {
|
||||
const loc = window.location
|
||||
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const base = baseUrl || '/api'
|
||||
return `${proto}//${loc.host}${base.replace(/\/+$/, '')}${path}${query}`
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(path, baseUrl)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return url.toString() + query
|
||||
} catch {
|
||||
const loc = window.location
|
||||
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${loc.host}/api${path}${query}`
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalTab(label?: string, cachedContent?: string): TerminalTab {
|
||||
tabCounter++
|
||||
const id = `term-${Date.now()}-${tabCounter}`
|
||||
const tab = makeTab(id, label ?? `${t('bots.terminal.defaultTabLabel')} ${tabCounter}`)
|
||||
tabs.push(tab)
|
||||
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
tab.containerEl = el as HTMLDivElement
|
||||
initTerminal(tab, cachedContent)
|
||||
})
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
function initTerminal(tab: TerminalTab, cachedContent?: string) {
|
||||
if (!tab.containerEl) return
|
||||
|
||||
const terminal = new Terminal({ ...TERMINAL_OPTIONS })
|
||||
const fitAddon = new FitAddon()
|
||||
const serializeAddon = new SerializeAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(serializeAddon)
|
||||
terminal.open(tab.containerEl)
|
||||
|
||||
tab.terminal = terminal
|
||||
tab.fitAddon = fitAddon
|
||||
tab.serializeAddon = serializeAddon
|
||||
|
||||
if (cachedContent) {
|
||||
terminal.write(cachedContent)
|
||||
terminal.write('\x1b[2K\r')
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (tab.id === activeTabId.value) fitAddon.fit()
|
||||
connectWs(tab)
|
||||
})
|
||||
}
|
||||
|
||||
function connectWs(tab: TerminalTab) {
|
||||
if (!tab.terminal) return
|
||||
closeWs(tab)
|
||||
|
||||
if (tab.id === activeTabId.value) {
|
||||
tab.fitAddon?.fit()
|
||||
}
|
||||
|
||||
const cols = tab.terminal.cols
|
||||
const rows = tab.terminal.rows
|
||||
|
||||
tab.status = 'connecting'
|
||||
const url = resolveTerminalWsUrl(props.botId, cols, rows)
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
tab.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
tab.status = 'connected'
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
tab.terminal?.write(new Uint8Array(event.data))
|
||||
} else if (typeof event.data === 'string') {
|
||||
tab.terminal?.write(event.data)
|
||||
}
|
||||
debouncedPersistCache()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
tab.status = 'disconnected'
|
||||
tab.terminal?.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
tab.status = 'disconnected'
|
||||
}
|
||||
|
||||
for (const d of tab.wsDisposables) d.dispose()
|
||||
tab.wsDisposables = []
|
||||
|
||||
tab.wsDisposables.push(
|
||||
tab.terminal.onData((data) => {
|
||||
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
||||
tab.ws.send(new TextEncoder().encode(data))
|
||||
}
|
||||
debouncedPersistCache()
|
||||
}),
|
||||
tab.terminal.onResize(({ cols: c, rows: r }) => {
|
||||
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
||||
tab.ws.send(JSON.stringify({ type: 'resize', cols: c, rows: r }))
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function closeWs(tab: TerminalTab) {
|
||||
if (tab.ws) {
|
||||
tab.ws.onclose = null
|
||||
tab.ws.onerror = null
|
||||
tab.ws.onmessage = null
|
||||
tab.ws.close()
|
||||
tab.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
function destroyTab(tab: TerminalTab) {
|
||||
closeWs(tab)
|
||||
for (const d of tab.wsDisposables) d.dispose()
|
||||
tab.wsDisposables = []
|
||||
tab.terminal?.dispose()
|
||||
tab.terminal = null
|
||||
tab.fitAddon = null
|
||||
tab.serializeAddon = null
|
||||
tab.containerEl = null
|
||||
}
|
||||
|
||||
function handleAddTab() {
|
||||
const tab = createTerminalTab()
|
||||
activeTabId.value = tab.id
|
||||
debouncedPersistCache()
|
||||
}
|
||||
|
||||
function handleCloseTab(tabId: string) {
|
||||
const idx = tabs.findIndex((t) => t.id === tabId)
|
||||
if (idx < 0) return
|
||||
|
||||
const target = tabs[idx]
|
||||
if (target) destroyTab(target)
|
||||
tabs.splice(idx, 1)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
handleAddTab()
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTabId.value === tabId) {
|
||||
const nextIdx = Math.min(idx, tabs.length - 1)
|
||||
const next = tabs[nextIdx]
|
||||
if (next) {
|
||||
activeTabId.value = next.id
|
||||
nextTick(() => next.fitAddon?.fit())
|
||||
}
|
||||
}
|
||||
debouncedPersistCache()
|
||||
}
|
||||
|
||||
function handleSwitchTab(tabId: string) {
|
||||
if (activeTabId.value === tabId) return
|
||||
activeTabId.value = tabId
|
||||
debouncedPersistCache()
|
||||
nextTick(() => {
|
||||
const tab = tabs.find((t) => t.id === tabId)
|
||||
tab?.fitAddon?.fit()
|
||||
})
|
||||
}
|
||||
|
||||
function handleReconnect() {
|
||||
const tab = activeTermTab.value
|
||||
if (tab) connectWs(tab)
|
||||
}
|
||||
|
||||
function persistCache() {
|
||||
if (!props.botId || tabs.length === 0) return
|
||||
const state: TerminalCacheState = {
|
||||
activeTabId: activeTabId.value,
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
content: tab.serializeAddon?.serialize() ?? '',
|
||||
savedAt: Date.now(),
|
||||
})),
|
||||
}
|
||||
saveCache(props.botId, state)
|
||||
}
|
||||
|
||||
function debouncedPersistCache() {
|
||||
if (cacheTimer) clearTimeout(cacheTimer)
|
||||
cacheTimer = setTimeout(() => {
|
||||
persistCache()
|
||||
}, CACHE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function restoreFromCache() {
|
||||
const cached = loadCache(props.botId)
|
||||
if (!cached || cached.tabs.length === 0) {
|
||||
handleAddTab()
|
||||
return
|
||||
}
|
||||
|
||||
for (const cachedTab of cached.tabs) {
|
||||
tabCounter++
|
||||
const tab = makeTab(cachedTab.id, cachedTab.label)
|
||||
tabs.push(tab)
|
||||
const content = cachedTab.content
|
||||
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(tab.id)
|
||||
if (!el) return
|
||||
tab.containerEl = el as HTMLDivElement
|
||||
initTerminal(tab, content)
|
||||
})
|
||||
}
|
||||
|
||||
const firstTab = cached.tabs[0]
|
||||
const targetId = cached.tabs.find((ct) => ct.id === cached.activeTabId)
|
||||
? cached.activeTabId
|
||||
: firstTab?.id ?? ''
|
||||
activeTabId.value = targetId
|
||||
}
|
||||
|
||||
function onBeforeUnload() {
|
||||
persistCache()
|
||||
}
|
||||
|
||||
function cleanupAll() {
|
||||
if (fitTimer) {
|
||||
clearTimeout(fitTimer)
|
||||
fitTimer = null
|
||||
}
|
||||
if (cacheTimer) {
|
||||
clearTimeout(cacheTimer)
|
||||
cacheTimer = null
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
for (const tab of tabs) {
|
||||
destroyTab(tab)
|
||||
}
|
||||
tabs.length = 0
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (resizeObserver || !wrapperRef.value) return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
fitTimer = setTimeout(() => {
|
||||
activeTermTab.value?.fitAddon?.fit()
|
||||
}, 50)
|
||||
})
|
||||
resizeObserver.observe(wrapperRef.value)
|
||||
}
|
||||
|
||||
function init() {
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
restoreFromCache()
|
||||
nextTick(() => setupResizeObserver())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
init()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible && tabs.length === 0) {
|
||||
nextTick(() => init())
|
||||
}
|
||||
if (!visible && tabs.length > 0) {
|
||||
persistCache()
|
||||
}
|
||||
if (visible) {
|
||||
nextTick(() => activeTermTab.value?.fitAddon?.fit())
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
persistCache()
|
||||
cleanupAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col absolute inset-0 p-4">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center gap-1 mb-2 min-h-[36px]">
|
||||
<div class="flex items-center gap-1 flex-1 overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border transition-colors whitespace-nowrap"
|
||||
:class="tab.id === activeTabId
|
||||
? 'bg-accent text-accent-foreground border-border'
|
||||
: 'text-muted-foreground border-transparent hover:bg-accent/50'"
|
||||
@click="handleSwitchTab(tab.id)"
|
||||
>
|
||||
<span
|
||||
class="size-1.5 rounded-full shrink-0"
|
||||
:class="{
|
||||
'bg-green-500': tab.status === 'connected',
|
||||
'bg-yellow-500': tab.status === 'connecting',
|
||||
'bg-muted-foreground': tab.status === 'idle' || tab.status === 'disconnected',
|
||||
}"
|
||||
/>
|
||||
<span>{{ tab.label }}</span>
|
||||
<span
|
||||
class="ml-1 size-4 inline-flex cursor-pointer items-center justify-center rounded hover:bg-destructive/20 hover:text-destructive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('bots.terminal.closeTab')"
|
||||
@click.stop="handleCloseTab(tab.id)"
|
||||
@keydown.enter.prevent.stop="handleCloseTab(tab.id)"
|
||||
@keydown.space.prevent.stop="handleCloseTab(tab.id)"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center size-7 rounded-md border border-dashed border-border text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground transition-colors shrink-0"
|
||||
:title="t('bots.terminal.newTab')"
|
||||
@click="handleAddTab"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0 ml-2">
|
||||
<span
|
||||
v-if="activeTermTab"
|
||||
class="inline-flex items-center gap-1.5 text-xs"
|
||||
:class="{
|
||||
'text-green-500': activeTermTab.status === 'connected',
|
||||
'text-yellow-500': activeTermTab.status === 'connecting',
|
||||
'text-muted-foreground': activeTermTab.status === 'idle' || activeTermTab.status === 'disconnected',
|
||||
}"
|
||||
>
|
||||
{{ t(`bots.terminal.status.${activeTermTab.status}`) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="activeTermTab?.status === 'disconnected'"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="handleReconnect"
|
||||
>
|
||||
{{ t('bots.terminal.reconnect') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal area -->
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="flex-1 relative min-h-0 rounded-md overflow-hidden border border-border terminal-wrapper"
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:id="tab.id"
|
||||
:key="tab.id"
|
||||
class="absolute inset-0 terminal-container"
|
||||
:style="{ display: tab.id === activeTabId ? 'block' : 'none' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-wrapper {
|
||||
background-color: #1a1b26;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -167,6 +167,7 @@
|
||||
"toolSpawnCount": "{count} tasks",
|
||||
"unknownUser": "{platform} User",
|
||||
"files": "Files",
|
||||
"clearMessages": "Clear Messages",
|
||||
"modelOverride": "Model",
|
||||
"modelDefault": "Default",
|
||||
"reasoningEffort": "Reasoning",
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
"toolSpawnCount": "{count} 个任务",
|
||||
"unknownUser": "{platform}用户",
|
||||
"files": "文件管理",
|
||||
"clearMessages": "清除消息",
|
||||
"modelOverride": "模型",
|
||||
"modelDefault": "默认",
|
||||
"reasoningEffort": "推理",
|
||||
|
||||
@@ -1,462 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, shallowReactive, onMounted, onBeforeUnmount, watch, computed, nextTick } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { client } from '@memohai/sdk/client'
|
||||
import { Button } from '@memohai/ui'
|
||||
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
|
||||
import { useTerminalCache } from '@/composables/useTerminalCache'
|
||||
import type { TerminalCacheState } from '@/composables/useTerminalCache'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import Terminal from '@/components/terminal/index.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { loadCache, saveCache } = useTerminalCache()
|
||||
|
||||
const botId = computed(() => route.params.botId as string)
|
||||
const activeTab = useSyncedQueryParam('tab', 'overview')
|
||||
|
||||
const TERMINAL_OPTIONS = {
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#c0caf5',
|
||||
selectionBackground: '#33467c',
|
||||
},
|
||||
} as const
|
||||
|
||||
interface TerminalTab {
|
||||
id: string
|
||||
label: string
|
||||
terminal: Terminal | null
|
||||
fitAddon: FitAddon | null
|
||||
serializeAddon: SerializeAddon | null
|
||||
ws: WebSocket | null
|
||||
status: 'idle' | 'connecting' | 'connected' | 'disconnected'
|
||||
containerEl: HTMLDivElement | null
|
||||
wsDisposables: Array<{ dispose(): void }>
|
||||
}
|
||||
|
||||
function makeTab(id: string, label: string): TerminalTab {
|
||||
return shallowReactive<TerminalTab>({
|
||||
id,
|
||||
label,
|
||||
terminal: null,
|
||||
fitAddon: null,
|
||||
serializeAddon: null,
|
||||
ws: null,
|
||||
status: 'idle',
|
||||
containerEl: null,
|
||||
wsDisposables: [],
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = reactive<TerminalTab[]>([])
|
||||
const activeTabId = ref('')
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null)
|
||||
let tabCounter = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let fitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let cacheTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const CACHE_DEBOUNCE_MS = 2000
|
||||
|
||||
const activeTermTab = computed(() => tabs.find((t) => t.id === activeTabId.value))
|
||||
|
||||
function resolveTerminalWsUrl(botIdValue: string, cols: number, rows: number): string {
|
||||
const baseUrl = String(client.getConfig().baseUrl || '').trim()
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
const path = `/bots/${encodeURIComponent(botIdValue)}/container/terminal/ws`
|
||||
const query = `?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`
|
||||
|
||||
if (!baseUrl || baseUrl.startsWith('/')) {
|
||||
const loc = window.location
|
||||
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const base = baseUrl || '/api'
|
||||
return `${proto}//${loc.host}${base.replace(/\/+$/, '')}${path}${query}`
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(path, baseUrl)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return url.toString() + query
|
||||
} catch {
|
||||
const loc = window.location
|
||||
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${loc.host}/api${path}${query}`
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalTab(label?: string, cachedContent?: string): TerminalTab {
|
||||
tabCounter++
|
||||
const id = `term-${Date.now()}-${tabCounter}`
|
||||
const tab = makeTab(id, label ?? `${t('bots.terminal.defaultTabLabel')} ${tabCounter}`)
|
||||
tabs.push(tab)
|
||||
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
tab.containerEl = el as HTMLDivElement
|
||||
initTerminal(tab, cachedContent)
|
||||
})
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
function initTerminal(tab: TerminalTab, cachedContent?: string) {
|
||||
if (!tab.containerEl) return
|
||||
|
||||
const terminal = new Terminal({ ...TERMINAL_OPTIONS })
|
||||
const fitAddon = new FitAddon()
|
||||
const serializeAddon = new SerializeAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(serializeAddon)
|
||||
terminal.open(tab.containerEl)
|
||||
|
||||
tab.terminal = terminal
|
||||
tab.fitAddon = fitAddon
|
||||
tab.serializeAddon = serializeAddon
|
||||
|
||||
if (cachedContent) {
|
||||
terminal.write(cachedContent)
|
||||
terminal.write('\x1b[2K\r')
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (tab.id === activeTabId.value) fitAddon.fit()
|
||||
connectWs(tab)
|
||||
})
|
||||
}
|
||||
|
||||
function connectWs(tab: TerminalTab) {
|
||||
if (!tab.terminal) return
|
||||
closeWs(tab)
|
||||
|
||||
if (tab.id === activeTabId.value) {
|
||||
tab.fitAddon?.fit()
|
||||
}
|
||||
|
||||
const cols = tab.terminal.cols
|
||||
const rows = tab.terminal.rows
|
||||
|
||||
tab.status = 'connecting'
|
||||
const url = resolveTerminalWsUrl(botId.value, cols, rows)
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
tab.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
tab.status = 'connected'
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
tab.terminal?.write(new Uint8Array(event.data))
|
||||
} else if (typeof event.data === 'string') {
|
||||
tab.terminal?.write(event.data)
|
||||
}
|
||||
debouncedPersistCache()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
tab.status = 'disconnected'
|
||||
tab.terminal?.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
tab.status = 'disconnected'
|
||||
}
|
||||
|
||||
for (const d of tab.wsDisposables) d.dispose()
|
||||
tab.wsDisposables = []
|
||||
|
||||
tab.wsDisposables.push(
|
||||
tab.terminal.onData((data) => {
|
||||
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
||||
tab.ws.send(new TextEncoder().encode(data))
|
||||
}
|
||||
debouncedPersistCache()
|
||||
}),
|
||||
tab.terminal.onResize(({ cols: c, rows: r }) => {
|
||||
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
||||
tab.ws.send(JSON.stringify({ type: 'resize', cols: c, rows: r }))
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function closeWs(tab: TerminalTab) {
|
||||
if (tab.ws) {
|
||||
tab.ws.onclose = null
|
||||
tab.ws.onerror = null
|
||||
tab.ws.onmessage = null
|
||||
tab.ws.close()
|
||||
tab.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
function destroyTab(tab: TerminalTab) {
|
||||
closeWs(tab)
|
||||
for (const d of tab.wsDisposables) d.dispose()
|
||||
tab.wsDisposables = []
|
||||
tab.terminal?.dispose()
|
||||
tab.terminal = null
|
||||
tab.fitAddon = null
|
||||
tab.serializeAddon = null
|
||||
tab.containerEl = null
|
||||
}
|
||||
|
||||
function handleAddTab() {
|
||||
const tab = createTerminalTab()
|
||||
activeTabId.value = tab.id
|
||||
debouncedPersistCache()
|
||||
}
|
||||
|
||||
function handleCloseTab(tabId: string) {
|
||||
const idx = tabs.findIndex((t) => t.id === tabId)
|
||||
if (idx < 0) return
|
||||
|
||||
const target = tabs[idx]
|
||||
if (target) destroyTab(target)
|
||||
tabs.splice(idx, 1)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
handleAddTab()
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTabId.value === tabId) {
|
||||
const nextIdx = Math.min(idx, tabs.length - 1)
|
||||
const next = tabs[nextIdx]
|
||||
if (next) {
|
||||
activeTabId.value = next.id
|
||||
nextTick(() => next.fitAddon?.fit())
|
||||
}
|
||||
}
|
||||
debouncedPersistCache()
|
||||
}
|
||||
|
||||
function handleSwitchTab(tabId: string) {
|
||||
if (activeTabId.value === tabId) return
|
||||
activeTabId.value = tabId
|
||||
debouncedPersistCache()
|
||||
nextTick(() => {
|
||||
const tab = tabs.find((t) => t.id === tabId)
|
||||
tab?.fitAddon?.fit()
|
||||
})
|
||||
}
|
||||
|
||||
function handleReconnect() {
|
||||
const tab = activeTermTab.value
|
||||
if (tab) connectWs(tab)
|
||||
}
|
||||
|
||||
function persistCache() {
|
||||
if (!botId.value || tabs.length === 0) return
|
||||
const state: TerminalCacheState = {
|
||||
activeTabId: activeTabId.value,
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
content: tab.serializeAddon?.serialize() ?? '',
|
||||
savedAt: Date.now(),
|
||||
})),
|
||||
}
|
||||
saveCache(botId.value, state)
|
||||
}
|
||||
|
||||
function debouncedPersistCache() {
|
||||
if (cacheTimer) clearTimeout(cacheTimer)
|
||||
cacheTimer = setTimeout(() => {
|
||||
persistCache()
|
||||
}, CACHE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function restoreFromCache() {
|
||||
const cached = loadCache(botId.value)
|
||||
if (!cached || cached.tabs.length === 0) {
|
||||
handleAddTab()
|
||||
return
|
||||
}
|
||||
|
||||
for (const cachedTab of cached.tabs) {
|
||||
tabCounter++
|
||||
const tab = makeTab(cachedTab.id, cachedTab.label)
|
||||
tabs.push(tab)
|
||||
const content = cachedTab.content
|
||||
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(tab.id)
|
||||
if (!el) return
|
||||
tab.containerEl = el as HTMLDivElement
|
||||
initTerminal(tab, content)
|
||||
})
|
||||
}
|
||||
|
||||
const firstTab = cached.tabs[0]
|
||||
const targetId = cached.tabs.find((ct) => ct.id === cached.activeTabId)
|
||||
? cached.activeTabId
|
||||
: firstTab?.id ?? ''
|
||||
activeTabId.value = targetId
|
||||
}
|
||||
|
||||
function onBeforeUnload() {
|
||||
persistCache()
|
||||
}
|
||||
|
||||
function cleanupAll() {
|
||||
if (fitTimer) {
|
||||
clearTimeout(fitTimer)
|
||||
fitTimer = null
|
||||
}
|
||||
if (cacheTimer) {
|
||||
clearTimeout(cacheTimer)
|
||||
cacheTimer = null
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
for (const tab of tabs) {
|
||||
destroyTab(tab)
|
||||
}
|
||||
tabs.length = 0
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (resizeObserver || !wrapperRef.value) return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (fitTimer) clearTimeout(fitTimer)
|
||||
fitTimer = setTimeout(() => {
|
||||
activeTermTab.value?.fitAddon?.fit()
|
||||
}, 50)
|
||||
})
|
||||
resizeObserver.observe(wrapperRef.value)
|
||||
}
|
||||
|
||||
function init() {
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
restoreFromCache()
|
||||
nextTick(() => setupResizeObserver())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (activeTab.value === 'terminal') {
|
||||
init()
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'terminal' && tabs.length === 0) {
|
||||
nextTick(() => init())
|
||||
}
|
||||
if (tab !== 'terminal' && tabs.length > 0) {
|
||||
persistCache()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
persistCache()
|
||||
cleanupAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col absolute inset-0 p-4">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center gap-1 mb-2 min-h-[36px]">
|
||||
<div class="flex items-center gap-1 flex-1 overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border transition-colors whitespace-nowrap"
|
||||
:class="tab.id === activeTabId
|
||||
? 'bg-accent text-accent-foreground border-border'
|
||||
: 'text-muted-foreground border-transparent hover:bg-accent/50'"
|
||||
@click="handleSwitchTab(tab.id)"
|
||||
>
|
||||
<span
|
||||
class="size-1.5 rounded-full shrink-0"
|
||||
:class="{
|
||||
'bg-green-500': tab.status === 'connected',
|
||||
'bg-yellow-500': tab.status === 'connecting',
|
||||
'bg-muted-foreground': tab.status === 'idle' || tab.status === 'disconnected',
|
||||
}"
|
||||
/>
|
||||
<span>{{ tab.label }}</span>
|
||||
<span
|
||||
class="ml-1 size-4 inline-flex cursor-pointer items-center justify-center rounded hover:bg-destructive/20 hover:text-destructive"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('bots.terminal.closeTab')"
|
||||
@click.stop="handleCloseTab(tab.id)"
|
||||
@keydown.enter.prevent.stop="handleCloseTab(tab.id)"
|
||||
@keydown.space.prevent.stop="handleCloseTab(tab.id)"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center size-7 rounded-md border border-dashed border-border text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground transition-colors shrink-0"
|
||||
:title="t('bots.terminal.newTab')"
|
||||
@click="handleAddTab"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0 ml-2">
|
||||
<span
|
||||
v-if="activeTermTab"
|
||||
class="inline-flex items-center gap-1.5 text-xs"
|
||||
:class="{
|
||||
'text-green-500': activeTermTab.status === 'connected',
|
||||
'text-yellow-500': activeTermTab.status === 'connecting',
|
||||
'text-muted-foreground': activeTermTab.status === 'idle' || activeTermTab.status === 'disconnected',
|
||||
}"
|
||||
>
|
||||
{{ t(`bots.terminal.status.${activeTermTab.status}`) }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="activeTermTab?.status === 'disconnected'"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="handleReconnect"
|
||||
>
|
||||
{{ t('bots.terminal.reconnect') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal area -->
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="flex-1 relative min-h-0 rounded-md overflow-hidden border border-border terminal-wrapper"
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:id="tab.id"
|
||||
:key="tab.id"
|
||||
class="absolute inset-0 terminal-container"
|
||||
:style="{ display: tab.id === activeTabId ? 'block' : 'none' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Terminal
|
||||
:bot-id="botId"
|
||||
:visible="activeTab === 'terminal'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-wrapper {
|
||||
background-color: #1a1b26;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,29 +17,6 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Session header -->
|
||||
<!-- <div class="border-b px-4 py-2 flex items-center justify-between min-h-12">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h2 class="text-xs font-medium truncate">
|
||||
{{ activeSession?.title || $t('chat.untitledSession') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:aria-label="$t('chat.newSession')"
|
||||
@click="chatStore.createNewSession()"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'plus']"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Messages -->
|
||||
<section class="flex-1 relative w-full px-3 sm:px-5 lg:px-8">
|
||||
<section class="absolute inset-0">
|
||||
@@ -80,7 +57,6 @@
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Media gallery lightbox -->
|
||||
<MediaGalleryLightbox
|
||||
:items="galleryItems"
|
||||
@@ -130,7 +106,7 @@
|
||||
<InputGroup class="bg-transparent overflow-hidden shadow-none! ring-0! border-border!">
|
||||
<InputGroupTextarea
|
||||
v-model="inputText"
|
||||
class="min-h-14 max-h-14 text-xs resize-none break-all!"
|
||||
class="min-h-14 max-h-14 text-xs resize-none break-all!"
|
||||
:placeholder="activeChatReadOnly ? $t('chat.readonlyHint') : $t('chat.inputPlaceholder')"
|
||||
:disabled="!currentBotId || activeChatReadOnly"
|
||||
style="scrollbar-width: none;"
|
||||
@@ -212,18 +188,6 @@
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="!currentBotId"
|
||||
:aria-label="$t('chat.files')"
|
||||
@click="fileManagerOpen = true"
|
||||
>
|
||||
<FolderOpen
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!streaming"
|
||||
type="button"
|
||||
@@ -258,58 +222,153 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- File manager panel -->
|
||||
<!-- Right sidebar panel -->
|
||||
<div
|
||||
v-if="fileManagerOpen"
|
||||
v-if="activeRightTab"
|
||||
class="flex shrink-0 h-full relative"
|
||||
:style="{ width: `${fileManagerWidth}px` }"
|
||||
:style="{ width: `${rightPanelWidth}px` }"
|
||||
>
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-1 h-full cursor-col-resize z-10 group"
|
||||
@mousedown="onFmResizeStart"
|
||||
@mousedown="onPanelResizeStart"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full transition-colors group-hover:bg-primary/20"
|
||||
:class="{ 'bg-primary/30': isFmResizing }"
|
||||
:class="{ 'bg-primary/30': isPanelResizing }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col h-full flex-1 min-w-0 border-l border-border bg-sidebar">
|
||||
<div class="flex items-center justify-between px-4 h-12 shrink-0">
|
||||
<span class="text-sm font-medium text-foreground">{{ $t('chat.files') }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="size-6"
|
||||
@click="fileManagerOpen = false"
|
||||
<div class="flex flex-col h-full flex-1 min-w-0 overflow-hidden border-l border-border bg-sidebar">
|
||||
<!-- Panel tab bar -->
|
||||
<div class="flex items-center h-12 shrink-0 border-b border-border">
|
||||
<button
|
||||
v-for="tab in rightTabs"
|
||||
:key="tab.id"
|
||||
class="flex items-center gap-1.5 px-4 h-full text-xs transition-colors border-b-2"
|
||||
:class="activeRightTab === tab.id
|
||||
? 'border-foreground text-foreground font-medium'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'"
|
||||
@click="activeRightTab = tab.id"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</Button>
|
||||
<component
|
||||
:is="tab.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Panel content -->
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<FileManager
|
||||
v-if="currentBotId"
|
||||
ref="fileManagerRef"
|
||||
:bot-id="currentBotId"
|
||||
:sync-url="false"
|
||||
/>
|
||||
<div
|
||||
v-show="activeRightTab === 'terminal'"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<TerminalComponent
|
||||
v-if="currentBotId"
|
||||
:bot-id="currentBotId"
|
||||
:visible="activeRightTab === 'terminal'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="activeRightTab === 'files'"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<FileManager
|
||||
v-if="currentBotId"
|
||||
ref="fileManagerRef"
|
||||
:bot-id="currentBotId"
|
||||
:sync-url="false"
|
||||
preview-layout="bottom"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeRightTab === 'info'"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Info
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Bar -->
|
||||
<div class="flex flex-col items-center w-10 shrink-0 h-full border-l border-border bg-sidebar">
|
||||
<div class="flex flex-col items-center gap-3 pt-4">
|
||||
<button
|
||||
v-for="tab in rightTabs"
|
||||
:key="tab.id"
|
||||
class="flex items-center justify-center size-7 rounded-md transition-colors"
|
||||
:class="activeRightTab === tab.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
|
||||
:title="tab.label"
|
||||
@click="toggleRightPanel(tab.id)"
|
||||
>
|
||||
<component
|
||||
:is="tab.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-auto pb-4">
|
||||
<button
|
||||
class="flex items-center justify-center size-7 rounded-md text-destructive/60 hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
:title="$t('chat.deleteSession')"
|
||||
:disabled="!sessionId"
|
||||
@click="confirmDeleteSession"
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete session confirmation dialog -->
|
||||
<Dialog v-model:open="deleteSessionDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('chat.deleteSession') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('chat.deleteSessionConfirm') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="deleteSessionLoading"
|
||||
@click="deleteSessionDialogOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="deleteSessionLoading"
|
||||
@click="handleDeleteSession"
|
||||
>
|
||||
<LoaderCircle
|
||||
v-if="deleteSessionLoading"
|
||||
class="mr-1 size-3 animate-spin"
|
||||
/>
|
||||
{{ $t('common.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect, watch } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect, watch, type Component } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { LoaderCircle, Image as ImageIcon, File as FileIcon, X, Paperclip, FolderOpen, Send, ChevronDown, Lightbulb } from 'lucide-vue-next'
|
||||
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Popover, PopoverContent, PopoverTrigger } from '@memohai/ui'
|
||||
import { LoaderCircle, Image as ImageIcon, File as FileIcon, X, Paperclip, FolderOpen, Send, ChevronDown, Lightbulb, TerminalSquare, BarChart3, Trash2 } from 'lucide-vue-next'
|
||||
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Popover, PopoverContent, PopoverTrigger, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@memohai/ui'
|
||||
import { useChatStore } from '@/store/chat-list'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import MessageItem from './message-item.vue'
|
||||
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
|
||||
import FileManager from '@/components/file-manager/index.vue'
|
||||
import TerminalComponent from '@/components/terminal/index.vue'
|
||||
import ModelOptions from '@/pages/bots/components/model-options.vue'
|
||||
import ReasoningEffortSelect from '@/pages/bots/components/reasoning-effort-select.vue'
|
||||
import { EFFORT_LABELS, EFFORT_OPACITY } from '@/pages/bots/components/reasoning-effort'
|
||||
@@ -326,31 +385,60 @@ const { t } = useI18n()
|
||||
const chatStore = useChatStore()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingFiles = ref<File[]>([])
|
||||
const fileManagerOpen = ref(false)
|
||||
const fileManagerRef = ref<InstanceType<typeof FileManager> | null>(null)
|
||||
const modelPopoverOpen = ref(false)
|
||||
const reasoningPopoverOpen = ref(false)
|
||||
|
||||
const FM_MIN_WIDTH = 320
|
||||
const FM_MAX_WIDTH = 800
|
||||
const FM_DEFAULT_WIDTH = 520
|
||||
// ---- Right sidebar panel ----
|
||||
|
||||
const fileManagerWidth = useLocalStorage('file-manager-panel-width', FM_DEFAULT_WIDTH)
|
||||
const isFmResizing = ref(false)
|
||||
type RightTabId = 'terminal' | 'files' | 'info'
|
||||
|
||||
function onFmResizeStart(e: MouseEvent) {
|
||||
interface RightTab {
|
||||
id: RightTabId
|
||||
label: string
|
||||
icon: Component
|
||||
}
|
||||
|
||||
const rightTabs = computed<RightTab[]>(() => [
|
||||
{ id: 'terminal', label: 'Terminal', icon: TerminalSquare },
|
||||
{ id: 'files', label: t('chat.files'), icon: FolderOpen },
|
||||
{ id: 'info', label: 'Info', icon: BarChart3 },
|
||||
])
|
||||
|
||||
const activeRightTab = ref<RightTabId | null>(null)
|
||||
|
||||
function toggleRightPanel(tabId: RightTabId) {
|
||||
activeRightTab.value = activeRightTab.value === tabId ? null : tabId
|
||||
}
|
||||
|
||||
function openRightPanel(tabId: RightTabId) {
|
||||
activeRightTab.value = tabId
|
||||
}
|
||||
|
||||
watch(activeRightTab, () => {
|
||||
nextTick(() => window.dispatchEvent(new Event('resize')))
|
||||
})
|
||||
|
||||
const PANEL_MIN_WIDTH = 320
|
||||
const PANEL_MAX_WIDTH = 800
|
||||
const PANEL_DEFAULT_WIDTH = 504
|
||||
|
||||
const rightPanelWidth = useLocalStorage('chat-right-panel-width', PANEL_DEFAULT_WIDTH)
|
||||
const isPanelResizing = ref(false)
|
||||
|
||||
function onPanelResizeStart(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
isFmResizing.value = true
|
||||
isPanelResizing.value = true
|
||||
const startX = e.clientX
|
||||
const startWidth = fileManagerWidth.value
|
||||
const startWidth = rightPanelWidth.value
|
||||
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
const delta = startX - ev.clientX
|
||||
fileManagerWidth.value = Math.min(FM_MAX_WIDTH, Math.max(FM_MIN_WIDTH, startWidth + delta))
|
||||
rightPanelWidth.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, startWidth + delta))
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isFmResizing.value = false
|
||||
isPanelResizing.value = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
@@ -368,6 +456,30 @@ onBeforeUnmount(() => {
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
// ---- Delete session ----
|
||||
|
||||
const deleteSessionDialogOpen = ref(false)
|
||||
const deleteSessionLoading = ref(false)
|
||||
|
||||
function confirmDeleteSession() {
|
||||
if (!sessionId.value) return
|
||||
deleteSessionDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteSession() {
|
||||
const sid = sessionId.value
|
||||
if (!sid || deleteSessionLoading.value) return
|
||||
deleteSessionLoading.value = true
|
||||
try {
|
||||
await chatStore.removeSession(sid)
|
||||
deleteSessionDialogOpen.value = false
|
||||
} finally {
|
||||
deleteSessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- File manager provider (for tool call components) ----
|
||||
|
||||
const FILE_MANAGER_ROOT = '/data'
|
||||
|
||||
function normalizeFileManagerPath(path: string): string {
|
||||
@@ -385,7 +497,7 @@ function normalizeFileManagerPath(path: string): string {
|
||||
|
||||
provide(openInFileManagerKey, (path: string, isDir = false) => {
|
||||
const normalizedPath = normalizeFileManagerPath(path)
|
||||
fileManagerOpen.value = true
|
||||
openRightPanel('files')
|
||||
nextTick(() => {
|
||||
if (!fileManagerRef.value) return
|
||||
if (isDir) {
|
||||
@@ -395,10 +507,14 @@ provide(openInFileManagerKey, (path: string, isDir = false) => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Chat store refs ----
|
||||
|
||||
const {
|
||||
messages,
|
||||
streaming,
|
||||
currentBotId,
|
||||
sessionId,
|
||||
activeChatReadOnly,
|
||||
loadingOlder,
|
||||
loadingChats,
|
||||
@@ -407,6 +523,8 @@ const {
|
||||
overrideReasoningEffort,
|
||||
} = storeToRefs(chatStore)
|
||||
|
||||
// ---- Model / provider queries ----
|
||||
|
||||
const { data: modelData } = useQuery({
|
||||
key: ['all-models'],
|
||||
query: async () => {
|
||||
@@ -501,6 +619,8 @@ function onReasoningSelected() {
|
||||
reasoningPopoverOpen.value = false
|
||||
}
|
||||
|
||||
// ---- Media gallery ----
|
||||
|
||||
const {
|
||||
items: galleryItems,
|
||||
openIndex: galleryOpenIndex,
|
||||
@@ -508,8 +628,9 @@ const {
|
||||
openBySrc: galleryOpenBySrc,
|
||||
} = useMediaGallery(messages)
|
||||
|
||||
const inputText = ref('')
|
||||
// ---- Input & scroll ----
|
||||
|
||||
const inputText = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -529,10 +650,9 @@ const elNode = useTemplateRef('scrollContainer')
|
||||
const descEl = computed(() => elNode.value?.$el?.children[0]?.children[0])
|
||||
const scrollEl = computed(() => descEl.value?.parentNode)
|
||||
const isAutoScroll = ref(true)
|
||||
const isInstant=ref(false)
|
||||
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value&&isInstant.value ? 'smooth' : 'instant') })
|
||||
const { height,bottom } = useElementBounding(descEl)
|
||||
|
||||
const isInstant = ref(false)
|
||||
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value && isInstant.value ? 'smooth' : 'instant') })
|
||||
const { height, bottom } = useElementBounding(descEl)
|
||||
|
||||
watchEffect(() => {
|
||||
if (directions.top) {
|
||||
@@ -543,7 +663,7 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
watchEffect(() => {
|
||||
if (isAutoScroll.value) {
|
||||
y.value = height.value
|
||||
}
|
||||
@@ -553,14 +673,14 @@ let Throttle = true
|
||||
|
||||
watchEffect(() => {
|
||||
if (directions.top && arrivedState.top && Throttle && hasMoreOlder.value && !loadingOlder.value) {
|
||||
const prev=bottom.value
|
||||
Throttle = false
|
||||
const prev = bottom.value
|
||||
Throttle = false
|
||||
chatStore.loadOlderMessages().then((count) => {
|
||||
setTimeout(() => {
|
||||
if (count > 0) {
|
||||
y.value = height.value-prev
|
||||
Throttle = true
|
||||
}
|
||||
if (count > 0) {
|
||||
y.value = height.value - prev
|
||||
Throttle = true
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -572,7 +692,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
handleSend()
|
||||
}
|
||||
|
||||
|
||||
function handleFileInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
@@ -611,7 +730,7 @@ async function fileToAttachment(file: File): Promise<ChatAttachment> {
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
isAutoScroll.value=true
|
||||
isAutoScroll.value = true
|
||||
const text = inputText.value.trim()
|
||||
const files = [...pendingFiles.value]
|
||||
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-full">
|
||||
<div class="flex h-full overflow-hidden">
|
||||
<template v-if="currentBotId">
|
||||
<SessionSidebar />
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user