feat(chat): 新增本次工作编辑摘要卡片——工作段末尾显示合并diff,支持显示时机设置
- write/edit 成功后以首次编辑前内容为基线重算净变化 diff,实时写入发起工作的 user 消息 metadata.edit_summary(含带行号上下文的 diff 行),随对话持久化,异常停止保留最后一刻状态 - 消息流工作段末尾渲染卡片(已编辑 N 个文件 + 各文件 +-),居中弹窗展示合并 diff(复用现有 diff 样式),桌面 hover 浮窗/点击钉住,移动端点击模态 - 个人空间新增「编辑摘要实时显示」开关(默认关闭=工作完成后才显示) - edit_summary_updated 事件 socket + 任务事件流双通道实时更新
This commit is contained in:
parent
d4301a0f56
commit
56a0acdad2
@ -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"])
|
||||
|
||||
|
||||
330
modules/edit_summary.py
Normal file
330
modules/edit_summary.py
Normal file
@ -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)
|
||||
@ -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"))
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -650,6 +650,13 @@
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 本次工作编辑摘要:仅在该工作段最后一条 assistant 末尾渲染 -->
|
||||
<EditSummaryCard
|
||||
v-if="editSummarySegmentEnds.has(index)"
|
||||
:summary="editSummarySegmentEnds.get(index)"
|
||||
:icon-style="iconStyleSafe"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="system-message">
|
||||
<div
|
||||
@ -726,6 +733,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||
import StackedBlocks from './StackedBlocks.vue';
|
||||
import MinimalBlocks from './MinimalBlocks.vue';
|
||||
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||
import EditSummaryCard from './EditSummaryCard.vue';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import FileChips from '@/components/chat/FileChips.vue';
|
||||
@ -1154,6 +1162,37 @@ const filteredMessages = computed(() => {
|
||||
});
|
||||
const getFilteredMessagesSafe = () =>
|
||||
Array.isArray(filteredMessages.value) ? filteredMessages.value : [];
|
||||
// 编辑摘要卡片挂载点映射:工作段最后一条 assistant 的下标 → 该段编辑摘要。
|
||||
// 数据存于发起工作的 user 消息 metadata.edit_summary(后端实时写入并广播),
|
||||
// 老对话无此 metadata 自然不渲染;工作进行中随消息推进实时挂在段尾。
|
||||
const editSummarySegmentEnds = computed<Map<number, any>>(() => {
|
||||
const map = new Map<number, any>();
|
||||
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;
|
||||
|
||||
513
static/src/components/chat/EditSummaryCard.vue
Normal file
513
static/src/components/chat/EditSummaryCard.vue
Normal file
@ -0,0 +1,513 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 本次工作编辑摘要卡片(Edit Summary Card)
|
||||
*
|
||||
* 数据来自发起本轮工作的 user 消息 metadata.edit_summary(后端在
|
||||
* write_file/edit_file 成功后实时写入并广播),渲染在该工作段最后一条
|
||||
* assistant 消息末尾。同一文件多次编辑只展示合并后的最终结果(净变化)。
|
||||
*
|
||||
* 交互:
|
||||
* - 桌面端 hover 行(150ms 意图延迟)打开居中浮窗;离开行与浮窗后自动关闭
|
||||
* - 点击行「钉住」弹窗(带遮罩 + 关闭按钮);移动端无 hover,点击为唯一入口
|
||||
* - Esc 关闭
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
const props = defineProps<{
|
||||
summary: any;
|
||||
iconStyle: (key: string, size?: string) => Record<string, string>;
|
||||
}>();
|
||||
|
||||
const uiStore = useUiStore();
|
||||
const isMobile = computed(() => !!uiStore.isMobileViewport);
|
||||
|
||||
interface SummaryFile {
|
||||
path: string;
|
||||
status?: string;
|
||||
added?: number;
|
||||
removed?: number;
|
||||
lines?: any[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
const files = computed<SummaryFile[]>(() => {
|
||||
const list = props.summary?.files;
|
||||
return Array.isArray(list) ? list.filter((f: any) => f && f.path) : [];
|
||||
});
|
||||
|
||||
const totals = computed(() => {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const f of files.value) {
|
||||
added += Number(f.added) || 0;
|
||||
removed += Number(f.removed) || 0;
|
||||
}
|
||||
return { added, removed };
|
||||
});
|
||||
|
||||
// ---------------- 弹窗状态 ----------------
|
||||
const activePath = ref<string | null>(null);
|
||||
const pinned = ref(false);
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const activeFile = computed<SummaryFile | null>(() => {
|
||||
if (!activePath.value) return null;
|
||||
return files.value.find((f) => f.path === activePath.value) || null;
|
||||
});
|
||||
|
||||
function clearOpenTimer() {
|
||||
if (openTimer) {
|
||||
clearTimeout(openTimer);
|
||||
openTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCloseTimer() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function open(path: string, pin: boolean) {
|
||||
clearOpenTimer();
|
||||
clearCloseTimer();
|
||||
activePath.value = path;
|
||||
pinned.value = pin;
|
||||
}
|
||||
|
||||
function scheduleOpen(path: string) {
|
||||
clearOpenTimer();
|
||||
openTimer = setTimeout(() => open(path, false), 150);
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
if (pinned.value) return;
|
||||
clearCloseTimer();
|
||||
closeTimer = setTimeout(() => {
|
||||
activePath.value = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function onRowMouseEnter(path: string) {
|
||||
if (isMobile.value) return;
|
||||
clearCloseTimer();
|
||||
scheduleOpen(path);
|
||||
}
|
||||
|
||||
function onRowMouseLeave() {
|
||||
if (isMobile.value) return;
|
||||
clearOpenTimer();
|
||||
scheduleClose();
|
||||
}
|
||||
|
||||
function onPanelMouseEnter() {
|
||||
if (!isMobile.value) clearCloseTimer();
|
||||
}
|
||||
|
||||
function onPanelMouseLeave() {
|
||||
if (!isMobile.value) scheduleClose();
|
||||
}
|
||||
|
||||
function onRowClick(path: string) {
|
||||
// 点击 = 钉住(桌面与移动端一致;移动端无 hover,这是唯一入口)
|
||||
open(path, true);
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearOpenTimer();
|
||||
clearCloseTimer();
|
||||
pinned.value = false;
|
||||
activePath.value = null;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && activePath.value) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
watch(activePath, (v) => {
|
||||
if (v) {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearOpenTimer();
|
||||
clearCloseTimer();
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
// ---------------- diff 行渲染辅助 ----------------
|
||||
function lineClass(line: any): string {
|
||||
if (line.type === 'add') return 'es-diff-add';
|
||||
if (line.type === 'remove') return 'es-diff-remove';
|
||||
return 'es-diff-context';
|
||||
}
|
||||
|
||||
function lineMarker(line: any): string {
|
||||
if (line.type === 'add') return '+';
|
||||
if (line.type === 'remove') return '-';
|
||||
return '';
|
||||
}
|
||||
|
||||
function lineNumber(line: any): string {
|
||||
const n = line.type === 'remove' ? line.old_no : line.new_no;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? String(n) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="files.length" class="edit-summary-card">
|
||||
<div class="edit-summary-card__header">
|
||||
<span
|
||||
class="icon icon-sm edit-summary-card__icon"
|
||||
:style="props.iconStyle('filePen')"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="edit-summary-card__title">已编辑 {{ files.length }} 个文件</span>
|
||||
<span class="edit-summary-card__totals">
|
||||
<span class="edit-summary-plus">+{{ totals.added }}</span>
|
||||
<span class="edit-summary-minus">-{{ totals.removed }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="edit-summary-card__list">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
class="edit-summary-card__row"
|
||||
:class="{ 'is-active': activePath === file.path }"
|
||||
@mouseenter="onRowMouseEnter(file.path)"
|
||||
@mouseleave="onRowMouseLeave"
|
||||
@click.stop="onRowClick(file.path)"
|
||||
>
|
||||
<span class="edit-summary-card__path" :title="file.path">{{ file.path }}</span>
|
||||
<span class="edit-summary-card__delta">
|
||||
<span class="edit-summary-plus">+{{ file.added || 0 }}</span>
|
||||
<span class="edit-summary-minus">-{{ file.removed || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 居中 diff 弹窗:桌面 hover 非模态,钉住/移动端带透明点击层(点击外部关闭,不虚化周围) -->
|
||||
<teleport to="body">
|
||||
<div v-if="activeFile" class="es-modal">
|
||||
<div
|
||||
v-if="pinned || isMobile"
|
||||
class="es-modal__overlay"
|
||||
aria-hidden="true"
|
||||
@click="close"
|
||||
></div>
|
||||
<div
|
||||
class="es-modal__panel"
|
||||
role="dialog"
|
||||
:aria-label="`文件变更:${activeFile.path}`"
|
||||
@mouseenter="onPanelMouseEnter"
|
||||
@mouseleave="onPanelMouseLeave"
|
||||
>
|
||||
<div class="es-modal__header">
|
||||
<span
|
||||
class="icon icon-sm es-modal__header-icon"
|
||||
:style="props.iconStyle('filePen')"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="es-modal__path" :title="activeFile.path">{{ activeFile.path }}</span>
|
||||
<span class="es-modal__delta">
|
||||
<span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span>
|
||||
<span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="pinned || isMobile"
|
||||
type="button"
|
||||
class="es-modal__close"
|
||||
aria-label="关闭"
|
||||
@click.stop="close"
|
||||
>
|
||||
<span class="icon icon-sm" :style="props.iconStyle('x')" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="es-modal__body">
|
||||
<template v-if="(activeFile.lines || []).length">
|
||||
<template v-for="(line, i) in activeFile.lines" :key="i">
|
||||
<div v-if="line.type === 'sep'" class="es-diff-sep" aria-hidden="true">⋮</div>
|
||||
<div v-else class="es-diff-line" :class="lineClass(line)">
|
||||
<span class="es-diff-line-number">{{ lineNumber(line) }}</span>
|
||||
<span class="es-diff-marker">{{ lineMarker(line) }}</span>
|
||||
<span class="es-diff-content">{{ line.content }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="activeFile.truncated" class="es-diff-note">内容过长,已截断展示</div>
|
||||
</template>
|
||||
<div v-else class="es-diff-empty">暂无可展示的文本变更行</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* ===== 卡片本体(消息流内嵌) ===== */
|
||||
.edit-summary-card {
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-raised);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-summary-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.edit-summary-card__icon {
|
||||
color: var(--text-secondary);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.edit-summary-card__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-summary-card__totals {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.edit-summary-plus {
|
||||
color: var(--state-success);
|
||||
}
|
||||
|
||||
.edit-summary-minus {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.edit-summary-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.edit-summary-card__row:hover,
|
||||
.edit-summary-card__row.is-active {
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.edit-summary-card__path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-summary-card__delta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== 居中 diff 弹窗 ===== */
|
||||
.es-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.es-modal__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.es-modal__panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-height: min(70vh, 640px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-strong);
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.es-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.es-modal__header-icon {
|
||||
color: var(--text-secondary);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.es-modal__path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.es-modal__delta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.es-modal__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.es-modal__close:hover {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.es-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== diff 行(与文件编辑工具结果 / 版本管理弹窗同一视觉体系) ===== */
|
||||
.es-diff-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 20px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.es-diff-line-number {
|
||||
width: 44px;
|
||||
flex: none;
|
||||
padding-right: 8px;
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.es-diff-marker {
|
||||
width: 16px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.es-diff-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.es-diff-add {
|
||||
background: var(--diff-add-bg);
|
||||
}
|
||||
|
||||
.es-diff-add .es-diff-marker {
|
||||
color: var(--state-success);
|
||||
}
|
||||
|
||||
.es-diff-remove {
|
||||
background: var(--diff-del-bg);
|
||||
}
|
||||
|
||||
.es-diff-remove .es-diff-marker {
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.es-diff-sep {
|
||||
padding: 2px 0 2px 60px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.es-diff-empty,
|
||||
.es-diff-note {
|
||||
padding: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 移动端:弹窗更宽、行号列收窄 */
|
||||
@media (max-width: 768px) {
|
||||
.es-modal__panel {
|
||||
width: calc(100vw - 24px);
|
||||
max-height: 76vh;
|
||||
}
|
||||
|
||||
.es-diff-line-number {
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.es-diff-sep {
|
||||
padding-left: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -779,6 +779,28 @@
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">编辑摘要实时显示</span
|
||||
><span class="settings-row-desc"
|
||||
>工作运行期间实时显示本次编辑过的文件;关闭时仅在每次工作完成后显示</span
|
||||
></span
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="form.edit_summary_live_display"
|
||||
@change="
|
||||
personalization.updateField({
|
||||
key: 'edit_summary_live_display',
|
||||
value: $event.target.checked
|
||||
})
|
||||
" /><span class="fancy-check" aria-hidden="true"
|
||||
><svg viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||||
pathLength="575.0541381835938"
|
||||
class="fancy-path"
|
||||
></path></svg></span
|
||||
></label>
|
||||
<label class="settings-toggle-row"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">预览窗口自动换行显示</span
|
||||
|
||||
@ -21,6 +21,7 @@ import { renderLatexInRealtime } from './useMarkdownRenderer';
|
||||
import { getMessageVisibility, messageStartsWork } from '../utils/messageVisibility';
|
||||
import { goalModeDebugLog } from '../app/methods/common';
|
||||
import { useQuickDockStore } from '../stores/quickDock';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
|
||||
export async function initializeLegacySocket(ctx: any) {
|
||||
try {
|
||||
@ -739,6 +740,21 @@ export async function initializeLegacySocket(ctx: any) {
|
||||
useQuickDockStore().setEditedFiles(data.edited_files || [], true);
|
||||
});
|
||||
|
||||
// 编辑摘要卡片:write/edit 成功后后端实时广播合并 diff,
|
||||
// 写入发起工作的 user 消息 metadata,卡片随工作推进实时更新。
|
||||
ctx.socket.on('edit_summary_updated', (data) => {
|
||||
if (!data || !data.conversation_id) {
|
||||
return;
|
||||
}
|
||||
if (data.conversation_id !== ctx.currentConversationId) {
|
||||
return;
|
||||
}
|
||||
useChatStore().updateEditSummaryByMessageId(
|
||||
typeof data.message_id === 'string' ? data.message_id : '',
|
||||
data.edit_summary || null
|
||||
);
|
||||
});
|
||||
|
||||
// 系统就绪
|
||||
ctx.socket.on('system_ready', (data) => {
|
||||
ctx.projectPath = data.project_path || '';
|
||||
|
||||
@ -113,6 +113,31 @@ export const useChatStore = defineStore('chat', {
|
||||
this.messages = [];
|
||||
this.currentMessageIndex = -1;
|
||||
},
|
||||
updateEditSummaryByMessageId(messageId: string, summary: any) {
|
||||
// 编辑摘要实时事件:按 message_id 定位发起工作的 user 消息并写入
|
||||
// metadata.edit_summary。运行中/历史 user 消息均透传后端 message_id;
|
||||
// 本地乐观插入的消息暂未携带 id 时,兜底落到当前工作段锚(最后一条
|
||||
// starts_work 的 user 消息)。
|
||||
const applyTo = (msg: any) => {
|
||||
msg.metadata = { ...(msg.metadata || {}), edit_summary: summary };
|
||||
};
|
||||
if (messageId) {
|
||||
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
|
||||
const m = this.messages[i];
|
||||
if (m?.role === 'user' && m?.message_id === messageId) {
|
||||
applyTo(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
|
||||
const m = this.messages[i];
|
||||
if (m?.role === 'user' && m?.metadata?.starts_work === true) {
|
||||
applyTo(m);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleBlock(blockId: string) {
|
||||
const next = cloneSet(this.expandedBlocks);
|
||||
if (next.has(blockId)) {
|
||||
|
||||
@ -33,6 +33,7 @@ interface PersonalForm {
|
||||
auto_open_terminal_panel: boolean;
|
||||
quick_dock_auto_expand: boolean;
|
||||
file_preview_auto_wrap: boolean;
|
||||
edit_summary_live_display: boolean;
|
||||
stacked_hide_borders: boolean;
|
||||
minimal_expand_height_limited: boolean;
|
||||
enhanced_tool_display_categories: string[];
|
||||
@ -229,6 +230,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
auto_open_terminal_panel: true,
|
||||
quick_dock_auto_expand: loadCachedQuickDockAutoExpand(),
|
||||
file_preview_auto_wrap: false,
|
||||
edit_summary_live_display: false,
|
||||
stacked_hide_borders: loadCachedStackedHideBorders(),
|
||||
minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(),
|
||||
enhanced_tool_display_categories: [],
|
||||
@ -438,6 +440,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
|
||||
quick_dock_auto_expand: data.quick_dock_auto_expand !== false,
|
||||
file_preview_auto_wrap: !!data.file_preview_auto_wrap,
|
||||
edit_summary_live_display: !!data.edit_summary_live_display,
|
||||
stacked_hide_borders: !!data.stacked_hide_borders,
|
||||
minimal_expand_height_limited: data.minimal_expand_height_limited !== false,
|
||||
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
|
||||
|
||||
@ -15,6 +15,7 @@ export const ICONS = Object.freeze({
|
||||
clock: '/static/icons/clock.svg',
|
||||
eye: '/static/icons/eye.svg',
|
||||
file: '/static/icons/file.svg',
|
||||
filePen: '/static/icons/file-pen.svg',
|
||||
flag: '/static/icons/flag.svg',
|
||||
folder: '/static/icons/folder.svg',
|
||||
folderClosed: '/static/icons/folder-closed.svg',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user