ForcePilot/backend/package/yuxi/services/chat_service.py

1126 lines
39 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.

import asyncio
import json
import traceback
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
from langgraph.types import Command
from yuxi import config as conf
from yuxi.agents.buildin import agent_manager
from yuxi.agents.state import AgentStatePayload
from yuxi.plugins.guard import content_guard
from yuxi.repositories.agent_config_repository import AgentConfigRepository
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.services.langfuse_service import (
LangfuseRunContext,
build_run_context,
flush_langfuse,
get_trace_info,
)
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger
from yuxi.utils.question_utils import (
normalize_options as _normalize_interrupt_options,
)
from yuxi.utils.question_utils import (
normalize_questions as _normalize_interrupt_questions,
)
def _build_state_files(attachments: list[dict]) -> dict:
"""将附件列表转换为 StateBackend 格式的 files 字典
StateBackend 期望的格式:
{
"/attachments/file.md": {
"content": ["line1", "line2", ...],
"created_at": "...",
"modified_at": "...",
}
}
"""
files = {}
for attachment in attachments:
if attachment.get("status") != "parsed":
continue
file_path = attachment.get("file_path")
markdown = attachment.get("markdown")
if not file_path or not markdown:
continue
now = datetime.now(UTC).isoformat()
# 将 markdown 内容按行拆分
content_lines = markdown.split("\n")
files[file_path] = {
"content": content_lines,
"created_at": attachment.get("uploaded_at", now),
"modified_at": attachment.get("uploaded_at", now),
}
return files
async def _get_langgraph_messages(agent_instance, config_dict):
graph = await agent_instance.get_graph()
state = await graph.aget_state(config_dict)
if not state or not state.values:
logger.warning("No state found in LangGraph")
return None
return state.values.get("messages", [])
def _build_langfuse_run_context(
*,
current_user,
thread_id: str,
agent_id: str,
request_id: str,
operation: str,
agent_config_id: int | None = None,
message_type: str | None = None,
) -> LangfuseRunContext:
return build_run_context(
user_id=str(current_user.id),
thread_id=thread_id,
agent_id=agent_id,
request_id=request_id,
operation=operation,
agent_config_id=agent_config_id,
message_type=message_type,
username=getattr(current_user, "username", None),
login_user_id=getattr(current_user, "user_id", None),
department_id=getattr(current_user, "department_id", None),
)
def extract_agent_state(values: dict) -> AgentStatePayload:
"""从 LangGraph state 中提取 agent 状态"""
if not isinstance(values, dict):
return {"todos": [], "files": {}, "artifacts": []}
# 直接获取,信任 state 的数据结构
todos = values.get("todos")
artifacts = values.get("artifacts")
result: AgentStatePayload = {
"todos": list(todos)[:20] if todos else [],
"files": values.get("files") or {},
"artifacts": list(artifacts) if artifacts else [],
}
return result
def _agent_state_signature(agent_state: AgentStatePayload | dict | None) -> str:
if not agent_state:
return ""
try:
return json.dumps(agent_state, ensure_ascii=False, sort_keys=True)
except Exception:
return str(agent_state)
async def _stream_agent_events(agent, messages, *, input_context=None, **kwargs):
if hasattr(agent, "stream_messages_with_state"):
async for mode, payload in agent.stream_messages_with_state(
messages,
input_context=input_context,
**kwargs,
):
yield mode, payload
return
async for msg, metadata in agent.stream_messages(messages, input_context=input_context, **kwargs):
yield "messages", (msg, metadata)
async def _get_existing_message_ids(conv_repo: ConversationRepository, thread_id: str) -> set[str]:
existing_messages = await conv_repo.get_messages_by_thread_id(thread_id)
return {
msg.extra_metadata["id"]
for msg in existing_messages
if msg.extra_metadata and "id" in msg.extra_metadata and isinstance(msg.extra_metadata["id"], str)
}
async def _save_ai_message(
conv_repo: ConversationRepository,
thread_id: str,
msg_dict: dict,
trace_info: dict[str, Any] | None = None,
) -> None:
content = msg_dict.get("content", "")
tool_calls_data = msg_dict.get("tool_calls", [])
extra_metadata = dict(msg_dict)
if trace_info:
extra_metadata.update(trace_info)
ai_msg = await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
role="assistant",
content=content,
message_type="text",
extra_metadata=extra_metadata,
)
if ai_msg and tool_calls_data:
for tc in tool_calls_data:
await conv_repo.add_tool_call(
message_id=ai_msg.id,
tool_name=tc.get("name", "unknown"),
tool_input=tc.get("args", {}),
status="pending",
langgraph_tool_call_id=tc.get("id"),
)
async def _save_tool_message(conv_repo: ConversationRepository, msg_dict: dict) -> None:
tool_call_id = msg_dict.get("tool_call_id")
content = msg_dict.get("content", "")
if not tool_call_id:
return
if isinstance(content, list):
tool_output = json.dumps(content) if content else ""
else:
tool_output = str(content)
await conv_repo.update_tool_call_output(
langgraph_tool_call_id=tool_call_id,
tool_output=tool_output,
status="success",
)
async def save_partial_message(
conv_repo: ConversationRepository,
thread_id: str,
full_msg=None,
error_message: str | None = None,
error_type: str = "interrupted",
trace_info: dict[str, Any] | None = None,
):
try:
extra_metadata = {
"error_type": error_type,
"is_error": True,
"error_message": error_message or f"发生错误: {error_type}",
}
if full_msg:
msg_dict = full_msg.model_dump() if hasattr(full_msg, "model_dump") else {}
content = full_msg.content if hasattr(full_msg, "content") else str(full_msg)
extra_metadata = msg_dict | extra_metadata
else:
content = ""
if trace_info:
extra_metadata.update(trace_info)
return await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
role="assistant",
content=content,
message_type="text",
extra_metadata=extra_metadata,
)
except Exception as e:
logger.error(f"Error saving message: {e}")
logger.error(traceback.format_exc())
return None
async def save_messages_from_langgraph_state(
agent_instance,
thread_id: str,
conv_repo: ConversationRepository,
config_dict: dict,
trace_info: dict[str, Any] | None = None,
) -> None:
try:
messages = await _get_langgraph_messages(agent_instance, config_dict)
if messages is None:
return
existing_ids = await _get_existing_message_ids(conv_repo, thread_id)
for msg in messages:
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
msg_type = msg_dict.get("type", "unknown")
if msg_type == "human" or getattr(msg, "id", None) in existing_ids:
continue
if msg_type == "ai":
await _save_ai_message(conv_repo, thread_id, msg_dict, trace_info=trace_info)
elif msg_type == "tool":
await _save_tool_message(conv_repo, msg_dict)
except Exception as e:
logger.error(f"Error saving messages from LangGraph state: {e}")
logger.error(traceback.format_exc())
def _extract_interrupt_info(state) -> Any | None:
"""从 LangGraph state 中提取中断信息"""
if hasattr(state, "tasks") and state.tasks:
for task in state.tasks:
if hasattr(task, "interrupts") and task.interrupts:
return task.interrupts[0]
interrupt_data = state.values.get("__interrupt__")
if isinstance(interrupt_data, list) and interrupt_data:
return interrupt_data[0]
return None
def _coerce_interrupt_payload(info: Any) -> dict:
"""将 LangGraph interrupt 对象转换为 dict 结构。"""
if isinstance(info, dict):
return info
payload = getattr(info, "value", None)
if isinstance(payload, dict):
return payload
questions = getattr(info, "questions", None)
question = getattr(info, "question", None)
question_id = getattr(info, "question_id", None)
options = getattr(info, "options", None)
multi_select = getattr(info, "multi_select", None)
allow_other = getattr(info, "allow_other", None)
operation = getattr(info, "operation", None)
source = getattr(info, "source", None)
result: dict[str, Any] = {}
if isinstance(questions, list):
result["questions"] = questions
if isinstance(question, str) and question.strip():
result["question"] = question
if isinstance(question_id, str) and question_id.strip():
result["question_id"] = question_id
if isinstance(options, list):
result["options"] = options
if isinstance(multi_select, bool):
result["multi_select"] = multi_select
if isinstance(allow_other, bool):
result["allow_other"] = allow_other
if isinstance(operation, str) and operation.strip():
result["operation"] = operation
if isinstance(source, str) and source.strip():
result["source"] = source
return result
def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any]:
"""将 interrupt 信息标准化为 ask_user_question_required 载荷。"""
payload = _coerce_interrupt_payload(info)
questions = _normalize_interrupt_questions(payload.get("questions"))
if not questions:
legacy_question = str(payload.get("question") or "").strip()
if legacy_question:
legacy_item: dict[str, Any] = {
"question_id": str(payload.get("question_id") or uuid.uuid4()),
"question": legacy_question,
"options": _normalize_interrupt_options(payload.get("options")),
"multi_select": bool(payload.get("multi_select", False)),
"allow_other": bool(payload.get("allow_other", True)),
}
legacy_operation = payload.get("operation")
if isinstance(legacy_operation, str) and legacy_operation.strip():
legacy_item["operation"] = legacy_operation.strip()
questions = [legacy_item]
if not questions:
questions = [
{
"question_id": str(uuid.uuid4()),
"question": "请选择一个选项",
"options": [],
"multi_select": False,
"allow_other": True,
}
]
source = str(payload.get("source") or payload.get("tool_name") or "interrupt")
return {
"questions": questions,
"source": source,
"thread_id": thread_id,
}
def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None:
"""如果 full_msg 为空且有累积内容,构建 AIMessage"""
if not full_msg and accumulated_content:
return AIMessage(content="".join(accumulated_content))
return full_msg
def _extract_ai_message(messages: list[Any] | None) -> AIMessage | None:
"""从消息列表中提取最后一条 AIMessage。"""
if not isinstance(messages, list):
return None
for msg in reversed(messages):
if isinstance(msg, AIMessage):
return msg
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
if msg_dict.get("type") == "ai":
content = msg_dict.get("content", "")
return msg if hasattr(msg, "content") else AIMessage(content=content)
return None
async def get_agent_config_by_id(db, user: User, agent_config_id: int):
"""按配置 ID 解析 AgentConfig 记录。"""
department_id = user.department_id
agent_config_repo = AgentConfigRepository(db)
config_item = await agent_config_repo.get_by_id(config_id=int(agent_config_id))
if config_item is None or config_item.department_id != department_id:
raise ValueError("配置不存在")
return config_item
async def _resolve_agent_config(db, agent_id: str, user: User, agent_config_id):
"""解析 agent_config返回 agent_config"""
department_id = user.department_id
agent_config_repo = AgentConfigRepository(db)
config_item = None
if agent_config_id is not None:
config_item = await get_agent_config_by_id(db, user, int(agent_config_id))
if config_item.agent_id != agent_id:
config_item = None
if config_item is None:
config_item = await agent_config_repo.get_or_create_default(
department_id=department_id, agent_id=agent_id, created_by=str(user.id)
)
return (config_item.config_json or {}).get("context", {})
async def check_and_handle_interrupts(
agent,
langgraph_config: dict,
make_chunk,
meta: dict,
thread_id: str,
) -> AsyncIterator[bytes]:
try:
graph = await agent.get_graph()
state = await graph.aget_state(langgraph_config)
if not state or not state.values:
return
interrupt_info = _extract_interrupt_info(state)
if interrupt_info:
question_payload = _build_ask_user_question_payload(interrupt_info, thread_id)
meta["interrupt"] = question_payload
yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
except Exception as e:
logger.error(f"Error checking interrupts: {e}")
logger.error(traceback.format_exc())
async def _ensure_thread_bound_agent_config(
*,
conv_repo: ConversationRepository,
agent_config_repo: AgentConfigRepository,
thread_id: str,
user_id: str,
department_id: int,
agent_id: str,
agent_config_id: int,
) -> None:
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
if not conversation:
conversation = await conv_repo.create_conversation(
user_id=user_id,
agent_id=agent_id,
thread_id=thread_id,
)
current_agent_config_id = (conversation.extra_metadata or {}).get("agent_config_id")
if current_agent_config_id != int(agent_config_id):
# 检查目标配置是否存在于配置表中
config_item = await agent_config_repo.get_by_id(int(agent_config_id))
if config_item is None:
# 配置已损坏或已移除,切换到默认配置
logger.warning(
f"Config {agent_config_id} not found for thread {thread_id}, "
f"switching to default config for agent {agent_id}"
)
default_config = await agent_config_repo.get_or_create_default(
department_id=department_id,
agent_id=agent_id,
created_by=user_id,
)
await conv_repo.bind_agent_config(thread_id, default_config.id)
else:
await conv_repo.bind_agent_config(thread_id, agent_config_id)
async def agent_chat(
*,
query: str,
agent_config_id: int,
thread_id: str | None,
meta: dict,
image_content: str | None,
current_user,
db,
) -> dict:
"""非流式对话,返回完整响应"""
start_time = asyncio.get_event_loop().time()
if image_content:
human_message = HumanMessage(
content=[
{"type": "text", "text": query},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_content}"}},
]
)
message_type = "multimodal_image"
else:
human_message = HumanMessage(content=query)
message_type = "text"
if conf.enable_content_guard and await content_guard.check(query):
return {
"status": "error",
"error_type": "content_guard_blocked",
"error_message": "输入内容包含敏感词",
"request_id": meta.get("request_id"),
}
if not current_user.department_id:
return {
"status": "error",
"error_type": "invalid_config",
"error_message": "当前用户未绑定部门",
"request_id": meta.get("request_id"),
}
user_id = str(current_user.id)
meta = dict(meta or {})
if "request_id" not in meta or not meta.get("request_id"):
logger.warning("请求缺少 request_id已自动生成一个新的 request_id")
meta["request_id"] = str(uuid.uuid4())
try:
config_item = await get_agent_config_by_id(db, current_user, agent_config_id)
except ValueError as e:
return {
"status": "error",
"error_type": "invalid_config",
"error_message": str(e),
"request_id": meta.get("request_id"),
}
agent_id = config_item.agent_id
meta.update(
{
"query": query,
"agent_id": agent_id,
"server_model_name": agent_id,
"thread_id": thread_id,
"user_id": current_user.id,
"has_image": bool(image_content),
}
)
try:
agent = agent_manager.get_agent(agent_id)
except Exception as e:
logger.error(f"Error getting agent {agent_id}: {e}, {traceback.format_exc()}")
return {
"status": "error",
"error_type": "agent_error",
"error_message": f"智能体 {agent_id} 获取失败: {str(e)}",
"request_id": meta.get("request_id"),
}
messages = [human_message]
agent_config = (config_item.config_json or {}).get("context", {})
if not thread_id:
thread_id = str(uuid.uuid4())
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
input_context = agent_config | {"user_id": user_id, "thread_id": thread_id}
langfuse_run = _build_langfuse_run_context(
current_user=current_user,
thread_id=thread_id,
agent_id=agent_id,
request_id=meta["request_id"],
operation="agent_chat_sync",
agent_config_id=agent_config_id,
message_type=message_type,
)
trace_info: dict[str, Any] = {}
try:
conv_repo = ConversationRepository(db)
agent_config_repo = AgentConfigRepository(db)
await _ensure_thread_bound_agent_config(
conv_repo=conv_repo,
agent_config_repo=agent_config_repo,
thread_id=thread_id,
user_id=user_id,
department_id=current_user.department_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
)
try:
await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
role="user",
content=query,
message_type=message_type,
image_content=image_content,
extra_metadata={"raw_message": human_message.model_dump()},
)
except Exception as e:
logger.error(f"Error saving user message: {e}")
langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
invoke_result = await agent.invoke_messages(
messages,
input_context=input_context,
callbacks=langfuse_run.callbacks,
metadata=langfuse_run.metadata,
tags=langfuse_run.tags,
)
full_msg = _extract_ai_message(invoke_result.get("messages") if isinstance(invoke_result, dict) else None)
trace_info = get_trace_info(langfuse_run)
if full_msg is None:
try:
graph = await agent.get_graph()
state = await graph.aget_state(langgraph_config)
full_msg = _extract_ai_message(getattr(state, "values", {}).get("messages", [])) if state else None
except Exception:
full_msg = None
full_content = full_msg.content if full_msg else ""
if conf.enable_content_guard and await content_guard.check(full_content):
await save_partial_message(
conv_repo,
thread_id,
full_msg,
"content_guard_blocked",
trace_info=trace_info,
)
return {
"status": "interrupted",
"message": "检测到敏感内容,已中断输出",
"request_id": meta.get("request_id"),
"time_cost": asyncio.get_event_loop().time() - start_time,
}
try:
graph = await agent.get_graph()
state = await graph.aget_state(langgraph_config)
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
except Exception:
agent_state = {}
await save_messages_from_langgraph_state(
agent_instance=agent,
thread_id=thread_id,
conv_repo=conv_repo,
config_dict=langgraph_config,
trace_info=trace_info,
)
return {
"status": "finished",
"response": full_content,
"request_id": meta.get("request_id"),
"thread_id": thread_id,
"agent_state": agent_state,
"time_cost": asyncio.get_event_loop().time() - start_time,
}
except Exception as e:
logger.error(f"Error in agent_chat: {e}, {traceback.format_exc()}")
return {
"status": "error",
"error_type": "unexpected_error",
"error_message": str(e),
"request_id": meta.get("request_id"),
}
finally:
flush_langfuse()
async def stream_agent_chat(
*,
query: str,
agent_config_id: int,
thread_id: str | None,
meta: dict,
image_content: str | None,
current_user,
db,
) -> AsyncIterator[bytes]:
start_time = asyncio.get_event_loop().time()
def make_chunk(content=None, **kwargs):
return (
json.dumps(
{"request_id": meta.get("request_id"), "response": content, **kwargs}, ensure_ascii=False
).encode("utf-8")
+ b"\n"
)
if image_content:
human_message = HumanMessage(
content=[
{"type": "text", "text": query},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_content}"}},
]
)
message_type = "multimodal_image"
else:
human_message = HumanMessage(content=query)
message_type = "text"
init_msg = {"role": "user", "content": query, "type": "human"}
if image_content:
init_msg["message_type"] = "multimodal_image"
init_msg["image_content"] = image_content
else:
init_msg["message_type"] = "text"
yield make_chunk(status="init", meta=meta, msg=init_msg)
if conf.enable_content_guard and await content_guard.check(query):
yield make_chunk(
status="error", error_type="content_guard_blocked", error_message="输入内容包含敏感词", meta=meta
)
return
if not current_user.department_id:
yield make_chunk(status="error", error_type="invalid_config", error_message="当前用户未绑定部门", meta=meta)
return
meta = dict(meta or {})
if "request_id" not in meta or not meta.get("request_id"):
logger.warning("请求缺少 request_id已自动生成一个新的 request_id")
meta["request_id"] = str(uuid.uuid4())
user_id = str(current_user.id)
try:
config_item = await get_agent_config_by_id(db, current_user, agent_config_id)
except ValueError as e:
yield make_chunk(status="error", error_type="invalid_config", error_message=str(e), meta=meta)
return
agent_id = config_item.agent_id
meta.update(
{
"query": query,
"agent_id": agent_id,
"server_model_name": agent_id,
"thread_id": thread_id,
"user_id": current_user.id,
"has_image": bool(image_content),
}
)
try:
agent = agent_manager.get_agent(agent_id)
except Exception as e:
logger.error(f"Error getting agent {agent_id}: {e}, {traceback.format_exc()}")
yield make_chunk(
status="error",
error_type="agent_error",
error_message=f"智能体 {agent_id} 获取失败: {str(e)}",
meta=meta,
)
return
messages = [human_message]
agent_config = (config_item.config_json or {}).get("context", {})
if not thread_id:
thread_id = str(uuid.uuid4())
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
input_context = agent_config | {"user_id": user_id, "thread_id": thread_id}
langfuse_run = _build_langfuse_run_context(
current_user=current_user,
thread_id=thread_id,
agent_id=agent_id,
request_id=meta["request_id"],
operation="agent_chat_stream",
agent_config_id=agent_config_id,
message_type=message_type,
)
full_msg = None
accumulated_content: list[str] = []
trace_info: dict[str, Any] = {}
last_agent_state_signature = ""
try:
conv_repo = ConversationRepository(db)
agent_config_repo = AgentConfigRepository(db)
await _ensure_thread_bound_agent_config(
conv_repo=conv_repo,
agent_config_repo=agent_config_repo,
thread_id=thread_id,
user_id=user_id,
department_id=current_user.department_id,
agent_id=agent_id,
agent_config_id=agent_config_id,
)
try:
await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
role="user",
content=query,
message_type=message_type,
image_content=image_content,
extra_metadata={"raw_message": human_message.model_dump()},
)
except Exception as e:
logger.error(f"Error saving user message: {e}")
# 先构建 langgraph_config
langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
# LangGraph 会自动从 checkpointer 恢复 state包括 uploads
# 无需手动加载或传递
full_msg = None
accumulated_content = []
async for mode, payload in _stream_agent_events(
agent,
messages,
input_context=input_context,
callbacks=langfuse_run.callbacks,
metadata=langfuse_run.metadata,
tags=langfuse_run.tags,
):
if mode == "values":
agent_state = extract_agent_state(payload if isinstance(payload, dict) else {})
signature = _agent_state_signature(agent_state)
if signature and signature != last_agent_state_signature:
last_agent_state_signature = signature
yield make_chunk(status="agent_state", agent_state=agent_state, meta=meta)
continue
msg, metadata = payload
if isinstance(msg, AIMessageChunk):
accumulated_content.append(msg.content)
trace_info = get_trace_info(langfuse_run)
content_for_check = "".join(accumulated_content[-10:])
if conf.enable_content_guard and await content_guard.check_with_keywords(content_for_check):
full_msg = AIMessage(content="".join(accumulated_content))
await save_partial_message(
conv_repo,
thread_id,
full_msg,
"content_guard_blocked",
trace_info=trace_info,
)
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
yield make_chunk(status="interrupted", message="检测到敏感内容,已中断输出", meta=meta)
return
yield make_chunk(content=msg.content, msg=msg.model_dump(), metadata=metadata, status="loading")
else:
msg_dict = msg.model_dump()
trace_info = get_trace_info(langfuse_run)
yield make_chunk(msg=msg_dict, metadata=metadata, status="loading")
full_msg = _ensure_full_msg(full_msg, accumulated_content)
trace_info = get_trace_info(langfuse_run)
if conf.enable_content_guard and hasattr(full_msg, "content") and await content_guard.check(full_msg.content):
await save_partial_message(
conv_repo,
thread_id,
full_msg,
"content_guard_blocked",
trace_info=trace_info,
)
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
yield make_chunk(status="interrupted", message="检测到敏感内容,已中断输出", meta=meta)
return
async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_chunk, meta, thread_id):
yield chunk
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
try:
graph = await agent.get_graph()
state = await graph.aget_state(langgraph_config)
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
except Exception:
agent_state = {}
final_signature = _agent_state_signature(agent_state)
if final_signature and final_signature != last_agent_state_signature:
last_agent_state_signature = final_signature
yield make_chunk(status="agent_state", agent_state=agent_state, meta=meta)
# 先存储数据库,再返回 finished避免前端查询时数据未落库
await save_messages_from_langgraph_state(
agent_instance=agent,
thread_id=thread_id,
conv_repo=conv_repo,
config_dict=langgraph_config,
trace_info=trace_info,
)
yield make_chunk(status="finished", meta=meta)
except (asyncio.CancelledError, ConnectionError) as e:
logger.warning(f"Client disconnected, cancelling stream: {e}")
async def save_cleanup():
nonlocal full_msg
full_msg = _ensure_full_msg(full_msg, accumulated_content)
async with pg_manager.get_async_session_context() as new_db:
new_conv_repo = ConversationRepository(new_db)
await save_partial_message(
new_conv_repo,
thread_id,
full_msg=full_msg,
error_message="对话已中断" if not full_msg else None,
error_type="interrupted",
trace_info=trace_info,
)
cleanup_task = asyncio.create_task(save_cleanup())
try:
await asyncio.shield(cleanup_task)
except asyncio.CancelledError:
pass
except Exception as exc:
logger.error(f"Error during cleanup save: {exc}")
yield make_chunk(status="interrupted", message="对话已中断", meta=meta)
except Exception as e:
logger.error(f"Error streaming messages: {e}, {traceback.format_exc()}")
error_msg = f"Error streaming messages: {e}"
error_type = "unexpected_error"
full_msg = _ensure_full_msg(full_msg, accumulated_content)
async with pg_manager.get_async_session_context() as new_db:
new_conv_repo = ConversationRepository(new_db)
await save_partial_message(
new_conv_repo,
thread_id,
full_msg=full_msg,
error_message=error_msg,
error_type=error_type,
trace_info=trace_info,
)
yield make_chunk(status="error", error_type=error_type, error_message=error_msg, meta=meta)
finally:
flush_langfuse()
async def stream_agent_resume(
*,
agent_id: str,
thread_id: str,
resume_input: Any,
meta: dict,
config: dict,
current_user,
db,
) -> AsyncIterator[bytes]:
start_time = asyncio.get_event_loop().time()
def make_resume_chunk(content=None, **kwargs):
return (
json.dumps(
{"request_id": meta.get("request_id"), "response": content, **kwargs}, ensure_ascii=False
).encode("utf-8")
+ b"\n"
)
try:
agent = agent_manager.get_agent(agent_id)
except Exception as e:
logger.error(f"Error getting agent {agent_id}: {e}, {traceback.format_exc()}")
yield (
f'{{"request_id": "{meta.get("request_id")}", "message": '
f'"Error getting agent {agent_id}: {e}", "status": "error"}}\n'
)
return
init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"}
yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
resume_command = Command(resume=resume_input)
user_id = str(current_user.id)
agent_config_id = (config or {}).get("agent_config_id")
try:
agent_config = await _resolve_agent_config(db, agent_id, current_user, agent_config_id)
except ValueError as e:
yield make_resume_chunk(status="error", error_type="invalid_config", error_message=str(e), meta=meta)
return
context = agent.context_schema()
context.update(agent_config or {})
context.update({"user_id": user_id, "thread_id": thread_id})
graph = await agent.get_graph(context=context)
langfuse_run = _build_langfuse_run_context(
current_user=current_user,
thread_id=thread_id,
agent_id=agent_id,
request_id=meta.get("request_id") or str(uuid.uuid4()),
operation="agent_chat_resume",
agent_config_id=agent_config_id,
message_type="resume",
)
trace_info: dict[str, Any] = {}
last_agent_state_signature = ""
stream_source = graph.astream(
resume_command,
context=context,
config={
"configurable": {"thread_id": thread_id, "user_id": user_id},
"callbacks": langfuse_run.callbacks,
"metadata": langfuse_run.metadata,
"tags": langfuse_run.tags,
},
stream_mode=["messages", "values"],
)
try:
async for mode, payload in stream_source:
if mode == "values":
agent_state = extract_agent_state(payload if isinstance(payload, dict) else {})
signature = _agent_state_signature(agent_state)
if signature and signature != last_agent_state_signature:
last_agent_state_signature = signature
yield make_resume_chunk(status="agent_state", agent_state=agent_state, meta=meta)
continue
msg, metadata = payload
trace_info = get_trace_info(langfuse_run)
msg_dict = msg.model_dump()
if "id" not in msg_dict:
msg_dict["id"] = str(uuid.uuid4())
yield make_resume_chunk(
content=getattr(msg, "content", ""), msg=msg_dict, metadata=metadata, status="loading"
)
langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": str(current_user.id)}}
async for chunk in check_and_handle_interrupts(agent, langgraph_config, make_resume_chunk, meta, thread_id):
yield chunk
meta["time_cost"] = asyncio.get_event_loop().time() - start_time
try:
state = await graph.aget_state(langgraph_config)
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
except Exception:
agent_state = {}
final_signature = _agent_state_signature(agent_state)
if final_signature and final_signature != last_agent_state_signature:
yield make_resume_chunk(status="agent_state", agent_state=agent_state, meta=meta)
# 先存储数据库,再返回 finished避免前端查询时数据未落库
conv_repo = ConversationRepository(db)
await save_messages_from_langgraph_state(
agent_instance=agent,
thread_id=thread_id,
conv_repo=conv_repo,
config_dict=langgraph_config,
trace_info=trace_info,
)
yield make_resume_chunk(status="finished", meta=meta)
except (asyncio.CancelledError, ConnectionError) as e:
logger.warning(f"Client disconnected during resume: {e}")
async with pg_manager.get_async_session_context() as new_db:
new_conv_repo = ConversationRepository(new_db)
await save_partial_message(
new_conv_repo,
thread_id,
error_message="对话恢复已中断",
error_type="resume_interrupted",
trace_info=trace_info,
)
yield make_resume_chunk(status="interrupted", message="对话恢复已中断", meta=meta)
except Exception as e:
logger.error(f"Error during resume: {e}, {traceback.format_exc()}")
async with pg_manager.get_async_session_context() as new_db:
new_conv_repo = ConversationRepository(new_db)
await save_partial_message(
new_conv_repo,
thread_id,
error_message=f"Error during resume: {e}",
error_type="resume_error",
trace_info=trace_info,
)
yield make_resume_chunk(message=f"Error during resume: {e}", status="error")
finally:
flush_langfuse()
async def get_agent_state_view(
*,
thread_id: str,
current_user_id: str,
db,
) -> dict:
conv_repo = ConversationRepository(db)
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
if not conversation or conversation.user_id != str(current_user_id) or conversation.status == "deleted":
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="对话线程不存在")
agent = agent_manager.get_agent(conversation.agent_id)
graph = await agent.get_graph()
langgraph_config = {"configurable": {"user_id": str(current_user_id), "thread_id": thread_id}}
state = await graph.aget_state(langgraph_config)
agent_state = extract_agent_state(getattr(state, "values", {})) if state else {}
return {"agent_state": agent_state}