agent-Specialization/server/conversation_bootstrap.py
JOJO 21f3acdf16 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
2026-07-20 22:30:43 +08:00

238 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""对话进入聚合接口(方案 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 无 actions」——actions 是渲染层结构,
文件态用内容字段等价判断。
"""
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})