- write/edit 成功后以首次编辑前内容为基线重算净变化 diff,实时写入发起工作的 user 消息 metadata.edit_summary(含带行号上下文的 diff 行),随对话持久化,异常停止保留最后一刻状态 - 消息流工作段末尾渲染卡片(已编辑 N 个文件 + 各文件 +-),居中弹窗展示合并 diff(复用现有 diff 样式),桌面 hover 浮窗/点击钉住,移动端点击模态 - 个人空间新增「编辑摘要实时显示」开关(默认关闭=工作完成后才显示) - edit_summary_updated 事件 socket + 任务事件流双通道实时更新
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""本次工作编辑摘要(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)
|