Merge pull request #363 from ggozad/feat/chat-tui-markdown-streaming

Support streaming for markdown
This commit is contained in:
Yiorgis Gozadinos 2026-05-12 17:16:22 +03:00 committed by GitHub
commit 45dfd4b382
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 46 additions and 12 deletions

View file

@ -9,6 +9,10 @@
- **`[s3]` optional extra** (`obstore>=0.9`). Required for `s3://` sources and the S3 watcher. Uses obstore — the Python binding to the same Rust `object_store` crate that LanceDB uses internally — so `monitor.s3[*].storage_options` accepts the same dict shape as `lancedb.storage_options`. Empty/missing options fall back to the AWS default credential chain.
- **`scripts/run-integration-tests.sh`** — wraps `docker compose up --wait`, `pytest -m integration`, and tear-down so the SeaweedFS-backed integration suite is a one-liner.
### Changed
- **Chat TUI streams markdown incrementally.** Assistant messages now use Textual's `MarkdownStream` (`Markdown.get_stream`) and write per-token deltas instead of re-parsing the entire accumulated message on every token. Removes the O(n²) re-parse that visibly stuttered long responses. Bumps `textual` floor to `>=8.2.4` so `Markdown.get_stream` is reachable via the public API.
### Fixed
- **Conversion options now apply to non-PDF formats.** `DoclingLocalConverter` previously wired its `PdfPipelineOptions` only to `InputFormat.PDF`, so user settings (OCR knobs, `picture_description.enabled`, `images_scale`, etc.) silently no-op'd for HTML, Markdown, DOCX, PPTX, and IMAGE inputs. The converter now shares a single `PdfPipelineOptions` instance across PDF, IMAGE, HTML, MD, DOCX, and PPTX `FormatOption`s. SimplePipeline-backed formats ignore the PDF-specific fields; `ConvertPipelineOptions`-level enrichments (picture description / classification / chart extraction) now run uniformly. HTML and Markdown additionally receive `HTMLBackendOptions` / `MarkdownBackendOptions` gated on `fetch_remote_images`.

View file

@ -233,9 +233,11 @@ class ChatApp(App):
assert isinstance(event, TextMessageContentEvent)
accumulated_text += event.delta
if message:
message.update_content(accumulated_text)
await message.append_delta(event.delta)
chat_history.scroll_end(animate=False)
elif event.type == EventType.TEXT_MESSAGE_END:
if message:
await message.finish_stream()
self._messages.append(
AssistantMessage(
id=str(uuid.uuid4()),
@ -308,9 +310,13 @@ class ChatApp(App):
except asyncio.CancelledError:
chat_history.hide_thinking()
if message:
await message.finish_stream()
await chat_history.add_message("assistant", "*Cancelled*")
except Exception as e:
chat_history.hide_thinking()
if message:
await message.finish_stream()
await chat_history.add_message("assistant", f"Error: {e}")
finally:
self._is_processing = False

View file

@ -1,8 +1,10 @@
from typing import TYPE_CHECKING, Any
from textual.containers import Horizontal, VerticalScroll
from textual.css.query import NoMatches
from textual.message import Message
from textual.widgets import Collapsible, LoadingIndicator, Markdown, Static
from textual.widgets.markdown import MarkdownStream
from haiku.rag.agents.research.models import Citation
@ -17,18 +19,36 @@ class ChatMessage(Static):
def __init__(self, role: str, content: str = "", **kwargs) -> None:
super().__init__(**kwargs)
self.role = role
self.content = content
self.body_text: str = content
self._stream: MarkdownStream | None = None
def compose(self) -> "ComposeResult":
prefix = "**You:**" if self.role == "user" else "**Assistant:**"
yield Markdown(f"{prefix}\n\n{self.content}", id="message-content")
prefix = "You:" if self.role == "user" else "Assistant:"
yield Static(prefix, classes="message-prefix")
yield Markdown(self.body_text, classes="message-body")
def update_content(self, content: str) -> None:
"""Update the message content (for streaming)."""
self.content = content
prefix = "**You:**" if self.role == "user" else "**Assistant:**"
markdown = self.query_one("#message-content", Markdown)
markdown.update(f"{prefix}\n\n{content}")
async def append_delta(self, delta: str) -> None:
if not delta:
return
if self._stream is None:
try:
body = self.query_one(".message-body", Markdown)
except NoMatches:
return
self._stream = Markdown.get_stream(body)
self.body_text += delta
await self._stream.write(delta)
async def finish_stream(self) -> None:
if self._stream is None:
return
await self._stream.stop()
self._stream = None
try:
body = self.query_one(".message-body", Markdown)
except NoMatches:
return
await body.update(self.body_text)
class ToolCallWidget(Static):
@ -216,6 +236,10 @@ class ChatHistory(VerticalScroll):
padding: 0;
}
ChatMessage .message-prefix {
text-style: bold;
}
/* Tool calls */
ToolCallWidget {
margin: 0 0 0 4;

View file

@ -53,7 +53,7 @@ cohere = ["cohere>=5.21.1"]
zeroentropy = ["zeroentropy>=0.1.0a11"]
jina = ["transformers>=4.40.0", "torch>=2.0.0"]
# TUI (chat and inspect commands)
tui = ["textual>=8.2.1", "textual-image>=0.8.5"]
tui = ["textual>=8.2.4", "textual-image>=0.8.5"]
# Model providers (delegated to pydantic-ai-slim)
anthropic = ["pydantic-ai-slim[anthropic]"]
groq = ["pydantic-ai-slim[groq]"]

View file

@ -37,7 +37,7 @@ dependencies = [
haiku-rag = "haiku.rag.cli:cli"
[project.optional-dependencies]
tui = ["textual>=1.0.0"]
tui = ["textual>=8.2.4"]
s3 = ["haiku.rag-slim[s3]==0.45.0"]
[build-system]