chat-console 0.2.0__py3-none-any.whl → 0.2.5__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 +1 -1
- app/api/ollama.py +741 -1
- app/main.py +192 -69
- app/ui/model_browser.py +1146 -0
- app/utils.py +23 -22
- {chat_console-0.2.0.dist-info → chat_console-0.2.5.dist-info}/METADATA +1 -1
- {chat_console-0.2.0.dist-info → chat_console-0.2.5.dist-info}/RECORD +11 -10
- {chat_console-0.2.0.dist-info → chat_console-0.2.5.dist-info}/WHEEL +0 -0
- {chat_console-0.2.0.dist-info → chat_console-0.2.5.dist-info}/entry_points.txt +0 -0
- {chat_console-0.2.0.dist-info → chat_console-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {chat_console-0.2.0.dist-info → chat_console-0.2.5.dist-info}/top_level.txt +0 -0
app/main.py
CHANGED
@@ -23,6 +23,7 @@ from app.config import CONFIG, OPENAI_API_KEY, ANTHROPIC_API_KEY, OLLAMA_BASE_UR
|
|
23
23
|
from app.ui.chat_interface import MessageDisplay, InputWithFocus
|
24
24
|
from app.ui.model_selector import ModelSelector, StyleSelector
|
25
25
|
from app.ui.chat_list import ChatList
|
26
|
+
from app.ui.model_browser import ModelBrowser
|
26
27
|
from app.api.base import BaseModelClient
|
27
28
|
from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title # Import title function
|
28
29
|
# Import version here to avoid potential circular import issues at top level
|
@@ -30,6 +31,26 @@ from app import __version__
|
|
30
31
|
|
31
32
|
# --- Remove SettingsScreen class entirely ---
|
32
33
|
|
34
|
+
class ModelBrowserScreen(Screen):
|
35
|
+
"""Screen for browsing Ollama models."""
|
36
|
+
|
37
|
+
BINDINGS = [
|
38
|
+
Binding("escape", "pop_screen", "Close"),
|
39
|
+
]
|
40
|
+
|
41
|
+
CSS = """
|
42
|
+
#browser-wrapper {
|
43
|
+
width: 100%;
|
44
|
+
height: 100%;
|
45
|
+
background: $surface;
|
46
|
+
}
|
47
|
+
"""
|
48
|
+
|
49
|
+
def compose(self) -> ComposeResult:
|
50
|
+
"""Create the model browser screen layout."""
|
51
|
+
with Container(id="browser-wrapper"):
|
52
|
+
yield ModelBrowser()
|
53
|
+
|
33
54
|
class HistoryScreen(Screen):
|
34
55
|
"""Screen for viewing chat history."""
|
35
56
|
|
@@ -279,18 +300,19 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
279
300
|
|
280
301
|
BINDINGS = [ # Keep SimpleChatApp BINDINGS, ensure Enter is not globally bound for settings
|
281
302
|
Binding("q", "quit", "Quit", show=True, key_display="q"),
|
282
|
-
#
|
283
|
-
Binding("
|
284
|
-
Binding("c", "action_new_conversation", "New Chat", show=False, key_display="c", priority=True), # Add priority to alias too
|
303
|
+
# Removed binding for "n" (new chat) since there's a dedicated button
|
304
|
+
Binding("c", "action_new_conversation", "New Chat", show=False, key_display="c", priority=True), # Keep alias with priority
|
285
305
|
Binding("escape", "escape", "Cancel / Stop", show=True, key_display="esc"), # Escape might close settings panel too
|
286
306
|
Binding("ctrl+c", "quit", "Quit", show=False),
|
287
307
|
Binding("h", "view_history", "History", show=True, key_display="h", priority=True), # Add priority
|
288
308
|
Binding("s", "settings", "Settings", show=True, key_display="s", priority=True), # Add priority
|
289
|
-
|
309
|
+
# Removed binding for "t" (title update) since there's a dedicated button
|
310
|
+
Binding("m", "model_browser", "Model Browser", show=True, key_display="m", priority=True), # Add model browser binding
|
290
311
|
] # Keep SimpleChatApp BINDINGS end
|
291
312
|
|
292
313
|
current_conversation = reactive(None) # Keep SimpleChatApp reactive var
|
293
314
|
is_generating = reactive(False) # Keep SimpleChatApp reactive var
|
315
|
+
current_generation_task: Optional[asyncio.Task] = None # Add task reference
|
294
316
|
|
295
317
|
def __init__(self, initial_text: Optional[str] = None): # Keep SimpleChatApp __init__
|
296
318
|
super().__init__() # Keep SimpleChatApp __init__
|
@@ -441,26 +463,32 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
441
463
|
await self.create_new_conversation() # Keep SimpleChatApp action_new_conversation
|
442
464
|
log("action_new_conversation finished") # Added log
|
443
465
|
|
444
|
-
def action_escape(self) -> None:
|
466
|
+
def action_escape(self) -> None:
|
445
467
|
"""Handle escape key globally."""
|
446
|
-
log("action_escape triggered")
|
468
|
+
log("action_escape triggered")
|
447
469
|
settings_panel = self.query_one("#settings-panel")
|
448
|
-
log(f"Settings panel visible: {settings_panel.has_class('visible')}")
|
470
|
+
log(f"Settings panel visible: {settings_panel.has_class('visible')}")
|
471
|
+
|
449
472
|
if settings_panel.has_class("visible"):
|
450
|
-
log("Hiding settings panel")
|
451
|
-
# If settings panel is visible, hide it
|
473
|
+
log("Hiding settings panel")
|
452
474
|
settings_panel.remove_class("visible")
|
453
|
-
self.query_one("#message-input").focus()
|
475
|
+
self.query_one("#message-input").focus()
|
454
476
|
elif self.is_generating:
|
455
|
-
log("
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
477
|
+
log("Attempting to cancel generation task")
|
478
|
+
if self.current_generation_task and not self.current_generation_task.done():
|
479
|
+
log("Cancelling active generation task.")
|
480
|
+
self.current_generation_task.cancel()
|
481
|
+
# The finally block in generate_response will handle is_generating = False and UI updates
|
482
|
+
self.notify("Stopping generation...", severity="warning", timeout=2) # Notify user immediately
|
483
|
+
else:
|
484
|
+
# This case might happen if is_generating is True, but no active task found to cancel. Resetting flag.")
|
485
|
+
self.is_generating = False # Reset flag manually if task is missing
|
486
|
+
loading = self.query_one("#loading-indicator")
|
487
|
+
loading.add_class("hidden")
|
488
|
+
else:
|
489
|
+
log("Escape pressed, but settings not visible and not actively generating.")
|
490
|
+
# Optionally add other escape behaviors here if needed for the main screen
|
491
|
+
# e.g., clear input, deselect item, etc.
|
464
492
|
|
465
493
|
def update_app_info(self) -> None:
|
466
494
|
"""Update the displayed app information."""
|
@@ -651,10 +679,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
651
679
|
log.error(f"Error updating UI: {str(e)}") # Use log instead of logger
|
652
680
|
|
653
681
|
# Generate the response with timeout and cleanup # Keep SimpleChatApp generate_response
|
654
|
-
|
682
|
+
self.current_generation_task = None # Clear previous task reference
|
655
683
|
try: # Keep SimpleChatApp generate_response
|
656
684
|
# Create a task for the response generation # Keep SimpleChatApp generate_response
|
657
|
-
|
685
|
+
self.current_generation_task = asyncio.create_task( # Keep SimpleChatApp generate_response
|
658
686
|
generate_streaming_response( # Keep SimpleChatApp generate_response
|
659
687
|
self, # Pass the app instance
|
660
688
|
api_messages, # Keep SimpleChatApp generate_response
|
@@ -666,11 +694,13 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
666
694
|
) # Keep SimpleChatApp generate_response
|
667
695
|
|
668
696
|
# Wait for response with timeout # Keep SimpleChatApp generate_response
|
669
|
-
|
697
|
+
log.info(f"Waiting for generation task {self.current_generation_task} with timeout...") # Add log
|
698
|
+
full_response = await asyncio.wait_for(self.current_generation_task, timeout=60) # Longer timeout # Keep SimpleChatApp generate_response
|
699
|
+
log.info(f"Generation task {self.current_generation_task} completed. Full response length: {len(full_response) if full_response else 0}") # Add log
|
670
700
|
|
671
|
-
# Save to database only if we got a complete response
|
672
|
-
if self.is_generating and full_response: #
|
673
|
-
log("Generation finished, saving full response to DB") # Added log
|
701
|
+
# Save to database only if we got a complete response and weren't cancelled
|
702
|
+
if self.is_generating and full_response: # Check is_generating flag here
|
703
|
+
log("Generation finished normally, saving full response to DB") # Added log
|
674
704
|
self.db.add_message( # Keep SimpleChatApp generate_response
|
675
705
|
self.current_conversation.id, # Keep SimpleChatApp generate_response
|
676
706
|
"assistant", # Keep SimpleChatApp generate_response
|
@@ -679,11 +709,24 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
679
709
|
# Force a final refresh # Keep SimpleChatApp generate_response
|
680
710
|
self.refresh(layout=True) # Keep SimpleChatApp generate_response
|
681
711
|
await asyncio.sleep(0.1) # Wait for UI to update # Keep SimpleChatApp generate_response
|
682
|
-
elif not full_response:
|
712
|
+
elif not full_response and self.is_generating: # Only log if not cancelled
|
683
713
|
log("Generation finished but full_response is empty/None") # Added log
|
714
|
+
else:
|
715
|
+
# This case handles cancellation where full_response might be partial or None
|
716
|
+
log("Generation was cancelled or finished without a full response.")
|
717
|
+
|
718
|
+
except asyncio.CancelledError: # Handle cancellation explicitly
|
719
|
+
log.warning("Generation task was cancelled.")
|
720
|
+
self.notify("Generation stopped by user.", severity="warning")
|
721
|
+
# Remove the potentially incomplete message from UI state
|
722
|
+
if self.messages and self.messages[-1].role == "assistant":
|
723
|
+
self.messages.pop()
|
724
|
+
await self.update_messages_ui() # Update UI to remove partial message
|
684
725
|
|
685
726
|
except asyncio.TimeoutError: # Keep SimpleChatApp generate_response
|
686
|
-
log.error("Response generation timed out") # Use log instead of logger
|
727
|
+
log.error(f"Response generation timed out waiting for task {self.current_generation_task}") # Use log instead of logger
|
728
|
+
# Log state at timeout
|
729
|
+
log.error(f"Timeout state: is_generating={self.is_generating}, task_done={self.current_generation_task.done() if self.current_generation_task else 'N/A'}")
|
687
730
|
error_msg = "Response generation timed out. The model may be busy or unresponsive. Please try again." # Keep SimpleChatApp generate_response
|
688
731
|
self.notify(error_msg, severity="error") # Keep SimpleChatApp generate_response
|
689
732
|
|
@@ -695,31 +738,36 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
695
738
|
await self.update_messages_ui() # Keep SimpleChatApp generate_response
|
696
739
|
|
697
740
|
finally: # Keep SimpleChatApp generate_response
|
698
|
-
# Ensure
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
await generation_task # Keep SimpleChatApp generate_response
|
705
|
-
except (asyncio.CancelledError, Exception) as e: # Keep SimpleChatApp generate_response
|
706
|
-
log.error(f"Error cleaning up generation task: {str(e)}") # Use log instead of logger
|
707
|
-
|
741
|
+
# Ensure flag is reset and task reference is cleared
|
742
|
+
log(f"Setting is_generating to False in finally block") # Added log
|
743
|
+
self.is_generating = False # Keep SimpleChatApp generate_response
|
744
|
+
self.current_generation_task = None # Clear task reference
|
745
|
+
loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
|
746
|
+
loading.add_class("hidden") # Keep SimpleChatApp generate_response
|
708
747
|
# Force a final UI refresh # Keep SimpleChatApp generate_response
|
709
748
|
self.refresh(layout=True) # Keep SimpleChatApp generate_response
|
710
749
|
|
711
750
|
except Exception as e: # Keep SimpleChatApp generate_response
|
712
|
-
|
751
|
+
# Catch any other unexpected errors during generation setup/handling
|
752
|
+
log.error(f"Unexpected exception during generate_response: {str(e)}") # Added log
|
713
753
|
self.notify(f"Error generating response: {str(e)}", severity="error") # Keep SimpleChatApp generate_response
|
714
|
-
# Add error message # Keep SimpleChatApp generate_response
|
715
|
-
error_msg = f"Error
|
754
|
+
# Add error message to UI # Keep SimpleChatApp generate_response
|
755
|
+
error_msg = f"Error: {str(e)}" # Keep SimpleChatApp generate_response
|
716
756
|
self.messages.append(Message(role="assistant", content=error_msg)) # Keep SimpleChatApp generate_response
|
717
757
|
await self.update_messages_ui() # Keep SimpleChatApp generate_response
|
718
|
-
|
719
|
-
|
720
|
-
|
758
|
+
# The finally block below will handle resetting is_generating and hiding loading
|
759
|
+
|
760
|
+
finally: # Keep SimpleChatApp generate_response - This finally block now primarily handles cleanup
|
761
|
+
log(f"Ensuring is_generating is False and task is cleared in outer finally block") # Added log
|
762
|
+
self.is_generating = False # Ensure flag is always reset
|
763
|
+
self.current_generation_task = None # Ensure task ref is cleared
|
721
764
|
loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
|
722
|
-
loading.add_class("hidden") #
|
765
|
+
loading.add_class("hidden") # Ensure loading indicator is hidden
|
766
|
+
# Re-focus input after generation attempt (success, failure, or cancel)
|
767
|
+
try:
|
768
|
+
self.query_one("#message-input").focus()
|
769
|
+
except Exception:
|
770
|
+
pass # Ignore if input not found
|
723
771
|
|
724
772
|
def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None: # Keep SimpleChatApp on_model_selector_model_selected
|
725
773
|
"""Handle model selection""" # Keep SimpleChatApp on_model_selector_model_selected docstring
|
@@ -822,6 +870,11 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
822
870
|
input_widget = self.query_one("#message-input", Input) # Keep SimpleChatApp action_view_history
|
823
871
|
if not input_widget.has_focus: # Keep SimpleChatApp action_view_history
|
824
872
|
await self.view_chat_history() # Keep SimpleChatApp action_view_history
|
873
|
+
|
874
|
+
def action_model_browser(self) -> None:
|
875
|
+
"""Open the Ollama model browser screen."""
|
876
|
+
# Always trigger regardless of focus
|
877
|
+
self.push_screen(ModelBrowserScreen())
|
825
878
|
|
826
879
|
def action_settings(self) -> None: # Modify SimpleChatApp action_settings
|
827
880
|
"""Action to open/close settings panel via key binding."""
|
@@ -852,40 +905,110 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
|
|
852
905
|
return
|
853
906
|
|
854
907
|
# --- Define the Modal Class ---
|
855
|
-
class
|
856
|
-
|
908
|
+
class ConfirmDialog(Static):
|
909
|
+
"""A simple confirmation dialog."""
|
910
|
+
|
911
|
+
class Confirmed(Message):
|
912
|
+
"""Message sent when the dialog is confirmed."""
|
913
|
+
def __init__(self, confirmed: bool):
|
914
|
+
self.confirmed = confirmed
|
915
|
+
super().__init__()
|
916
|
+
|
917
|
+
def __init__(self, message: str):
|
857
918
|
super().__init__()
|
858
|
-
self.
|
859
|
-
|
919
|
+
self.message = message
|
920
|
+
|
860
921
|
def compose(self) -> ComposeResult:
|
861
|
-
with Vertical(id="
|
862
|
-
yield Static(
|
863
|
-
yield Input(value=self.current_title, id="title-input")
|
922
|
+
with Vertical(id="confirm-dialog"):
|
923
|
+
yield Static(self.message, id="confirm-message")
|
864
924
|
with Horizontal():
|
865
|
-
yield Button("
|
866
|
-
yield Button("
|
867
|
-
|
868
|
-
@on(Button.Pressed, "#
|
869
|
-
def
|
870
|
-
|
871
|
-
|
872
|
-
|
873
|
-
|
874
|
-
asyncio.create_task(self.app.update_conversation_title(new_title))
|
875
|
-
self.remove() # Close the modal
|
876
|
-
|
877
|
-
@on(Button.Pressed, "#cancel-button")
|
925
|
+
yield Button("No", id="no-button", variant="error")
|
926
|
+
yield Button("Yes", id="yes-button", variant="success")
|
927
|
+
|
928
|
+
@on(Button.Pressed, "#yes-button")
|
929
|
+
def confirm(self, event: Button.Pressed) -> None:
|
930
|
+
self.post_message(self.Confirmed(True))
|
931
|
+
self.remove() # Close the dialog
|
932
|
+
|
933
|
+
@on(Button.Pressed, "#no-button")
|
878
934
|
def cancel(self, event: Button.Pressed) -> None:
|
879
|
-
self.
|
880
|
-
|
935
|
+
self.post_message(self.Confirmed(False))
|
936
|
+
self.remove() # Close the dialog
|
937
|
+
|
938
|
+
def on_confirmed(self, event: Confirmed) -> None:
|
939
|
+
"""Event handler for confirmation - used by the app to get the result."""
|
940
|
+
pass
|
941
|
+
|
881
942
|
def on_mount(self) -> None:
|
882
|
-
"""
|
883
|
-
self.
|
943
|
+
"""Set the CSS style when mounted."""
|
944
|
+
self.styles.width = "40"
|
945
|
+
self.styles.height = "auto"
|
946
|
+
self.styles.background = "var(--surface)"
|
947
|
+
self.styles.border = "thick var(--primary)"
|
948
|
+
self.styles.align = "center middle"
|
949
|
+
self.styles.padding = "1 2"
|
950
|
+
self.styles.layer = "modal"
|
951
|
+
|
952
|
+
class TitleInputModal(Static):
|
953
|
+
def __init__(self, current_title: str):
|
954
|
+
super().__init__()
|
955
|
+
self.current_title = current_title
|
956
|
+
|
957
|
+
def compose(self) -> ComposeResult:
|
958
|
+
with Vertical(id="title-modal"):
|
959
|
+
yield Static("Enter new conversation title:", id="modal-label")
|
960
|
+
yield Input(value=self.current_title, id="title-input")
|
961
|
+
with Horizontal():
|
962
|
+
yield Button("Cancel", id="cancel-button", variant="error")
|
963
|
+
yield Button("Update", id="update-button", variant="success")
|
964
|
+
|
965
|
+
@on(Button.Pressed, "#update-button")
|
966
|
+
def update_title(self, event: Button.Pressed) -> None:
|
967
|
+
input_widget = self.query_one("#title-input", Input)
|
968
|
+
new_title = input_widget.value.strip()
|
969
|
+
if new_title:
|
970
|
+
# Call the app's update method asynchronously
|
971
|
+
asyncio.create_task(self.app.update_conversation_title(new_title))
|
972
|
+
self.remove() # Close the modal
|
973
|
+
|
974
|
+
@on(Button.Pressed, "#cancel-button")
|
975
|
+
def cancel(self, event: Button.Pressed) -> None:
|
976
|
+
self.remove() # Close the modal
|
977
|
+
|
978
|
+
async def on_mount(self) -> None:
|
979
|
+
"""Focus the input when the modal appears."""
|
980
|
+
self.query_one("#title-input", Input).focus()
|
884
981
|
|
885
982
|
# --- Show the modal ---
|
886
983
|
modal = TitleInputModal(self.current_conversation.title)
|
887
984
|
await self.mount(modal) # Use await for mounting
|
888
985
|
|
986
|
+
async def run_modal(self, modal_type: str, *args, **kwargs) -> bool:
|
987
|
+
"""Run a modal dialog and return the result."""
|
988
|
+
if modal_type == "confirm_dialog":
|
989
|
+
# Create a confirmation dialog with the message from args
|
990
|
+
message = args[0] if args else "Are you sure?"
|
991
|
+
dialog = ConfirmDialog(message)
|
992
|
+
await self.mount(dialog)
|
993
|
+
|
994
|
+
# Setup event handler to receive the result
|
995
|
+
result = False
|
996
|
+
|
997
|
+
def on_confirm(event: ConfirmDialog.Confirmed) -> None:
|
998
|
+
nonlocal result
|
999
|
+
result = event.confirmed
|
1000
|
+
|
1001
|
+
# Add listener for the confirmation event
|
1002
|
+
dialog.on_confirmed = on_confirm
|
1003
|
+
|
1004
|
+
# Wait for the dialog to close
|
1005
|
+
while dialog.is_mounted:
|
1006
|
+
await self.sleep(0.1)
|
1007
|
+
|
1008
|
+
return result
|
1009
|
+
|
1010
|
+
return False
|
1011
|
+
|
889
1012
|
async def update_conversation_title(self, new_title: str) -> None:
|
890
1013
|
"""Update the current conversation title"""
|
891
1014
|
if not self.current_conversation:
|
@@ -931,4 +1054,4 @@ def main(initial_text: Optional[str] = typer.Argument(None, help="Initial text t
|
|
931
1054
|
app.run() # Keep main function
|
932
1055
|
|
933
1056
|
if __name__ == "__main__": # Keep main function entry point
|
934
|
-
typer.run(main) # Keep main function entry point
|
1057
|
+
typer.run(main) # Keep main function entry point
|