This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"""Floating in-app Help assistant — the app icon pinned to the bottom-right of
|
||||
the main window, on every screen. Click it to expand a compact chat panel that
|
||||
greets the user (in the display language) and answers how-to-use-the-app
|
||||
questions only. A chevron on its left collapses it to a thin tab at the screen
|
||||
edge when the user doesn't want it visible.
|
||||
|
||||
It is deliberately minimal: no tools, no file access, no agent loop — just a
|
||||
single ``provider.chat`` per message (same pattern as the AI-draft helpers),
|
||||
scoped by the built-in "help" admin agent's system prompt (see
|
||||
``core.admin_agents``: task_kind "help"). The agent is managed in Monitoring →
|
||||
Agents Admin, so the Admin can pick which provider/model answers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import admin_agents
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import tr
|
||||
from .icons import icon
|
||||
|
||||
_ASSETS = Path(__file__).resolve().parent.parent / "assets"
|
||||
|
||||
_MARGIN = 18 # gap from the window's bottom-right corner
|
||||
_LAUNCHER = 64 # collapsed app-icon badge size (a clean rounded card, like image 2)
|
||||
_LAUNCHER_ICON = 52 # the icon inside it, inset so the light badge frames it
|
||||
_COLLAPSE_W, _COLLAPSE_H = 18, 44 # the "hide to the edge" chevron beside it
|
||||
_GAP = 2
|
||||
_TAB_W, _TAB_H = 16, 48 # the thin "show" tab when hidden at the edge
|
||||
_PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size
|
||||
|
||||
# The three states the floating assistant cycles through.
|
||||
_HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel"
|
||||
|
||||
|
||||
def _current_user() -> str:
|
||||
return (os.environ.get("USERNAME") or os.environ.get("USER") or "").strip()
|
||||
|
||||
|
||||
def _app_icon() -> QIcon:
|
||||
"""The app's own icon.png (falls back to the generic robot glyph if the
|
||||
asset is somehow missing)."""
|
||||
p = _ASSETS / "icon.png"
|
||||
return QIcon(str(p)) if p.exists() else icon("robot")
|
||||
|
||||
|
||||
def _app_pixmap(size: int) -> QPixmap:
|
||||
"""icon.png scaled to ``size`` (smooth), for the launcher badge label."""
|
||||
p = _ASSETS / "icon.png"
|
||||
if p.exists():
|
||||
return QPixmap(str(p)).scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
return icon("robot").pixmap(size, size)
|
||||
|
||||
|
||||
class _IconTap(QLabel):
|
||||
"""A QLabel that behaves like a button (click → signal) — used for the
|
||||
launcher badge so it carries NO QPushButton chrome/box, just the icon on a
|
||||
clean rounded card."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, e): # noqa: N802 - Qt override
|
||||
if e.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
|
||||
class HelpAgentWidget(QWidget):
|
||||
"""Overlay child of the main window; anchors itself bottom-right and cycles
|
||||
hidden-tab → launcher icon → expanded chat panel."""
|
||||
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx, parent=None, user_name: str = ""):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._user_name = user_name or _current_user()
|
||||
self._state = _LAUNCHER_ST
|
||||
self._busy = False
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
# Conversation history (excludes the system prompt, prepended per call).
|
||||
# Seeded with the greeting so the panel always opens on a friendly hello.
|
||||
self._history: List[Dict[str, str]] = [
|
||||
{"role": "assistant", "content": self._greeting()}
|
||||
]
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self._pal = self._compute_palette()
|
||||
self._build_edge_tab()
|
||||
self._build_launcher()
|
||||
self._build_panel()
|
||||
self._apply_style()
|
||||
self._apply_state()
|
||||
|
||||
# ---- theming ----------------------------------------------------------
|
||||
def _compute_palette(self) -> Dict[str, str]:
|
||||
"""Chat-body colours that FOLLOW the app's light/dark theme. The header
|
||||
is intentionally NOT themed here (it stays a fixed light bar — see
|
||||
_apply_style), only the conversation area adapts."""
|
||||
from ..theme import resolve_theme
|
||||
dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark"
|
||||
if dark:
|
||||
return {
|
||||
"panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720",
|
||||
"input_bg": "#1b2733", "border": "#33404d",
|
||||
"user_bg": "#123a52", "user_label": "#58c0ee",
|
||||
"bot_bg": "#232f3b", "bot_label": "#6fe3a4",
|
||||
}
|
||||
return {
|
||||
"panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb",
|
||||
"input_bg": "#ffffff", "border": "#d5d9de",
|
||||
"user_bg": "#dceff8", "user_label": "#0077B6",
|
||||
"bot_bg": "#eef1f4", "bot_label": "#2f7d55",
|
||||
}
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Re-style + re-render when the app theme switches (called from
|
||||
MainWindow._apply_theme). Header stays fixed; chat body re-colours."""
|
||||
self._pal = self._compute_palette()
|
||||
self._apply_style()
|
||||
self._render()
|
||||
|
||||
def _apply_style(self) -> None:
|
||||
# The HEADER bar is a FIXED light strip in both themes (per request); only
|
||||
# the chat body below follows the app's light/dark palette (self._pal).
|
||||
from ..theme import ACCENT, ACCENT2, GRADIENT
|
||||
p = self._pal
|
||||
self.setStyleSheet(f"""
|
||||
/* Clean rounded app-icon badge (like image 2): a fixed light card
|
||||
framing the icon — no QPushButton box. */
|
||||
#helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2;
|
||||
border-radius: 16px; }}
|
||||
#helpLauncher:hover {{ background: #dcedfb; }}
|
||||
#helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none;
|
||||
border-radius: 6px; }}
|
||||
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }}
|
||||
#helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']};
|
||||
border-radius: 14px; color: {p['text']}; }}
|
||||
/* Faint-blue header bar — LOCKED light, dark title, in both themes.
|
||||
The header AND its child labels set fixed backgrounds so the dark
|
||||
theme never bleeds into the App-Assistant title strip. */
|
||||
#helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2;
|
||||
border-top-left-radius: 14px; border-top-right-radius: 14px; }}
|
||||
#helpHeader QLabel {{ background: transparent; color: #14212b; }}
|
||||
#helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }}
|
||||
#helpMinBtn {{ background: transparent; border: none; }}
|
||||
#helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }}
|
||||
#helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }}
|
||||
#helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px;
|
||||
border-bottom-right-radius: 14px; }}
|
||||
#helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px;
|
||||
background: {p['input_bg']}; color: {p['text']}; }}
|
||||
#helpInput:focus {{ border: 1px solid {ACCENT}; }}
|
||||
#helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }}
|
||||
#helpSendBtn:hover {{ background: {ACCENT2}; }}
|
||||
#helpSendBtn:disabled {{ background: #b7c0c9; }}
|
||||
""")
|
||||
|
||||
# ---- greeting / labels ------------------------------------------------
|
||||
def _greeting(self) -> str:
|
||||
name = self._user_name or tr("help_agent.default_user")
|
||||
return tr("help_agent.greeting", name=name)
|
||||
|
||||
# ---- construction -----------------------------------------------------
|
||||
def _build_edge_tab(self) -> None:
|
||||
# Shown only while hidden: a thin tab at the right edge to bring the
|
||||
# assistant back (chevron points left = "slide out").
|
||||
self.edge_tab = QPushButton(self)
|
||||
self.edge_tab.setObjectName("helpEdgeTab")
|
||||
self.edge_tab.setIcon(icon("chevron-left", color="#5a6570"))
|
||||
self.edge_tab.setCursor(Qt.PointingHandCursor)
|
||||
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
|
||||
self.edge_tab.clicked.connect(self._show_launcher)
|
||||
|
||||
def _build_launcher(self) -> None:
|
||||
# A left-side chevron collapses the assistant to the edge…
|
||||
self.collapse_btn = QPushButton(self)
|
||||
self.collapse_btn.setObjectName("helpCollapseBtn")
|
||||
self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570"))
|
||||
self.collapse_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
|
||||
self.collapse_btn.clicked.connect(self._hide_to_edge)
|
||||
# …and the app icon itself opens the chat — a clean rounded badge (like
|
||||
# image 2), NOT a QPushButton (which added a pale box around the icon).
|
||||
self.launcher = _IconTap(self)
|
||||
self.launcher.setObjectName("helpLauncher")
|
||||
self.launcher.setFixedSize(_LAUNCHER, _LAUNCHER)
|
||||
self.launcher.setAlignment(Qt.AlignCenter)
|
||||
self.launcher.setPixmap(_app_pixmap(_LAUNCHER_ICON))
|
||||
self.launcher.setCursor(Qt.PointingHandCursor)
|
||||
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
|
||||
self.launcher.clicked.connect(self._expand)
|
||||
|
||||
def _build_panel(self) -> None:
|
||||
self.panel = QFrame(self)
|
||||
self.panel.setObjectName("helpPanel")
|
||||
|
||||
v = QVBoxLayout(self.panel)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(0)
|
||||
|
||||
# Header: app icon + title + minimize (plain white bar, no colour fill)
|
||||
header = QFrame(self.panel)
|
||||
header.setObjectName("helpHeader")
|
||||
hb = QHBoxLayout(header)
|
||||
hb.setContentsMargins(12, 8, 8, 8)
|
||||
self.title_icon = QLabel(header)
|
||||
self.title_icon.setPixmap(_app_icon().pixmap(20, 20))
|
||||
hb.addWidget(self.title_icon)
|
||||
self.title = QLabel(tr("help_agent.title"), header)
|
||||
self.title.setObjectName("helpTitle")
|
||||
hb.addWidget(self.title, 1)
|
||||
self.min_btn = QPushButton(header)
|
||||
self.min_btn.setObjectName("helpMinBtn")
|
||||
self.min_btn.setIcon(icon("minus", color="#5a6570"))
|
||||
self.min_btn.setFixedSize(24, 24)
|
||||
self.min_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
|
||||
self.min_btn.clicked.connect(self._collapse)
|
||||
hb.addWidget(self.min_btn)
|
||||
v.addWidget(header)
|
||||
|
||||
# Conversation log
|
||||
self.log = QTextBrowser(self.panel)
|
||||
self.log.setObjectName("helpLog")
|
||||
self.log.setOpenExternalLinks(False)
|
||||
v.addWidget(self.log, 1)
|
||||
|
||||
# Input row
|
||||
row = QFrame(self.panel)
|
||||
row.setObjectName("helpInputRow")
|
||||
rb = QHBoxLayout(row)
|
||||
rb.setContentsMargins(8, 8, 8, 8)
|
||||
rb.setSpacing(6)
|
||||
self.input = QLineEdit(row)
|
||||
self.input.setObjectName("helpInput")
|
||||
self.input.setPlaceholderText(tr("help_agent.placeholder"))
|
||||
self.input.returnPressed.connect(self._send)
|
||||
rb.addWidget(self.input, 1)
|
||||
self.send_btn = QPushButton(row)
|
||||
self.send_btn.setObjectName("helpSendBtn")
|
||||
self.send_btn.setIcon(icon("send"))
|
||||
self.send_btn.setFixedSize(32, 30)
|
||||
self.send_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.send_btn.clicked.connect(self._send)
|
||||
rb.addWidget(self.send_btn)
|
||||
v.addWidget(row)
|
||||
|
||||
self._render()
|
||||
|
||||
# ---- state transitions ------------------------------------------------
|
||||
def _expand(self) -> None:
|
||||
self._state = _PANEL
|
||||
self._apply_state()
|
||||
self.input.setFocus()
|
||||
|
||||
def _collapse(self) -> None:
|
||||
self._state = _LAUNCHER_ST
|
||||
self._apply_state()
|
||||
|
||||
def _hide_to_edge(self) -> None:
|
||||
self._state = _HIDDEN
|
||||
self._apply_state()
|
||||
|
||||
def _show_launcher(self) -> None:
|
||||
self._state = _LAUNCHER_ST
|
||||
self._apply_state()
|
||||
|
||||
def _apply_state(self) -> None:
|
||||
st = self._state
|
||||
self.edge_tab.setVisible(st == _HIDDEN)
|
||||
self.collapse_btn.setVisible(st == _LAUNCHER_ST)
|
||||
self.launcher.setVisible(st == _LAUNCHER_ST)
|
||||
self.panel.setVisible(st == _PANEL)
|
||||
if st == _PANEL:
|
||||
self.resize(_PANEL_W, _PANEL_H)
|
||||
self.panel.setGeometry(0, 0, _PANEL_W, _PANEL_H)
|
||||
elif st == _LAUNCHER_ST:
|
||||
w = _LAUNCHER + _GAP + _COLLAPSE_W
|
||||
self.resize(w, _LAUNCHER)
|
||||
# Icon on the left, the collapse chevron on the RIGHT (toward the
|
||||
# screen edge it tucks into).
|
||||
self.launcher.setGeometry(0, 0, _LAUNCHER, _LAUNCHER)
|
||||
self.collapse_btn.setGeometry(_LAUNCHER + _GAP, (_LAUNCHER - _COLLAPSE_H) // 2,
|
||||
_COLLAPSE_W, _COLLAPSE_H)
|
||||
else: # hidden
|
||||
self.resize(_TAB_W, _TAB_H)
|
||||
self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H)
|
||||
self.reposition()
|
||||
self.raise_()
|
||||
|
||||
def reposition(self) -> None:
|
||||
"""Pin to the parent's bottom-right corner (called on parent resize)."""
|
||||
p = self.parentWidget()
|
||||
if p is None:
|
||||
return
|
||||
x = max(0, p.width() - self.width() - _MARGIN)
|
||||
y = max(0, p.height() - self.height() - _MARGIN)
|
||||
self.move(x, y)
|
||||
|
||||
# ---- rendering --------------------------------------------------------
|
||||
def _bubble_html(self, who: str, content: str) -> str:
|
||||
"""One message as a clearly-separated, labelled bubble: the user's turns
|
||||
sit right-aligned with an accent tint, the assistant's left-aligned on a
|
||||
neutral fill, each headed by its speaker name — so who said what is never
|
||||
ambiguous. (QTextDocument has no border-radius, so filled table cells do
|
||||
the bubble work.)"""
|
||||
p = self._pal
|
||||
text = (content or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace("\n", "<br>")
|
||||
if who == "user":
|
||||
align, bg, label_color = "right", p["user_bg"], p["user_label"]
|
||||
label = tr("chat.you")
|
||||
else:
|
||||
align, bg, label_color = "left", p["bot_bg"], p["bot_label"]
|
||||
label = tr("help_agent.title")
|
||||
return (
|
||||
f'<table width="100%" cellspacing="0" cellpadding="0"><tr>'
|
||||
f'<td align="{align}">'
|
||||
f'<table width="80%" cellspacing="0" cellpadding="7" bgcolor="{bg}"><tr>'
|
||||
f'<td style="color:{p["text"]};">'
|
||||
f'<b style="color:{label_color};">{label}</b><br>{text}'
|
||||
f'</td></tr></table></td></tr></table>'
|
||||
'<div style="line-height:6px;"> </div>' # gap between turns
|
||||
)
|
||||
|
||||
def _render(self, pending: bool = False) -> None:
|
||||
parts = [self._bubble_html(m["role"], m["content"]) for m in self._history]
|
||||
if pending:
|
||||
parts.append(self._bubble_html("assistant", "…"))
|
||||
self.log.setHtml("".join(parts))
|
||||
self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())
|
||||
|
||||
# ---- send a message ---------------------------------------------------
|
||||
def _send(self) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
text = self.input.text().strip()
|
||||
if not text:
|
||||
return
|
||||
self.input.clear()
|
||||
self._history.append({"role": "user", "content": text})
|
||||
self._set_busy(True)
|
||||
self._render(pending=True)
|
||||
|
||||
agent = admin_agents.ensure_help_agent(
|
||||
admin_agents.agents_admin_dir(self.ctx.config.shared_dir))
|
||||
history = list(self._history)
|
||||
|
||||
def job(worker):
|
||||
provider = admin_agents.build_agent_provider(self.ctx, agent)
|
||||
messages = [{"role": "system", "content": agent.effective_prompt()}] + history
|
||||
result = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
content = result.get("content", "") if isinstance(result, dict) else str(result)
|
||||
return {"content": provider.strip_think(content) or ""}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(self._on_reply)
|
||||
worker.failed.connect(self._on_failed)
|
||||
self._worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_reply(self, result: Dict[str, Any]) -> None:
|
||||
content = (result or {}).get("content", "").strip() or tr("help_agent.empty_reply")
|
||||
self._history.append({"role": "assistant", "content": content})
|
||||
self._set_busy(False)
|
||||
self._render()
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._history.append({"role": "assistant",
|
||||
"content": tr("help_agent.error", error=err)})
|
||||
self._set_busy(False)
|
||||
self._render()
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self.input.setEnabled(not busy)
|
||||
self.send_btn.setEnabled(not busy)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(tr("help_agent.title"))
|
||||
self.input.setPlaceholderText(tr("help_agent.placeholder"))
|
||||
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
|
||||
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
|
||||
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
|
||||
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
|
||||
Reference in New Issue
Block a user