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

View file

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

View file

@ -53,7 +53,7 @@ cohere = ["cohere>=5.21.1"]
zeroentropy = ["zeroentropy>=0.1.0a11"] zeroentropy = ["zeroentropy>=0.1.0a11"]
jina = ["transformers>=4.40.0", "torch>=2.0.0"] jina = ["transformers>=4.40.0", "torch>=2.0.0"]
# TUI (chat and inspect commands) # 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) # Model providers (delegated to pydantic-ai-slim)
anthropic = ["pydantic-ai-slim[anthropic]"] anthropic = ["pydantic-ai-slim[anthropic]"]
groq = ["pydantic-ai-slim[groq]"] groq = ["pydantic-ai-slim[groq]"]

View file

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