From c0490c9688ef36c71e134d1278b853bfe3aa3607 Mon Sep 17 00:00:00 2001 From: Acbox Date: Tue, 31 Mar 2026 17:18:45 +0800 Subject: [PATCH] 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) --- .../web/src/components/file-manager/index.vue | 16 +- apps/web/src/components/terminal/index.vue | 466 ++++++++++++++++++ apps/web/src/i18n/locales/en.json | 1 + apps/web/src/i18n/locales/zh.json | 1 + .../pages/bots/components/bot-terminal.vue | 457 +---------------- .../src/pages/home/components/chat-area.vue | 295 +++++++---- apps/web/src/pages/home/index.vue | 2 +- 7 files changed, 694 insertions(+), 544 deletions(-) create mode 100644 apps/web/src/components/terminal/index.vue diff --git a/apps/web/src/components/file-manager/index.vue b/apps/web/src/components/file-manager/index.vue index 8dbb4eeb..89806490 100644 --- a/apps/web/src/components/file-manager/index.vue +++ b/apps/web/src/components/file-manager/index.vue @@ -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 }) -
+
+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({ + id, + label, + terminal: null, + fitAddon: null, + serializeAddon: null, + ws: null, + status: 'idle', + containerEl: null, + wsDisposables: [], + }) +} + +const tabs = reactive([]) +const activeTabId = ref('') +const wrapperRef = ref(null) +let tabCounter = 0 +let resizeObserver: ResizeObserver | null = null +let fitTimer: ReturnType | null = null +let cacheTimer: ReturnType | 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() +}) + + + + + diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 46830dda..b113d414 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -167,6 +167,7 @@ "toolSpawnCount": "{count} tasks", "unknownUser": "{platform} User", "files": "Files", + "clearMessages": "Clear Messages", "modelOverride": "Model", "modelDefault": "Default", "reasoningEffort": "Reasoning", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 9f375d51..6028293d 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -163,6 +163,7 @@ "toolSpawnCount": "{count} 个任务", "unknownUser": "{platform}用户", "files": "文件管理", + "clearMessages": "清除消息", "modelOverride": "模型", "modelDefault": "默认", "reasoningEffort": "推理", diff --git a/apps/web/src/pages/bots/components/bot-terminal.vue b/apps/web/src/pages/bots/components/bot-terminal.vue index 13a9c42b..396d0746 100644 --- a/apps/web/src/pages/bots/components/bot-terminal.vue +++ b/apps/web/src/pages/bots/components/bot-terminal.vue @@ -1,462 +1,17 @@ - - diff --git a/apps/web/src/pages/home/components/chat-area.vue b/apps/web/src/pages/home/components/chat-area.vue index 94711fcd..12679fd5 100644 --- a/apps/web/src/pages/home/components/chat-area.vue +++ b/apps/web/src/pages/home/components/chat-area.vue @@ -17,29 +17,6 @@