feat: persistent chat message storage + Android pull-to-load history
Backend:
- Add ChatMessage model + Alembic migration 024
- Add on_message callback to AgentRuntime for persisting messages during SSE streaming
- Plumb session_id from ChatRequest to AgentContext in all 4 chat endpoints
- Add GET /agent-chat/{id}/sessions and /sessions/{sid}/messages with cursor pagination
Android:
- Add DTOs/ApiService/MessageDao for server-side chat history
- ChatRepository: fetchOlderMessages (API + Room cache), offline fallback
- ChatViewModel: loadMoreHistory with isLoadingMore/hasMoreMessages state
- ChatScreen: scroll-to-top detection + top loading indicator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,11 @@ from app.agent_runtime import (
|
||||
AgentBudgetConfig,
|
||||
AgentMemoryConfig,
|
||||
AgentStep,
|
||||
AgentContext,
|
||||
AgentOrchestrator,
|
||||
OrchestratorAgentConfig,
|
||||
)
|
||||
from app.models.chat_message import ChatMessage
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,6 +70,32 @@ def _make_llm_logger(
|
||||
return _log
|
||||
|
||||
|
||||
def _make_message_saver(
|
||||
db: Session,
|
||||
agent_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
):
|
||||
"""创建消息持久化回调,将每条消息写入 chat_messages 表。"""
|
||||
def _save(msg: dict):
|
||||
try:
|
||||
record = ChatMessage(
|
||||
session_id=msg.get("session_id"),
|
||||
agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
tool_name=msg.get("tool_name"),
|
||||
tool_input=msg.get("tool_input"),
|
||||
tool_output=msg.get("tool_output"),
|
||||
iteration=msg.get("iteration", 0),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("写入 ChatMessage 失败: %s", e)
|
||||
return _save
|
||||
|
||||
|
||||
async def _sse_stream(gen: AsyncGenerator[dict, None]) -> AsyncGenerator[str, None]:
|
||||
"""将 run_stream 生成的 dict 事件格式化为 SSE 文本流。"""
|
||||
async for event in gen:
|
||||
@@ -99,6 +127,43 @@ class ChatResponse(BaseModel):
|
||||
token_usage: Optional[Dict[str, Any]] = Field(default=None, description="Token 预算摘要")
|
||||
|
||||
|
||||
class MessageItem(BaseModel):
|
||||
"""消息历史条目"""
|
||||
id: str
|
||||
session_id: str
|
||||
agent_id: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
role: str
|
||||
content: Optional[str] = None
|
||||
tool_name: Optional[str] = None
|
||||
tool_input: Optional[str] = None
|
||||
tool_output: Optional[str] = None
|
||||
iteration: int = 0
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MessageHistoryResponse(BaseModel):
|
||||
"""消息历史分页响应"""
|
||||
messages: List[MessageItem]
|
||||
has_more: bool
|
||||
total: int
|
||||
|
||||
|
||||
class SessionItem(BaseModel):
|
||||
"""会话列表条目"""
|
||||
session_id: str
|
||||
title: Optional[str] = None
|
||||
last_message: Optional[str] = None
|
||||
message_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class SessionListResponse(BaseModel):
|
||||
"""会话列表响应"""
|
||||
sessions: List[SessionItem]
|
||||
|
||||
|
||||
class OrchestrateAgentItem(BaseModel):
|
||||
"""编排中单个 Agent 的定义"""
|
||||
id: str
|
||||
@@ -270,7 +335,9 @@ async def chat_bare(
|
||||
if req.system_prompt_override:
|
||||
config.system_prompt = req.system_prompt_override
|
||||
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
|
||||
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call, streamlined=req.streamlined)
|
||||
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
result = await runtime.run(req.message)
|
||||
|
||||
# 流式美化:为 steps 生成累计摘要
|
||||
@@ -333,7 +400,9 @@ async def chat_bare_stream(
|
||||
if req.system_prompt_override:
|
||||
config.system_prompt = req.system_prompt_override
|
||||
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
|
||||
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call, streamlined=req.streamlined)
|
||||
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
return StreamingResponse(
|
||||
_sse_stream(runtime.run_stream(req.message)),
|
||||
media_type="text/event-stream",
|
||||
@@ -417,7 +486,9 @@ async def chat_with_agent(
|
||||
config.system_prompt = req.system_prompt_override
|
||||
|
||||
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
|
||||
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call, streamlined=req.streamlined)
|
||||
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
result = await runtime.run(req.message)
|
||||
|
||||
# 流式美化:为 steps 生成累计摘要
|
||||
@@ -511,7 +582,9 @@ async def chat_with_agent_stream(
|
||||
config.system_prompt = req.system_prompt_override
|
||||
|
||||
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
|
||||
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call, streamlined=req.streamlined)
|
||||
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
|
||||
context = AgentContext(session_id=req.session_id)
|
||||
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
|
||||
return StreamingResponse(
|
||||
_sse_stream(runtime.run_stream(req.message)),
|
||||
media_type="text/event-stream",
|
||||
@@ -523,6 +596,127 @@ async def chat_with_agent_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{agent_id}/sessions", response_model=SessionListResponse)
|
||||
async def list_agent_sessions(
|
||||
agent_id: str,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取 Agent 的会话列表,按最近活跃时间排序。"""
|
||||
from sqlalchemy import func as sa_func, desc
|
||||
|
||||
# 验证 agent 存在或有权限
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
ChatMessage.session_id,
|
||||
sa_func.min(ChatMessage.created_at).label("created_at"),
|
||||
sa_func.max(ChatMessage.created_at).label("updated_at"),
|
||||
sa_func.count(ChatMessage.id).label("message_count"),
|
||||
)
|
||||
.filter(ChatMessage.agent_id == agent_id)
|
||||
.group_by(ChatMessage.session_id)
|
||||
.order_by(desc("updated_at"))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
sessions = []
|
||||
for row in rows:
|
||||
# 取第一条 user 消息作为标题
|
||||
first_user_msg = (
|
||||
db.query(ChatMessage)
|
||||
.filter(
|
||||
ChatMessage.session_id == row.session_id,
|
||||
ChatMessage.role == "user",
|
||||
)
|
||||
.order_by(ChatMessage.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
# 取最后一条消息作为预览
|
||||
last_msg = (
|
||||
db.query(ChatMessage)
|
||||
.filter(ChatMessage.session_id == row.session_id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
sessions.append(SessionItem(
|
||||
session_id=row.session_id,
|
||||
title=first_user_msg.content[:100] if first_user_msg and first_user_msg.content else None,
|
||||
last_message=last_msg.content[:200] if last_msg and last_msg.content else None,
|
||||
message_count=row.message_count,
|
||||
created_at=row.created_at.isoformat() if row.created_at else None,
|
||||
updated_at=row.updated_at.isoformat() if row.updated_at else None,
|
||||
))
|
||||
|
||||
return SessionListResponse(sessions=sessions)
|
||||
|
||||
|
||||
@router.get("/{agent_id}/sessions/{session_id}/messages", response_model=MessageHistoryResponse)
|
||||
async def get_session_messages(
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
before_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取会话的消息历史(分页),从旧到新排序。"""
|
||||
# limit 限制
|
||||
limit = min(max(limit, 1), 200)
|
||||
|
||||
base_q = db.query(ChatMessage).filter(
|
||||
ChatMessage.agent_id == agent_id,
|
||||
ChatMessage.session_id == session_id,
|
||||
)
|
||||
|
||||
# 游标分页:before_id 之前的老消息
|
||||
if before_id:
|
||||
cursor_msg = db.query(ChatMessage).filter(ChatMessage.id == before_id).first()
|
||||
if cursor_msg and cursor_msg.created_at:
|
||||
base_q = base_q.filter(ChatMessage.created_at < cursor_msg.created_at)
|
||||
|
||||
# 取 N+1 条,判断 has_more(按时间降序取最新 N 条,再反转)
|
||||
batch = (
|
||||
base_q
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
|
||||
has_more = len(batch) > limit
|
||||
if has_more:
|
||||
batch = batch[:limit]
|
||||
|
||||
# 反转为从旧到新
|
||||
batch.reverse()
|
||||
|
||||
total = base_q.count()
|
||||
|
||||
messages = [
|
||||
MessageItem(
|
||||
id=m.id,
|
||||
session_id=m.session_id,
|
||||
agent_id=m.agent_id,
|
||||
user_id=m.user_id,
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
tool_name=m.tool_name,
|
||||
tool_input=m.tool_input,
|
||||
tool_output=m.tool_output,
|
||||
iteration=m.iteration or 0,
|
||||
created_at=m.created_at.isoformat() if m.created_at else None,
|
||||
)
|
||||
for m in batch
|
||||
]
|
||||
|
||||
return MessageHistoryResponse(messages=messages, has_more=has_more, total=total)
|
||||
|
||||
|
||||
def _find_agent_node_config(nodes: list) -> Dict[str, Any]:
|
||||
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
|
||||
if not nodes:
|
||||
|
||||
Reference in New Issue
Block a user