haiku.rag/haiku_rag_slim/haiku/rag/chat/app.py
2026-03-03 11:16:44 +02:00

411 lines
14 KiB
Python

# pyright: reportPossiblyUnboundVariable=false
import asyncio
import json
import uuid
from collections.abc import Iterable
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from haiku.rag.client import HaikuRAG
from haiku.rag.config import get_config
from haiku.rag.skills.rag import AGENT_PREAMBLE, RAGState
from haiku.skills.agent import (
SkillToolset,
run_agui_stream,
)
from haiku.skills.models import Skill
from haiku.skills.prompts import build_system_prompt
if TYPE_CHECKING:
from textual.app import ComposeResult
try:
import logfire
logfire.configure(send_to_logfire="if-token-present", console=False)
logfire.instrument_pydantic_ai()
except ImportError:
pass
try:
import textual_image.widget # noqa: F401 - import early for renderer detection
from ag_ui.core import (
AssistantMessage,
EventType,
RunAgentInput,
StateDeltaEvent,
TextMessageContentEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
UserMessage,
)
from jsonpatch import JsonPatch
from pydantic_ai import Agent
from pydantic_ai.ag_ui import AGUIAdapter
from textual.app import App, SystemCommand
from textual.binding import Binding
from textual.widgets import Footer, Header, Input
from textual.worker import Worker
from haiku.rag.chat.widgets.chat_history import ChatHistory, CitationWidget
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
App = object # type: ignore
SystemCommand = object # type: ignore
RAG_STATE_NAMESPACE = "rag"
class ChatApp(App):
"""Textual TUI for conversational RAG."""
TITLE = "haiku.rag Chat"
CSS = """
Screen {
layout: grid;
grid-size: 1 2;
grid-rows: 1fr auto;
background: $surface;
}
#chat-history {
height: 100%;
}
Header {
background: $primary;
}
Footer {
background: $surface-darken-1;
}
"""
BINDINGS = [
Binding("escape", "focus_input", "Focus Input", show=False),
]
def __init__(
self,
db_path: Path,
skill: Skill,
read_only: bool = False,
before: datetime | None = None,
model: str | None = None,
) -> None:
super().__init__()
self.db_path = db_path
self._skill = skill
self.read_only = read_only
self.before = before
self._model = model
self.client: HaikuRAG | None = None
self.config = get_config()
self._toolset: SkillToolset | None = None
self._agent: Agent[None, str] | None = None
self._messages: list[Any] = []
self._state: dict[str, Any] = {}
self._is_processing = False
self._current_worker: Worker[None] | None = None
self._document_filter: list[str] = []
def compose(self) -> "ComposeResult":
"""Compose the UI layout."""
yield Header()
yield ChatHistory(id="chat-history")
yield Input(placeholder="Ask a question...", id="chat-input")
yield Footer()
def get_system_commands(self, screen: Any) -> Iterable[SystemCommand]:
"""Add commands to the command palette."""
yield from super().get_system_commands(screen)
yield SystemCommand(
"Clear chat",
"Clear the chat history and reset session",
self.action_clear_chat,
)
yield SystemCommand(
"Filter documents",
"Select documents to filter searches",
self.action_show_filter,
)
yield SystemCommand(
"Show visual grounding",
"Show visual grounding for selected citation",
self.action_show_visual,
)
yield SystemCommand(
"Database info",
"Show database information",
self.action_show_info,
)
yield SystemCommand(
"View state",
"Show the current session state",
self.action_view_state,
)
async def on_mount(self) -> None:
"""Initialize the app when mounted."""
self.client = HaikuRAG(
db_path=self.db_path,
config=self.config,
read_only=self.read_only,
before=self.before,
)
await self.client.__aenter__()
self._toolset = SkillToolset(skills=[self._skill])
self._agent = Agent(
self._model,
instructions=build_system_prompt(
self._toolset.skill_catalog, preamble=AGENT_PREAMBLE
),
toolsets=[self._toolset],
)
self._state = self._toolset.build_state_snapshot()
self.query_one(Input).focus()
async def on_unmount(self) -> None:
"""Clean up when unmounting."""
if self.client:
await self.client.__aexit__(None, None, None)
async def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle user input submission."""
user_message = event.value.strip()
if not user_message or self._is_processing:
return
event.input.clear()
chat_history = self.query_one(ChatHistory)
await chat_history.add_message("user", user_message)
self._messages.append(
UserMessage(
id=str(uuid.uuid4()),
role="user",
content=user_message,
)
)
self._is_processing = True
self.query_one(Input).disabled = True
self._current_worker = self.run_worker(
self._run_agent(user_message), exclusive=True
)
async def _run_agent(self, user_message: str) -> None:
"""Run the agent in a background worker."""
if not self._agent or not self._toolset:
return
chat_history = self.query_one(ChatHistory)
await chat_history.show_thinking()
run_input = RunAgentInput(
thread_id="tui",
run_id=str(uuid.uuid4()),
messages=self._messages,
state=self._state,
tools=[],
context=[],
forwarded_props={},
)
adapter = AGUIAdapter(agent=self._agent, run_input=run_input)
message = None
accumulated_text = ""
tool_args_deltas: dict[str, str] = {}
try:
async with run_agui_stream(self._toolset, adapter) as stream:
async for event in stream:
if event.type == EventType.TEXT_MESSAGE_START:
chat_history.hide_thinking()
message = await chat_history.add_message("assistant")
accumulated_text = ""
elif event.type == EventType.TEXT_MESSAGE_CONTENT:
assert isinstance(event, TextMessageContentEvent)
accumulated_text += event.delta
if message:
message.update_content(accumulated_text)
chat_history.scroll_end(animate=False)
elif event.type == EventType.TEXT_MESSAGE_END:
self._messages.append(
AssistantMessage(
id=str(uuid.uuid4()),
role="assistant",
content=accumulated_text,
)
)
# Show citations from RAG state
await self._show_citations(chat_history)
elif event.type == EventType.TOOL_CALL_START:
assert isinstance(event, ToolCallStartEvent)
chat_history.hide_thinking()
await chat_history.add_tool_call(
event.tool_call_id, event.tool_call_name
)
tool_args_deltas[event.tool_call_id] = ""
await chat_history.show_thinking("Executing tasks...")
elif event.type == EventType.TOOL_CALL_ARGS:
assert isinstance(event, ToolCallArgsEvent)
tool_args_deltas[event.tool_call_id] = (
tool_args_deltas.get(event.tool_call_id, "") + event.delta
)
try:
args = json.loads(tool_args_deltas[event.tool_call_id])
chat_history.update_tool_args(event.tool_call_id, args)
except json.JSONDecodeError:
pass
elif event.type == EventType.TOOL_CALL_END:
assert isinstance(event, ToolCallEndEvent)
chat_history.mark_tool_complete(event.tool_call_id)
elif event.type == EventType.STATE_DELTA:
assert isinstance(event, StateDeltaEvent)
patch = JsonPatch(event.delta)
self._state = patch.apply(self._state)
self._toolset.restore_state_snapshot(self._state)
elif event.type == EventType.STATE_SNAPSHOT:
self._state = getattr(event, "snapshot", self._state)
self._toolset.restore_state_snapshot(self._state)
elif event.type == EventType.RUN_FINISHED:
chat_history.hide_thinking()
elif event.type == EventType.RUN_ERROR:
chat_history.hide_thinking()
error_msg = getattr(event, "message", "Unknown error")
await chat_history.add_message(
"assistant", f"Error: {error_msg}"
)
except asyncio.CancelledError:
chat_history.hide_thinking()
await chat_history.add_message("assistant", "*Cancelled*")
except Exception as e:
chat_history.hide_thinking()
await chat_history.add_message("assistant", f"Error: {e}")
finally:
self._is_processing = False
self._current_worker = None
chat_input = self.query_one(Input)
chat_input.disabled = False
chat_input.focus()
async def _show_citations(self, chat_history: "ChatHistory") -> None:
"""Show citations from the RAG state after an agent response."""
if not self._toolset:
return
rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE)
if rag_state is None:
return
citations = getattr(rag_state, "citations", [])
if citations:
# Show only new citations (since last response)
await chat_history.add_citations(citations)
async def action_clear_chat(self) -> None:
"""Clear the chat history and reset session."""
chat_history = self.query_one(ChatHistory)
await chat_history.clear_messages()
self._messages.clear()
# Reset state
if self._toolset:
self._state = self._toolset.build_state_snapshot()
def action_focus_input(self) -> None:
"""Focus the input field, or cancel if processing."""
if self._is_processing and self._current_worker:
self._current_worker.cancel()
self.query_one(Input).focus()
def _clear_citation_selection(self) -> None:
"""Clear citation selection."""
chat_history = self.query_one(ChatHistory)
for widget in chat_history.query(CitationWidget):
widget.remove_class("selected")
def on_descendant_focus(self, _event: object) -> None:
"""Clear citation selection when chat input is focused."""
if isinstance(self.focused, Input) and self.focused.id == "chat-input":
self._clear_citation_selection()
async def action_show_visual(self) -> None:
"""Show visual grounding for the selected citation."""
if not self.client:
return
chat_history = self.query_one(ChatHistory)
selected_widgets = list(chat_history.query(CitationWidget).filter(".selected"))
if not selected_widgets:
return
citation = selected_widgets[0].citation
chunk = await self.client.get_chunk_by_id(citation.chunk_id)
if not chunk:
return
from haiku.rag.inspector.widgets.visual_modal import VisualGroundingModal
await self.push_screen(VisualGroundingModal(chunk=chunk, client=self.client))
async def action_show_info(self) -> None:
"""Show database info modal."""
if not self.client:
return
from haiku.rag.inspector.widgets.info_modal import InfoModal
await self.push_screen(InfoModal(self.client, self.db_path))
def action_view_state(self) -> None:
"""Show the current session state."""
from haiku.skills.chat.app import StateScreen
self.push_screen(StateScreen(self._state))
def on_citation_widget_selected(self, event: CitationWidget.Selected) -> None:
"""Handle citation selection."""
chat_history = self.query_one(ChatHistory)
for widget in chat_history.query(CitationWidget):
widget.remove_class("selected")
event.widget.add_class("selected")
async def action_show_filter(self) -> None:
"""Show document filter modal."""
if not self.client:
return
from haiku.rag.chat.widgets.document_filter_modal import DocumentFilterModal
await self.push_screen(
DocumentFilterModal(
client=self.client,
selected=self._document_filter,
)
)
def on_document_filter_modal_filter_changed(self, event: Any) -> None:
"""Handle document filter changes from modal."""
from haiku.rag.tools.filters import build_multi_document_filter
self._document_filter = event.selected
if self._toolset:
rag_state = self._toolset.get_namespace(RAG_STATE_NAMESPACE)
if isinstance(rag_state, RAGState):
rag_state.document_filter = build_multi_document_filter(
self._document_filter
)
self._state = self._toolset.build_state_snapshot()