From 9183e0caf0e39225ec8345a0661b106e1e259cb1 Mon Sep 17 00:00:00 2001
From: JOJO <1498581755@qq.com>
Date: Sun, 14 Dec 2025 21:03:21 +0800
Subject: [PATCH] fix: refine memory monitor scrolling and snapshots
---
static/src/app.ts | 3 +-
.../components/chat/VirtualMonitorSurface.vue | 15 +-
.../chat/monitor/MonitorDirector.ts | 285 ++++++++++++++++--
.../components/chat/_virtual-monitor.scss | 68 ++++-
web_server.py | 54 ++++
5 files changed, 378 insertions(+), 47 deletions(-)
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,