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/__init__.py +6 -0
- app/api/__init__.py +1 -0
- app/api/anthropic.py +92 -0
- app/api/base.py +74 -0
- app/api/ollama.py +116 -0
- app/api/openai.py +78 -0
- app/config.py +127 -0
- app/database.py +285 -0
- app/main.py +599 -0
- app/models.py +83 -0
- app/ui/__init__.py +1 -0
- app/ui/chat_interface.py +345 -0
- app/ui/chat_list.py +336 -0
- app/ui/model_selector.py +296 -0
- app/ui/search.py +308 -0
- app/ui/styles.py +275 -0
- app/utils.py +202 -0
- chat_console-0.1.1.dist-info/LICENSE +21 -0
- chat_console-0.1.1.dist-info/METADATA +111 -0
- chat_console-0.1.1.dist-info/RECORD +23 -0
- chat_console-0.1.1.dist-info/WHEEL +5 -0
- chat_console-0.1.1.dist-info/entry_points.txt +3 -0
- chat_console-0.1.1.dist-info/top_level.txt +1 -0
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."""
|