fix(conversation): 修复版本回溯被旧内存merge覆盖,统一presend用户输入口径

- 回溯后同步对话级 terminal 内存为磁盘快照,避免 merge-on-save 把被裁消息当"内存独有"救回(此前只能回溯后立刻重启进程)
- 工作区级服务实例不再挂载对话历史(focus-only 仅恢复焦点+模式),settings/通知判定/工作流游标等保存路径改读磁盘回传,消除旧内存写回污染源
- 左侧输入导航与深度压缩的用户输入口径补 presend(提前输入排队转正),guidance 维持不计入;统一字段缺失老消息视为 user
This commit is contained in:
JOJO 2026-08-25 22:43:58 +08:00
parent c6c8bf2d0c
commit 2d9b4eb5a6
7 changed files with 147 additions and 46 deletions

View File

@ -39,8 +39,10 @@ class WebTerminal(MainTerminal):
对话级 terminal 只服务绑定对话必须恢复该对话保存的模型
restore_model=True否则重启后新建的对话级 terminal 会用默认
模型跑任务/回写 metadata把用户切换的模型覆盖掉
工作区级 terminal未绑定保持原有最近对话行为且刻意不恢复
模型restore_model=False避免 /new 页面显示旧对话的模型
工作区级 terminal未绑定保持原有最近对话行为恢复焦点与模式
且刻意不恢复模型restore_model=False避免 /new 页面显示旧对话的模型
经由 load_conversation attach_history 分流工作区级不挂载消息历史
历史权威在磁盘 + 对话级实例 merge-on-save 旧内存写回污染源
"""
if self.context_manager.current_conversation_id:
return
@ -107,6 +109,15 @@ class WebTerminal(MainTerminal):
container_session=container_session,
usage_tracker=usage_tracker
)
# 工作区级服务实例标记:不持有/不回写对话消息历史(历史权威在磁盘 + 对话级实例)。
# 工作区级挂载历史只会成为 merge-on-save 的污染源(版本回溯被旧内存“救回”覆盖的事故根因)。
# 注意必须在 super().__init__ 之后设置context_manager 由父类创建);
# 而 load 分流不依赖此标记(用 _bound_conversation_idsuper 之前已设),初始化时序安全。
try:
self.context_manager._service_instance_no_history = conversation_id is None
except Exception:
pass
# Web特有属性
self.message_callback = message_callback
@ -417,7 +428,12 @@ class WebTerminal(MainTerminal):
Dict: 加载结果
"""
try:
success = self.context_manager.load_conversation_by_id(conversation_id)
# 工作区级服务实例_bound_conversation_id 为空):仅恢复会话焦点
# current_conversation_id与运行模式不挂载消息历史——历史权威在磁盘
# 与对话级实例,服务实例持历史只会成为 merge-on-save 的写回污染源。
# 对话级实例(绑定对话):挂载历史,供任务执行链路使用。
attach_history = bool(getattr(self, "_bound_conversation_id", None))
success = self.context_manager.load_conversation_by_id(conversation_id, attach_history=attach_history)
if success:
# 根据对话元数据同步运行模式与推理强度
try:
@ -516,7 +532,8 @@ class WebTerminal(MainTerminal):
"success": True,
"conversation_id": conversation_id,
"title": conversation_data.get("title", "未知对话"),
"messages_count": len(self.context_manager.conversation_history),
# 消息数以磁盘数据为准服务实例不挂载历史len(conversation_history) 恒为 0
"messages_count": len(conversation_data.get("messages") or []),
"run_mode": self.run_mode,
"thinking_mode": self.thinking_mode,
"model_key": getattr(self, "model_key", None),
@ -630,6 +647,7 @@ class WebTerminal(MainTerminal):
"context": {
"usage_percent": context_status['usage_percent'],
"total_size": context_status['sizes']['total'],
# 本实例内存上下文中的消息数;工作区级服务实例不挂载历史,恒为 0前端不消费该字段
"conversation_count": len(self.context_manager.conversation_history)
},
"focused_files": focused_files_dict,

View File

@ -47,6 +47,23 @@ from server.monitor import get_cached_monitor_snapshot
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
def _save_conversation_meta_via_disk(ctx, conversation_id: str, **meta_kwargs) -> None:
"""设置类端点保存对话元数据的统一入口。
工作区级服务实例不挂载消息历史conversation_history 恒空直接拿内存保存会把
/陈旧历史写回这里读磁盘消息回传merge 语义下消息保持不变仅更新元数据
"""
if not conversation_id:
return
manager = ctx._get_conversation_manager_for_id(conversation_id)
existing = manager.load_conversation(conversation_id) or {}
manager.save_conversation(
conversation_id=conversation_id,
messages=existing.get("messages") or [],
**meta_kwargs,
)
@chat_bp.route('/api/thinking-mode', methods=['POST'])
@api_login_required
@with_terminal
@ -67,20 +84,19 @@ def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, userna
terminal.set_run_mode(target_mode)
session['thinking_mode'] = terminal.thinking_mode
session['run_mode'] = terminal.run_mode
# 更新当前对话的元数据
# 更新当前对话的元数据(服务实例不挂载历史:读磁盘回传,仅更新元数据)
ctx = terminal.context_manager
if ctx.current_conversation_id:
try:
ctx.conversation_manager.save_conversation(
conversation_id=ctx.current_conversation_id,
messages=ctx.conversation_history,
_save_conversation_meta_via_disk(
ctx,
ctx.current_conversation_id,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode,
model_key=getattr(terminal, "model_key", None),
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False)
has_videos=getattr(ctx, "has_videos", False),
)
except Exception as exc:
logger.error(f"[API] 保存思考模式到对话失败: {exc}")
@ -123,21 +139,20 @@ def update_reasoning_effort(terminal: WebTerminal, workspace: UserWorkspace, use
if is_current_target:
terminal.set_reasoning_effort(effort)
save_conversation_id = target_conversation_id or ctx.current_conversation_id
# 更新对话的元数据
# 更新对话的元数据(服务实例不挂载历史:统一读磁盘回传,仅更新元数据)
if save_conversation_id:
try:
if is_current_target:
ctx.conversation_manager.save_conversation(
conversation_id=save_conversation_id,
messages=ctx.conversation_history,
_save_conversation_meta_via_disk(
ctx,
save_conversation_id,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode,
reasoning_effort=effort,
model_key=getattr(terminal, "model_key", None),
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False)
has_videos=getattr(ctx, "has_videos", False),
)
else:
# 所属对话已不是当前对话:只更新推理强度 meta。
@ -212,17 +227,16 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
if requested_cid and current_cid:
if current_cid == requested_cid:
try:
ctx.conversation_manager.save_conversation(
conversation_id=current_cid,
messages=ctx.conversation_history,
_save_conversation_meta_via_disk(
ctx,
current_cid,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode,
reasoning_effort=terminal.reasoning_effort,
model_key=terminal.model_key,
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False)
has_videos=getattr(ctx, "has_videos", False),
)
except Exception as exc:
logger.error(f"[API] 保存模型到对话失败: {exc}")
@ -338,11 +352,10 @@ def update_personalization_settings(terminal: WebTerminal, workspace: UserWorksp
ctx = getattr(terminal, 'context_manager', None)
if ctx and getattr(ctx, 'current_conversation_id', None):
try:
ctx.conversation_manager.save_conversation(
conversation_id=ctx.current_conversation_id,
messages=ctx.conversation_history,
_save_conversation_meta_via_disk(
ctx,
ctx.current_conversation_id,
project_path=str(ctx.project_path),
todo_list=ctx.todo_list,
thinking_mode=terminal.thinking_mode,
run_mode=terminal.run_mode
)

View File

@ -214,6 +214,41 @@ def _normalize_conv_id(conversation_id: str) -> str:
return conv if conv.startswith("conv_") else f"conv_{conv}"
def _sync_restored_conversation_memory(conversation_id: str) -> None:
"""版本回溯后同步内存实例:把绑定该对话的对话级 terminal 内存替换为磁盘最新。
回溯用 allow_shrink 覆写裁短磁盘若持有旧更长历史的对话级实例之后保存
merge-on-save 会把被裁消息当作内存独有追加救回回溯随即被撤销
此前用户只能回溯后立刻重启进程的根因工作区级服务实例不挂载历史
天然无需处理 WebTerminal.load_conversation attach_history 分流
"""
from copy import deepcopy
from server import state as server_state
normalized = _normalize_conv_id(conversation_id)
user_terminals = getattr(server_state, "user_terminals", None) or {}
for term_key, term in list(user_terminals.items()):
try:
bound = getattr(term, "_bound_conversation_id", None)
if not bound or _normalize_conv_id(str(bound)) != normalized:
continue
ctx = getattr(term, "context_manager", None)
if ctx is None:
continue
target_manager = ctx._get_conversation_manager_for_id(normalized)
data = target_manager.load_conversation(normalized) or {}
ctx.conversation_history = list(data.get("messages") or [])
ctx.conversation_metadata = deepcopy(data.get("metadata") or {})
todo_data = data.get("todo_list")
ctx.todo_list = deepcopy(todo_data) if todo_data else None
debug_log(
f"[Versioning][Restore] synced in-memory history term={term_key} "
f"messages={len(ctx.conversation_history)}"
)
except Exception as exc:
debug_log(f"[Versioning][Restore] sync memory failed for term={term_key}: {exc}")
def _normalize_versioning_tracking_mode(value: Optional[str]) -> str:
return ConversationVersioningManager.normalize_tracking_mode(value)
@ -1632,16 +1667,13 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
terminal, workspace, normalized_id, target_conversation_id, seq, tracking_mode, host_mode, backup_mode
)
# overwrite 场景需要清空内存历史避免反向覆写copy 场景目标是新对话,无需处理。
# overwrite 场景:回溯已裁短磁盘,必须同步所有持有该对话的内存实例(对话级
# terminal 常驻 24h否则旧内存后续保存经 merge 会把被裁消息「救回」,回溯被撤销。
# copy 场景目标是新对话,无缓存实例,无需处理。
if restore_mode == "overwrite":
try:
current_loaded_id = getattr(terminal.context_manager, "current_conversation_id", None)
if current_loaded_id == normalized_id:
terminal.context_manager.conversation_history = []
debug_log(f"[Versioning][Restore] cleared in-memory history before reload conv={normalized_id}")
except Exception as exc:
debug_log(f"[Versioning][Restore] clear in-memory history failed: {exc}")
_sync_restored_conversation_memory(target_conversation_id)
# 恢复模式与焦点到当前工作区级terminal服务实例不挂载历史仅读磁盘元数据
terminal.load_conversation(target_conversation_id)
debug_log(
f"[Versioning][Restore] reload done conv={target_conversation_id} "
@ -1898,7 +1930,12 @@ def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: s
announced = terminal._announced_sub_agent_tasks
notified_from_history = set()
try:
history = getattr(terminal.context_manager, "conversation_history", []) or []
# 服务实例(工作区级)不挂载历史:通知去重标记以磁盘对话为准
history = []
if conversation_id:
notify_manager = terminal.context_manager._get_conversation_manager_for_id(conversation_id)
notify_conv_data = notify_manager.load_conversation(conversation_id) or {}
history = notify_conv_data.get("messages") or []
for msg in history:
meta = msg.get("metadata") or {}
task_id = meta.get("task_id")

View File

@ -340,12 +340,16 @@ def _extract_text_only(content: Any) -> str:
def _collect_user_texts(messages: List[Dict[str, Any]]) -> List[str]:
# 口径:用户亲手输入 = message_source 为 'user'(直接发送)或 'presend'(提前输入排队后转正),
# 字段缺失的老消息视为 'user'guidance手动引导/notify/compression 等运行期注入消息不计入。
# 与前端左侧输入导航 isRealUserNavMessageChatArea.vue口径保持一致。
result: List[str] = []
for msg in messages:
if msg.get("role") != "user":
continue
metadata = msg.get("metadata") if isinstance(msg.get("metadata"), dict) else {}
if str(metadata.get("message_source") or "").strip().lower() != "user":
source = str(metadata.get("message_source") or "user").strip().lower()
if source not in ("user", "presend"):
continue
text = _extract_text_only(msg.get("content"))
if text.strip():

View File

@ -120,12 +120,16 @@ def api_activate_workflow(terminal, workspace, username):
try:
# 激活提示消息将追加到历史末尾,阶段消息游标取当前长度 + 1。
# 新建对话:激活消息是第 1 条,游标固定为 1服务 terminal 的 history 长度不可信)。
# 新建对话:激活消息是第 1 条,游标固定为 1。
# 服务实例(工作区级)不挂载历史:非新建时游标以磁盘消息数为准。
if created_new:
msg_index = 1
else:
try:
msg_index = len(getattr(terminal.context_manager, "conversation_history", []) or []) + 1
cm_router = getattr(terminal, "context_manager", None)
wf_manager = cm_router._get_conversation_manager_for_id(conversation_id) if cm_router else None
conv_data = wf_manager.load_conversation(conversation_id) if wf_manager else None
msg_index = len((conv_data or {}).get("messages") or []) + 1
except Exception:
msg_index = 0
result = activate_workflow(

View File

@ -2397,7 +2397,9 @@ function getGeneratingLetters(message: any) {
}
// ===== =====
// role === 'user' metadata.message_source === 'user'//
// = metadata.message_source 'user' 'presend'
// 'user'guidance/notify/compression
// _collect_user_textsserver/deep_compression.py
const shellRef = ref<HTMLElement | null>(null);
const virtualizerRef = ref<any>(null);
const quickNavActiveIndex = ref(-1);
@ -2407,7 +2409,8 @@ const isRealUserNavMessage = (msg: any): boolean => {
if (!msg || msg.role !== 'user') {
return false;
}
return String(msg?.metadata?.message_source || 'user').trim().toLowerCase() === 'user';
const source = String(msg?.metadata?.message_source || 'user').trim().toLowerCase();
return source === 'user' || source === 'presend';
};
function extractUserFirstLine(msg: any): string {

View File

@ -179,12 +179,16 @@ class ConversationMixin:
print(f"📝 开始新对话: {conversation_id}")
return conversation_id
def load_conversation_by_id(self, conversation_id: str) -> bool:
def load_conversation_by_id(self, conversation_id: str, attach_history: bool = True) -> bool:
"""
加载指定对话
Args:
conversation_id: 对话ID
attach_history: 是否把消息历史/元数据挂载到内存工作区级服务实例应传 False
仅恢复焦点 current_conversation_id 与运行模式对话历史的权威载体是
磁盘 + 对话级 terminal服务实例挂载历史只会成为 merge-on-save 的污染源
版本回溯被旧内存救回覆盖即此机制事故
Returns:
bool: 加载是否成功
@ -221,12 +225,18 @@ class ConversationMixin:
# 更新当前状态
self.current_conversation_id = conversation_id
self.conversation_history = conversation_data.get("messages", [])
todo_data = conversation_data.get("todo_list")
self.todo_list = deepcopy(todo_data) if todo_data else None
self.conversation_metadata = deepcopy(conversation_data.get("metadata", {}) or {})
if attach_history:
self.conversation_history = conversation_data.get("messages", [])
todo_data = conversation_data.get("todo_list")
self.todo_list = deepcopy(todo_data) if todo_data else None
self.conversation_metadata = deepcopy(conversation_data.get("metadata", {}) or {})
else:
# 服务实例:仅焦点,不挂载消息/元数据保持空内存merge 语义下任何保存都不会回写消息)
self.conversation_history = []
self.todo_list = None
self.conversation_metadata = {}
# 恢复项目文件树快照(如已存在)
meta = self.conversation_metadata
meta = deepcopy(conversation_data.get("metadata", {}) or {})
if meta.get("project_file_tree"):
self.project_snapshot = {
"file_tree": meta.get("project_file_tree"),
@ -264,7 +274,7 @@ class ConversationMixin:
model_key = metadata.get("model_key")
self.has_images = metadata.get("has_images", False)
self.has_videos = metadata.get("has_videos", False)
if not self.has_images or not self.has_videos:
if attach_history and (not self.has_images or not self.has_videos):
for msg in self.conversation_history:
if not isinstance(msg, dict):
continue
@ -321,6 +331,13 @@ class ConversationMixin:
return True
def _is_service_instance_no_history(self) -> bool:
"""是否为「不持有对话历史」的工作区级服务实例(由 WebTerminal 初始化时打标)。
服务实例不挂载/不回写消息历史历史权威在磁盘与对话级 terminal
"""
return bool(getattr(self, "_service_instance_no_history", False))
def save_current_conversation(self) -> bool:
"""
保存当前对话
@ -328,6 +345,9 @@ class ConversationMixin:
Returns:
bool: 保存是否成功
"""
if self._is_service_instance_no_history():
# 服务实例不持有历史,保存无语义;直接跳过,防止任何路径把空/陈旧内存写回
return False
if not self.current_conversation_id:
print("⚠️ 没有当前对话ID无法保存")
return False
@ -362,6 +382,8 @@ class ConversationMixin:
def auto_save_conversation(self, force: bool = False):
"""自动保存对话(静默模式,减少日志输出)"""
if self._is_service_instance_no_history():
return
if not self.auto_save_enabled or not self.current_conversation_id:
return
if not force and not self.conversation_history: