diff --git a/core/main_terminal_parts/context/messages.py b/core/main_terminal_parts/context/messages.py index 6cc724d8..37002eac 100644 --- a/core/main_terminal_parts/context/messages.py +++ b/core/main_terminal_parts/context/messages.py @@ -125,9 +125,23 @@ class MessagesMixin: logger.warning(f"[messages] 加载多智能体主 prompt 失败: {exc}") system_prompt_template = self.load_prompt(prompt_name) # main_system.txt / main_system_vl.txt 仅使用 {model_description} - return system_prompt_template.format( + base_prompt = system_prompt_template.format( model_description=prompt_replacements.get("model_description", "") ) + # 修改留痕附言:开关开启且 conversation_id 可用时追加独立 prompt 段落 + # (含本对话专属留痕目录;冻结在 prompt 中,与该对话终身一致) + try: + from modules.modify_history import build_modify_history_prompt_note + note = build_modify_history_prompt_note( + project_path=getattr(self, "project_path", None), + data_dir=getattr(self, "data_dir", None), + conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None), + ) + if note: + base_prompt = f"{base_prompt}\n\n{note}" + except Exception as exc: + logger.warning(f"[messages] 构建修改留痕 prompt 失败: {exc}") + return base_prompt main_system_frozen_key = ( "frozen_main_system_prompt_multi_agent" diff --git a/modules/edit_summary.py b/modules/edit_summary.py index e221217e..2bea3266 100644 --- a/modules/edit_summary.py +++ b/modules/edit_summary.py @@ -163,6 +163,28 @@ def _find_current_work_user_message(context_manager: Any) -> Optional[Dict[str, return None +def _strip_fulltext_for_broadcast(summary: Dict[str, Any]) -> Dict[str, Any]: + """广播副本剥离 current_text(前端只用 lines/计数,且全文可能很大)。 + + 持久化的对话 JSON 保留 current_text,供修改留痕(modify_history)重建 diff。 + """ + files = summary.get("files") + if not isinstance(files, list): + return summary + stripped_files: List[Dict[str, Any]] = [] + changed = False + for entry in files: + if isinstance(entry, dict) and "current_text" in entry: + entry = {k: v for k, v in entry.items() if k != "current_text"} + changed = True + stripped_files.append(entry) + if not changed: + return summary + out = dict(summary) + out["files"] = stripped_files + return out + + def _persist_and_broadcast( context_manager: Any, conversation_id: str, @@ -170,7 +192,13 @@ def _persist_and_broadcast( summary: Dict[str, Any], web_callback: WebCallback, ) -> None: - """持久化对话并广播最新编辑摘要。""" + """持久化对话并广播最新编辑摘要;同步刷新修改留痕文件。""" + # 实时修改留痕:每次摘要变化即重渲染本轮 diff 文件(编辑时刻内容,零磁盘依赖) + try: + from modules.modify_history import update_modify_history_for_task + update_modify_history_for_task(context_manager, msg, summary) + except Exception as exc: + print(f"⚠️ 修改留痕实时写入失败: {exc}") try: context_manager.auto_save_conversation(force=True) except Exception as exc: @@ -180,7 +208,7 @@ def _persist_and_broadcast( web_callback("edit_summary_updated", { "conversation_id": conversation_id, "message_id": msg.get("message_id"), - "edit_summary": summary, + "edit_summary": _strip_fulltext_for_broadcast(summary), }) except Exception: pass @@ -246,12 +274,16 @@ def update_edit_summary( status = "added" if entry.get("baseline") is None and not entry.get("baseline_truncated") else "modified" now_iso = datetime.now().isoformat() + current_too_large = isinstance(current_text, str) and len(current_text) > MAX_BASELINE_CHARS entry.update({ "status": status, "added": diff["added"], "removed": diff["removed"], "lines": diff["lines"], "truncated": bool(diff["truncated"]) or bool(entry.get("baseline_truncated")), + # 编辑时刻的最新全文:供修改留痕重建 diff,不依赖任务结束时的磁盘状态 + "current_text": None if current_too_large else current_text, + "current_truncated": current_too_large, "updated_at": now_iso, }) summary["updated_at"] = now_iso diff --git a/modules/modify_history.py b/modules/modify_history.py new file mode 100644 index 00000000..cf1933b2 --- /dev/null +++ b/modules/modify_history.py @@ -0,0 +1,465 @@ +"""修改留痕(Modify History):把本轮工作的净修改实时落盘为增强版 unified diff。 + +设计要点(已与用户确认): +- **实时写入**:与编辑摘要(edit_summary)完全同步——每次 write_file / edit_file / + delete_file / rename_file 引起摘要变化时,立即重渲染本轮留痕文件 + (``update_modify_history_for_task``,挂在 ``modules/edit_summary._persist_and_broadcast``)。 + 任务中途异常停止,留痕也保留到最后一次编辑的状态。 +- **零磁盘依赖**:diff 的新侧内容来自 edit_summary entry 在编辑时刻记录的 + ``current_text``(全文,与 baseline 同享 400KB 上限),不读任务结束时的磁盘—— + ``run_command`` 对文件的改动/删除不会混入留痕(用户定义:只记录原生编辑工具)。 +- **任务结束收尾**(``finalize_modify_history_for_task``,挂在 + ``server/chat_flow_task_main.finalize_user_work_timer``):更新头部完成时间, + 并做文件存在性检查——任务结束时已不存在(被 rm/移动)的文件,小节转为 + ``/dev/null → 最后内容`` 的 new file diff 并标注,``git apply`` 可直接恢复该文件。 +- 输出:``<工作区>/.astrion/modify_history//<用户输入截断>_<任务开始时间>.diff`` + —— 一次任务(一条用户输入)一个文件。 +- 格式:``#`` 注释头(任务信息)+ 每文件小节注释 + 标准 ``diff --git`` 主体。 + 注释行在 diff 块之外,不影响 ``git apply`` / ``patch``(已实测验证): + 文件处于修改前状态时 ``git apply`` 重做,处于修改后状态时 ``git apply -R`` 撤销。 +- 直接 IO 写入:绕开 write_file / edit_file 工具层,不触发深度备份 + (shallow_versioning.track_edit)与编辑摘要,零递归。 +- 开关:个性化 ``modify_history_enabled``(默认开启);关闭时既不落盘也不注入 + system prompt 附言。 +- 已知边界:baseline 或 current 超 400KB 被截断的文件无法重建完整 diff, + 小节内会明确标注,仅保留计数。 +""" + +from __future__ import annotations + +import difflib +import logging +import re +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +_ASTRION_DIR_NAME = ".astrion" +MODIFY_HISTORY_DIR_NAME = "modify_history" +_PROMPT_FILE_NAME = "modify_history.txt" + +_COMMENT_BAR_HEAVY = "# " + "═" * 60 +_COMMENT_BAR_LIGHT = "# " + "─" * 60 + +# summary 中记录本轮留痕文件名的私有字段(随对话 JSON 持久化,供收尾定位同一文件) +_HISTORY_FILE_KEY = "_history_file" + +# 文件名中用户输入片段的长度上限与文件系统非法字符 +_FILENAME_INPUT_MAX_CHARS = 40 +_FILENAME_ILLEGAL_RE = re.compile(r'[/\\:*?"<>|\x00-\x1f]') + + +# --------------------------------------------------------------------------- +# 路径与开关 +# --------------------------------------------------------------------------- + + +def get_modify_history_dir(project_path: Any, conversation_id: Optional[str]) -> Optional[Path]: + """本对话的留痕目录;conversation_id 缺失时返回 None。""" + conv_id = str(conversation_id or "").strip() + if not project_path or not conv_id: + return None + return ( + Path(project_path).expanduser().resolve() + / _ASTRION_DIR_NAME + / MODIFY_HISTORY_DIR_NAME + / conv_id + ) + + +def is_modify_history_enabled(data_dir: Any) -> bool: + """读取个性化开关;读取失败按默认开启处理。""" + try: + from modules.personalization_manager import load_personalization_config + + config = load_personalization_config(data_dir) + return bool(config.get("modify_history_enabled", True)) + except Exception: + return True + + +# --------------------------------------------------------------------------- +# system prompt 附言 +# --------------------------------------------------------------------------- + + +def build_modify_history_prompt_note( + *, + project_path: Any, + data_dir: Any, + conversation_id: Optional[str], +) -> str: + """构建注入 frozen system prompt 的留痕说明段落;开关关闭或信息缺失时返回空串。""" + if not is_modify_history_enabled(data_dir): + return "" + history_dir = get_modify_history_dir(project_path, conversation_id) + if history_dir is None: + return "" + try: + prompt_path = Path(__file__).resolve().parent.parent / "prompts" / _PROMPT_FILE_NAME + template = prompt_path.read_text(encoding="utf-8") + return template.format(modify_history_dir=str(history_dir)).strip() + except Exception as exc: + logger.warning(f"[modify_history] 加载留痕 prompt 失败: {exc}") + return "" + + +# --------------------------------------------------------------------------- +# diff 渲染 +# --------------------------------------------------------------------------- + + +def _split_text_lines(text: Optional[str]) -> List[str]: + if not text: + return [] + return str(text).splitlines() + + +def _render_file_diff_body(path: str, status: str, old_text: Optional[str], new_text: str) -> List[str]: + """单文件的标准 diff 主体(diff --git 头 + hunk),保证 git apply 可用。""" + out: List[str] = [f"diff --git a/{path} b/{path}"] + if status == "added": + out.append("new file mode 100644") + out.append("--- /dev/null") + else: + out.append(f"--- a/{path}") + out.append(f"+++ b/{path}") + old_lines = _split_text_lines(old_text) + new_lines = _split_text_lines(new_text) + diff_iter = difflib.unified_diff( + old_lines, + new_lines, + fromfile=f"a/{path}", + tofile=f"b/{path}", + n=3, + lineterm="", + ) + for line in list(diff_iter)[2:]: # 跳过 difflib 自产 ---/+++ 头,使用自拼头部 + out.append(line) + return out + + +def render_modify_history_diff( + *, + conversation_id: str, + user_message_text: str, + started_at: Optional[str], + finished_at: Optional[str], + files: List[Dict[str, Any]], +) -> str: + """渲染完整留痕文件内容。 + + files 元素: + path, status, added, removed, old_text, new_text, + truncated —— baseline/current 超上限,仅保留计数,不产 diff 主体 + missing_at_finalize —— 收尾时文件已不存在:主体改为 /dev/null→new_text,可恢复 + unrecoverable —— 文件已不存在且无内容记录:仅标注,不产 diff 主体 + """ + total_added = sum(int(f.get("added") or 0) for f in files) + total_removed = sum(int(f.get("removed") or 0) for f in files) + task_summary = " ".join(str(user_message_text or "").split()) + if len(task_summary) > 200: + task_summary = task_summary[:200] + "…" + + lines: List[str] = [ + _COMMENT_BAR_HEAVY, + "# 修改记录(Astrion Modify History)", + _COMMENT_BAR_HEAVY, + f"# 对话: {conversation_id}", + f"# 任务: {task_summary}", + f"# 时间: {started_at or '?'} → {finished_at or '?'}", + f"# 合计: {len(files)} 个文件,+{total_added} / −{total_removed}", + "#", + "# 恢复方式:", + "# 重做本次修改(文件处于修改前状态时): git apply 本文件", + "# 撤销本次修改(文件处于修改后状态时): git apply -R 本文件", + "# 恢复已删除文件(小节标注「已不存在」时): git apply 本文件", + "# 所有 # 注释行不影响 git apply / patch,无需剔除", + "", + ] + + for index, entry in enumerate(files, 1): + path = str(entry.get("path") or "") + status = str(entry.get("status") or "modified") + status_label = "新建" if status == "added" else "修改" + added = int(entry.get("added") or 0) + removed = int(entry.get("removed") or 0) + truncated = bool(entry.get("truncated")) + missing = bool(entry.get("missing_at_finalize")) + unrecoverable = bool(entry.get("unrecoverable")) + lines.extend( + [ + _COMMENT_BAR_LIGHT, + f"# [{index}/{len(files)}] {path}", + f"# {status_label} · +{added} / −{removed}", + ] + ) + if truncated: + lines.append("# ⚠ 文件内容超出保存上限(400KB),无法重建完整 diff,仅保留计数") + if missing and not unrecoverable: + lines.append("# ⚠ 文件在任务结束时已不存在(可能被删除/移动),以下按其最后记录内容完整保留,git apply 可直接恢复") + if unrecoverable: + lines.append("# ⚠ 文件在任务结束时已不存在,且本轮无内容记录,无法重建") + lines.append(_COMMENT_BAR_LIGHT) + if truncated or unrecoverable: + lines.append("") + continue + if missing: + # 已消失文件:产出 new file diff(/dev/null → 最后内容),git apply 即恢复 + lines.extend( + _render_file_diff_body( + path=path, + status="added", + old_text=None, + new_text=str(entry.get("new_text") or ""), + ) + ) + else: + lines.extend( + _render_file_diff_body( + path=path, + status=status, + old_text=entry.get("old_text"), + new_text=str(entry.get("new_text") or ""), + ) + ) + lines.append("") + + return "\n".join(lines).rstrip("\n") + "\n" + + +# --------------------------------------------------------------------------- +# 任务信息辅助 +# --------------------------------------------------------------------------- + + +def _parse_iso(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).replace(tzinfo=None) + except Exception: + return None + + +def _task_started_at(msg: Dict[str, Any]) -> Optional[str]: + metadata = msg.get("metadata") or {} + timer = metadata.get("work_timer") + if isinstance(timer, dict) and timer.get("started_at"): + return str(timer.get("started_at")) + return msg.get("timestamp") or None + + +def _message_text(msg: Dict[str, Any]) -> str: + content = msg.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + # 多模态消息:拼接其中的文本片段 + parts = [] + for item in content: + if isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + return " ".join(parts) + return str(content or "") + + +def _sanitize_input_for_filename(text: Any, max_chars: int = _FILENAME_INPUT_MAX_CHARS) -> str: + """用户输入转文件名片段:压缩空白、非法字符替换为 _、截断;空结果回退 task。""" + cleaned = " ".join(str(text or "").split()) + cleaned = _FILENAME_ILLEGAL_RE.sub("_", cleaned) + cleaned = re.sub(r"_+", "_", cleaned).strip(" ._") + if len(cleaned) > max_chars: + cleaned = cleaned[:max_chars].rstrip(" ._") + return cleaned or "task" + + +def _ensure_history_file_name( + msg: Dict[str, Any], + summary: Dict[str, Any], + *, + history_dir: Optional[Path] = None, +) -> str: + """本轮留痕文件名:<用户输入截断>_<任务开始时间>.diff;首次生成后存入 summary 复用。 + + 同对话内输入(截断后)同名且同窗时间戳冲突时追加 _2/_3 序号,避免后轮覆盖前轮。 + """ + existing = summary.get(_HISTORY_FILE_KEY) + if isinstance(existing, str) and existing.strip(): + return existing.strip() + input_part = _sanitize_input_for_filename(_message_text(msg)) + dt = _parse_iso(_task_started_at(msg)) or datetime.now() + base = f"{input_part}_{dt.strftime('%Y-%m-%d_%H%M%S_%f')[:-3]}" + name = f"{base}.diff" + if history_dir is not None: + seq = 2 + while (history_dir / name).exists(): + name = f"{base}_{seq}.diff" + seq += 1 + summary[_HISTORY_FILE_KEY] = name + return name + + +def _build_files_payload( + files: List[Any], + *, + project_root: Optional[Path] = None, +) -> List[Dict[str, Any]]: + """把 edit_summary entries 转成渲染载荷;数据源为编辑时刻记录的 baseline/current。 + + project_root 提供时做存在性检查(任务结束收尾):已消失的文件按 + missing_at_finalize(有内容记录,可恢复)或 unrecoverable(无记录)处理。 + """ + payload: List[Dict[str, Any]] = [] + for entry in files: + if not isinstance(entry, dict): + continue + rel_path = str(entry.get("path") or "").strip() + if not rel_path: + continue + current = entry.get("current_text") + truncated = ( + bool(entry.get("truncated")) + or bool(entry.get("baseline_truncated")) + or bool(entry.get("current_truncated")) + ) + item: Dict[str, Any] = { + "path": rel_path, + "status": str(entry.get("status") or "modified"), + "added": entry.get("added") or 0, + "removed": entry.get("removed") or 0, + "old_text": entry.get("baseline"), + "new_text": current if isinstance(current, str) else "", + "truncated": truncated, + } + if project_root is not None: + try: + exists = (project_root / rel_path).is_file() + except Exception: + exists = True # 检查本身失败时按存在处理,不误标 + if not exists: + if isinstance(current, str) and not truncated: + item["missing_at_finalize"] = True + else: + item["unrecoverable"] = True + payload.append(item) + return payload + + +def _write_history_file(history_dir: Path, file_name: str, content: str) -> str: + history_dir.mkdir(parents=True, exist_ok=True) + target = history_dir / file_name + target.write_text(content, encoding="utf-8") + return str(target) + + +# --------------------------------------------------------------------------- +# 实时写入(每次编辑后)与任务收尾(任务结束时) +# --------------------------------------------------------------------------- + + +def update_modify_history_for_task( + context_manager: Any, + msg: Dict[str, Any], + summary: Dict[str, Any], +) -> Optional[str]: + """编辑摘要每次变化时重渲染本轮留痕文件(实时)。 + + 由 modules/edit_summary._persist_and_broadcast 调用(覆盖 update/remove/rename + 三条路径);数据源全部来自 summary entries(baseline + current_text),不读磁盘。 + 返回留痕文件路径;开关关闭/信息缺失/无有效文件时返回 None。 + """ + try: + if not isinstance(summary, dict): + return None + project_path = getattr(context_manager, "project_path", None) + data_dir = getattr(context_manager, "data_dir", None) + conversation_id = getattr(context_manager, "current_conversation_id", None) + history_dir = get_modify_history_dir(project_path, conversation_id) + if history_dir is None: + return None + + files = summary.get("files") + if not isinstance(files, list) or not files: + # 本轮编辑已全部回滚(如 write 后又 delete_file):移除已生成的留痕文件 + existing_name = summary.get(_HISTORY_FILE_KEY) + if isinstance(existing_name, str) and existing_name.strip(): + if not is_modify_history_enabled(data_dir): + return None + try: + (history_dir / existing_name.strip()).unlink(missing_ok=True) + except Exception: + pass + summary.pop(_HISTORY_FILE_KEY, None) + return None + + if not is_modify_history_enabled(data_dir): + return None + + file_name = _ensure_history_file_name(msg, summary, history_dir=history_dir) + payload = _build_files_payload(files) + if not payload: + return None + content = render_modify_history_diff( + conversation_id=str(conversation_id), + user_message_text=_message_text(msg), + started_at=_task_started_at(msg), + finished_at=datetime.now().isoformat(timespec="seconds"), + files=payload, + ) + return _write_history_file(history_dir, file_name, content) + except Exception as exc: + logger.warning(f"[modify_history] 实时写入修改留痕失败: {exc}") + return None + + +def finalize_modify_history_for_task( + *, + project_path: Any, + data_dir: Any, + conversation_id: Optional[str], + msg: Dict[str, Any], + finished_at: Optional[str] = None, +) -> Optional[str]: + """任务结束收尾:写入完成时间,并检查文件存在性。 + + 任务结束时已不存在(被 rm/移动)的文件,其小节转为 /dev/null→最后内容 的 + new file diff(git apply 可直接恢复);无内容记录的标注为无法重建。 + 由 server/chat_flow_task_main.finalize_user_work_timer 调用;直接 IO,失败仅告警。 + """ + try: + metadata = msg.get("metadata") if isinstance(msg, dict) else None + summary = (metadata or {}).get("edit_summary") + if not isinstance(summary, dict): + return None + files = summary.get("files") + if not isinstance(files, list) or not files: + return None + if not is_modify_history_enabled(data_dir): + return None + history_dir = get_modify_history_dir(project_path, conversation_id) + if history_dir is None: + return None + project_root = Path(project_path).expanduser().resolve() + + file_name = summary.get(_HISTORY_FILE_KEY) + if not isinstance(file_name, str) or not file_name.strip(): + # 实时路径未写过(异常/旧对话):按收尾补一次完整渲染 + file_name = _ensure_history_file_name(msg, summary, history_dir=history_dir) + + payload = _build_files_payload(files, project_root=project_root) + if not payload: + return None + content = render_modify_history_diff( + conversation_id=str(conversation_id), + user_message_text=_message_text(msg), + started_at=_task_started_at(msg), + finished_at=finished_at or datetime.now().isoformat(timespec="seconds"), + files=payload, + ) + return _write_history_file(history_dir, file_name.strip(), content) + except Exception as exc: + logger.warning(f"[modify_history] 收尾写入修改留痕失败: {exc}") + return None diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index bdc377a8..6de1bebf 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -109,6 +109,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "quick_dock_auto_expand": True, # 快捷窗口自动展开:True-有内容时自动展开 / False-只能手动点击按钮展开 "file_preview_auto_wrap": False, # 文件预览窗口自动换行:True-按面板宽度换行显示 / False-长行横向滚动 "edit_summary_live_display": False, # 编辑摘要卡片显示时机:False-一次工作完成后才显示(默认) / True-工作运行期间实时显示 + "modify_history_enabled": True, # 修改留痕:True-任务完成时把本轮净修改落盘到工作区 .astrion/modify_history/(默认开启) / False-不落盘也不注入 prompt "group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话 "sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表 "sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序 @@ -557,6 +558,12 @@ def sanitize_personalization_payload( else: base["edit_summary_live_display"] = bool(base.get("edit_summary_live_display", False)) + # 修改留痕(默认开启 = 任务完成时落盘 diff 并注入 prompt) + if "modify_history_enabled" in data: + base["modify_history_enabled"] = bool(data.get("modify_history_enabled")) + else: + base["modify_history_enabled"] = bool(base.get("modify_history_enabled", True)) + # 侧边栏按工作区/项目分组显示对话 if "group_sidebar_by_workspace" in data: base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace")) diff --git a/prompts/modify_history.txt b/prompts/modify_history.txt new file mode 100644 index 00000000..806ffc89 --- /dev/null +++ b/prompts/modify_history.txt @@ -0,0 +1,10 @@ +## 修改留痕(Modify History) + +本工作区开启了修改留痕:每次你使用 write_file / edit_file 编辑文件后,系统会把本次任务的修改实时记录(unified diff,含任务说明注释)到以下目录: + +{modify_history_dir} + +该目录按对话隔离,上述路径即本对话专属目录;每次任务(一条用户输入)对应一个 .diff 文件,文件名为「该任务的用户输入(截断)+ 任务开始时间」,记录该任务全部原生编辑(write_file / edit_file)的净 +/- 修改,随每次编辑实时更新。留痕文件可直接用 `git apply`(重做)或 `git apply -R`(撤销)应用,用于版本控制缺失或被误操作(如 git checkout 覆盖)时的人工恢复;标注「已不存在」的小节为任务结束时已被删除/移动的文件,对其执行 `git apply` 可直接恢复。 + +- 该目录由系统自动维护:不要创建、修改、移动或删除其中任何文件 +- 它不影响你的正常工作流程,无需主动向用户提及,除非用户询问修改恢复相关事宜 diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index a87d01ce..7ce7dcdd 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -1638,6 +1638,19 @@ async def handle_task_with_sender( target_msg["metadata"] = metadata history[target_index] = target_msg web_terminal.context_manager.auto_save_conversation(force=True) + # 修改留痕收尾:写入完成时间;被 rm/移动的文件转为可恢复 diff(实时内容已在编辑时落盘) + try: + from modules.modify_history import finalize_modify_history_for_task + finalize_modify_history_for_task( + project_path=getattr(web_terminal, "project_path", None), + data_dir=getattr(web_terminal, "data_dir", None), + conversation_id=conversation_id, + msg=target_msg, + finished_at=timer.get("finished_at"), + ) + except Exception as exc: + import logging + logging.getLogger(__name__).warning(f"[modify_history] 收尾钩子执行失败: {exc}") finalized_work_message_indices.add(target_index) def switch_current_work_timer_to_latest_user(): diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index dc49a0b3..94c1938a 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -892,6 +892,23 @@ }) " /> + +
版本控制
diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 149fd46c..09e763bc 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -34,6 +34,7 @@ interface PersonalForm { quick_dock_auto_expand: boolean; file_preview_auto_wrap: boolean; edit_summary_live_display: boolean; + modify_history_enabled: boolean; stacked_hide_borders: boolean; minimal_expand_height_limited: boolean; enhanced_tool_display_categories: string[]; @@ -231,6 +232,7 @@ const defaultForm = (): PersonalForm => ({ quick_dock_auto_expand: loadCachedQuickDockAutoExpand(), file_preview_auto_wrap: false, edit_summary_live_display: false, + modify_history_enabled: true, stacked_hide_borders: loadCachedStackedHideBorders(), minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(), enhanced_tool_display_categories: [], @@ -441,6 +443,7 @@ export const usePersonalizationStore = defineStore('personalization', { 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, + modify_history_enabled: data.modify_history_enabled !== false, 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)