feat: live agent answers (SSE streaming) + «Чат» tab on phones
CI / ruff · pytest (pull_request) Successful in 2m43s
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:
@@ -59,3 +59,53 @@ export const api = {
|
||||
outbox: async (token: string) =>
|
||||
(await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies,
|
||||
};
|
||||
|
||||
export type StreamEvent =
|
||||
| { kind: "status"; text: string }
|
||||
| { kind: "delta"; text: string }
|
||||
| { kind: "reset" }
|
||||
| { kind: "done"; replies: Reply[] };
|
||||
|
||||
/** POST /dialog/{token}/events/stream (Server-Sent Events): agent steps and the answer as it is written,
|
||||
* then the final replies. Resolves with the final replies; throws if the stream breaks (caller falls back). */
|
||||
export async function sendEventStream(
|
||||
token: string,
|
||||
event: DialogEvent,
|
||||
onEvent: (e: StreamEvent) => void,
|
||||
): Promise<Reply[]> {
|
||||
const res = await fetch(`${BASE}/dialog/${token}/events/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new ApiError(res.status, res.statusText);
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let cut: number;
|
||||
while ((cut = buf.indexOf("\n\n")) >= 0) {
|
||||
const block = buf.slice(0, cut);
|
||||
buf = buf.slice(cut + 2);
|
||||
let kind = "message";
|
||||
let data = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) kind = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
const payload = JSON.parse(data);
|
||||
if (kind === "status") onEvent({ kind, text: String(payload) });
|
||||
else if (kind === "delta") onEvent({ kind, text: String(payload) });
|
||||
else if (kind === "reset") onEvent({ kind: "reset" });
|
||||
else if (kind === "done") {
|
||||
onEvent({ kind: "done", replies: payload as Reply[] });
|
||||
return payload as Reply[];
|
||||
} else if (kind === "error") throw new ApiError(500, "stream error");
|
||||
}
|
||||
}
|
||||
throw new ApiError(0, "stream closed");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Icon, LogoMark, type IconName } from "./Icon";
|
||||
|
||||
const TABS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/today", label: ru.nav.today, icon: "home" },
|
||||
{ to: "/messages", label: ru.nav.chat, icon: "message" },
|
||||
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
|
||||
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
|
||||
{ to: "/profile", label: ru.nav.profile, icon: "user" },
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ru = {
|
||||
doctor: "Врач",
|
||||
profile: "Профиль",
|
||||
messages: "Сообщения",
|
||||
chat: "Чат",
|
||||
signIn: "Вход",
|
||||
help: "Помощь",
|
||||
},
|
||||
@@ -251,6 +252,7 @@ export const ru = {
|
||||
noticeTitle: "Опрос приостановлен",
|
||||
},
|
||||
messages: {
|
||||
typing: "Помощник печатает",
|
||||
title: "Сообщения",
|
||||
inputLabel: "Сообщение",
|
||||
inputPlaceholder: "Напишите сообщение",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Messages() {
|
||||
useEffect(() => {
|
||||
dialog.markRead();
|
||||
endRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [dialog.entries.length]);
|
||||
}, [dialog.entries.length, dialog.live?.text, dialog.live?.status.length]);
|
||||
|
||||
const send = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
@@ -56,6 +56,32 @@ export function Messages() {
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{dialog.live && (
|
||||
<li className="bubble bubble--bot bubble--live" aria-busy="true">
|
||||
<span className="bubble__avatar">
|
||||
<Icon name="smile" size={20} />
|
||||
</span>
|
||||
<div className="live">
|
||||
{dialog.live.status.map((st, i) => (
|
||||
<p key={i} className={`live__step${i === dialog.live!.status.length - 1 && !dialog.live!.text ? " is-active" : ""}`}>
|
||||
{st}
|
||||
</p>
|
||||
))}
|
||||
{dialog.live.text ? (
|
||||
<p className="live__text">
|
||||
{dialog.live.text}
|
||||
<span className="live__cursor" aria-hidden="true" />
|
||||
</p>
|
||||
) : (
|
||||
<p className="live__dots" aria-label={t.typing}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
{quick.length > 0 && (
|
||||
<div className="quickreplies">
|
||||
|
||||
@@ -103,7 +103,7 @@ button:disabled {
|
||||
inset: auto 0 0 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
height: calc(var(--nav-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: var(--c-surface);
|
||||
@@ -2937,3 +2937,19 @@ button.row-card:hover:not(:disabled) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Live agent reply: steps + the answer as it is written (SSE) */
|
||||
.bubble--live .live { display: grid; gap: 4px; }
|
||||
.live__step { margin: 0; font-size: 13px; color: var(--muted, #6b7280); }
|
||||
.live__step.is-active::after { content: ""; display: inline-block; width: 1em; animation: live-ellipsis 1.2s steps(4) infinite; overflow: hidden; vertical-align: bottom; }
|
||||
.live__text { margin: 2px 0 0; white-space: pre-wrap; }
|
||||
.live__cursor { display: inline-block; width: 7px; height: 1em; margin-left: 2px; background: currentColor; vertical-align: text-bottom; animation: live-blink 1s steps(2) infinite; }
|
||||
.live__dots { display: inline-flex; gap: 4px; margin: 4px 0 0; }
|
||||
.live__dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--muted, #9ca3af); animation: live-dot 1.2s infinite ease-in-out; }
|
||||
.live__dots span:nth-child(2) { animation-delay: .15s; }
|
||||
.live__dots span:nth-child(3) { animation-delay: .3s; }
|
||||
@keyframes live-blink { 50% { opacity: 0; } }
|
||||
@keyframes live-dot { 0%, 80%, 100% { opacity: .3; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
||||
@keyframes live-ellipsis { from { width: 0; } to { width: 1em; } }
|
||||
.tabbar__item span { font-size: 11px; }
|
||||
|
||||
Reference in New Issue
Block a user