Compare commits

...

2 Commits

Author SHA1 Message Date
56a0acdad2 feat(chat): 新增本次工作编辑摘要卡片——工作段末尾显示合并diff,支持显示时机设置
- write/edit 成功后以首次编辑前内容为基线重算净变化 diff,实时写入发起工作的 user 消息 metadata.edit_summary(含带行号上下文的 diff 行),随对话持久化,异常停止保留最后一刻状态
- 消息流工作段末尾渲染卡片(已编辑 N 个文件 + 各文件 +-),居中弹窗展示合并 diff(复用现有 diff 样式),桌面 hover 浮窗/点击钉住,移动端点击模态
- 个人空间新增「编辑摘要实时显示」开关(默认关闭=工作完成后才显示)
- edit_summary_updated 事件 socket + 任务事件流双通道实时更新
2026-08-11 21:05:06 +08:00
d4301a0f56 fix(sidebar): 修复收起时透明对话列表可误触切换、类型切换器未隐藏的问题 2026-08-11 19:03:38 +08:00
12 changed files with 1054 additions and 0 deletions

View File

@ -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")
# 编辑摘要:写入合并 difforiginal_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")
# 编辑摘要:写入合并 difforiginal_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
View 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 内容上限约 100KB8000 行足以覆盖正常文件全量 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)

View File

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

View File

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

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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

View File

@ -117,6 +117,7 @@
.sidebar-nav-label,
.conversation-search,
.conversation-list,
.conversation-type-switch,
.personal-label {
opacity: 1;
transition: opacity 220ms ease;
@ -126,11 +127,27 @@
.conversation-sidebar.collapsed .sidebar-nav-label,
.conversation-sidebar.collapsed .conversation-search,
.conversation-sidebar.collapsed .conversation-list,
.conversation-sidebar.collapsed .conversation-type-switch,
.conversation-sidebar.collapsed .personal-label {
opacity: 0;
transition-delay: 0ms;
}
/* 收起时隐藏的可交互容器必须同时脱离指针事件与聚焦序列
它们只是透明落在 44px 图标栏宽度内的部分仍占据布局
不禁止的话点击不可见区域仍会触发切换对话/聚焦输入 */
.conversation-sidebar.collapsed .conversation-search,
.conversation-sidebar.collapsed .conversation-list,
.conversation-sidebar.collapsed .conversation-type-switch {
pointer-events: none;
visibility: hidden;
/* visibility 延迟到淡出结束后再切换避免收起时内容瞬间消失
展开时本规则不再匹配visibility 不在基础 transition 列表中立即恢复 visible */
transition:
opacity 220ms ease,
visibility 0s linear 220ms;
}
.sidebar-nav-label {
min-width: 0;
padding-left: 6px;

View File

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