fix(conversation): 修复版本回溯被旧内存merge覆盖,统一presend用户输入口径
- 回溯后同步对话级 terminal 内存为磁盘快照,避免 merge-on-save 把被裁消息当"内存独有"救回(此前只能回溯后立刻重启进程) - 工作区级服务实例不再挂载对话历史(focus-only 仅恢复焦点+模式),settings/通知判定/工作流游标等保存路径改读磁盘回传,消除旧内存写回污染源 - 左侧输入导航与深度压缩的用户输入口径补 presend(提前输入排队转正),guidance 维持不计入;统一字段缺失老消息视为 user
This commit is contained in:
parent
c6c8bf2d0c
commit
2d9b4eb5a6
@ -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_id,super 之前已设),初始化时序安全。
|
||||
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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 等运行期注入消息不计入。
|
||||
# 与前端左侧输入导航 isRealUserNavMessage(ChatArea.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():
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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_texts(server/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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user