diff --git a/docs/widgets/(Widget)-Ai-Chat.md b/docs/widgets/(Widget)-Ai-Chat.md index 54f5baa3..d6cee009 100644 --- a/docs/widgets/(Widget)-Ai-Chat.md +++ b/docs/widgets/(Widget)-Ai-Chat.md @@ -6,6 +6,7 @@ The AI Chat widget provides a flexible, interactive chat interface that works wi | Option | Type | Default | Description | |---------------------|---------|-----------------|-------------| | `label` | string | `"AI Chat"` | The label displayed for the widget. | +| `auto_focus_input` | boolean | `false` | Automatically focus the input field when the chat window is opened. | | `chat` | dict | See example | Popup menu configuration (blur, corners, etc). | | `icons` | dict | See example | Icons for send, stop, clear, assistant. | | `notification_dot` | dict | `{'enabled': false, 'corner': 'bottom_left', 'color': 'red', 'margin': [1, 1]}` | A dictionary specifying the notification dot settings for the widget. | @@ -22,6 +23,7 @@ ai_chat: type: "yasb.ai_chat.AiChatWidget" options: label: "\uf086" + auto_focus_input: true chat: blur: true round_corners: true @@ -58,6 +60,7 @@ ai_chat: label: "GPT3.5 Turbo" - name: "gpt-4" label: "GPT4" + default: true temperature: 0.3 top_p: 0.95 max_tokens: 4096 @@ -91,6 +94,7 @@ This widget is ideal for integrating any LLM service that follows the OpenAI API ## Description of Options - **label:** The label displayed for the widget. +- **auto_focus_input:** Automatically focus the input field when the chat window is opened. - **chat:** Dictionary for popup menu appearance. - **blur**: Enable blur effect - **round_corners**: Enable system rounded corners @@ -125,6 +129,7 @@ This widget is ideal for integrating any LLM service that follows the OpenAI API - **models**: List of models, each with: - **name**: Model name - **label**: Display label + - **default**: Optionally mark this provider+model as the default selection (only one model per widget should have this set to `true`) - **max_tokens**: Max tokens per response - **temperature**: Sampling temperature - **top_p**: Nucleus sampling @@ -361,5 +366,11 @@ If you want to use different styles for the context menu, you can target the `.a - If instructions are a file path, it must end with `_chatmode.md` and be accessible. - If streaming fails, check network/API credentials and error messages. +> [!NOTE] +> AI Chat widget supports toggle visibility using the `toggle-widget ai_chat` command in the CLI. More information about the CLI commands can be found in the [CLI documentation](https://github.com/amnweb/yasb/wiki/CLI#toggle-widget-visibility). +> Additionally, you can toggle visibility of specific widget instances by their configuration name — e.g. `toggle-widget ai_chat1` or `toggle-widget `. + + + ## Preview of the Widget ![AI Chat YASB Widget](assets/ec1b9764-1a027260-3e58-1f50-e78022a4eede.png) \ No newline at end of file diff --git a/src/core/utils/widget_builder.py b/src/core/utils/widget_builder.py index 5d2e4c6e..64d3861c 100644 --- a/src/core/utils/widget_builder.py +++ b/src/core/utils/widget_builder.py @@ -68,7 +68,9 @@ def _build_widget(self, widget_name: str) -> Optional[QWidget]: self._collect_nested_listeners(child_names) except Exception: logging.debug("WidgetBuilder failed to collect nested listeners for Grouper") - return widget_cls(**normalized_options) + widget = widget_cls(**normalized_options) + widget.widget_config_name = widget_name + return widget except (AttributeError, ValueError, ModuleNotFoundError): logging.exception(f"Failed to import widget with type {widget_config['type']}") self._invalid_widget_types[widget_name] = widget_config["type"] diff --git a/src/core/validation/widgets/yasb/ai_chat.py b/src/core/validation/widgets/yasb/ai_chat.py index a027c4e3..a56d6b9b 100644 --- a/src/core/validation/widgets/yasb/ai_chat.py +++ b/src/core/validation/widgets/yasb/ai_chat.py @@ -1,5 +1,6 @@ DEFAULTS = { "label": "AI Chat", + "auto_focus_input": False, "chat": { "blur": True, "round_corners": True, @@ -29,6 +30,7 @@ VALIDATION_SCHEMA = { "label": {"type": "string", "default": DEFAULTS["label"]}, + "auto_focus_input": {"type": "boolean", "default": DEFAULTS["auto_focus_input"]}, "container_padding": { "type": "dict", "required": False, @@ -164,6 +166,7 @@ "schema": { "name": {"type": "string", "required": True}, "label": {"type": "string", "required": True}, + "default": {"type": "boolean", "required": False, "default": False}, "max_tokens": {"type": "integer", "required": False, "default": 0}, "temperature": {"type": "number", "required": False, "default": 0.7}, "top_p": {"type": "number", "required": False, "default": 0.95}, diff --git a/src/core/widgets/base.py b/src/core/widgets/base.py index 38849863..8d5b17d7 100644 --- a/src/core/widgets/base.py +++ b/src/core/widgets/base.py @@ -23,6 +23,7 @@ def __init__(self, timer_interval: int = None, class_name: str = ""): self.bar = None self.bar_id = None self.monitor_hwnd = None + self.widget_config_name = None if class_name: self._widget_frame.setProperty("class", f"widget {class_name}") diff --git a/src/core/widgets/yasb/ai_chat.py b/src/core/widgets/yasb/ai_chat.py index e21a73ce..ca5a9dfc 100644 --- a/src/core/widgets/yasb/ai_chat.py +++ b/src/core/widgets/yasb/ai_chat.py @@ -22,11 +22,13 @@ QWidget, ) +from core.event_service import EventService from core.utils.utilities import PopupWidget, add_shadow from core.utils.widgets.ai_chat.client import AiChatClient from core.utils.widgets.ai_chat.client_helper import format_chat_text from core.utils.widgets.animation_manager import AnimationManager from core.utils.win32.utilities import apply_qmenu_style +from core.utils.win32.window_actions import force_foreground_focus from core.validation.widgets.yasb.ai_chat import VALIDATION_SCHEMA from core.widgets.base import BaseWidget @@ -226,10 +228,12 @@ def paintEvent(self, a0: QPaintEvent | None): class AiChatWidget(BaseWidget): validation_schema = VALIDATION_SCHEMA _persistent_chat_history = {} + handle_widget_cli = pyqtSignal(str, str) def __init__( self, label: str, + auto_focus_input: bool, chat: dict, icons: dict, notification_dot: dict[str, Any], @@ -242,12 +246,14 @@ def __init__( ): super().__init__(class_name="ai-chat-widget") self._label_content = label + self._auto_focus_input = auto_focus_input self._icons = icons self._notification_dot: dict[str, Any] = notification_dot self._providers = providers or [] self._provider = None self._provider_config = None self._model = None + self._initialize_provider_and_model() self._popup_chat = None self._animation = animation self._padding = container_padding @@ -278,6 +284,38 @@ def __init__( self._thinking_label = None self._new_notification = False + self._event_service = EventService() + self.handle_widget_cli.connect(self._handle_widget_cli) + self._event_service.register_event("handle_widget_cli", self.handle_widget_cli) + + def _initialize_provider_and_model(self): + """Initialize provider and model by finding the model with default: true flag. + + Validates that only one model has the default flag set. + """ + default_models = [] + + # Find all models with default flag set to true + for provider_cfg in self._providers: + for model_cfg in provider_cfg.get("models", []): + if model_cfg.get("default", False): + default_models.append((provider_cfg["provider"], model_cfg["name"])) + + # Logs warning if more than one model has default flag set + if len(default_models) > 1: + logging.warning( + f"Multiple models have default flag set: {default_models}. Using first model: {default_models[0]}" + ) + + # Set the default provider and model if found + if default_models: + self._provider = default_models[0][0] + self._model = default_models[0][1] + + # Set provider config + if self._provider: + self._provider_config = next((p for p in self._providers if p["provider"] == self._provider), None) + def _create_dynamically_label(self, content: str): label_parts = re.split("(.*?)", content) label_parts = [part for part in label_parts if part] @@ -440,6 +478,19 @@ def _reconnect_streaming_if_needed(self): if not self._streaming_state.get("partial_text", ""): self._start_thinking_animation(msg_label) + def _focus_input(self): + """Activate the popup window and set focus to input field""" + if not self._is_popup_valid(): + return + try: + # Use Win32 API to force the popup window to foreground + hwnd = int(self._popup_chat.winId()) + force_foreground_focus(hwnd) + # Then set focus to the input field + self.input_edit.setFocus() + except RuntimeError as e: + logging.error(f"Error bringing ai_chat window to foreground: {e}") + def _toggle_chat(self): # If popup is not visible or doesn't exist, open it if self._popup_chat is None or not (self._popup_chat and self._popup_chat.isVisible()): @@ -451,6 +502,16 @@ def _toggle_chat(self): self._popup_chat.deleteLater() self._popup_chat = None + def _handle_widget_cli(self, widget: str, screen: str): + """Handle widget CLI commands""" + # Match if widget is "ai_chat" (backward compatibility) or matches widget_config_name + if widget != "ai_chat" and widget != self.widget_config_name: + return + current_screen = self.window().screen() if self.window() else None + current_screen_name = current_screen.name() if current_screen else None + if not screen or (current_screen_name and screen.lower() == current_screen_name.lower()): + self._toggle_chat() + def _show_chat(self): """Show the AI chat popup with all components initialized.""" self._new_notification = False @@ -610,6 +671,8 @@ def _show_chat(self): self._popup_chat.show() self._reconnect_streaming_if_needed() self._update_send_button_state() + if self._auto_focus_input: + self._focus_input() def _populate_provider_menu(self): self.provider_menu.clear()