haiku.rag-slim 0.16.0__py3-none-any.whl → 0.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of haiku.rag-slim might be problematic. Click here for more details.

Files changed (94) hide show
  1. haiku/rag/app.py +430 -72
  2. haiku/rag/chunkers/__init__.py +31 -0
  3. haiku/rag/chunkers/base.py +31 -0
  4. haiku/rag/chunkers/docling_local.py +164 -0
  5. haiku/rag/chunkers/docling_serve.py +179 -0
  6. haiku/rag/cli.py +207 -24
  7. haiku/rag/cli_chat.py +489 -0
  8. haiku/rag/client.py +1251 -266
  9. haiku/rag/config/__init__.py +16 -10
  10. haiku/rag/config/loader.py +5 -44
  11. haiku/rag/config/models.py +126 -17
  12. haiku/rag/converters/__init__.py +31 -0
  13. haiku/rag/converters/base.py +63 -0
  14. haiku/rag/converters/docling_local.py +193 -0
  15. haiku/rag/converters/docling_serve.py +229 -0
  16. haiku/rag/converters/text_utils.py +237 -0
  17. haiku/rag/embeddings/__init__.py +123 -24
  18. haiku/rag/embeddings/voyageai.py +175 -20
  19. haiku/rag/graph/__init__.py +0 -11
  20. haiku/rag/graph/agui/__init__.py +8 -2
  21. haiku/rag/graph/agui/cli_renderer.py +1 -1
  22. haiku/rag/graph/agui/emitter.py +219 -31
  23. haiku/rag/graph/agui/server.py +20 -62
  24. haiku/rag/graph/agui/stream.py +1 -2
  25. haiku/rag/graph/research/__init__.py +5 -2
  26. haiku/rag/graph/research/dependencies.py +12 -126
  27. haiku/rag/graph/research/graph.py +390 -135
  28. haiku/rag/graph/research/models.py +91 -112
  29. haiku/rag/graph/research/prompts.py +99 -91
  30. haiku/rag/graph/research/state.py +35 -27
  31. haiku/rag/inspector/__init__.py +8 -0
  32. haiku/rag/inspector/app.py +259 -0
  33. haiku/rag/inspector/widgets/__init__.py +6 -0
  34. haiku/rag/inspector/widgets/chunk_list.py +100 -0
  35. haiku/rag/inspector/widgets/context_modal.py +89 -0
  36. haiku/rag/inspector/widgets/detail_view.py +130 -0
  37. haiku/rag/inspector/widgets/document_list.py +75 -0
  38. haiku/rag/inspector/widgets/info_modal.py +209 -0
  39. haiku/rag/inspector/widgets/search_modal.py +183 -0
  40. haiku/rag/inspector/widgets/visual_modal.py +126 -0
  41. haiku/rag/mcp.py +106 -102
  42. haiku/rag/monitor.py +33 -9
  43. haiku/rag/providers/__init__.py +5 -0
  44. haiku/rag/providers/docling_serve.py +108 -0
  45. haiku/rag/qa/__init__.py +12 -10
  46. haiku/rag/qa/agent.py +43 -61
  47. haiku/rag/qa/prompts.py +35 -57
  48. haiku/rag/reranking/__init__.py +9 -6
  49. haiku/rag/reranking/base.py +1 -1
  50. haiku/rag/reranking/cohere.py +5 -4
  51. haiku/rag/reranking/mxbai.py +5 -2
  52. haiku/rag/reranking/vllm.py +3 -4
  53. haiku/rag/reranking/zeroentropy.py +6 -5
  54. haiku/rag/store/__init__.py +2 -1
  55. haiku/rag/store/engine.py +242 -42
  56. haiku/rag/store/exceptions.py +4 -0
  57. haiku/rag/store/models/__init__.py +8 -2
  58. haiku/rag/store/models/chunk.py +190 -0
  59. haiku/rag/store/models/document.py +46 -0
  60. haiku/rag/store/repositories/chunk.py +141 -121
  61. haiku/rag/store/repositories/document.py +25 -84
  62. haiku/rag/store/repositories/settings.py +11 -14
  63. haiku/rag/store/upgrades/__init__.py +19 -3
  64. haiku/rag/store/upgrades/v0_10_1.py +1 -1
  65. haiku/rag/store/upgrades/v0_19_6.py +65 -0
  66. haiku/rag/store/upgrades/v0_20_0.py +68 -0
  67. haiku/rag/store/upgrades/v0_23_1.py +100 -0
  68. haiku/rag/store/upgrades/v0_9_3.py +3 -3
  69. haiku/rag/utils.py +371 -146
  70. {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/METADATA +15 -12
  71. haiku_rag_slim-0.24.0.dist-info/RECORD +78 -0
  72. {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/WHEEL +1 -1
  73. haiku/rag/chunker.py +0 -65
  74. haiku/rag/embeddings/base.py +0 -25
  75. haiku/rag/embeddings/ollama.py +0 -28
  76. haiku/rag/embeddings/openai.py +0 -26
  77. haiku/rag/embeddings/vllm.py +0 -29
  78. haiku/rag/graph/agui/events.py +0 -254
  79. haiku/rag/graph/common/__init__.py +0 -5
  80. haiku/rag/graph/common/models.py +0 -42
  81. haiku/rag/graph/common/nodes.py +0 -265
  82. haiku/rag/graph/common/prompts.py +0 -46
  83. haiku/rag/graph/common/utils.py +0 -44
  84. haiku/rag/graph/deep_qa/__init__.py +0 -1
  85. haiku/rag/graph/deep_qa/dependencies.py +0 -27
  86. haiku/rag/graph/deep_qa/graph.py +0 -243
  87. haiku/rag/graph/deep_qa/models.py +0 -20
  88. haiku/rag/graph/deep_qa/prompts.py +0 -59
  89. haiku/rag/graph/deep_qa/state.py +0 -56
  90. haiku/rag/graph/research/common.py +0 -87
  91. haiku/rag/reader.py +0 -135
  92. haiku_rag_slim-0.16.0.dist-info/RECORD +0 -71
  93. {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/entry_points.txt +0 -0
  94. {haiku_rag_slim-0.16.0.dist-info → haiku_rag_slim-0.24.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,209 @@
1
+ import json
2
+ from importlib.metadata import version as pkg_version
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Vertical, VerticalScroll
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Static
11
+
12
+ from haiku.rag.utils import format_bytes
13
+
14
+ if TYPE_CHECKING:
15
+ from haiku.rag.client import HaikuRAG
16
+
17
+
18
+ class InfoModal(ModalScreen): # pragma: no cover
19
+ """Modal screen for displaying database information."""
20
+
21
+ BINDINGS = [
22
+ Binding("escape", "dismiss", "Close", show=True),
23
+ Binding("i", "dismiss", "Close", show=True),
24
+ ]
25
+
26
+ CSS = """
27
+ InfoModal {
28
+ align: center middle;
29
+ }
30
+
31
+ #info-container {
32
+ width: 80;
33
+ height: auto;
34
+ max-height: 80%;
35
+ background: $surface;
36
+ border: solid $primary;
37
+ padding: 1 2;
38
+ }
39
+
40
+ #info-header {
41
+ height: auto;
42
+ margin-bottom: 1;
43
+ }
44
+
45
+ #info-content {
46
+ height: auto;
47
+ max-height: 100%;
48
+ }
49
+ """
50
+
51
+ def __init__(self, client: "HaikuRAG", db_path: Path):
52
+ super().__init__()
53
+ self.client = client
54
+ self.db_path = db_path
55
+ self._content_widget = Static("Loading...")
56
+
57
+ def compose(self) -> ComposeResult:
58
+ with Vertical(id="info-container"):
59
+ yield Static("[bold]Database Info[/bold]", id="info-header")
60
+ with VerticalScroll(id="info-content"):
61
+ yield self._content_widget
62
+
63
+ async def on_mount(self) -> None:
64
+ """Load and display database info."""
65
+ import lancedb
66
+
67
+ lines: list[str] = []
68
+
69
+ # Path
70
+ lines.append(f"[bold $accent]path[/bold $accent]: {self.db_path}")
71
+
72
+ if not self.db_path.exists():
73
+ lines.append("[red]Database path does not exist.[/red]")
74
+ self._content_widget.update("\n".join(lines))
75
+ return
76
+
77
+ # Connect to get table info
78
+ try:
79
+ db = lancedb.connect(self.db_path)
80
+ table_names = set(db.table_names())
81
+ except Exception as e:
82
+ lines.append(f"[red]Failed to open database: {e}[/red]")
83
+ self._content_widget.update("\n".join(lines))
84
+ return
85
+
86
+ # Get versions
87
+ try:
88
+ ldb_version = pkg_version("lancedb")
89
+ except Exception:
90
+ ldb_version = "unknown"
91
+ try:
92
+ hr_version = pkg_version("haiku.rag-slim")
93
+ except Exception:
94
+ hr_version = "unknown"
95
+ try:
96
+ docling_version = pkg_version("docling")
97
+ except Exception:
98
+ docling_version = "unknown"
99
+
100
+ # Get stats from store
101
+ table_stats = self.client.store.get_stats()
102
+
103
+ # Read settings
104
+ stored_version = "unknown"
105
+ embed_provider: str | None = None
106
+ embed_model: str | None = None
107
+ vector_dim: int | None = None
108
+
109
+ if "settings" in table_names:
110
+ settings_tbl = db.open_table("settings")
111
+ arrow = settings_tbl.search().where("id = 'settings'").limit(1).to_arrow()
112
+ rows = arrow.to_pylist() if arrow is not None else []
113
+ if rows:
114
+ raw = rows[0].get("settings") or "{}"
115
+ data = json.loads(raw) if isinstance(raw, str) else (raw or {})
116
+ stored_version = str(data.get("version", stored_version))
117
+ embeddings = data.get("embeddings", {})
118
+ embed_model_obj = embeddings.get("model", {})
119
+ embed_provider = embed_model_obj.get("provider")
120
+ embed_model = embed_model_obj.get("name")
121
+ vector_dim = embed_model_obj.get("vector_dim")
122
+
123
+ num_docs = table_stats["documents"].get("num_rows", 0)
124
+ doc_bytes = table_stats["documents"].get("total_bytes", 0)
125
+
126
+ num_chunks = table_stats["chunks"].get("num_rows", 0)
127
+ chunk_bytes = table_stats["chunks"].get("total_bytes", 0)
128
+
129
+ has_vector_index = table_stats["chunks"].get("has_vector_index", False)
130
+ num_indexed_rows = table_stats["chunks"].get("num_indexed_rows", 0)
131
+ num_unindexed_rows = table_stats["chunks"].get("num_unindexed_rows", 0)
132
+
133
+ # Table versions
134
+ doc_versions = (
135
+ len(list(db.open_table("documents").list_versions()))
136
+ if "documents" in table_names
137
+ else 0
138
+ )
139
+ chunk_versions = (
140
+ len(list(db.open_table("chunks").list_versions()))
141
+ if "chunks" in table_names
142
+ else 0
143
+ )
144
+
145
+ # Build output
146
+ lines.append(
147
+ f"[bold $accent]haiku.rag version (db)[/bold $accent]: {stored_version}"
148
+ )
149
+
150
+ if embed_provider or embed_model or vector_dim:
151
+ provider_part = embed_provider or "unknown"
152
+ model_part = embed_model or "unknown"
153
+ dim_part = f"{vector_dim}" if vector_dim is not None else "unknown"
154
+ lines.append(
155
+ f"[bold $accent]embeddings[/bold $accent]: "
156
+ f"{provider_part}/{model_part} (dim: {dim_part})"
157
+ )
158
+ else:
159
+ lines.append("[bold $accent]embeddings[/bold $accent]: unknown")
160
+
161
+ lines.append(
162
+ f"[bold $accent]documents[/bold $accent]: {num_docs} ({format_bytes(doc_bytes)})"
163
+ )
164
+ lines.append(
165
+ f"[bold $accent]chunks[/bold $accent]: {num_chunks} ({format_bytes(chunk_bytes)})"
166
+ )
167
+
168
+ # Vector index info
169
+ if has_vector_index:
170
+ lines.append("[bold $accent]vector index[/bold $accent]: ✓ exists")
171
+ lines.append(
172
+ f"[bold $accent]indexed chunks[/bold $accent]: {num_indexed_rows}"
173
+ )
174
+ if num_unindexed_rows > 0:
175
+ lines.append(
176
+ f"[bold $accent]unindexed chunks[/bold $accent]: [yellow]{num_unindexed_rows}[/yellow]"
177
+ )
178
+ else:
179
+ lines.append(
180
+ f"[bold $accent]unindexed chunks[/bold $accent]: {num_unindexed_rows}"
181
+ )
182
+ else:
183
+ if num_chunks >= 256:
184
+ lines.append(
185
+ "[bold $accent]vector index[/bold $accent]: [yellow]✗ not created[/yellow]"
186
+ )
187
+ else:
188
+ lines.append(
189
+ f"[bold $accent]vector index[/bold $accent]: ✗ not created "
190
+ f"(need {256 - num_chunks} more chunks)"
191
+ )
192
+
193
+ lines.append(
194
+ f"[bold $accent]versions (documents)[/bold $accent]: {doc_versions}"
195
+ )
196
+ lines.append(
197
+ f"[bold $accent]versions (chunks)[/bold $accent]: {chunk_versions}"
198
+ )
199
+
200
+ lines.append("")
201
+ lines.append("[bold]Versions[/bold]")
202
+ lines.append(f"[bold $accent]haiku.rag[/bold $accent]: {hr_version}")
203
+ lines.append(f"[bold $accent]lancedb[/bold $accent]: {ldb_version}")
204
+ lines.append(f"[bold $accent]docling[/bold $accent]: {docling_version}")
205
+
206
+ self._content_widget.update("\n".join(lines))
207
+
208
+ async def action_dismiss(self, result=None) -> None:
209
+ self.app.pop_screen()
@@ -0,0 +1,183 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.binding import Binding
4
+ from textual.containers import Horizontal, Vertical, VerticalScroll
5
+ from textual.message import Message
6
+ from textual.screen import Screen
7
+ from textual.widgets import Input, ListItem, ListView, Static
8
+
9
+ from haiku.rag.client import HaikuRAG
10
+ from haiku.rag.inspector.widgets.detail_view import DetailView
11
+ from haiku.rag.store.models import Chunk, SearchResult
12
+
13
+
14
+ class SearchModal(Screen): # pragma: no cover
15
+ """Screen for searching chunks."""
16
+
17
+ BINDINGS = [
18
+ Binding("escape", "dismiss", "Close", show=True),
19
+ Binding("v", "show_visual", "Visual", show=True),
20
+ ]
21
+
22
+ CSS = """
23
+ SearchModal {
24
+ background: $surface;
25
+ layout: vertical;
26
+ }
27
+
28
+ #search-header {
29
+ dock: top;
30
+ height: auto;
31
+ }
32
+
33
+ #search-content {
34
+ height: 1fr;
35
+ width: 100%;
36
+ }
37
+
38
+ #search-results-container {
39
+ width: 1fr;
40
+ border: solid $primary;
41
+ }
42
+
43
+ #search-detail {
44
+ width: 2fr;
45
+ border: solid $accent;
46
+ }
47
+
48
+ ListItem {
49
+ overflow: hidden;
50
+ }
51
+
52
+ ListItem Static {
53
+ overflow: hidden;
54
+ text-overflow: ellipsis;
55
+ }
56
+ """
57
+
58
+ class ChunkSelected(Message):
59
+ """Message sent when a chunk is selected from search results."""
60
+
61
+ def __init__(self, chunk: Chunk) -> None:
62
+ super().__init__()
63
+ self.chunk = chunk
64
+
65
+ def __init__(self, client: HaikuRAG):
66
+ super().__init__()
67
+ self.client = client
68
+ self.chunks: list[Chunk] = []
69
+ self.search_results: list[SearchResult] = []
70
+
71
+ def compose(self) -> ComposeResult:
72
+ """Compose the search screen."""
73
+ with Vertical(id="search-header"):
74
+ yield Static("[bold]Search Chunks[/bold]")
75
+ yield Input(placeholder="Enter search query...", id="search-input")
76
+ yield Static("", id="status-label")
77
+ with Horizontal(id="search-content"):
78
+ with VerticalScroll(id="search-results-container"):
79
+ yield ListView(id="search-results")
80
+ yield DetailView(id="search-detail")
81
+
82
+ async def on_mount(self) -> None:
83
+ """Focus the search input when mounted."""
84
+ status_label = self.query_one("#status-label", Static)
85
+ status_label.update("Type query and press Enter to search")
86
+ search_input = self.query_one("#search-input", Input)
87
+ search_input.focus()
88
+
89
+ @on(Input.Submitted, "#search-input")
90
+ async def search_submitted(self, event: Input.Submitted) -> None:
91
+ """Handle search query submission."""
92
+ query = event.value.strip()
93
+ if query:
94
+ await self.run_search(query)
95
+
96
+ async def run_search(self, query: str) -> None:
97
+ """Perform the search."""
98
+ status_label = self.query_one("#status-label", Static)
99
+ list_view = self.query_one("#search-results", ListView)
100
+
101
+ status_label.update("Searching...")
102
+
103
+ try:
104
+ # Perform search using client API
105
+ self.search_results = await self.client.search(query=query, limit=50)
106
+
107
+ # Get chunks for the results
108
+ self.chunks = []
109
+ for result in self.search_results:
110
+ if result.chunk_id:
111
+ chunk = await self.client.chunk_repository.get_by_id(
112
+ result.chunk_id
113
+ )
114
+ if chunk:
115
+ self.chunks.append(chunk)
116
+
117
+ # Clear and populate results
118
+ await list_view.clear()
119
+ for result in self.search_results:
120
+ first_line = result.content.split("\n")[0][:60]
121
+ score_str = f"{result.score:.2f}"
122
+ # Add page info if available
123
+ page_info = ""
124
+ if result.page_numbers:
125
+ pages = ", ".join(str(p) for p in result.page_numbers[:3])
126
+ page_info = f" (p.{pages})"
127
+ item = ListItem(Static(f"[{score_str}]{page_info} {first_line}"))
128
+ await list_view.append(item)
129
+
130
+ # Update status
131
+ status_label.update(f"Found {len(self.chunks)} results")
132
+
133
+ # Select first result, show in detail view, and focus list
134
+ if self.chunks and self.search_results:
135
+ list_view.index = 0
136
+ detail_view = self.query_one("#search-detail", DetailView)
137
+ await detail_view.show_search_result(
138
+ self.chunks[0], self.search_results[0]
139
+ )
140
+ list_view.focus()
141
+ except Exception as e:
142
+ status_label.update(f"Error: {str(e)}")
143
+
144
+ async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
145
+ """Handle chunk navigation (arrow keys)."""
146
+ list_view = self.query_one("#search-results", ListView)
147
+ if event.list_view != list_view or event.item is None:
148
+ return
149
+ idx = event.list_view.index
150
+ detail_view = self.query_one("#search-detail", DetailView)
151
+ await detail_view.show_search_result(self.chunks[idx], self.search_results[idx]) # type: ignore[index]
152
+
153
+ async def on_list_view_selected(self, event: ListView.Selected) -> None:
154
+ """Handle chunk selection (Enter key)."""
155
+ list_view = self.query_one("#search-results", ListView)
156
+ if event.list_view != list_view:
157
+ return
158
+ idx = event.list_view.index
159
+ self.post_message(self.ChunkSelected(self.chunks[idx])) # type: ignore[index]
160
+ self.app.pop_screen()
161
+
162
+ async def action_dismiss(self, result=None) -> None:
163
+ self.app.pop_screen()
164
+
165
+ async def action_show_visual(self) -> None:
166
+ """Show visual grounding for the current chunk."""
167
+ list_view = self.query_one("#search-results", ListView)
168
+ idx = list_view.index
169
+ if idx is None or not self.chunks:
170
+ return
171
+
172
+ chunk = self.chunks[idx]
173
+
174
+ from haiku.rag.inspector.widgets.visual_modal import VisualGroundingModal
175
+
176
+ # Use app's _switch_modal to close this modal before opening visual
177
+ await self.app._switch_modal( # type: ignore[attr-defined]
178
+ VisualGroundingModal(
179
+ chunk=chunk,
180
+ client=self.client,
181
+ document_uri=chunk.document_uri,
182
+ )
183
+ )
@@ -0,0 +1,126 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.screen import Screen
7
+ from textual.widget import Widget
8
+ from textual.widgets import Static
9
+ from textual_image.widget import Image as TextualImage
10
+
11
+ if TYPE_CHECKING:
12
+ from PIL.Image import Image as PILImage
13
+
14
+ from haiku.rag.client import HaikuRAG
15
+ from haiku.rag.store.models import Chunk
16
+
17
+
18
+ class VisualGroundingModal(Screen): # pragma: no cover
19
+ """Modal screen for displaying visual grounding with bounding boxes."""
20
+
21
+ BINDINGS = [
22
+ Binding("escape", "dismiss", "Close", show=True),
23
+ Binding("v", "dismiss", "Close", show=True),
24
+ Binding("left", "prev_page", "Previous Page"),
25
+ Binding("right", "next_page", "Next Page"),
26
+ ]
27
+
28
+ CSS = """
29
+ VisualGroundingModal {
30
+ background: $surface;
31
+ layout: vertical;
32
+ }
33
+
34
+ #visual-header {
35
+ dock: top;
36
+ height: auto;
37
+ padding: 1;
38
+ }
39
+
40
+ #visual-content {
41
+ height: 1fr;
42
+ width: 100%;
43
+ align: center middle;
44
+ }
45
+
46
+ #visual-content Image {
47
+ width: auto;
48
+ height: 100%;
49
+ }
50
+
51
+ #page-nav {
52
+ dock: bottom;
53
+ height: auto;
54
+ padding: 1;
55
+ }
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ chunk: "Chunk",
61
+ client: "HaikuRAG",
62
+ document_uri: str | None = None,
63
+ ):
64
+ super().__init__()
65
+ self.chunk = chunk
66
+ self.client = client
67
+ self.document_uri = document_uri or chunk.document_uri
68
+ self.images: list[PILImage] = []
69
+ self.current_page_idx = 0
70
+ self._image_widget: Widget = Static("Loading...", id="image-display")
71
+ self._page_info = Static("", id="page-info")
72
+
73
+ def compose(self) -> ComposeResult:
74
+ uri_display = self.document_uri or "Document"
75
+ with Vertical(id="visual-header"):
76
+ yield Static(f"[bold]Visual Grounding[/bold] - {uri_display}")
77
+ with Horizontal(id="visual-content"):
78
+ yield self._image_widget
79
+ with Horizontal(id="page-nav"):
80
+ yield self._page_info
81
+
82
+ async def on_mount(self) -> None:
83
+ """Load images and display the first page."""
84
+ self.images = await self.client.visualize_chunk(self.chunk)
85
+ await self._render_current_page()
86
+
87
+ async def _render_current_page(self) -> None:
88
+ """Render the current page."""
89
+ if not self.images:
90
+ if isinstance(self._image_widget, Static):
91
+ self._image_widget.update(
92
+ "[yellow]No page images available[/yellow]\n"
93
+ "This document was converted without page images."
94
+ )
95
+ self._page_info.update("")
96
+ return
97
+
98
+ self._page_info.update(
99
+ f"Page {self.current_page_idx + 1}/{len(self.images)} - Use ←/→ to navigate"
100
+ )
101
+
102
+ try:
103
+ image = self.images[self.current_page_idx]
104
+ new_widget = TextualImage(image, id="rendered-image")
105
+ await self._image_widget.remove()
106
+ content = self.query_one("#visual-content", Horizontal)
107
+ await content.mount(new_widget)
108
+ self._image_widget = new_widget
109
+ except Exception as e:
110
+ if isinstance(self._image_widget, Static):
111
+ self._image_widget.update(f"[red]Error: {e}[/red]")
112
+
113
+ async def action_dismiss(self, result=None) -> None:
114
+ self.app.pop_screen()
115
+
116
+ async def action_prev_page(self) -> None:
117
+ """Navigate to the previous page."""
118
+ if self.current_page_idx > 0:
119
+ self.current_page_idx -= 1
120
+ await self._render_current_page()
121
+
122
+ async def action_next_page(self) -> None:
123
+ """Navigate to the next page."""
124
+ if self.current_page_idx < len(self.images) - 1:
125
+ self.current_page_idx += 1
126
+ await self._render_current_page()