feat: live agent answers (SSE streaming) + «Чат» tab on phones
CI / ruff · pytest (pull_request) Successful in 2m43s

- POST /dialog/{token}/events/stream: Server-Sent Events with the agent's steps («Смотрю вашу карту ЕМИАС…»,
  «Ищу в клинических рекомендациях…»), the answer as the model writes it (OpenRouter stream=true), then the
  final replies (same as /events, incl. the forbidden-output filter and sources). X-Accel-Buffering: no.
- Web: free-text messages use the stream — a live bubble with steps, typing dots and a cursor; falls back to
  the plain request if the stream breaks.
- Phones: the bottom tab bar gets «Чат» (it had no way back to the chat).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 12:52:19 +03:00
co-authored by Claude Opus 5.5
parent d662a69c7e
commit d1f92407f9
11 changed files with 381 additions and 6 deletions
+75
View File
@@ -16,6 +16,7 @@ import httpx
from pydantic import BaseModel, Field
from app.config import Settings
from app.llm.agent import stream
log = logging.getLogger(__name__)
@@ -53,6 +54,8 @@ class OpenRouterProvider:
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
if stream.active():
return await self._chat_stream(messages, tools)
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
@@ -174,6 +177,78 @@ class GigaChatToolProvider:
return AssistantTurn(content=msg.content or None, tool_calls=calls)
# ------------------------------------------------------------------------------------------------ streaming
async def _iter_sse(resp: httpx.Response):
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue # comments («: OPENROUTER PROCESSING») and blank lines
data = line[5:].strip()
if data == "[DONE]":
return
try:
yield json.loads(data)
except json.JSONDecodeError:
continue
async def _openrouter_stream(self: "OpenRouterProvider", messages: list[dict[str, Any]], tools: list[dict[str, Any]]):
"""Same request with `stream: true`: content deltas go to the live channel, tool calls are assembled."""
body: dict[str, Any] = {
"model": self._model,
"messages": messages,
"tools": tools,
"temperature": 0.1,
"max_tokens": 900,
"stream": True,
}
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required"
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
content: list[str] = []
calls: dict[int, dict[str, Any]] = {}
try:
client = self._client or httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy)
try:
async with client.stream("POST", self._url, json=body, headers=headers, timeout=self._timeout) as resp:
if resp.status_code != 200:
await resp.aread()
raise ProviderError(f"http {resp.status_code}")
async for chunk in _iter_sse(resp):
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
if delta.get("content"):
content.append(delta["content"])
stream.emit("delta", delta["content"])
for tc in delta.get("tool_calls") or []:
slot = calls.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
slot["id"] = tc.get("id") or slot["id"]
fn = tc.get("function") or {}
slot["name"] += fn.get("name") or ""
slot["args"] += fn.get("arguments") or ""
finally:
if self._client is None:
await client.aclose()
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
out = []
for i in sorted(calls):
c = calls[i]
try:
args = json.loads(c["args"] or "{}")
except json.JSONDecodeError:
args = {}
out.append(ToolCall(id=c["id"] or c["name"] or f"call{i}", name=c["name"], arguments=args))
if out and content:
stream.emit("reset", None) # a preface before tool calls is not the answer
log.info("openrouter stream ok calls=%d chars=%d", len(out), sum(map(len, content)))
return AssistantTurn(content="".join(content) or None, tool_calls=out)
OpenRouterProvider._chat_stream = _openrouter_stream # type: ignore[attr-defined]
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(