feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery

This commit is contained in:
2026-08-21 18:45:27 -04:00
parent 2364e1ee7d
commit e5810b0bcf
8 changed files with 642 additions and 40 deletions
+30 -13
View File
@@ -34,7 +34,7 @@ from app.rag.llm import EmbeddingError, LLMClient, LLMError
from app.rag.prompts import build_deflect_prompt, build_high_prompt
from app.rag.retriever import RetrievedChunk, retrieve, select_documents, weak_hit_titles
from app.rag.suggestions import derive_suggestions
from app.schemas import ChatDoneEvent, ChatRequest, SourceRef
from app.schemas import ChatDoneEvent, ChatErrorEvent, ChatRequest, SourceRef
logger = logging.getLogger("app.chat")
router = APIRouter(tags=["chat"])
@@ -122,12 +122,19 @@ async def chat(
try:
question_vec = await llm.embed_one(request.message)
except EmbeddingError as e:
logger.error("chat: embedding failed question=%r — %s", request.message, e)
embed_ms = int((time.monotonic() - t0) * 1000)
total_ms = int((time.monotonic() - started) * 1000)
logger.error(
"chat: question=%r embed_ms=%d total_ms=%d — embedding failed: %s",
request.message,
embed_ms,
total_ms,
e,
)
yield sse_event(
{
"type": "error",
"detail": "I couldn't reach the embedding model — please try again.",
}
ChatErrorEvent(
detail="I couldn't reach the embedding model — please try again."
).model_dump()
)
return
embed_ms = int((time.monotonic() - t0) * 1000)
@@ -139,12 +146,15 @@ async def chat(
chunks = retrieve(db, question_vec)
plan = plan_turn(chunks, settings)
except Exception: # noqa: BLE001 — DB failure mid-turn
logger.exception("chat: retrieval failed question=%r", request.message)
logger.exception(
"chat: retrieval failed question=%r total_ms=%d",
request.message,
int((time.monotonic() - started) * 1000),
)
yield sse_event(
{
"type": "error",
"detail": "The knowledge base went offline mid-question — is Postgres up?",
}
ChatErrorEvent(
detail="The knowledge base went offline mid-question — is Postgres up?"
).model_dump()
)
return
source_paths = [f"{d.source}/{d.path}" for d in plan.docs]
@@ -158,9 +168,16 @@ async def chat(
async for piece in llm.chat_stream(messages):
yield sse_event({"type": "delta", "text": piece})
except LLMError as e:
logger.error("chat: LLM stream failed question=%r — %s", request.message, e)
logger.error(
"chat: LLM stream failed question=%r total_ms=%d — %s",
request.message,
int((time.monotonic() - started) * 1000),
e,
)
yield sse_event(
{"type": "error", "detail": "The chat model dropped the connection — try again?"}
ChatErrorEvent(
detail="The chat model dropped the connection — try again?"
).model_dump()
)
return
+12
View File
@@ -34,6 +34,18 @@ class ChatDoneEvent(BaseModel):
suggestions: list[str] = []
class ChatErrorEvent(BaseModel):
"""SSE error event: a turn that cannot complete (PLAN §4).
The client's loading-feedback state machine (phase 06) keys off this
exact shape — ``{type: "error", detail: str}`` — to flip to the error
state and re-enable the send button.
"""
type: str = "error"
detail: str
class DocSummary(BaseModel):
"""One indexed document as shown on the Sources page / API."""