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
+41 -4
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api } from "../api/client";
import { api, sendEventStream } from "../api/client";
import type { DialogEvent, Reply } from "../api/types";
import { useSession } from "../session/SessionContext";
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
@@ -17,8 +17,15 @@ let seq = 0;
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
/** The agent at work: its steps and the answer as it is being written (cleared when the final reply lands). */
export interface LiveReply {
status: string[];
text: string;
}
interface DialogApi extends DialogSnapshot {
busy: boolean;
live: LiveReply | null;
error: boolean;
started: boolean;
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
@@ -36,6 +43,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
const [owner, setOwner] = useState(token);
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
const [busy, setBusy] = useState(false);
const [live, setLive] = useState<LiveReply | null>(null);
const [error, setError] = useState(false);
const tokenRef = useRef(token);
tokenRef.current = token;
@@ -58,7 +66,35 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setBusy(true);
setError(false);
try {
const replies = await api.sendEvent(t, event);
let replies: Reply[];
if (event.type === "text") {
// Free text may go to the agent: stream its steps and the answer so the viewer sees it working.
const said = echo;
if (said) {
// Show the patient's message right away, while the agent is still working.
setSnap((prev) => ({
...prev,
entries: [...prev.entries, { id: newId(), from: "patient" as const, text: said }],
}));
echo = undefined;
}
setLive({ status: [], text: "" });
try {
replies = await sendEventStream(t, event, (e) =>
setLive((prev) => {
const cur = prev ?? { status: [], text: "" };
if (e.kind === "status") return { ...cur, status: [...cur.status, e.text] };
if (e.kind === "delta") return { ...cur, text: cur.text + e.text };
if (e.kind === "reset") return { ...cur, text: "" };
return cur;
}),
);
} catch {
replies = await api.sendEvent(t, event); // proxies without SSE support: plain request
}
} else {
replies = await api.sendEvent(t, event);
}
setSnap((prev) => ({
entries: [
...prev.entries,
@@ -73,6 +109,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setError(true);
return false;
} finally {
setLive(null);
setBusy(false);
}
},
@@ -102,8 +139,8 @@ export function DialogProvider({ children }: { children: ReactNode }) {
}, [token]);
const value = useMemo<DialogApi>(
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, error, send, start, markRead],
() => ({ ...snap, busy, live, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, live, error, send, start, markRead],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}