From 2e54f2c34373644b44134b120549c85423b31bd8 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 12 Jun 2026 00:18:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(terminal):=20=E5=AE=9E=E6=97=B6=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=94=B9=E9=80=A0=E4=B8=BA=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=EF=BC=8C=E6=94=AF=E6=8C=81=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E5=88=86=E5=89=B2=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将实时终端从独立页面改造为 Vue3 侧边栏面板,与 Git 面板上下排列 - 使用 xterm.js + Grid 布局,支持三主题配色适配 - 上下拖拽分割条,动态调整终端/Git 面板比例 - 输入栏上方新增终端数量按钮,点击快捷打开 - 个人空间新增「自动打开终端面板」开关(外观与显示分类) - REST API /api/terminals 获取终端列表,多时机自动刷新 - 有终端自动打开面板,无终端自动关闭 --- package-lock.json | 24 +- package.json | 5 +- static/src/App.vue | 50 +- static/src/app/components.ts | 2 + static/src/app/computed.ts | 16 + static/src/app/lifecycle.ts | 3 + static/src/app/methods/conversation.ts | 1 + static/src/app/methods/taskPolling.ts | 1 + static/src/app/methods/ui.ts | 111 +++- static/src/app/state.ts | 10 + static/src/components/input/InputComposer.vue | 30 + .../src/components/panels/TerminalPanel.vue | 581 ++++++++++++++++++ .../personalization/PersonalizationDrawer.vue | 22 + static/src/composables/useLegacySocket.ts | 116 ++++ static/src/stores/personalization.ts | 3 + static/src/stores/ui.ts | 5 + .../components/panels/_right-panel.scss | 75 ++- 17 files changed, 1022 insertions(+), 33 deletions(-) create mode 100644 static/src/components/panels/TerminalPanel.vue diff --git a/package-lock.json b/package-lock.json index fdeb960..447f4ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@tiptap/suggestion": "^3.23.6", "@tiptap/vue-3": "^3.23.6", "@types/html2canvas": "^0.5.35", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "diff": "^8.0.3", "dompurify": "^3.2.7", "enquirer": "^2.4.1", @@ -33,7 +35,8 @@ "socket.io-client": "^4.7.5", "unified": "^11.0.5", "vue": "^3.4.15", - "vue-stick-to-bottom": "^1.0.0" + "vue-stick-to-bottom": "^1.0.0", + "xterm": "^5.3.0" }, "devDependencies": { "@types/node": "^20.10.5", @@ -2609,6 +2612,18 @@ "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", "license": "MIT" }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7367,6 +7382,13 @@ "node": ">=0.4.0" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index fec8e1b..71b703f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@tiptap/suggestion": "^3.23.6", "@tiptap/vue-3": "^3.23.6", "@types/html2canvas": "^0.5.35", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "diff": "^8.0.3", "dompurify": "^3.2.7", "enquirer": "^2.4.1", @@ -40,7 +42,8 @@ "socket.io-client": "^4.7.5", "unified": "^11.0.5", "vue": "^3.4.15", - "vue-stick-to-bottom": "^1.0.0" + "vue-stick-to-bottom": "^1.0.0", + "xterm": "^5.3.0" }, "devDependencies": { "@types/node": "^20.10.5", diff --git a/static/src/App.vue b/static/src/App.vue index 5734137..1a23c75 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -292,6 +292,7 @@ :block-upload="policyUiBlocks.block_upload" :block-tool-toggle="policyUiBlocks.block_tool_toggle" :block-realtime-terminal="policyUiBlocks.block_realtime_terminal" + :terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0" :block-focus-panel="policyUiBlocks.block_focus_panel" :block-token-panel="policyUiBlocks.block_token_panel" :block-compress-conversation="policyUiBlocks.block_compress_conversation" @@ -354,23 +355,42 @@
- - + +
+
+ +
+
+
+ +
+
diff --git a/static/src/app/components.ts b/static/src/app/components.ts index 4f364b6..f34ecef 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -4,6 +4,7 @@ import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue'; import LeftPanel from '../components/panels/LeftPanel.vue'; import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue'; import GitChangesPanel from '../components/panels/GitChangesPanel.vue'; +import TerminalPanel from '../components/panels/TerminalPanel.vue'; import TokenDrawer from '../components/token/TokenDrawer.vue'; import QuickMenu from '../components/input/QuickMenu.vue'; import InputComposer from '../components/input/InputComposer.vue'; @@ -50,6 +51,7 @@ export const appComponents = { LeftPanel, ToolApprovalPanel, GitChangesPanel, + TerminalPanel, TokenDrawer, PersonalizationDrawer, QuickMenu, diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 2e3153a..45cdce0 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -218,5 +218,21 @@ export const computed = { }, showScrollToBottomButton() { return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom; + }, + rightPanelsStyle() { + const w = Math.min(this.rightWidth || 420, 600); + const t = this.terminalPanelOpen; + const g = this.gitChangesPanelOpen; + const ratio = this.rightSplitRatio ?? 0.5; + const r = Math.max(0.15, Math.min(0.85, Number(ratio) || 0.5)); + let rows: string; + if (t && g) rows = `${r}fr 2px ${1 - r}fr`; + else if (t) rows = '1fr 0px 0fr'; + else if (g) rows = '0fr 0px 1fr'; + else rows = '0fr 0px 0fr'; + return { + width: w + 'px', + gridTemplateRows: rows + }; } }; diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index d8fffc6..6020a52 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -44,6 +44,8 @@ export async function mounted() { this.refreshProjectGitSummary?.(); this.startProjectGitSummaryIdleRefresh?.(); this.startConnectionHeartbeat(); + this.fetchTerminalCount(); + this.startTerminalCountIdleRefresh(); this.checkTutorialPrompt(); if (initialDataPromise && typeof initialDataPromise.then === 'function') { initialDataPromise @@ -120,6 +122,7 @@ export function beforeUnmount() { this.subAgentStopPolling(); this.backgroundCommandStopPolling(); this.stopProjectGitSummaryIdleRefresh?.(); + this.stopTerminalCountIdleRefresh?.(); this.resourceStopContainerStatsPolling(); this.resourceStopProjectStoragePolling(); this.resourceStopUsageQuotaPolling(); diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 38b5767..f1d2c07 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -339,6 +339,7 @@ export const conversationMethods = { this.skipConversationHistoryReload = true; this.currentConversationId = conversationId; this.refreshProjectGitSummary?.(); + this.fetchTerminalCount(); this.currentConversationTitle = result.title; this.titleReady = true; this.suppressTitleTyping = false; diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index d80eaf2..5911d0a 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -1308,6 +1308,7 @@ export const taskPollingMethods = { const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled', 'canceled']); if (terminalStatuses.has(String(data.status))) { this.refreshProjectGitSummary?.(); + this.fetchTerminalCount(); } } if (data.result !== undefined) { diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index bcde5af..c7b4698 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -165,6 +165,20 @@ export const uiMethods = { }, 5000); }, + startTerminalCountIdleRefresh() { + if (this.terminalCountRefreshTimer) return; + this.terminalCountRefreshTimer = window.setInterval(() => { + if (!this.isConnected) return; + this.fetchTerminalCount(); + }, 5000); + }, + + stopTerminalCountIdleRefresh() { + if (!this.terminalCountRefreshTimer) return; + window.clearInterval(this.terminalCountRefreshTimer); + this.terminalCountRefreshTimer = null; + }, + stopProjectGitSummaryIdleRefresh() { if (!this.projectGitSummaryRefreshTimer) { return; @@ -1226,6 +1240,7 @@ export const uiMethods = { type: 'success' }); this.refreshProjectGitSummary?.(); + this.fetchTerminalCount(); } catch (error) { this.conversations = previousConversationList; this.searchActive = previousSearchActive; @@ -2229,7 +2244,65 @@ export const uiMethods = { if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) { return; } - this.openRealtimeTerminal(); + this.toggleTerminalPanel(); + }, + + toggleTerminalPanel() { + this.terminalPanelOpen = !this.terminalPanelOpen; + if (this.terminalPanelOpen) { + this.subscribeTerminalEvents(); + } + }, + + closeTerminalPanel() { + this.terminalPanelOpen = false; + }, + + subscribeTerminalEvents() { + const socket = this.socket; + if (!socket) return; + socket.emit('terminal_subscribe', { all: true }); + }, + + // REST API 获取终端列表,用于输入栏按钮显示 + async fetchTerminalCount() { + try { + const res = await fetch('/api/terminals'); + if (!res.ok) return; + const data = await res.json(); + if (data?.sessions) { + const sessions: Record = {}; + for (const s of data.sessions) { + const name = s.session_name || s.name || s.session || s.id; + if (name) sessions[name] = {}; + } + this.terminalSessions = sessions; + // 自动打开/关闭终端面板 + const personalizationStore = usePersonalizationStore(); + const autoOpen = personalizationStore.form.auto_open_terminal_panel !== false; + const hasTerminals = Object.keys(sessions).length > 0; + if (autoOpen && hasTerminals && !this.terminalPanelOpen) { + this.toggleTerminalPanel(); + } else if (autoOpen && !hasTerminals && this.terminalPanelOpen) { + this.closeTerminalPanel(); + } + } + } catch {} + }, + + setTerminalSessions(sessions: Record) { + this.terminalSessions = sessions; + }, + + setTerminalActiveSession(session: string) { + this.terminalActiveSession = session; + }, + + switchTerminalSession(name: string) { + this.terminalActiveSession = name; + if (this.socket) { + this.socket.emit('get_terminal_output', { session: name, lines: 0 }); + } }, handleFocusPanelToggleClick() { @@ -2836,6 +2909,36 @@ export const uiMethods = { stopPanelResize(this); }, + // 右侧面板上下分割拖拽 + startRightSplitResize(event) { + event.preventDefault(); + const container = event.target.closest('.right-panels') as HTMLElement; + if (!container) return; + const startY = event.clientY; + const startHeight = container.offsetHeight; + const startRatio = this.rightSplitRatio ?? 0.5; + + // 拖拽期间禁用 CSS transition,确保跟手 + container.classList.add('right-panels--resizing'); + + const onMove = (e: MouseEvent) => { + const dy = e.clientY - startY; + const newRatio = startRatio + dy / startHeight; + this.rightSplitRatio = Math.max(0.15, Math.min(0.85, newRatio)); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + container.classList.remove('right-panels--resizing'); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + }, + getInputComposerRef() { return this.$refs.inputComposer || null; }, @@ -3395,11 +3498,7 @@ export const uiMethods = { connectionDiag('log', 'heartbeat-stop', {}, { force: true }); }, - openRealtimeTerminal() { - const { protocol, hostname, port } = window.location; - const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`; - window.open(target, '_blank'); - }, + async loadInitialData() { try { diff --git a/static/src/app/state.ts b/static/src/app/state.ts index e7ddc91..d9b5274 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -38,6 +38,7 @@ export function dataState() { runtimeQueueSyncLockUntil: 0, projectGitSummary: null, projectGitSummaryRefreshTimer: null, + terminalCountRefreshTimer: null, projectGitSummaryRefreshing: false, gitChangesPanelOpen: false, gitChangesLoading: false, @@ -46,6 +47,15 @@ export function dataState() { gitChangesFileContexts: {}, gitChangesFoldContexts: {}, gitChangesDiff: null, + // 实时终端面板 + terminalPanelOpen: false, + terminalSessions: {} as Record, + terminalActiveSession: '', + terminalSessionLogs: {} as Record, + terminalHydrated: {} as Record, + terminalCount: 0, + // 右侧面板上下分割比例(终端占比 0.15~0.85) + rightSplitRatio: 0.5, // 输入区动态保留高度(用于同步扩大消息区可滚动范围) composerReservedHeight: 80, // 记录上一次成功加载历史的对话ID,防止初始化阶段重复加载导致动画播放两次 diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 7a758d0..33aef8a 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -132,6 +132,14 @@ > 等待回答问题 +