diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 814fc6ef..68a13c95 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -8,6 +8,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Set from modules.shallow_versioning import ShallowVersioningManager +from modules.edit_summary import ( + remove_edit_summary_entry, + rename_edit_summary_entry, + update_edit_summary, +) try: from config import ( @@ -736,6 +741,54 @@ class MainTerminalToolsExecutionMixin: self._mutate_edited_files(_rename) + # ---------------- 本次工作编辑摘要(user 消息 metadata.edit_summary) ---------------- + + def _web_callback_for_edit_summary(self): + cm = getattr(self, "context_manager", None) + return getattr(cm, "_web_terminal_callback", None) if cm is not None else None + + def _update_edit_summary(self, path: Any, original_text: Any, current_text: Any) -> None: + """write_file/edit_file 成功后,把该文件合并 diff 写入当前工作 user 消息 metadata。""" + try: + if not isinstance(current_text, str): + # 容器模式 write_file 不返回 new_file:回读文件获取当前内容 + try: + read_result = self.file_manager.read_file(str(path)) + if isinstance(read_result, dict) and read_result.get("success"): + current_text = read_result.get("content") + except Exception: + pass + update_edit_summary( + self.context_manager, + path=path, + original_text=original_text if isinstance(original_text, str) else None, + current_text=current_text if isinstance(current_text, str) else None, + web_callback=self._web_callback_for_edit_summary(), + ) + except Exception as exc: + print(f"⚠️ 更新编辑摘要失败: {exc}") + + def _remove_edit_summary(self, path: Any) -> None: + try: + remove_edit_summary_entry( + self.context_manager, + path=path, + web_callback=self._web_callback_for_edit_summary(), + ) + except Exception as exc: + print(f"⚠️ 移除编辑摘要失败: {exc}") + + def _rename_edit_summary(self, old_path: Any, new_path: Any) -> None: + try: + rename_edit_summary_entry( + self.context_manager, + old_path=old_path, + new_path=new_path, + web_callback=self._web_callback_for_edit_summary(), + ) + except Exception as exc: + print(f"⚠️ 重命名编辑摘要失败: {exc}") + def _track_shallow_versioning(self, file_path: Any) -> None: """Track a file edit for shallow versioning (conversation-scoped undo).""" conversation_id = getattr(self.context_manager, "current_conversation_id", None) @@ -1404,6 +1457,8 @@ class MainTerminalToolsExecutionMixin: deleted_path = result.get("path") # 快捷窗口:从编辑文件记录中移除 self._remove_edited_file(deleted_path) + # 编辑摘要:同步移除该文件 + self._remove_edit_summary(deleted_path) # 删除备注 if deleted_path in self.context_manager.file_annotations: del self.context_manager.file_annotations[deleted_path] @@ -1422,6 +1477,8 @@ class MainTerminalToolsExecutionMixin: new_path = result.get("new_path") # 快捷窗口:同步编辑文件记录中的路径 self._rename_edited_file(old_path, new_path) + # 编辑摘要:同步路径 + self._rename_edit_summary(old_path, new_path) # 更新备注 if old_path in self.context_manager.file_annotations: annotation = self.context_manager.file_annotations[old_path] @@ -1450,6 +1507,12 @@ class MainTerminalToolsExecutionMixin: self._mark_file_as_read_visited(result.get("path") or path) # 快捷窗口:记录本次对话写入过的文件 self._record_edited_file(result.get("path") or path, "write") + # 编辑摘要:写入合并 diff(original_file/new_file 为写前/后全文) + self._update_edit_summary( + result.get("path") or path, + result.get("original_file"), + result.get("new_file"), + ) elif tool_name == "edit_file": read_guard_error = self._check_read_before_edit_prerequisite(tool_name, arguments) @@ -1469,6 +1532,12 @@ class MainTerminalToolsExecutionMixin: self._mark_file_as_read_visited(result.get("path") or path) # 快捷窗口:记录本次对话编辑过的文件 self._record_edited_file(result.get("path") or path, "edit") + # 编辑摘要:写入合并 diff(original_file/new_file 为改前/后全文) + self._update_edit_summary( + result.get("path") or path, + result.get("original_file"), + result.get("new_file"), + ) elif tool_name == "create_folder": result = self.file_manager.create_folder(arguments["path"]) diff --git a/modules/edit_summary.py b/modules/edit_summary.py new file mode 100644 index 00000000..e221217e --- /dev/null +++ b/modules/edit_summary.py @@ -0,0 +1,330 @@ +"""本次工作编辑摘要(Edit Summary)。 + +write_file / edit_file 成功后由工具执行层调用 `update_edit_summary`: +以「该文件本轮工作第一次被编辑前的内容(baseline)」为基线,与当前内容 +做合并 diff(净变化口径),把统计与带行号上下文的 diff 行写入当前工作 +user 消息的 metadata.edit_summary,并实时广播给前端渲染卡片。 + +设计要点: +- 同一文件多次编辑:baseline 只在首次记录时写入,之后每次编辑重算合并 + 结果并整体覆盖——metadata 与前端显示始终是最后一次编辑后的最终状态。 +- 工作中每次编辑都会持久化(auto_save force)并广播,任务异常停止时 + 卡片保留最后一刻的状态,刷新后照常显示。 +- metadata 不会进入模型上下文(api_client 发送前有字段白名单清洗)。 +""" + +from __future__ import annotations + +import difflib +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + +EDIT_SUMMARY_VERSION = 1 + +# 单文件 diff 行数上限(超出截断并标记 truncated)。 +# write_file 内容上限约 100KB,8000 行足以覆盖正常文件全量 diff。 +MAX_DIFF_LINES = 8000 +# baseline 内容存储上限(字符数);超过则不持久化 baseline,退化为 +# 「仅当次编辑 diff」,避免超大文件把对话 JSON 撑爆。 +MAX_BASELINE_CHARS = 400_000 +# 每个变更块两端保留的上下文行数 +CONTEXT_LINES = 3 + +WebCallback = Optional[Callable[[str, Dict[str, Any]], None]] + + +def _split_lines(text: Optional[str]) -> List[str]: + if not text: + return [] + return str(text).splitlines() + + +def compute_file_diff( + baseline_text: Optional[str], + current_text: Optional[str], +) -> Dict[str, Any]: + """计算 baseline → current 的合并 diff。 + + 返回 {added, removed, lines, truncated};lines 元素: + {"type": "context", "content", "old_no", "new_no"} + {"type": "add", "content", "new_no"} + {"type": "remove", "content", "old_no"} + {"type": "sep"} —— 变更块(hunk)之间的分隔 + """ + result: Dict[str, Any] = {"added": 0, "removed": 0, "lines": [], "truncated": False} + old_lines = _split_lines(baseline_text) + new_lines = _split_lines(current_text) + if old_lines == new_lines: + return result + + try: + opcodes = difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes() + except Exception: + # 兜底:不给明细,只给整文件行数计数 + result["added"] = len(new_lines) + result["removed"] = len(old_lines) + result["truncated"] = True + return result + + # 1) 统计增删 + 收集变更段(两端各扩 CONTEXT_LINES 上下文),相邻段合并 + ranges: List[List[int]] = [] # [old_lo, old_hi, new_lo, new_hi] + for tag, i1, i2, j1, j2 in opcodes: + if tag == "equal": + continue + if tag in ("replace", "delete"): + result["removed"] += i2 - i1 + if tag in ("replace", "insert"): + result["added"] += j2 - j1 + r = [ + max(0, i1 - CONTEXT_LINES), + min(len(old_lines), i2 + CONTEXT_LINES), + max(0, j1 - CONTEXT_LINES), + min(len(new_lines), j2 + CONTEXT_LINES), + ] + if ranges and r[0] <= ranges[-1][1] and r[2] <= ranges[-1][3]: + ranges[-1][1] = max(ranges[-1][1], r[1]) + ranges[-1][3] = max(ranges[-1][3], r[3]) + else: + ranges.append(r) + + # 2) 逐段生成带行号的 diff 行(段内对子序列再跑一次 diff 保证对齐) + lines: List[Dict[str, Any]] = [] + truncated = False + for range_index, (o_lo, o_hi, n_lo, n_hi) in enumerate(ranges): + if range_index > 0: + lines.append({"type": "sep"}) + sub_old = old_lines[o_lo:o_hi] + sub_new = new_lines[n_lo:n_hi] + try: + sub_opcodes = difflib.SequenceMatcher(None, sub_old, sub_new).get_opcodes() + except Exception: + sub_opcodes = [("replace", 0, len(sub_old), 0, len(sub_new))] + old_no = o_lo + 1 + new_no = n_lo + 1 + for tag, i1, i2, j1, j2 in sub_opcodes: + if tag == "equal": + for k in range(i1, i2): + lines.append({ + "type": "context", + "content": sub_old[k], + "old_no": old_no, + "new_no": new_no, + }) + old_no += 1 + new_no += 1 + else: + if tag in ("replace", "delete"): + for k in range(i1, i2): + lines.append({"type": "remove", "content": sub_old[k], "old_no": old_no}) + old_no += 1 + if tag in ("replace", "insert"): + for k in range(j1, j2): + lines.append({"type": "add", "content": sub_new[k], "new_no": new_no}) + new_no += 1 + if len(lines) >= MAX_DIFF_LINES: + truncated = True + break + if truncated: + break + + result["lines"] = lines[:MAX_DIFF_LINES] + result["truncated"] = truncated + return result + + +def _find_current_work_user_message(context_manager: Any) -> Optional[Dict[str, Any]]: + """定位当前这轮工作归属的 user 消息(与 work_timer / 浅版本控制同锚)。""" + history = getattr(context_manager, "conversation_history", None) or [] + # 1) 精确:当前工作 user 消息 id(与浅版本控制共用同一归属字段) + message_id = getattr(context_manager, "current_shallow_message_id", None) + if message_id: + for msg in reversed(history): + if ( + isinstance(msg, dict) + and msg.get("role") == "user" + and msg.get("message_id") == message_id + ): + return msg + # 2) 兜底:最后一条仍在 working 的 user 消息(工具执行期间必然 working) + for msg in reversed(history): + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + timer = (msg.get("metadata") or {}).get("work_timer") + if isinstance(timer, dict) and timer.get("status") == "working": + return msg + # 3) 再兜底:最后一条 starts_work 的 user 消息 + for msg in reversed(history): + if ( + isinstance(msg, dict) + and msg.get("role") == "user" + and (msg.get("metadata") or {}).get("starts_work") is True + ): + return msg + return None + + +def _persist_and_broadcast( + context_manager: Any, + conversation_id: str, + msg: Dict[str, Any], + summary: Dict[str, Any], + web_callback: WebCallback, +) -> None: + """持久化对话并广播最新编辑摘要。""" + try: + context_manager.auto_save_conversation(force=True) + except Exception as exc: + print(f"⚠️ 编辑摘要持久化失败: {exc}") + if callable(web_callback): + try: + web_callback("edit_summary_updated", { + "conversation_id": conversation_id, + "message_id": msg.get("message_id"), + "edit_summary": summary, + }) + except Exception: + pass + + +def update_edit_summary( + context_manager: Any, + *, + path: Any, + original_text: Optional[str], + current_text: Optional[str], + web_callback: WebCallback = None, +) -> None: + """write_file / edit_file 成功后更新当前工作 user 消息的 edit_summary。 + + original_text:本次编辑前的文件全文(新建文件为 None)。 + current_text:本次编辑后的文件全文。 + """ + try: + rel_path = str(path or "").strip().replace("\\", "/") + conversation_id = getattr(context_manager, "current_conversation_id", None) + if not rel_path or not conversation_id: + return + msg = _find_current_work_user_message(context_manager) + if msg is None: + return + + metadata = msg.setdefault("metadata", {}) + summary = metadata.get("edit_summary") + if not isinstance(summary, dict) or not isinstance(summary.get("files"), list): + summary = {"version": EDIT_SUMMARY_VERSION, "updated_at": "", "files": []} + metadata["edit_summary"] = summary + + entry = next( + ( + item + for item in summary["files"] + if isinstance(item, dict) and item.get("path") == rel_path + ), + None, + ) + if entry is None: + # 首次记录:写入 baseline(本轮工作第一次编辑前的内容;None 表示新建文件)。 + # 此后 baseline 不再变化,保证多次编辑合并为净变化。 + baseline_too_large = isinstance(original_text, str) and len(original_text) > MAX_BASELINE_CHARS + entry = { + "path": rel_path, + "baseline": None if baseline_too_large else original_text, + "baseline_truncated": baseline_too_large, + "created_at": datetime.now().isoformat(), + } + summary["files"].append(entry) + + # baseline 因超大被丢弃时无法用真实基线重算:退化为当次编辑 diff + if entry.get("baseline_truncated") and isinstance(original_text, str): + baseline_for_diff: Optional[str] = original_text + else: + baseline_for_diff = entry.get("baseline") + + diff = compute_file_diff(baseline_for_diff, current_text) + # 新建文件(首次记录时文件不存在)全程保持 added;其余为 modified。 + # delete_file 走 remove_edit_summary_entry 移除记录,这里不会出现 deleted。 + status = "added" if entry.get("baseline") is None and not entry.get("baseline_truncated") else "modified" + + now_iso = datetime.now().isoformat() + entry.update({ + "status": status, + "added": diff["added"], + "removed": diff["removed"], + "lines": diff["lines"], + "truncated": bool(diff["truncated"]) or bool(entry.get("baseline_truncated")), + "updated_at": now_iso, + }) + summary["updated_at"] = now_iso + + _persist_and_broadcast(context_manager, conversation_id, msg, summary, web_callback) + except Exception as exc: + print(f"⚠️ 更新编辑摘要失败: {exc}") + + +def _mutate_summary_files( + context_manager: Any, + web_callback: WebCallback, + mutator: Callable[[List[Dict[str, Any]]], bool], +) -> None: + """读取-修改-写回当前工作 user 消息的 edit_summary.files。""" + try: + conversation_id = getattr(context_manager, "current_conversation_id", None) + if not conversation_id: + return + msg = _find_current_work_user_message(context_manager) + if msg is None: + return + metadata = msg.get("metadata") or {} + summary = metadata.get("edit_summary") + if not isinstance(summary, dict) or not isinstance(summary.get("files"), list): + return + if not mutator(summary["files"]): + return + summary["updated_at"] = datetime.now().isoformat() + msg["metadata"] = metadata + _persist_and_broadcast(context_manager, conversation_id, msg, summary, web_callback) + except Exception as exc: + print(f"⚠️ 更新编辑摘要失败: {exc}") + + +def remove_edit_summary_entry( + context_manager: Any, + *, + path: Any, + web_callback: WebCallback = None, +) -> None: + """文件被删除后从编辑摘要中移除(与快捷窗口文件记录行为一致)。""" + rel_path = str(path or "").strip().replace("\\", "/") + if not rel_path: + return + + def _remove(files: List[Dict[str, Any]]) -> bool: + before = len(files) + files[:] = [item for item in files if item.get("path") != rel_path] + return len(files) != before + + _mutate_summary_files(context_manager, web_callback, _remove) + + +def rename_edit_summary_entry( + context_manager: Any, + *, + old_path: Any, + new_path: Any, + web_callback: WebCallback = None, +) -> None: + """文件重命名后同步更新编辑摘要中的路径(与快捷窗口文件记录行为一致)。""" + old_rel = str(old_path or "").strip().replace("\\", "/") + new_rel = str(new_path or "").strip().replace("\\", "/") + if not old_rel or not new_rel or old_rel == new_rel: + return + + def _rename(files: List[Dict[str, Any]]) -> bool: + for item in files: + if item.get("path") == old_rel: + item["path"] = new_rel + item["updated_at"] = datetime.now().isoformat() + return True + return False + + _mutate_summary_files(context_manager, web_callback, _rename) diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 0e20180b..bdc377a8 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -108,6 +108,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列) "quick_dock_auto_expand": True, # 快捷窗口自动展开:True-有内容时自动展开 / False-只能手动点击按钮展开 "file_preview_auto_wrap": False, # 文件预览窗口自动换行:True-按面板宽度换行显示 / False-长行横向滚动 + "edit_summary_live_display": False, # 编辑摘要卡片显示时机:False-一次工作完成后才显示(默认) / True-工作运行期间实时显示 "group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话 "sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表 "sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序 @@ -550,6 +551,12 @@ def sanitize_personalization_payload( else: base["file_preview_auto_wrap"] = bool(base.get("file_preview_auto_wrap", False)) + # 编辑摘要卡片显示时机(默认关闭 = 工作完成后才显示) + if "edit_summary_live_display" in data: + base["edit_summary_live_display"] = bool(data.get("edit_summary_live_display")) + else: + base["edit_summary_live_display"] = bool(base.get("edit_summary_live_display", False)) + # 侧边栏按工作区/项目分组显示对话 if "group_sidebar_by_workspace" in data: base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace")) diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 3442d916..9855672e 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -2,6 +2,7 @@ import { debugLog, goalModeDebugLog } from '../common'; import { useTaskStore } from '../../../stores/task'; import { useQuickDockStore } from '../../../stores/quickDock'; +import { useChatStore } from '../../../stores/chat'; import { getMessageVisibility, messageStartsWork } from '../../../utils/messageVisibility'; import { debugNotifyLog, @@ -307,6 +308,17 @@ export const lifecycleMethods = { true ); break; + + case 'edit_summary_updated': + // 编辑摘要卡片:合并 diff 实时写入发起工作的 user 消息 metadata。 + // 重放历史事件时跳过——历史消息的 metadata 已随对话加载落地, + // 重写反而可能用旧事件覆盖最新状态。 + if (this._rebuildingFromScratch) break; + useChatStore().updateEditSummaryByMessageId( + typeof eventData?.message_id === 'string' ? eventData.message_id : '', + eventData?.edit_summary || null + ); + break; case 'compression_state': // 重放历史事件时不处理管理类事件,避免触发副作用(如 toast 闪烁 / 状态回退)。 if (this._rebuildingFromScratch) break; diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 103fd5fc..0e269eaa 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -650,6 +650,13 @@ + + +
{ }); const getFilteredMessagesSafe = () => Array.isArray(filteredMessages.value) ? filteredMessages.value : []; +// 编辑摘要卡片挂载点映射:工作段最后一条 assistant 的下标 → 该段编辑摘要。 +// 数据存于发起工作的 user 消息 metadata.edit_summary(后端实时写入并广播), +// 老对话无此 metadata 自然不渲染;工作进行中随消息推进实时挂在段尾。 +const editSummarySegmentEnds = computed>(() => { + const map = new Map(); + const messages = getFilteredMessagesSafe(); + // 显示时机(个人空间「编辑摘要实时显示」,默认关闭): + // 关闭时仅在锚 user 消息的工作计时完成后显示卡片;开启后运行期间实时显示。 + const liveDisplay = !!personalization.form.edit_summary_live_display; + for (let i = 0; i < messages.length; i += 1) { + const msg = messages[i]; + if (!msg || msg.role !== 'user' || !messageStartsWork(msg)) continue; + const summary = msg.metadata?.edit_summary; + if (!summary || !Array.isArray(summary.files) || summary.files.length === 0) continue; + if (!liveDisplay) { + const timerStatus = msg.metadata?.work_timer?.status; + // 工作进行中(working)不显示;无 timer 视为已完成(防御) + if (timerStatus && timerStatus !== 'completed') continue; + } + let lastAssistant = -1; + for (let j = i + 1; j < messages.length; j += 1) { + const next = messages[j]; + if (next?.role === 'user' && messageStartsWork(next)) break; + if (next?.role === 'assistant') lastAssistant = j; + } + if (lastAssistant >= 0) { + map.set(lastAssistant, summary); + } + } + return map; +}); const latestMessageIndex = computed(() => getFilteredMessagesSafe().length - 1); const nowMs = ref(Date.now()); let timerHandle: number | null = null; diff --git a/static/src/components/chat/EditSummaryCard.vue b/static/src/components/chat/EditSummaryCard.vue new file mode 100644 index 00000000..b63a999b --- /dev/null +++ b/static/src/components/chat/EditSummaryCard.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index c1eb25f5..a9d63d09 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -779,6 +779,28 @@ class="fancy-path" > +