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.
app/main.py ADDED
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Simplified version of Chat CLI with AI functionality
4
+ """
5
+ import os
6
+ import asyncio
7
+ from typing import List, Optional, Callable, Awaitable
8
+ from datetime import datetime
9
+
10
+ from textual.app import App, ComposeResult
11
+ from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, Center
12
+ from textual.reactive import reactive
13
+ from textual.widgets import Button, Input, Label, Static, Header, Footer, ListView, ListItem
14
+ from textual.binding import Binding
15
+ from textual import work
16
+ from textual.screen import Screen
17
+ from openai import OpenAI
18
+ from app.models import Message, Conversation
19
+ from app.database import ChatDatabase
20
+ from app.config import CONFIG, OPENAI_API_KEY, ANTHROPIC_API_KEY, OLLAMA_BASE_URL
21
+ from app.ui.chat_interface import MessageDisplay
22
+ from app.ui.model_selector import ModelSelector, StyleSelector
23
+ from app.ui.chat_list import ChatList
24
+ from app.api.base import BaseModelClient
25
+ from app.utils import generate_streaming_response
26
+
27
+ class SettingsScreen(Screen):
28
+ """Screen for model and style settings."""
29
+
30
+ CSS = """
31
+ #settings-container {
32
+ width: 60;
33
+ height: auto;
34
+ background: $surface;
35
+ border: solid $primary;
36
+ padding: 1;
37
+ }
38
+
39
+ #title {
40
+ width: 100%;
41
+ height: 2;
42
+ content-align: center middle;
43
+ text-align: center;
44
+ background: $surface-darken-2;
45
+ border-bottom: solid $primary-darken-2;
46
+ }
47
+
48
+ #button-row {
49
+ width: 100%;
50
+ height: auto;
51
+ align-horizontal: right;
52
+ margin-top: 1;
53
+ }
54
+
55
+ #button-row Button {
56
+ width: auto;
57
+ min-width: 8;
58
+ height: 2;
59
+ margin-left: 1;
60
+ border: solid $primary;
61
+ color: $text;
62
+ background: $primary-darken-1;
63
+ content-align: center middle;
64
+ }
65
+ """
66
+
67
+ def compose(self) -> ComposeResult:
68
+ """Create the settings screen layout."""
69
+ with Center():
70
+ with Container(id="settings-container"):
71
+ yield Static("Settings", id="title")
72
+ yield ModelSelector(self.app.selected_model)
73
+ yield StyleSelector(self.app.selected_style)
74
+ with Horizontal(id="button-row"):
75
+ yield Button("Cancel", variant="default")
76
+ yield Button("Done", variant="primary")
77
+
78
+ BINDINGS = [
79
+ Binding("escape", "action_cancel", "Cancel"),
80
+ ]
81
+
82
+ def action_cancel(self) -> None:
83
+ """Handle cancel action"""
84
+ self.app.pop_screen()
85
+
86
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
87
+ """Handle button presses in settings screen."""
88
+ # Pop screen for both Done and Cancel
89
+ self.app.pop_screen()
90
+
91
+ # Only update settings if Done was pressed
92
+ if event.button.label == "Done" and self.app.current_conversation:
93
+ try:
94
+ self.app.db.update_conversation(
95
+ self.app.current_conversation.id,
96
+ model=self.app.selected_model,
97
+ style=self.app.selected_style
98
+ )
99
+ self.app.current_conversation.model = self.app.selected_model
100
+ self.app.current_conversation.style = self.app.selected_style
101
+ except Exception as e:
102
+ self.app.notify(f"Error updating settings: {str(e)}", severity="error")
103
+
104
+ class HistoryScreen(Screen):
105
+ """Screen for viewing chat history."""
106
+
107
+ BINDINGS = [
108
+ Binding("escape", "pop_screen", "Close"),
109
+ ]
110
+
111
+ CSS = """
112
+ #history-container {
113
+ width: 80;
114
+ height: 40;
115
+ background: $surface;
116
+ border: round $primary;
117
+ padding: 1;
118
+ }
119
+
120
+ #title {
121
+ width: 100%;
122
+ content-align: center middle;
123
+ text-align: center;
124
+ padding-bottom: 1;
125
+ }
126
+
127
+ ListView {
128
+ width: 100%;
129
+ height: 1fr;
130
+ border: solid $primary;
131
+ }
132
+
133
+ ListItem {
134
+ padding: 1;
135
+ border-bottom: solid $primary-darken-2;
136
+ }
137
+
138
+ ListItem:hover {
139
+ background: $primary-darken-1;
140
+ }
141
+
142
+ #button-row {
143
+ width: 100%;
144
+ height: 3;
145
+ align-horizontal: center;
146
+ margin-top: 1;
147
+ }
148
+ """
149
+
150
+ def __init__(self, conversations: List[dict], callback: Callable[[int], Awaitable[None]]):
151
+ super().__init__()
152
+ self.conversations = conversations
153
+ self.callback = callback
154
+
155
+ def compose(self) -> ComposeResult:
156
+ """Create the history screen layout."""
157
+ with Center():
158
+ with Container(id="history-container"):
159
+ yield Static("Chat History", id="title")
160
+ yield ListView(id="history-list")
161
+ with Horizontal(id="button-row"):
162
+ yield Button("Cancel", variant="primary")
163
+
164
+ async def on_mount(self) -> None:
165
+ """Initialize the history list after mount."""
166
+ list_view = self.query_one("#history-list", ListView)
167
+ for conv in self.conversations:
168
+ title = conv["title"]
169
+ model = conv["model"]
170
+ if model in CONFIG["available_models"]:
171
+ model = CONFIG["available_models"][model]["display_name"]
172
+ item = ListItem(Label(f"{title} ({model})"))
173
+ # Prefix numeric IDs with 'conv-' to make them valid identifiers
174
+ item.id = f"conv-{conv['id']}"
175
+ await list_view.mount(item)
176
+
177
+ async def on_list_view_selected(self, event: ListView.Selected) -> None:
178
+ """Handle conversation selection."""
179
+ # Remove 'conv-' prefix to get the numeric ID
180
+ conv_id = int(event.item.id.replace('conv-', ''))
181
+ self.app.pop_screen()
182
+ await self.callback(conv_id)
183
+
184
+ def on_button_pressed(self, event: Button.Pressed) -> None:
185
+ if event.button.label == "Cancel":
186
+ self.app.pop_screen()
187
+
188
+ class SimpleChatApp(App):
189
+ """Simplified Chat CLI application."""
190
+
191
+ TITLE = "Chat CLI"
192
+ SUB_TITLE = "AI Chat Interface"
193
+ DARK = True
194
+
195
+ CSS = """
196
+ #main-content {
197
+ width: 100%;
198
+ height: 100%;
199
+ padding: 0 1;
200
+ }
201
+
202
+ #conversation-title {
203
+ width: 100%;
204
+ height: 2;
205
+ background: $surface-darken-2;
206
+ color: $text;
207
+ content-align: center middle;
208
+ text-align: center;
209
+ border-bottom: solid $primary-darken-2;
210
+ }
211
+
212
+ #messages-container {
213
+ width: 100%;
214
+ height: 1fr;
215
+ min-height: 10;
216
+ border-bottom: solid $primary-darken-2;
217
+ overflow: auto;
218
+ padding: 0 1;
219
+ }
220
+
221
+ #loading-indicator {
222
+ width: 100%;
223
+ height: 1;
224
+ background: $primary-darken-1;
225
+ color: $text;
226
+ content-align: center middle;
227
+ text-align: center;
228
+ }
229
+
230
+ #loading-indicator.hidden {
231
+ display: none;
232
+ }
233
+
234
+ #input-area {
235
+ width: 100%;
236
+ height: auto;
237
+ min-height: 4;
238
+ max-height: 10;
239
+ padding: 1;
240
+ }
241
+
242
+ #message-input {
243
+ width: 1fr;
244
+ min-height: 2;
245
+ height: auto;
246
+ margin-right: 1;
247
+ border: solid $primary-darken-2;
248
+ }
249
+
250
+ #message-input:focus {
251
+ border: solid $primary;
252
+ }
253
+
254
+ #send-button {
255
+ width: auto;
256
+ min-width: 8;
257
+ height: 2;
258
+ color: $text;
259
+ background: $primary;
260
+ border: solid $primary;
261
+ content-align: center middle;
262
+ }
263
+
264
+ #button-row {
265
+ width: 100%;
266
+ height: auto;
267
+ align-horizontal: right;
268
+ }
269
+
270
+ #new-chat-button {
271
+ width: auto;
272
+ min-width: 8;
273
+ height: 2;
274
+ color: $text;
275
+ background: $success;
276
+ border: solid $success-lighten-1;
277
+ content-align: center middle;
278
+ }
279
+
280
+ #view-history-button, #settings-button {
281
+ width: auto;
282
+ min-width: 8;
283
+ height: 2;
284
+ color: $text;
285
+ background: $primary-darken-1;
286
+ border: solid $primary;
287
+ margin-right: 1;
288
+ content-align: center middle;
289
+ }
290
+ """
291
+
292
+ BINDINGS = [
293
+ Binding("q", "quit", "Quit"),
294
+ Binding("n", "action_new_conversation", "New Chat"),
295
+ Binding("escape", "escape", "Cancel"),
296
+ Binding("ctrl+c", "quit", "Quit"),
297
+ ]
298
+
299
+ current_conversation = reactive(None)
300
+ is_generating = reactive(False)
301
+
302
+ def __init__(self):
303
+ super().__init__()
304
+ self.db = ChatDatabase()
305
+ self.messages = []
306
+ self.selected_model = CONFIG["default_model"]
307
+ self.selected_style = CONFIG["default_style"]
308
+
309
+ def compose(self) -> ComposeResult:
310
+ """Create the simplified application layout."""
311
+ yield Header()
312
+
313
+ with Vertical(id="main-content"):
314
+ # Conversation title
315
+ yield Static("New Conversation", id="conversation-title")
316
+
317
+ # Messages area
318
+ with ScrollableContainer(id="messages-container"):
319
+ # Will be populated with messages
320
+ pass
321
+
322
+ # Loading indicator
323
+ yield Static("Generating response...", id="loading-indicator", classes="hidden")
324
+
325
+ # Input area
326
+ with Container(id="input-area"):
327
+ yield Input(placeholder="Type your message here...", id="message-input")
328
+ yield Button("Send", id="send-button", variant="primary")
329
+ with Horizontal(id="button-row"):
330
+ yield Button("Settings", id="settings-button", variant="primary")
331
+ yield Button("View History", id="view-history-button", variant="primary")
332
+ yield Button("+ New Chat", id="new-chat-button")
333
+
334
+ yield Footer()
335
+
336
+ async def on_mount(self) -> None:
337
+ """Initialize the application on mount."""
338
+ # Check API keys and services
339
+ api_issues = []
340
+ if not OPENAI_API_KEY:
341
+ api_issues.append("- OPENAI_API_KEY is not set")
342
+ if not ANTHROPIC_API_KEY:
343
+ api_issues.append("- ANTHROPIC_API_KEY is not set")
344
+
345
+ # Check Ollama availability
346
+ from app.api.ollama import OllamaClient
347
+ try:
348
+ ollama = OllamaClient()
349
+ models = await ollama.get_available_models()
350
+ if not models:
351
+ api_issues.append("- No Ollama models found")
352
+ except Exception:
353
+ api_issues.append("- Ollama server not running")
354
+
355
+ if api_issues:
356
+ self.notify(
357
+ "Service issues detected:\n" + "\n".join(api_issues) +
358
+ "\n\nEnsure services are configured and running.",
359
+ title="Service Warning",
360
+ severity="warning",
361
+ timeout=10
362
+ )
363
+
364
+ # Create a new conversation
365
+ await self.create_new_conversation()
366
+ # Focus the input
367
+ self.query_one("#message-input").focus()
368
+
369
+ async def create_new_conversation(self) -> None:
370
+ """Create a new chat conversation."""
371
+ # Create new conversation in database using selected model and style
372
+ model = self.selected_model
373
+ style = self.selected_style
374
+
375
+ # Create a title for the new conversation
376
+ title = f"New conversation ({datetime.now().strftime('%Y-%m-%d %H:%M')})"
377
+
378
+ # Create conversation in database using the correct method
379
+ conversation_id = self.db.create_conversation(title, model, style)
380
+
381
+ # Get the full conversation data
382
+ conversation_data = self.db.get_conversation(conversation_id)
383
+
384
+ # Set as current conversation
385
+ self.current_conversation = Conversation.from_dict(conversation_data)
386
+
387
+ # Update UI
388
+ title = self.query_one("#conversation-title", Static)
389
+ title.update(self.current_conversation.title)
390
+
391
+ # Clear messages and update UI
392
+ self.messages = []
393
+ await self.update_messages_ui()
394
+
395
+ async def action_new_conversation(self) -> None:
396
+ """Handle the new conversation action."""
397
+ await self.create_new_conversation()
398
+
399
+ def action_escape(self) -> None:
400
+ """Handle escape key."""
401
+ if self.is_generating:
402
+ self.is_generating = False
403
+ self.notify("Generation stopped", severity="warning")
404
+ loading = self.query_one("#loading-indicator")
405
+ loading.add_class("hidden")
406
+ elif self.screen is not self.screen_stack[-1]:
407
+ # If we're in a sub-screen, pop it
408
+ self.pop_screen()
409
+
410
+ async def update_messages_ui(self) -> None:
411
+ """Update the messages UI."""
412
+ # Clear existing messages
413
+ messages_container = self.query_one("#messages-container")
414
+ messages_container.remove_children()
415
+
416
+ # Add messages with a small delay between each
417
+ for message in self.messages:
418
+ display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"])
419
+ messages_container.mount(display)
420
+ messages_container.scroll_end(animate=False)
421
+ await asyncio.sleep(0.01) # Small delay to prevent UI freezing
422
+
423
+ # Final scroll to bottom
424
+ messages_container.scroll_end(animate=False)
425
+
426
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
427
+ """Handle input submission."""
428
+ await self.action_send_message()
429
+
430
+ async def action_send_message(self) -> None:
431
+ """Initiate message sending."""
432
+ input_widget = self.query_one("#message-input", Input)
433
+ content = input_widget.value.strip()
434
+
435
+ if not content or not self.current_conversation:
436
+ return
437
+
438
+ # Clear input
439
+ input_widget.value = ""
440
+
441
+ # Create user message
442
+ user_message = Message(role="user", content=content)
443
+ self.messages.append(user_message)
444
+
445
+ # Save to database
446
+ self.db.add_message(
447
+ self.current_conversation.id,
448
+ "user",
449
+ content
450
+ )
451
+
452
+ # Update UI
453
+ await self.update_messages_ui()
454
+
455
+ # Generate AI response
456
+ await self.generate_response()
457
+
458
+ # Focus back on input
459
+ input_widget.focus()
460
+
461
+ async def generate_response(self) -> None:
462
+ """Generate an AI response."""
463
+ if not self.current_conversation or not self.messages:
464
+ return
465
+
466
+ self.is_generating = True
467
+ loading = self.query_one("#loading-indicator")
468
+ loading.remove_class("hidden")
469
+
470
+ try:
471
+ # Get conversation parameters
472
+ model = self.selected_model
473
+ style = self.selected_style
474
+
475
+ # Convert messages to API format
476
+ api_messages = []
477
+ for msg in self.messages:
478
+ api_messages.append({
479
+ "role": msg.role,
480
+ "content": msg.content
481
+ })
482
+
483
+ # Get appropriate client
484
+ client = BaseModelClient.get_client_for_model(model)
485
+
486
+ # Start streaming response
487
+ assistant_message = Message(role="assistant", content="")
488
+ self.messages.append(assistant_message)
489
+ messages_container = self.query_one("#messages-container")
490
+ message_display = MessageDisplay(assistant_message, highlight_code=CONFIG["highlight_code"])
491
+ messages_container.mount(message_display)
492
+ messages_container.scroll_end(animate=False)
493
+
494
+ # Stream chunks to the UI
495
+ async def update_ui(chunk: str):
496
+ if not self.is_generating:
497
+ return
498
+
499
+ try:
500
+ assistant_message.content += chunk
501
+ # Update UI directly
502
+ message_display.update_content(assistant_message.content)
503
+ messages_container.scroll_end(animate=False)
504
+ # Let the event loop process the update
505
+ await asyncio.sleep(0)
506
+ except Exception as e:
507
+ self.notify(f"Error updating UI: {str(e)}", severity="error")
508
+
509
+ # Generate the response
510
+ full_response = await generate_streaming_response(
511
+ api_messages,
512
+ model,
513
+ style,
514
+ client,
515
+ update_ui
516
+ )
517
+
518
+ # Save to database
519
+ if self.is_generating: # Only save if not cancelled
520
+ self.db.add_message(
521
+ self.current_conversation.id,
522
+ "assistant",
523
+ full_response
524
+ )
525
+
526
+ except Exception as e:
527
+ self.notify(f"Error generating response: {str(e)}", severity="error")
528
+ # Add error message
529
+ error_msg = f"Error generating response: {str(e)}"
530
+ self.messages.append(Message(role="assistant", content=error_msg))
531
+ await self.update_messages_ui()
532
+ finally:
533
+ self.is_generating = False
534
+ loading = self.query_one("#loading-indicator")
535
+ loading.add_class("hidden")
536
+
537
+ def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None:
538
+ """Handle model selection"""
539
+ self.selected_model = event.model_id
540
+
541
+ def on_style_selector_style_selected(self, event: StyleSelector.StyleSelected) -> None:
542
+ """Handle style selection"""
543
+ self.selected_style = event.style_id
544
+
545
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
546
+ """Handle button presses."""
547
+ button_id = event.button.id
548
+
549
+ if button_id == "send-button":
550
+ await self.action_send_message()
551
+ elif button_id == "new-chat-button":
552
+ await self.create_new_conversation()
553
+ elif button_id == "settings-button":
554
+ self.push_screen(SettingsScreen())
555
+ elif button_id == "view-history-button":
556
+ await self.view_chat_history()
557
+
558
+ async def view_chat_history(self) -> None:
559
+ """Show chat history in a popup."""
560
+ # Get recent conversations
561
+ conversations = self.db.get_all_conversations(limit=CONFIG["max_history_items"])
562
+ if not conversations:
563
+ self.notify("No chat history found", severity="warning")
564
+ return
565
+
566
+ async def handle_selection(selected_id: int) -> None:
567
+ if not selected_id:
568
+ return
569
+
570
+ # Get full conversation
571
+ conversation_data = self.db.get_conversation(selected_id)
572
+ if not conversation_data:
573
+ self.notify("Could not load conversation", severity="error")
574
+ return
575
+
576
+ # Update current conversation
577
+ self.current_conversation = Conversation.from_dict(conversation_data)
578
+
579
+ # Update title
580
+ title = self.query_one("#conversation-title", Static)
581
+ title.update(self.current_conversation.title)
582
+
583
+ # Load messages
584
+ self.messages = [Message(**msg) for msg in self.current_conversation.messages]
585
+ await self.update_messages_ui()
586
+
587
+ # Update model and style selectors
588
+ self.selected_model = self.current_conversation.model
589
+ self.selected_style = self.current_conversation.style
590
+
591
+ self.push_screen(HistoryScreen(conversations, handle_selection))
592
+
593
+ def main():
594
+ """Entry point for the chat-cli application"""
595
+ app = SimpleChatApp()
596
+ app.run()
597
+
598
+ if __name__ == "__main__":
599
+ main()
app/models.py ADDED
@@ -0,0 +1,83 @@
1
+ from dataclasses import dataclass
2
+ from typing import List, Dict, Any, Optional
3
+ from datetime import datetime
4
+
5
+ @dataclass
6
+ class Message:
7
+ """Represents a chat message"""
8
+ id: int = None
9
+ conversation_id: int = None
10
+ role: str = "" # 'user', 'assistant', 'system'
11
+ content: str = ""
12
+ timestamp: str = None
13
+
14
+ @classmethod
15
+ def from_dict(cls, data: Dict[str, Any]) -> 'Message':
16
+ return cls(
17
+ id=data.get('id'),
18
+ conversation_id=data.get('conversation_id'),
19
+ role=data.get('role', ''),
20
+ content=data.get('content', ''),
21
+ timestamp=data.get('timestamp')
22
+ )
23
+
24
+ def to_dict(self) -> Dict[str, Any]:
25
+ return {
26
+ 'id': self.id,
27
+ 'conversation_id': self.conversation_id,
28
+ 'role': self.role,
29
+ 'content': self.content,
30
+ 'timestamp': self.timestamp or datetime.now().isoformat()
31
+ }
32
+
33
+ @dataclass
34
+ class Conversation:
35
+ """Represents a chat conversation"""
36
+ id: int = None
37
+ title: str = ""
38
+ model: str = ""
39
+ created_at: str = None
40
+ updated_at: str = None
41
+ style: str = "default"
42
+ tags: List[str] = None
43
+ messages: List[Message] = None
44
+ message_count: int = 0
45
+
46
+ def __post_init__(self):
47
+ if self.tags is None:
48
+ self.tags = []
49
+ if self.messages is None:
50
+ self.messages = []
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: Dict[str, Any]) -> 'Conversation':
54
+ messages = []
55
+ if 'messages' in data:
56
+ messages = [Message.from_dict(m) if isinstance(m, dict) else m
57
+ for m in data.get('messages', [])]
58
+
59
+ return cls(
60
+ id=data.get('id'),
61
+ title=data.get('title', ''),
62
+ model=data.get('model', ''),
63
+ created_at=data.get('created_at'),
64
+ updated_at=data.get('updated_at'),
65
+ style=data.get('style', 'default'),
66
+ tags=data.get('tags', []),
67
+ messages=messages,
68
+ message_count=data.get('message_count', len(messages))
69
+ )
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ now = datetime.now().isoformat()
73
+ return {
74
+ 'id': self.id,
75
+ 'title': self.title,
76
+ 'model': self.model,
77
+ 'created_at': self.created_at or now,
78
+ 'updated_at': self.updated_at or now,
79
+ 'style': self.style,
80
+ 'tags': self.tags,
81
+ 'messages': [m.to_dict() if isinstance(m, Message) else m for m in self.messages],
82
+ 'message_count': self.message_count or len(self.messages)
83
+ }
app/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """UI components for the terminal chat application."""