diff --git a/static/src/app.ts b/static/src/app.ts index f6a6886..5c46f5f 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -301,7 +301,8 @@ const appOptions = { return this.streamingMessage || this.hasPendingToolActions(); }, composerBusy() { - return this.streamingUi || this.monitorIsLocked || this.stopRequested; + const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor'; + return this.streamingUi || monitorLock || this.stopRequested; } }, diff --git a/static/src/components/chat/VirtualMonitorSurface.vue b/static/src/components/chat/VirtualMonitorSurface.vue index a4b39d2..0d0d1e6 100644 --- a/static/src/components/chat/VirtualMonitorSurface.vue +++ b/static/src/components/chat/VirtualMonitorSurface.vue @@ -130,21 +130,14 @@
-
- - - - 记忆记录 -
- 同步待命 + + + + 记忆
-
diff --git a/static/src/components/chat/monitor/MonitorDirector.ts b/static/src/components/chat/monitor/MonitorDirector.ts index 7e30e19..ffe1a7f 100644 --- a/static/src/components/chat/monitor/MonitorDirector.ts +++ b/static/src/components/chat/monitor/MonitorDirector.ts @@ -232,6 +232,7 @@ export class MonitorDirector implements MonitorDriver { private progressBubbleTimer: number | null = null; private progressBubbleBase: string | null = null; private progressSceneName: string | null = null; + private latestMemoryScroll = 0; private thinkingBubbleTimer: number | null = null; private thinkingBubblePhase = 0; private waitingBubbleTimer: number | null = null; @@ -343,7 +344,6 @@ export class MonitorDirector implements MonitorDriver { this.terminalLastFocusedAt = 0; this.elements.readerLines.innerHTML = ''; this.elements.readerOcr.innerHTML = ''; - this.elements.memoryList.innerHTML = ''; this.elements.todoBacklog.innerHTML = ''; this.elements.todoDoing.innerHTML = ''; this.elements.todoDone.innerHTML = ''; @@ -1379,6 +1379,244 @@ export class MonitorDirector implements MonitorDriver { } } + private async ensureMemoryWindowVisible(options: { initialEntries?: string[]; memoryType?: string } = {}) { + const { initialEntries = [], memoryType = 'main' } = options; + const hydrate = async () => { + if (initialEntries.length) { + this.renderMemoryEntries(initialEntries); + return; + } + if (!this.getMemoryItems().length) { + await this.loadMemoryEntries(memoryType); + } + }; + if (this.isWindowVisible(this.elements.memoryWindow)) { + this.showWindow(this.elements.memoryWindow); + if (!this.getMemoryItems().length || initialEntries.length) { + await hydrate(); + } + return; + } + await this.movePointerToApp('memory'); + await this.click(); + this.showWindow(this.elements.memoryWindow); + await hydrate(); + } + + private getMemoryItems(): HTMLElement[] { + if (!this.elements.memoryList) return []; + return Array.from(this.elements.memoryList.children) as HTMLElement[]; + } + + private extractMemorySnapshotEntries(payload: any, stage: 'before' | 'after' = 'before'): string[] { + const key = stage === 'after' ? 'monitor_snapshot_after' : 'monitor_snapshot'; + const snapshot = payload?.[key]; + const entries = snapshot?.entries; + if (Array.isArray(entries)) { + return entries.map(entry => String(entry ?? '')); + } + return []; + } + + private getMemoryItemByIndex(index: number): HTMLElement | null { + const items = this.getMemoryItems(); + if (!index || index < 1 || index > items.length) { + return null; + } + return items[index - 1]; + } + + private async waitForScrollSettled(el: HTMLElement, targetTop: number, timeout = 600) { + const started = Date.now(); + return new Promise(resolve => { + const tick = () => { + const arrived = Math.abs(el.scrollTop - targetTop) < 2; + const expired = Date.now() - started > timeout; + if (arrived || expired) { + resolve(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + } + + private async scrollMemoryToBottom(waitMs = 200) { + const body = this.elements.memoryList; + if (!body) return; + const target = body.scrollHeight; + body.scrollTo({ top: target, behavior: 'smooth' }); + await this.waitForScrollSettled(body, target); + this.latestMemoryScroll = Date.now(); + if (waitMs > 0) { + await sleep(waitMs); + } + } + + private async scrollMemoryItemIntoView(card: HTMLElement | null, waitMs = 200) { + if (!card) return; + const body = this.elements.memoryList; + if (!body) return; + const targetTop = card.offsetTop - body.clientHeight * 0.35; + const clamped = Math.max(0, targetTop); + body.scrollTo({ top: clamped, behavior: 'smooth' }); + await this.waitForScrollSettled(body, clamped); + this.latestMemoryScroll = Date.now(); + if (waitMs > 0) { + await sleep(waitMs); + } + } + + private ensureMemoryTypingVisible(card: HTMLElement | null) { + if (!card) return; + const body = this.elements.memoryList; + if (!body) return; + const bodyTop = body.scrollTop; + const bodyBottom = bodyTop + body.clientHeight; + const cardTop = card.offsetTop; + const cardBottom = cardTop + card.offsetHeight; + // 如果底部被遮挡,向下滚动到露出多 12px 缓冲 + if (cardBottom > bodyBottom - 4) { + const delta = cardBottom - bodyBottom + 12; + body.scrollTop = bodyTop + delta; + this.latestMemoryScroll = Date.now(); + } else if (cardTop < bodyTop) { + // 若顶部超出,滚回顶部 + body.scrollTop = Math.max(0, cardTop - 8); + this.latestMemoryScroll = Date.now(); + } + } + + private highlightMemoryCard(card: HTMLElement, active = true) { + if (!card) return; + card.classList.toggle('editing', !!active); + } + + private updateMemoryMeta() { + if (!this.elements.memoryList) return; + const count = this.elements.memoryList.children.length; + if (this.elements.memoryCount) { + this.elements.memoryCount.textContent = String(count); + } + if (this.elements.memoryStatus) { + this.elements.memoryStatus.textContent = '记忆已同步'; + } + if (this.elements.memoryTime) { + this.elements.memoryTime.textContent = new Date().toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + } + } + + private createMemoryCard(text: string) { + const item = document.createElement('div'); + item.className = 'memory-item new'; + const body = document.createElement('div'); + body.className = 'memory-text'; + body.textContent = text; + item.appendChild(body); + return item; + } + + private async typeIntoMemoryCard(card: HTMLElement, text: string, options: { clearFirst?: boolean } = {}) { + if (!card) return; + const body = card.querySelector('.memory-text') as HTMLElement | null; + if (!body) return; + if (options.clearFirst) { + const existing = body.textContent || ''; + for (let i = existing.length; i > 0; i--) { + body.textContent = existing.slice(0, i - 1); + await sleep(18); + } + } + const chars = Array.from(text); + let idx = 0; + for (const ch of chars) { + idx += 1; + body.textContent = (body.textContent || '') + ch; + if (idx % 4 === 0) { + this.ensureMemoryTypingVisible(card); + } + await sleep(18); + } + // 最终再校准一次,确保结尾可见 + this.ensureMemoryTypingVisible(card); + } + + private async animateMemoryAppend(text: string) { + if (!this.elements.memoryList) return; + await this.scrollMemoryToBottom(); + const card = this.createMemoryCard(''); + this.elements.memoryList.appendChild(card); + requestAnimationFrame(() => card.classList.add('visible')); + await sleep(160); + await this.scrollMemoryToBottom(); + await this.movePointerToElement(card, { duration: 420 }); + await this.click(); + this.highlightMemoryCard(card, true); + await this.typeIntoMemoryCard(card, text, { clearFirst: true }); + // 若内容超出单行,确保滚动至卡片底部 + await this.scrollMemoryItemIntoView(card); + this.highlightMemoryCard(card, false); + } + + private async animateMemoryReplace(index: number, text: string) { + const card = this.getMemoryItemByIndex(index); + if (!card) { + await this.animateMemoryAppend(text); + return; + } + await this.scrollMemoryItemIntoView(card); + await this.movePointerToElement(card, { duration: 420 }); + await this.click(); + this.highlightMemoryCard(card, true); + await this.typeIntoMemoryCard(card, text, { clearFirst: true }); + await this.scrollMemoryItemIntoView(card); + this.highlightMemoryCard(card, false); + } + + private async animateMemoryDelete(index: number) { + const card = this.getMemoryItemByIndex(index); + if (!card || !this.elements.memoryList) { + return; + } + await this.scrollMemoryItemIntoView(card); + await this.movePointerToElement(card, { duration: 360 }); + await this.click(); + this.highlightMemoryCard(card, true); + card.classList.add('swipe-out'); + await this.movePointerToElement(card, { offsetX: card.clientWidth * 0.45, duration: 240 }); + await sleep(200); + card.remove(); + this.highlightMemoryCard(card, false); + } + + private async loadMemoryEntries(memoryType = 'main') { + try { + const resp = await fetch(`/api/memory?type=${encodeURIComponent(memoryType)}`); + const data = await resp.json(); + if (!data?.success || !Array.isArray(data.entries)) { + return; + } + this.renderMemoryEntries(data.entries); + } catch (error) { + console.warn('loadMemoryEntries failed', error); + } + } + + private renderMemoryEntries(entries: string[]) { + if (!this.elements.memoryList) return; + this.elements.memoryList.innerHTML = ''; + entries.forEach(text => { + const card = this.createMemoryCard(text); + card.classList.add('visible'); + this.elements.memoryList.appendChild(card); + }); + this.updateMemoryMeta(); + } + private async revealTerminalWindow(instance: TerminalShell, title: string) { await this.movePointerToApp('terminal'); await this.click(); @@ -2509,12 +2747,25 @@ export class MonitorDirector implements MonitorDriver { this.sceneHandlers.memoryUpdate = async (payload, runtime) => { this.applySceneStatus(runtime, 'memoryUpdate', '正在同步记忆'); - await this.movePointerToApp('memory'); - await this.click({ count: 2 }); - this.showWindow(this.elements.memoryWindow); - const content = payload?.arguments?.content || payload?.arguments?.memory || payload?.result?.content || '记忆内容'; - this.addMemoryCard(content); - await sleep(500); + const initialEntries = this.extractMemorySnapshotEntries(payload, 'before'); + const memoryType = + (payload?.arguments?.memory_type || payload?.result?.memory_type || 'main').toString().toLowerCase(); + await this.ensureMemoryWindowVisible({ initialEntries, memoryType }); + + const op = (payload?.arguments?.operation || payload?.result?.operation || 'append').toLowerCase(); + const content = payload?.arguments?.content || payload?.result?.content || ''; + const index = Number(payload?.arguments?.index || payload?.result?.index || 0) || 0; + + if (op === 'replace') { + await this.animateMemoryReplace(index, content || ''); + } else if (op === 'delete') { + await this.animateMemoryDelete(index); + } else { + await this.animateMemoryAppend(content || '新记忆'); + } + + this.updateMemoryMeta(); + await sleep(360); }; this.sceneHandlers.todoCreate = async (payload, runtime) => { @@ -4732,22 +4983,10 @@ export class MonitorDirector implements MonitorDriver { } private addMemoryCard(text: string) { - const item = document.createElement('div'); - item.className = 'memory-item new'; - const tag = document.createElement('div'); - tag.className = 'memory-tag'; - tag.textContent = 'MEM'; - const body = document.createElement('div'); - body.className = 'memory-text'; - body.textContent = text; - item.appendChild(tag); - item.appendChild(body); - this.elements.memoryList.appendChild(item); - requestAnimationFrame(() => item.classList.add('visible')); - const count = this.elements.memoryList.children.length; - this.elements.memoryCount.textContent = String(count); - this.elements.memoryStatus.textContent = '记忆已同步'; - this.elements.memoryTime.textContent = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + const card = this.createMemoryCard(text); + this.elements.memoryList.appendChild(card); + requestAnimationFrame(() => card.classList.add('visible')); + this.updateMemoryMeta(); } private pushTodoCard(column: 'backlog' | 'doing' | 'done', title: string) { diff --git a/static/src/styles/components/chat/_virtual-monitor.scss b/static/src/styles/components/chat/_virtual-monitor.scss index d46e2c2..84ba492 100644 --- a/static/src/styles/components/chat/_virtual-monitor.scss +++ b/static/src/styles/components/chat/_virtual-monitor.scss @@ -193,6 +193,19 @@ max-height: calc(100% - 36px); } +/* 隐藏窗口内所有滚动条(横向与纵向),仍保留滚动能力 */ +.virtual-monitor-surface .window, +.virtual-monitor-surface .window * { + scrollbar-width: none; + -ms-overflow-style: none; +} +.virtual-monitor-surface .window::-webkit-scrollbar, +.virtual-monitor-surface .window *::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + .virtual-monitor-surface .monitor-screen.manual-interactive .window-header { cursor: grab; user-select: none; @@ -1056,28 +1069,54 @@ } .virtual-monitor-surface .memory-window { - background: #fff9f2; - border: 1px solid rgba(189, 93, 58, 0.3); + background: rgba(244, 246, 251, 0.98); + border: 1px solid rgba(15, 23, 42, 0.12); + display: flex; + flex-direction: column; +} + +.virtual-monitor-surface .memory-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } .virtual-monitor-surface .memory-list { display: flex; flex-direction: column; gap: 12px; + flex: 1 1 auto; + min-height: 0; + height: auto; + max-height: none; + overflow-y: auto; + overflow-x: hidden; } .virtual-monitor-surface .memory-item { border-radius: 14px; padding: 12px 14px; background: #fff; - border: 1px solid rgba(189, 93, 58, 0.2); - box-shadow: 0 8px 20px rgba(83, 41, 27, 0.12); + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08); display: flex; - gap: 12px; + gap: 10px; align-items: flex-start; + flex: 0 0 auto; + min-height: 44px; opacity: 0; transform: translateY(8px); - transition: all 0.3s ease; + transition: all 0.32s ease; +} +.virtual-monitor-surface .memory-text { + line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; + flex: 1 1 auto; + display: block; + width: 100%; + overflow: visible; } .virtual-monitor-surface .memory-item.visible { @@ -1085,14 +1124,19 @@ transform: translateY(0); } -.virtual-monitor-surface .memory-tag { - font-size: 12px; - letter-spacing: 0.1em; - color: #a35a2d; - min-width: 70px; - text-transform: uppercase; +.virtual-monitor-surface .memory-item.editing { + box-shadow: 0 0 0 2px rgba(64, 99, 178, 0.25), 0 10px 26px rgba(15, 23, 42, 0.18); + transform: translateY(-1px) scale(1.01); } +.virtual-monitor-surface .memory-item.swipe-out { + opacity: 0; + transform: translateX(140%); + transition: transform 0.24s ease, opacity 0.22s ease; +} + +/* memory-tag removed to simplify UI */ + .virtual-monitor-surface .todo-columns { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/web_server.py b/web_server.py index 1d4f618..eed9756 100644 --- a/web_server.py +++ b/web_server.py @@ -146,7 +146,9 @@ connection_users: Dict[str, str] = {} stop_flags: Dict[str, Dict[str, Any]] = {} MONITOR_FILE_TOOLS = {'append_to_file', 'modify_file', 'write_file_diff'} +MONITOR_MEMORY_TOOLS = {'update_memory'} MONITOR_SNAPSHOT_CHAR_LIMIT = 60000 +MONITOR_MEMORY_ENTRY_LIMIT = 256 RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque) FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {} pending_socket_tokens: Dict[str, Dict[str, Any]] = {} @@ -1617,6 +1619,21 @@ def gui_text_entry(terminal: WebTerminal, workspace: UserWorkspace, username: st return jsonify({"success": False, "error": str(exc)}), 400 +@app.route('/api/memory', methods=['GET']) +@api_login_required +@with_terminal +def api_memory_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """返回主/任务记忆条目列表,供虚拟显示器加载""" + memory_type = request.args.get('type', 'main') + if memory_type not in ('main', 'task'): + return jsonify({"success": False, "error": "type 必须是 main 或 task"}), 400 + try: + entries = terminal.memory_manager._read_entries(memory_type) # type: ignore + return jsonify({"success": True, "type": memory_type, "entries": entries}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @app.route('/api/gui/monitor_snapshot', methods=['GET']) @api_login_required def get_monitor_snapshot_api(): @@ -2791,6 +2808,12 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client return trimmed return None + + def resolve_monitor_memory(entries: Any) -> Optional[List[str]]: + if isinstance(entries, list): + return [str(item) for item in entries][:MONITOR_MEMORY_ENTRY_LIMIT] + return None + def capture_monitor_snapshot(path: Optional[str]) -> Optional[Dict[str, Any]]: if not path: return None @@ -4274,11 +4297,25 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}" monitor_snapshot = None snapshot_path = None + memory_snapshot_type = None if function_name in MONITOR_FILE_TOOLS: snapshot_path = resolve_monitor_path(arguments) monitor_snapshot = capture_monitor_snapshot(snapshot_path) if monitor_snapshot: cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot) + elif function_name in MONITOR_MEMORY_TOOLS: + memory_snapshot_type = (arguments.get('memory_type') or 'main').lower() + before_entries = None + try: + before_entries = resolve_monitor_memory(web_terminal.memory_manager._read_entries(memory_snapshot_type)) + except Exception as exc: + debug_log(f"[MonitorSnapshot] 读取记忆失败: {memory_snapshot_type} ({exc})") + if before_entries is not None: + monitor_snapshot = { + 'memory_type': memory_snapshot_type, + 'entries': before_entries + } + cache_monitor_snapshot(tool_display_id, 'before', monitor_snapshot) sender('tool_start', { 'id': tool_display_id, @@ -4344,6 +4381,23 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client if not result_path: result_path = resolve_monitor_path(arguments, snapshot_path) or snapshot_path monitor_snapshot_after = capture_monitor_snapshot(result_path) + elif function_name in MONITOR_MEMORY_TOOLS: + memory_after_type = str( + arguments.get('memory_type') + or (isinstance(result_data, dict) and result_data.get('memory_type')) + or memory_snapshot_type + or 'main' + ).lower() + after_entries = None + try: + after_entries = resolve_monitor_memory(web_terminal.memory_manager._read_entries(memory_after_type)) + except Exception as exc: + debug_log(f"[MonitorSnapshot] 读取记忆失败(after): {memory_after_type} ({exc})") + if after_entries is not None: + monitor_snapshot_after = { + 'memory_type': memory_after_type, + 'entries': after_entries + } update_payload = { 'id': tool_display_id,