feat(server): 新增对话 bootstrap 聚合接口(方案B)

GET /api/conversations/<id>/bootstrap 一次性返回进入对话所需的
meta/messages/running/task_replay(needs_rebuild/replay_from 判定移植自
前端 compression.ts),只读语义不写对话文件,规避 safe_nav 双轨与旧内存
回写面。设计文档:docs/conversation_load_unification_plan.md
This commit is contained in:
JOJO 2026-07-20 22:30:43 +08:00
parent 17c48474d9
commit 21f3acdf16
2 changed files with 239 additions and 0 deletions

View File

@ -34,6 +34,7 @@ from server.status import status_bp
from server.tasks import tasks_bp
from server.api_v1 import api_v1_bp
from server.multi_agent import multi_agent_bp
from server.conversation_bootstrap import conversation_bootstrap_bp
from server.socket_handlers import socketio
from server.security import attach_security_hooks
from werkzeug.utils import secure_filename
@ -298,6 +299,7 @@ app.register_blueprint(status_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(api_v1_bp)
app.register_blueprint(multi_agent_bp)
app.register_blueprint(conversation_bootstrap_bp)
# 安全钩子CSRF 校验 + 响应头)
attach_security_hooks(app)

View File

@ -0,0 +1,237 @@
"""对话进入聚合接口(方案 B统一加载协议
GET /api/conversations/<id>/bootstrap 一次性返回进入对话所需的全部数据
元数据文件态消息运行状态聚合与任务重放决策needs_rebuild/replay_from
只读语义不写对话文件不调用 terminal.load_conversation不切换工作区级 terminal
的当前上下文天然规避 safe_nav 双轨问题与旧内存回写面为聚合后台运行状态
会按需获取/创建对话级 terminal与既有 running-status 端点行为一致属内存态操作
设计文档docs/conversation_load_unification_plan.md
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from flask import Blueprint, jsonify, request, session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources
from server.tasks import task_manager
from server.tasks.helpers import _task_public_payload
from server.utils_common import debug_log
conversation_bootstrap_bp = Blueprint("conversation_bootstrap", __name__)
_ACTIVE_TASK_STATUSES = {"pending", "running", "cancel_requested"}
# 与前端 compression.ts needsRebuild 判据中的事件类型集合对齐
_ASSISTANT_RESPONSE_EVENTS = {
"ai_message_start", "thinking_start", "thinking_chunk", "thinking_end",
"text_start", "text_chunk", "text_end",
"tool_preparing", "tool_start", "tool_update", "tool_complete",
}
_ASSISTANT_CONTENT_EVENTS = {
"thinking_chunk", "text_chunk",
"tool_preparing", "tool_start", "tool_update", "tool_complete",
}
def _normalize_conv_id(conversation_id: str) -> str:
conv = (conversation_id or "").strip()
if not conv:
return conv
return conv if conv.startswith("conv_") else f"conv_{conv}"
def _find_active_main_task(username: str, normalized_id: str):
"""取该对话最新的活动主任务(对齐 running-status 语义rec 侧做前缀归一)。"""
main_rec = None
for rec in task_manager.list_tasks(username):
rec_cid = _normalize_conv_id(getattr(rec, "conversation_id", "") or "")
if rec_cid != normalized_id or rec.status not in _ACTIVE_TASK_STATUSES:
continue
if main_rec is None or rec.created_at > main_rec.created_at:
main_rec = rec
return main_rec
def _analyze_task_events(events: List[Dict[str, Any]]) -> Dict[str, bool]:
"""分析任务事件流的流式状态(移植自前端 compression.ts needsRebuild 判据)。"""
in_thinking = False
in_text = False
has_text_chunk_event = False
has_assistant_response_event = False
has_assistant_content_event = False
for event in events:
event_type = str((event or {}).get("type") or "")
if event_type in _ASSISTANT_RESPONSE_EVENTS:
has_assistant_response_event = True
if event_type in _ASSISTANT_CONTENT_EVENTS:
has_assistant_content_event = True
if event_type == "thinking_start":
in_thinking = True
elif event_type == "thinking_end":
in_thinking = False
if event_type == "text_start":
in_text = True
elif event_type == "text_end":
in_text = False
if event_type == "text_chunk":
has_text_chunk_event = True
return {
"in_thinking": in_thinking,
"in_text": in_text,
"has_text_chunk_event": has_text_chunk_event,
"has_assistant_response_event": has_assistant_response_event,
"has_assistant_content_event": has_assistant_content_event,
"force_rebuild_for_streaming_text": in_text or has_text_chunk_event or in_thinking,
}
def _is_empty_assistant_message(message: Optional[Dict[str, Any]]) -> bool:
"""文件态的「空 assistant」无正文、无思考内容、无工具调用。
对应前端判据末条 assistant actionsactions 是渲染层结构
文件态用内容字段等价判断
"""
if not isinstance(message, dict):
return True
if str(message.get("content") or "").strip():
return False
if str(message.get("reasoning_content") or "").strip():
return False
if message.get("tool_calls"):
return False
return True
def _decide_task_replay(messages: List[Dict[str, Any]], events: List[Dict[str, Any]]) -> Dict[str, Any]:
"""移植前端 needsRebuild 判据:任一成立即 needs_rebuild=True。
1. 文件态末条不是 assistant任务产生的 assistant 内容未落盘
2. 文件态末条是空 assistant
3. 事件流处于流式中段inText / hasTextChunkEvent / inThinking
4. 文件态末条 assistant tool_calls工具结果消息尚未落盘视为进行中
"""
last_message = messages[-1] if messages else None
is_assistant = isinstance(last_message, dict) and last_message.get("role") == "assistant"
event_state = _analyze_task_events(events)
empty_assistant = _is_empty_assistant_message(last_message) if is_assistant else False
has_pending_tool_calls = bool(
is_assistant and isinstance(last_message, dict) and last_message.get("tool_calls")
)
needs_rebuild = (
not is_assistant
or empty_assistant
or event_state["force_rebuild_for_streaming_text"]
or has_pending_tool_calls
)
event_count = len(events)
return {
"event_count": event_count,
"needs_rebuild": needs_rebuild,
"replay_from": 0 if needs_rebuild else event_count,
"decision_inputs": {
"is_assistant_last": is_assistant,
"empty_assistant_last": empty_assistant,
"has_pending_tool_calls": has_pending_tool_calls,
**event_state,
},
}
@conversation_bootstrap_bp.route("/api/conversations/<conversation_id>/bootstrap", methods=["GET"])
@api_login_required
def bootstrap_conversation(conversation_id: str):
"""进入对话聚合接口meta + messages + running + task_replay纯只读"""
username = get_current_username()
normalized_id = _normalize_conv_id(conversation_id)
if not normalized_id:
return jsonify({"success": False, "error": "缺少 conversation_id"}), 400
workspace_id = (request.args.get("workspace_id") or "").strip() or None
# 工作区级资源:只取 context_manager 读文件,绝不调用 load_conversation
try:
ws_terminal, _workspace = get_user_resources(username, workspace_id=workspace_id)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
ctx_manager = getattr(ws_terminal, "context_manager", None)
if ctx_manager is None:
return jsonify({"success": False, "error": "终端上下文不可用"}), 503
cm = ctx_manager._get_conversation_manager_for_id(normalized_id)
conversation_data = cm.load_conversation(normalized_id) if cm else None
if not conversation_data:
return jsonify({"success": False, "error": f"对话 {normalized_id} 不存在"}), 404
raw_meta = conversation_data.get("metadata", {}) or {}
messages = conversation_data.get("messages", []) or []
run_mode = raw_meta.get("run_mode") or ("thinking" if raw_meta.get("thinking_mode") else "fast")
# 运行状态聚合(对齐 tasks/api.py running-status主 task + 三类后台)
main_rec = _find_active_main_task(username, normalized_id)
effective_workspace_id = (
workspace_id
or (getattr(main_rec, "workspace_id", None) if main_rec else None)
or (session.get("workspace_id") or None)
)
bg_status = {
"has_running_sub_agents": False,
"has_running_background_commands": False,
"has_running_multi_agent": False,
}
try:
conv_terminal, _ = get_user_resources(
username, workspace_id=effective_workspace_id, conversation_id=normalized_id
)
except Exception as exc:
debug_log(f"[Bootstrap] 获取对话级终端失败: {exc}")
conv_terminal = None
if conv_terminal is not None:
bg_status = task_manager.get_conversation_running_status(conv_terminal, normalized_id)
is_main_running = main_rec is not None
running = {
"is_main_running": is_main_running,
"main_task_id": getattr(main_rec, "task_id", None) if main_rec else None,
"main_task_type": getattr(main_rec, "task_type", "chat") if main_rec else None,
**bg_status,
"is_truly_active": is_main_running or any(bg_status.values()),
}
data: Dict[str, Any] = {
"conversation_id": normalized_id,
"meta": {
"title": conversation_data.get("title", "未知对话"),
"run_mode": run_mode,
"thinking_mode": bool(raw_meta.get("thinking_mode", run_mode != "fast")),
"model_key": raw_meta.get("model_key"),
"multi_agent_mode": bool(raw_meta.get("multi_agent_mode", False)),
"permission_mode": raw_meta.get("permission_mode") or "",
"execution_mode": raw_meta.get("execution_mode") or "",
"network_permission": raw_meta.get("network_permission") or "",
"messages_count": len(messages),
},
"messages": messages,
"running": running,
}
if is_main_running:
events = list(getattr(main_rec, "events", []) or [])
replay = _decide_task_replay(messages, events)
replay["task_id"] = main_rec.task_id
# 任务摘要与全量事件:前端恢复时免去 GET /api/tasks 与 GET /api/tasks/{id} 两次请求
replay["task"] = _task_public_payload(main_rec)
replay["events"] = events
data["task_replay"] = replay
debug_log(
f"[Bootstrap] conv={normalized_id} task={main_rec.task_id} "
f"events={replay['event_count']} needs_rebuild={replay['needs_rebuild']} "
f"inputs={replay['decision_inputs']}"
)
return jsonify({"success": True, "data": data})