From 56a0acdad2bfa16dba327b0a26e7679ff822b073 Mon Sep 17 00:00:00 2001
From: JOJO <1498581755@qq.com>
Date: Tue, 11 Aug 2026 21:05:06 +0800
Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=96=B0=E5=A2=9E=E6=9C=AC?=
=?UTF-8?q?=E6=AC=A1=E5=B7=A5=E4=BD=9C=E7=BC=96=E8=BE=91=E6=91=98=E8=A6=81?=
=?UTF-8?q?=E5=8D=A1=E7=89=87=E2=80=94=E2=80=94=E5=B7=A5=E4=BD=9C=E6=AE=B5?=
=?UTF-8?q?=E6=9C=AB=E5=B0=BE=E6=98=BE=E7=A4=BA=E5=90=88=E5=B9=B6diff?=
=?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=98=BE=E7=A4=BA=E6=97=B6=E6=9C=BA?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- write/edit 成功后以首次编辑前内容为基线重算净变化 diff,实时写入发起工作的 user 消息 metadata.edit_summary(含带行号上下文的 diff 行),随对话持久化,异常停止保留最后一刻状态
- 消息流工作段末尾渲染卡片(已编辑 N 个文件 + 各文件 +-),居中弹窗展示合并 diff(复用现有 diff 样式),桌面 hover 浮窗/点击钉住,移动端点击模态
- 个人空间新增「编辑摘要实时显示」开关(默认关闭=工作完成后才显示)
- edit_summary_updated 事件 socket + 任务事件流双通道实时更新
---
core/main_terminal_parts/tools_execution.py | 69 +++
modules/edit_summary.py | 330 +++++++++++
modules/personalization_manager.py | 7 +
.../src/app/methods/taskPolling/lifecycle.ts | 12 +
static/src/components/chat/ChatArea.vue | 39 ++
.../src/components/chat/EditSummaryCard.vue | 513 ++++++++++++++++++
.../personalization/PersonalizationDrawer.vue | 22 +
static/src/composables/useLegacySocket.ts | 16 +
static/src/stores/chat.ts | 25 +
static/src/stores/personalization.ts | 3 +
static/src/utils/icons.ts | 1 +
11 files changed, 1037 insertions(+)
create mode 100644 modules/edit_summary.py
create mode 100644 static/src/components/chat/EditSummaryCard.vue
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 @@
+
+
+