diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b046edb..421c376d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/haiku_rag_slim/haiku/rag/chat/app.py b/haiku_rag_slim/haiku/rag/chat/app.py index 6d7e3a48..1b320271 100644 --- a/haiku_rag_slim/haiku/rag/chat/app.py +++ b/haiku_rag_slim/haiku/rag/chat/app.py @@ -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 diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py index d619f76e..34d64a6e 100644 --- a/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py +++ b/haiku_rag_slim/haiku/rag/chat/widgets/chat_history.py @@ -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; diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml index 83b83af9..8846c436 100644 --- a/haiku_rag_slim/pyproject.toml +++ b/haiku_rag_slim/pyproject.toml @@ -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]"] diff --git a/pyproject.toml b/pyproject.toml index 26755586..4c47624f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]