fix(model): 修复重启后进入对话模型回变为默认模型

对话级隔离后存在四处模型覆盖/错存路径:
1. 对话级 terminal 绑定加载用 restore_model=False,重启后新建的
   对话级 terminal 丢失对话保存的模型 -> 绑定加载恢复模型
2. /api/model 把工作区级 terminal 的陈旧 current_conversation_id
   当目标保存,模型写到错误对话或丢失(且用陈旧 history 全量保存
   有覆盖新消息风险)-> 仅当请求显式携带 conversation_id 且与
   terminal 当前对话一致时才持久化
3. 前端切换模型不携带 conversation_id -> handleModelSelect 带上
4. _apply_workspace_personalization_preferences 把 session 全局模型
   /个性化默认模型覆盖到对话级 terminal -> 对话级 terminal 跳过,
   其模型由绑定加载权威确定
This commit is contained in:
JOJO 2026-07-20 02:20:06 +08:00
parent a4eb688814
commit 1289c31dfe
4 changed files with 58 additions and 21 deletions

View File

@ -35,7 +35,11 @@ class WebTerminal(MainTerminal):
对话级 terminal_bound_conversation_id 非空加载绑定的对话 对话级 terminal_bound_conversation_id 非空加载绑定的对话
加载失败不 fallback 最近对话避免串对话保持无对话状态 加载失败不 fallback 最近对话避免串对话保持无对话状态
由任务执行链路的 ensure_conversation_loaded 显式处理 由任务执行链路的 ensure_conversation_loaded 显式处理
工作区级 terminal未绑定保持原有最近对话行为 对话级 terminal 只服务绑定对话必须恢复该对话保存的模型
restore_model=True否则重启后新建的对话级 terminal 会用默认
模型跑任务/回写 metadata把用户切换的模型覆盖掉
工作区级 terminal未绑定保持原有最近对话行为且刻意不恢复
模型restore_model=False避免 /new 页面显示旧对话的模型
""" """
if self.context_manager.current_conversation_id: if self.context_manager.current_conversation_id:
return return
@ -44,7 +48,7 @@ class WebTerminal(MainTerminal):
if bound_id: if bound_id:
if not str(bound_id).startswith("conv_"): if not str(bound_id).startswith("conv_"):
bound_id = f"conv_{bound_id}" bound_id = f"conv_{bound_id}"
result = self.load_conversation(bound_id, restore_model=False) result = self.load_conversation(bound_id, restore_model=True)
if result.get("success"): if result.get("success"):
print(f"[WebTerminal] 已加载绑定对话: {bound_id}") print(f"[WebTerminal] 已加载绑定对话: {bound_id}")
else: else:

View File

@ -164,23 +164,38 @@ def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str)
session["run_mode"] = terminal.run_mode session["run_mode"] = terminal.run_mode
session["thinking_mode"] = terminal.thinking_mode session["thinking_mode"] = terminal.thinking_mode
# 更新当前对话元数据 # 更新对话元数据:仅当请求显式携带 conversation_id 时持久化。
# 对话级隔离后,未带 conversation_id 时拿到的是工作区级 terminal
# 其 current_conversation_id 可能是陈旧对话(安全导航/切换后),
# 盲目保存会把模型写到错误对话,或用陈旧 history 覆盖新消息。
# /new 页面(无 conversation_id不持久化新对话在首个任务时
# 由前端随任务传入的 model_key 落盘。
requested_cid = str(data.get("conversation_id") or "").strip()
if requested_cid and not requested_cid.startswith("conv_"):
requested_cid = f"conv_{requested_cid}"
ctx = terminal.context_manager ctx = terminal.context_manager
if ctx.current_conversation_id: current_cid = getattr(ctx, "current_conversation_id", None)
try: if requested_cid and current_cid:
ctx.conversation_manager.save_conversation( if current_cid == requested_cid:
conversation_id=ctx.current_conversation_id, try:
messages=ctx.conversation_history, ctx.conversation_manager.save_conversation(
project_path=str(ctx.project_path), conversation_id=current_cid,
todo_list=ctx.todo_list, messages=ctx.conversation_history,
thinking_mode=terminal.thinking_mode, project_path=str(ctx.project_path),
run_mode=terminal.run_mode, todo_list=ctx.todo_list,
model_key=terminal.model_key, thinking_mode=terminal.thinking_mode,
has_images=getattr(ctx, "has_images", False), run_mode=terminal.run_mode,
has_videos=getattr(ctx, "has_videos", False) model_key=terminal.model_key,
has_images=getattr(ctx, "has_images", False),
has_videos=getattr(ctx, "has_videos", False)
)
except Exception as exc:
print(f"[API] 保存模型到对话失败: {exc}")
else:
print(
f"[API] 跳过模型保存:请求对话 {requested_cid} "
f"与 terminal 当前对话 {current_cid} 不一致"
) )
except Exception as exc:
print(f"[API] 保存模型到对话失败: {exc}")
status = terminal.get_status() status = terminal.get_status()
socketio.emit('status_update', status, room=f"user_{username}") socketio.emit('status_update', status, room=f"user_{username}")

View File

@ -122,16 +122,28 @@ def _apply_workspace_personalization_preferences(terminal: WebTerminal, workspac
if isinstance(raw_session_model, str) and raw_session_model.strip(): if isinstance(raw_session_model, str) and raw_session_model.strip():
session_model = raw_session_model.strip() session_model = raw_session_model.strip()
# 对话级 terminal_bound_conversation_id的模型由绑定加载权威恢复
# (对话文件 metadata.model_keysession 级模型是全局的最后选择,
# 不能覆盖到某个具体对话的 terminal 上,否则重启后进入对话会被
# session 里的其它模型回写(模型回变默认的 bug
is_conversation_bound = bool(getattr(terminal, "_bound_conversation_id", None))
# default_model 是“新会话初始偏好”,不能在每次 /api/status、任务创建、 # default_model 是“新会话初始偏好”,不能在每次 /api/status、任务创建、
# 加载资源时覆盖用户已经在当前会话里手动切换的模型。 # 加载资源时覆盖用户已经在当前会话里手动切换的模型。
if session_model and session_model != getattr(terminal, "model_key", None): if (
session_model
and not is_conversation_bound
and session_model != getattr(terminal, "model_key", None)
):
try: try:
terminal.set_model(session_model) terminal.set_model(session_model)
except Exception as exc: except Exception as exc:
debug_log(f"[Personalization] 恢复会话模型失败: {session_model} ({exc})") debug_log(f"[Personalization] 恢复会话模型失败: {session_model} ({exc})")
apply_default_model = not bool(session_model) and not bool( apply_default_model = (
getattr(terminal, "_workspace_default_model_applied", False) not is_conversation_bound
and not bool(session_model)
and not bool(getattr(terminal, "_workspace_default_model_applied", False))
) )
terminal.apply_personalization_preferences(config, apply_default_model=apply_default_model) terminal.apply_personalization_preferences(config, apply_default_model=apply_default_model)
if apply_default_model: if apply_default_model:

View File

@ -72,10 +72,16 @@ export const modelMethods = {
} }
const prev = this.currentModelKey; const prev = this.currentModelKey;
try { try {
// 携带当前对话 id对话级隔离后后端需要把模型设置到该对话专属的
// 对话级 terminal 并保存到正确的对话文件,避免写到工作区级 terminal
// 的陈旧对话(否则重启后模型回变为默认)。
const resp = await fetch('/api/model', { const resp = await fetch('/api/model', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_key: key }) body: JSON.stringify({
model_key: key,
conversation_id: this.currentConversationId || undefined
})
}); });
const payload = await resp.json(); const payload = await resp.json();
if (!resp.ok || !payload.success) { if (!resp.ok || !payload.success) {