Files
socaichat/backend/app/api/chat.py
T
gongzhiyongandClaude Opus 4.6 3759ecd1a6 feat(backend): Phase 3 - Jina search + Redis cache
- Add web_search tool with Jina Search/Reader/Rerank
- Flash mode: top 3, 8s timeout, no Reader/Rerank
- Pro mode: top 10, 20s timeout, concurrent Reader + Rerank top 5
- Add Redis async cache (TTL=300s) for search results
- Register "search" in ALL_TOOLS mapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:39:43 +08:00

141 lines
4.6 KiB
Python

"""SSE streaming chat endpoint."""
from __future__ import annotations
import json
import uuid
from collections.abc import AsyncIterator
from langchain_core.messages import HumanMessage
from litestar import post
from litestar.response import Stream
from app.graph.builder import get_chat_graph
from app.schemas import ChatRequest
from app.store.postgres import Conversation, Message, async_session_factory
from app.tools import resolve_tools
from app.tools.search import set_search_model
async def _ensure_conversation(conversation_id: str, first_message: str) -> None:
"""Create conversation and persist the user message."""
async with async_session_factory() as session:
existing = await session.get(Conversation, conversation_id)
if existing is None:
# Use first ~50 chars of message as title
title = first_message[:50].strip() or "New conversation"
conv = Conversation(id=conversation_id, title=title)
session.add(conv)
# Persist user message
msg = Message(
conversation_id=conversation_id,
role="human",
content=first_message,
)
session.add(msg)
await session.commit()
async def _persist_ai_message(conversation_id: str, content: str) -> None:
"""Persist the AI response message."""
async with async_session_factory() as session:
msg = Message(
conversation_id=conversation_id,
role="ai",
content=content,
)
session.add(msg)
await session.commit()
async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
"""Stream LLM response tokens via SSE."""
# Ensure conversation exists and persist user message
await _ensure_conversation(request.conversation_id, request.message)
# Resolve tools from frontend tool keys
active_tools = resolve_tools(request.tools)
# Set search strategy model so the web_search tool knows depth
set_search_model(request.model)
graph = await get_chat_graph(model=request.model, tools=active_tools)
config = {
"configurable": {"thread_id": request.conversation_id},
}
# When using ReAct agent (with tools), input is just messages.
# When using plain graph (no tools), input includes model key.
if active_tools:
input_data = {"messages": [HumanMessage(content=request.message)]}
else:
input_data = {
"messages": [HumanMessage(content=request.message)],
"model": request.model,
}
full_content: list[str] = []
async for event in graph.astream_events(
input_data,
config=config,
version="v2",
):
kind = event.get("event", "")
if kind == "on_chat_model_stream":
chunk = event.get("data", {}).get("chunk")
if chunk and hasattr(chunk, "content") and chunk.content:
# Only stream text content, skip tool call chunks
if isinstance(chunk.content, str):
full_content.append(chunk.content)
sse_data = json.dumps(
{"type": "token", "content": chunk.content},
ensure_ascii=False,
)
yield f"data: {sse_data}\n\n".encode("utf-8")
elif kind == "on_tool_start":
# Notify frontend that a tool is being called
tool_name = event.get("name", "unknown")
sse_data = json.dumps(
{"type": "tool_start", "tool": tool_name},
ensure_ascii=False,
)
yield f"data: {sse_data}\n\n".encode("utf-8")
elif kind == "on_tool_end":
tool_name = event.get("name", "unknown")
sse_data = json.dumps(
{"type": "tool_end", "tool": tool_name},
ensure_ascii=False,
)
yield f"data: {sse_data}\n\n".encode("utf-8")
# Persist AI response
ai_content = "".join(full_content)
if ai_content:
await _persist_ai_message(request.conversation_id, ai_content)
# Send done signal
done_data = json.dumps({"type": "done"})
yield f"data: {done_data}\n\n".encode("utf-8")
@post("/api/chat/stream")
async def stream_chat(data: ChatRequest) -> Stream:
"""POST /api/chat/stream - SSE streaming chat endpoint."""
if not data.conversation_id:
data.conversation_id = str(uuid.uuid4())
return Stream(
_stream_response(data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)