Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/widgets/(Widget)-Ai-Chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -22,6 +23,7 @@ ai_chat:
type: "yasb.ai_chat.AiChatWidget"
options:
label: "<span>\uf086</span>"
auto_focus_input: true
chat:
blur: true
round_corners: true
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <widget-name>`.



## Preview of the Widget
![AI Chat YASB Widget](assets/ec1b9764-1a027260-3e58-1f50-e78022a4eede.png)
4 changes: 3 additions & 1 deletion src/core/utils/widget_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 3 additions & 0 deletions src/core/validation/widgets/yasb/ai_chat.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
DEFAULTS = {
"label": "AI Chat",
"auto_focus_input": False,
"chat": {
"blur": True,
"round_corners": True,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions src/core/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
63 changes: 63 additions & 0 deletions src/core/widgets/yasb/ai_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -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("(<span.*?>.*?</span>)", content)
label_parts = [part for part in label_parts if part]
Expand Down Expand Up @@ -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()):
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down