feat: persistent chat message storage + Android pull-to-load history
Some checks failed
CI/CD Pipeline / Backend — Lint & Test (push) Has been cancelled
CI/CD Pipeline / Frontend — Lint & Build (push) Has been cancelled
CI/CD Pipeline / Docker — Build Check (push) Has been cancelled

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:
2026-06-30 00:07:26 +08:00
parent 569e3ab7df
commit a06082480a
12 changed files with 705 additions and 18 deletions

View File

@@ -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: