chat-console 0.1.1__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.
@@ -0,0 +1,296 @@
1
+ from typing import Dict, List, Any, Optional
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Container
4
+ from textual.widgets import Select, Label, Input
5
+ from textual.widget import Widget
6
+ from textual.message import Message
7
+
8
+ from ..config import CONFIG
9
+ from .chat_interface import ChatInterface
10
+
11
+ class ModelSelector(Container):
12
+ """Widget for selecting the AI model to use"""
13
+
14
+ DEFAULT_CSS = """
15
+ ModelSelector {
16
+ width: 100%;
17
+ height: auto;
18
+ padding: 0;
19
+ background: $surface-darken-1;
20
+ }
21
+
22
+ #selector-container {
23
+ width: 100%;
24
+ layout: horizontal;
25
+ height: 3;
26
+ padding: 0;
27
+ }
28
+
29
+ #provider-select {
30
+ width: 30%;
31
+ height: 3;
32
+ margin-right: 1;
33
+ }
34
+
35
+ #model-select, #custom-model-input {
36
+ width: 1fr;
37
+ height: 3;
38
+ }
39
+
40
+ #custom-model-input {
41
+ display: none;
42
+ }
43
+
44
+ #custom-model-input.show {
45
+ display: block;
46
+ }
47
+
48
+ #model-select.hide {
49
+ display: none;
50
+ }
51
+ """
52
+
53
+ class ModelSelected(Message):
54
+ """Event sent when a model is selected"""
55
+ def __init__(self, model_id: str):
56
+ self.model_id = model_id
57
+ super().__init__()
58
+
59
+ def __init__(
60
+ self,
61
+ selected_model: str = None,
62
+ name: Optional[str] = None,
63
+ id: Optional[str] = None
64
+ ):
65
+ super().__init__(name=name, id=id)
66
+ self.selected_model = selected_model or CONFIG["default_model"]
67
+ # Handle custom models not in CONFIG
68
+ if self.selected_model in CONFIG["available_models"]:
69
+ self.selected_provider = CONFIG["available_models"][self.selected_model]["provider"]
70
+ else:
71
+ # Default to OpenAI for custom models
72
+ self.selected_provider = "openai"
73
+
74
+ def compose(self) -> ComposeResult:
75
+ """Set up the model selector"""
76
+ with Container(id="selector-container"):
77
+ # Provider options including Ollama
78
+ provider_options = [
79
+ ("OpenAI", "openai"),
80
+ ("Anthropic", "anthropic"),
81
+ ("Ollama", "ollama")
82
+ ]
83
+
84
+ # Provider selector
85
+ yield Select(
86
+ provider_options,
87
+ id="provider-select",
88
+ value=self.selected_provider,
89
+ allow_blank=False
90
+ )
91
+
92
+ # Get initial model options synchronously
93
+ initial_options = []
94
+ for model_id, model_info in CONFIG["available_models"].items():
95
+ if model_info["provider"] == self.selected_provider:
96
+ initial_options.append((model_info["display_name"], model_id))
97
+
98
+ # Ensure we have at least the custom option
99
+ if not initial_options or self.selected_model not in [opt[1] for opt in initial_options]:
100
+ initial_options.append(("Custom Model...", "custom"))
101
+ is_custom = True
102
+ initial_value = "custom"
103
+ else:
104
+ is_custom = False
105
+ initial_value = self.selected_model
106
+
107
+ # Model selector and custom input
108
+ yield Select(
109
+ initial_options,
110
+ id="model-select",
111
+ value=initial_value,
112
+ classes="hide" if is_custom else "",
113
+ allow_blank=False
114
+ )
115
+ yield Input(
116
+ value=self.selected_model if is_custom else "",
117
+ placeholder="Enter custom model name",
118
+ id="custom-model-input",
119
+ classes="" if is_custom else "hide"
120
+ )
121
+
122
+ async def on_mount(self) -> None:
123
+ """Initialize model options after mount"""
124
+ # Only update options if using Ollama provider since it needs async API call
125
+ if self.selected_provider == "ollama":
126
+ model_select = self.query_one("#model-select", Select)
127
+ model_options = await self._get_model_options(self.selected_provider)
128
+ model_select.set_options(model_options)
129
+ if not self.selected_model or self.selected_model not in CONFIG["available_models"]:
130
+ model_select.value = "custom"
131
+ else:
132
+ model_select.value = self.selected_model
133
+
134
+ async def _get_model_options(self, provider: str) -> List[tuple]:
135
+ """Get model options for a specific provider"""
136
+ options = [
137
+ (model_info["display_name"], model_id)
138
+ for model_id, model_info in CONFIG["available_models"].items()
139
+ if model_info["provider"] == provider
140
+ ]
141
+
142
+ # Add available Ollama models
143
+ if provider == "ollama":
144
+ try:
145
+ from app.api.ollama import OllamaClient
146
+ ollama = OllamaClient()
147
+ ollama_models = await ollama.get_available_models()
148
+ for model in ollama_models:
149
+ if model["id"] not in CONFIG["available_models"]:
150
+ options.append((model["name"], model["id"]))
151
+ except:
152
+ pass
153
+
154
+ options.append(("Custom Model...", "custom"))
155
+ return options
156
+
157
+ async def on_select_changed(self, event: Select.Changed) -> None:
158
+ """Handle select changes"""
159
+ if event.select.id == "provider-select":
160
+ self.selected_provider = event.value
161
+ # Update model options
162
+ model_select = self.query_one("#model-select", Select)
163
+ model_options = await self._get_model_options(self.selected_provider)
164
+ model_select.set_options(model_options)
165
+ # Select first model of new provider
166
+ if model_options:
167
+ self.selected_model = model_options[0][1]
168
+ model_select.value = self.selected_model
169
+ self.post_message(self.ModelSelected(self.selected_model))
170
+
171
+ elif event.select.id == "model-select":
172
+ if event.value == "custom":
173
+ # Show custom input
174
+ model_select = self.query_one("#model-select")
175
+ custom_input = self.query_one("#custom-model-input")
176
+ model_select.add_class("hide")
177
+ custom_input.remove_class("hide")
178
+ custom_input.focus()
179
+ else:
180
+ # Hide custom input
181
+ model_select = self.query_one("#model-select")
182
+ custom_input = self.query_one("#custom-model-input")
183
+ model_select.remove_class("hide")
184
+ custom_input.add_class("hide")
185
+ self.selected_model = event.value
186
+ self.post_message(self.ModelSelected(event.value))
187
+
188
+ def on_input_changed(self, event: Input.Changed) -> None:
189
+ """Handle custom model input changes"""
190
+ if event.input.id == "custom-model-input":
191
+ value = event.value.strip()
192
+ if value: # Only update if there's actual content
193
+ self.selected_model = value
194
+ self.post_message(self.ModelSelected(value))
195
+
196
+
197
+ def get_selected_model(self) -> str:
198
+ """Get the current selected model ID"""
199
+ return self.selected_model
200
+
201
+ def set_selected_model(self, model_id: str) -> None:
202
+ """Set the selected model"""
203
+ self.selected_model = model_id
204
+ if model_id in CONFIG["available_models"]:
205
+ select = self.query_one("#model-select", Select)
206
+ custom_input = self.query_one("#custom-model-input")
207
+ select.value = model_id
208
+ select.remove_class("hide")
209
+ custom_input.add_class("hide")
210
+ else:
211
+ select = self.query_one("#model-select", Select)
212
+ custom_input = self.query_one("#custom-model-input")
213
+ select.value = "custom"
214
+ select.add_class("hide")
215
+ custom_input.value = model_id
216
+ custom_input.remove_class("hide")
217
+
218
+ class StyleSelector(Container):
219
+ """Widget for selecting the AI response style"""
220
+
221
+ DEFAULT_CSS = """
222
+ StyleSelector {
223
+ width: 100%;
224
+ height: auto;
225
+ padding: 0;
226
+ background: $surface-darken-1;
227
+ }
228
+
229
+ #selector-container {
230
+ width: 100%;
231
+ layout: horizontal;
232
+ height: 3;
233
+ padding: 0;
234
+ }
235
+
236
+ #style-label {
237
+ width: 30%;
238
+ height: 3;
239
+ content-align: left middle;
240
+ padding-right: 1;
241
+ }
242
+
243
+ #style-select {
244
+ width: 1fr;
245
+ height: 3;
246
+ }
247
+ """
248
+
249
+ class StyleSelected(Message):
250
+ """Event sent when a style is selected"""
251
+ def __init__(self, style_id: str):
252
+ self.style_id = style_id
253
+ super().__init__()
254
+
255
+ def __init__(
256
+ self,
257
+ selected_style: str = None,
258
+ name: Optional[str] = None,
259
+ id: Optional[str] = None
260
+ ):
261
+ super().__init__(name=name, id=id)
262
+ self.selected_style = selected_style or CONFIG["default_style"]
263
+
264
+ def compose(self) -> ComposeResult:
265
+ """Set up the style selector"""
266
+ with Container(id="selector-container"):
267
+ yield Label("Style:", id="style-label")
268
+
269
+ # Get style options
270
+ options = []
271
+ for style_id, style_info in CONFIG["user_styles"].items():
272
+ options.append((style_info["name"], style_id))
273
+
274
+ yield Select(
275
+ options,
276
+ id="style-select",
277
+ value=self.selected_style,
278
+ allow_blank=False
279
+ )
280
+
281
+ def on_select_changed(self, event: Select.Changed) -> None:
282
+ """Handle select changes"""
283
+ if event.select.id == "style-select":
284
+ self.selected_style = event.value
285
+ self.post_message(self.StyleSelected(event.value))
286
+
287
+ def get_selected_style(self) -> str:
288
+ """Get the current selected style ID"""
289
+ return self.selected_style
290
+
291
+ def set_selected_style(self, style_id: str) -> None:
292
+ """Set the selected style"""
293
+ if style_id in CONFIG["user_styles"]:
294
+ self.selected_style = style_id
295
+ select = self.query_one("#style-select", Select)
296
+ select.value = style_id
app/ui/search.py ADDED
@@ -0,0 +1,308 @@
1
+ from typing import List, Dict, Any, Optional, Callable
2
+ import time
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Container, ScrollableContainer
6
+ from textual.reactive import reactive
7
+ from textual.widgets import Button, Input, Label, Static
8
+ from textual.widget import Widget
9
+ from textual.message import Message
10
+ from textual.timer import Timer
11
+
12
+ from ..models import Conversation
13
+ from ..database import ChatDatabase
14
+ from ..config import CONFIG
15
+
16
+ class SearchResult(Static):
17
+ """Widget to display a single search result"""
18
+
19
+ DEFAULT_CSS = """
20
+ SearchResult {
21
+ width: 100%;
22
+ height: auto;
23
+ min-height: 3;
24
+ padding: 1;
25
+ border-bottom: solid $primary-darken-3;
26
+ }
27
+
28
+ SearchResult:hover {
29
+ background: $primary-darken-2;
30
+ }
31
+
32
+ .result-title {
33
+ width: 100%;
34
+ content-align: center middle;
35
+ text-align: left;
36
+ text-style: bold;
37
+ }
38
+
39
+ .result-preview {
40
+ width: 100%;
41
+ color: $text-muted;
42
+ margin-top: 1;
43
+ text-align: left;
44
+ }
45
+
46
+ .result-date {
47
+ width: 100%;
48
+ color: $text-muted;
49
+ text-align: right;
50
+ text-style: italic;
51
+ }
52
+ """
53
+
54
+ class ResultSelected(Message):
55
+ """Event sent when a search result is selected"""
56
+ def __init__(self, conversation_id: int):
57
+ self.conversation_id = conversation_id
58
+ super().__init__()
59
+
60
+ def __init__(
61
+ self,
62
+ conversation: Conversation,
63
+ name: Optional[str] = None
64
+ ):
65
+ super().__init__(name=name)
66
+ self.conversation = conversation
67
+
68
+ def compose(self) -> ComposeResult:
69
+ """Set up the search result"""
70
+ yield Label(self.conversation.title, classes="result-title")
71
+
72
+ # Preview text (truncate if too long)
73
+ preview = getattr(self.conversation, 'preview', '')
74
+ if preview and len(preview) > 100:
75
+ preview = preview[:100] + "..."
76
+
77
+ yield Label(preview, classes="result-preview")
78
+
79
+ # Format date
80
+ updated_at = self.conversation.updated_at
81
+ if updated_at:
82
+ try:
83
+ from datetime import datetime
84
+ dt = datetime.fromisoformat(updated_at)
85
+ formatted_date = dt.strftime("%Y-%m-%d %H:%M")
86
+ except:
87
+ formatted_date = updated_at
88
+ else:
89
+ formatted_date = "Unknown"
90
+
91
+ yield Label(formatted_date, classes="result-date")
92
+
93
+ def on_click(self) -> None:
94
+ """Handle click events"""
95
+ self.post_message(self.ResultSelected(self.conversation.id))
96
+
97
+ class SearchBar(Container):
98
+ """Widget for searching conversations"""
99
+
100
+ DEFAULT_CSS = """
101
+ SearchBar {
102
+ width: 100%;
103
+ height: auto;
104
+ padding: 1;
105
+ background: $surface-darken-1;
106
+ }
107
+
108
+ #search-input {
109
+ width: 100%;
110
+ height: 3;
111
+ margin-bottom: 1;
112
+ }
113
+
114
+ #search-results-container {
115
+ width: 100%;
116
+ height: auto;
117
+ max-height: 15;
118
+ background: $surface;
119
+ display: none;
120
+ }
121
+
122
+ #search-results-count {
123
+ width: 100%;
124
+ height: 2;
125
+ background: $primary-darken-1;
126
+ color: $text;
127
+ content-align: center middle;
128
+ text-align: center;
129
+ }
130
+
131
+ #loading-indicator {
132
+ width: 100%;
133
+ height: 1;
134
+ background: $primary-darken-1;
135
+ color: $text;
136
+ display: none;
137
+ }
138
+
139
+ #no-results {
140
+ width: 100%;
141
+ height: 3;
142
+ color: $text-muted;
143
+ content-align: center middle;
144
+ text-align: center;
145
+ display: none;
146
+ }
147
+ """
148
+
149
+ is_searching = reactive(False)
150
+ search_results = reactive([])
151
+ search_timer: Optional[Timer] = None
152
+
153
+ class SearchResultSelected(Message):
154
+ """Event sent when a search result is selected"""
155
+ def __init__(self, conversation_id: int):
156
+ self.conversation_id = conversation_id
157
+ super().__init__()
158
+
159
+ def __init__(
160
+ self,
161
+ db: ChatDatabase,
162
+ name: Optional[str] = None,
163
+ id: Optional[str] = None
164
+ ):
165
+ super().__init__(name=name, id=id)
166
+ self.db = db
167
+
168
+ def compose(self) -> ComposeResult:
169
+ """Set up the search bar"""
170
+ yield Input(placeholder="Search conversations...", id="search-input")
171
+
172
+ with Container(id="loading-indicator"):
173
+ yield Label("Searching...", id="loading-text")
174
+
175
+ with Container(id="search-results-container"):
176
+ yield Label("Search Results", id="search-results-count")
177
+
178
+ with ScrollableContainer(id="results-scroll"):
179
+ yield Label("No results found.", id="no-results")
180
+
181
+ def on_unmount(self) -> None:
182
+ """Clean up when unmounting"""
183
+ if self.search_timer:
184
+ self.search_timer.stop()
185
+ self.search_timer = None
186
+
187
+ def on_input_changed(self, event: Input.Changed) -> None:
188
+ """Handle input changes"""
189
+ if event.input.id == "search-input":
190
+ query = event.value.strip()
191
+
192
+ # Cancel existing timer if any
193
+ if self.search_timer:
194
+ self.search_timer.stop()
195
+ self.search_timer = None
196
+
197
+ if not query:
198
+ # Hide results when search is cleared
199
+ self.clear_results()
200
+ try:
201
+ results_container = self.query_one("#search-results-container")
202
+ if results_container:
203
+ results_container.display = False
204
+ except Exception:
205
+ pass
206
+ # Return focus to chat input
207
+ self._return_focus_to_chat()
208
+ return
209
+
210
+ # Start a new timer (debounce)
211
+ try:
212
+ self.search_timer = self.set_timer(0.3, self.perform_search, query)
213
+ except Exception:
214
+ # If timer creation fails, perform search immediately
215
+ self.perform_search(query)
216
+
217
+ def _return_focus_to_chat(self) -> None:
218
+ """Helper to return focus to chat input"""
219
+ try:
220
+ from .chat_interface import ChatInterface
221
+ chat_interface = self.app.query_one("#chat-interface", expect_type=ChatInterface)
222
+ if chat_interface:
223
+ input_field = chat_interface.query_one("#message-input")
224
+ if input_field:
225
+ self.app.set_focus(input_field)
226
+ except Exception:
227
+ pass
228
+
229
+ def perform_search(self, query: str) -> None:
230
+ """Perform the search after debounce"""
231
+ if not self.is_mounted:
232
+ return
233
+
234
+ self.is_searching = True
235
+
236
+ try:
237
+ search_results = self.db.search_conversations(query)
238
+ self.search_results = [Conversation.from_dict(c) for c in search_results]
239
+
240
+ if self.is_mounted: # Check if still mounted before updating UI
241
+ try:
242
+ results_container = self.query_one("#search-results-container")
243
+ if results_container:
244
+ results_container.display = True
245
+ self._update_results_ui()
246
+ except Exception:
247
+ pass
248
+ except Exception:
249
+ # Handle search errors gracefully
250
+ self.search_results = []
251
+ if self.is_mounted:
252
+ try:
253
+ self._update_results_ui()
254
+ except Exception:
255
+ pass
256
+ finally:
257
+ self.is_searching = False
258
+ if self.search_timer:
259
+ self.search_timer.stop()
260
+ self.search_timer = None
261
+
262
+ def _update_results_ui(self) -> None:
263
+ """Update the UI with current search results"""
264
+ results_count = self.query_one("#search-results-count")
265
+ results_count.update(f"Found {len(self.search_results)} results")
266
+
267
+ scroll_container = self.query_one("#results-scroll")
268
+
269
+ # Clear previous results
270
+ for child in scroll_container.children:
271
+ if not child.id == "no-results":
272
+ child.remove()
273
+
274
+ no_results = self.query_one("#no-results")
275
+
276
+ if not self.search_results:
277
+ no_results.display = True
278
+ return
279
+ else:
280
+ no_results.display = False
281
+
282
+ # Mount results directly without using batch_update
283
+ for result in self.search_results:
284
+ scroll_container.mount(SearchResult(result))
285
+
286
+ def on_search_result_result_selected(self, event: SearchResult.ResultSelected) -> None:
287
+ """Handle search result selection"""
288
+ self.post_message(self.SearchResultSelected(event.conversation_id))
289
+
290
+ # Clear search and hide results
291
+ input_widget = self.query_one("#search-input", Input)
292
+ input_widget.value = ""
293
+
294
+ results_container = self.query_one("#search-results-container")
295
+ results_container.display = False
296
+
297
+ # Return focus to chat input
298
+ self._return_focus_to_chat()
299
+
300
+ def clear_results(self) -> None:
301
+ """Clear search results"""
302
+ self.search_results = []
303
+ self._update_results_ui()
304
+
305
+ def watch_is_searching(self, is_searching: bool) -> None:
306
+ """Watch the is_searching property"""
307
+ loading = self.query_one("#loading-indicator")
308
+ loading.display = True if is_searching else False