fix: refine memory monitor scrolling and snapshots

This commit is contained in:
JOJO 2025-12-14 21:03:21 +08:00
parent ca8f65fe35
commit 9183e0caf0
5 changed files with 378 additions and 47 deletions

View File

@ -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;
}
},

View File

@ -130,21 +130,14 @@
<div class="window memory-window" ref="memoryWindow">
<div class="window-header">
<div>
<span class="traffic-dot red"></span>
<span class="traffic-dot yellow"></span>
<span class="traffic-dot green"></span>
<span>记忆记录</span>
</div>
<span ref="memoryStatus">同步待命</span>
<span class="traffic-dot red"></span>
<span class="traffic-dot yellow"></span>
<span class="traffic-dot green"></span>
<span class="window-title">记忆</span>
</div>
<div class="memory-body">
<div class="memory-list" ref="memoryList"></div>
</div>
<div class="memory-footer">
<span>总计 <strong ref="memoryCount">0</strong> </span>
<span>最后更新 <strong ref="memoryTime">--:--</strong></span>
</div>
</div>
<div class="window todo-window" ref="todoWindow">

View File

@ -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<void>(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) {

View File

@ -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);

View File

@ -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,