@allansantos-dev/smart-tool 0.9.31 → 0.9.33
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.
- package/CHANGELOG.md +26 -0
- package/README.md +24 -9
- package/affected_tests.py +8 -2
- package/autostart.py +4 -0
- package/code_graph.py +4 -3
- package/code_graph_go.py +85 -0
- package/config.py +3 -0
- package/index_profile.py +4 -2
- package/model_client.py +2 -0
- package/model_defaults.py +10 -2
- package/model_health.py +163 -0
- package/model_upgrade.py +140 -0
- package/notice_action.py +58 -0
- package/package.json +1 -1
- package/setup_audit.py +106 -8
- package/setup_ui.py +119 -1
- package/smart_tool_daemon.py +28 -7
- package/toast_notice.py +99 -0
- package/tray.py +39 -0
- package/version.py +1 -1
package/toast_notice.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Windows toast with buttons, no dependency: PowerShell 5.1 (always present) shows it through the WinRT toast API and
|
|
2
|
+
each button is a protocol link that Windows hands to `notice_action.py`.
|
|
3
|
+
|
|
4
|
+
An unpackaged app has no COM activator, so a button cannot call back into a running process. The protocol route needs
|
|
5
|
+
only HKCU keys (no admin): one AppUserModelId key (name and icon of the toast) and one URL protocol whose command runs
|
|
6
|
+
`pythonw notice_action.py "<uri>"` without a window. Actions are therefore small independent commands that read and
|
|
7
|
+
write the config file, never state held by the tray.
|
|
8
|
+
"""
|
|
9
|
+
import base64
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from xml.sax.saxutils import quoteattr, escape
|
|
15
|
+
|
|
16
|
+
APP_ID = "SmartTool.Notifications"
|
|
17
|
+
PROTOCOL = "smart-tool-action"
|
|
18
|
+
ACTION_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "notice_action.py")
|
|
19
|
+
|
|
20
|
+
_SHOW_SCRIPT = r"""
|
|
21
|
+
$ErrorActionPreference = 'Stop'
|
|
22
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
23
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
24
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
25
|
+
$xml.LoadXml($env:SMART_TOOL_TOAST_XML)
|
|
26
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|
27
|
+
if ($env:SMART_TOOL_TOAST_TAG) { $toast.Tag = $env:SMART_TOOL_TOAST_TAG; $toast.Group = 'smart-tool' }
|
|
28
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($env:SMART_TOOL_TOAST_APPID).Show($toast)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pythonw():
|
|
33
|
+
candidate = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
|
|
34
|
+
return candidate if os.path.isfile(candidate) else sys.executable
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def action_command():
|
|
38
|
+
"""The command the protocol runs; `%1` is the clicked link."""
|
|
39
|
+
return f'"{_pythonw()}" "{ACTION_SCRIPT}" "%1"'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register():
|
|
43
|
+
"""Writes the HKCU keys (idempotent). Raises on any failure: a toast whose buttons do nothing is worse than none."""
|
|
44
|
+
import winreg
|
|
45
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\AppUserModelId\{APP_ID}") as key:
|
|
46
|
+
winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "Smart Tool")
|
|
47
|
+
root = rf"Software\Classes\{PROTOCOL}"
|
|
48
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, root) as key:
|
|
49
|
+
winreg.SetValueEx(key, None, 0, winreg.REG_SZ, "URL:Smart Tool action")
|
|
50
|
+
winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "")
|
|
51
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, root + r"\shell\open\command") as key:
|
|
52
|
+
winreg.SetValueEx(key, None, 0, winreg.REG_SZ, action_command())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unregister():
|
|
56
|
+
"""Deletes the HKCU keys `register` wrote, deepest first; returns how many existed (missing keys are not an error)."""
|
|
57
|
+
import winreg
|
|
58
|
+
count = 0
|
|
59
|
+
for path in (rf"Software\Classes\{PROTOCOL}\shell\open\command", rf"Software\Classes\{PROTOCOL}\shell\open",
|
|
60
|
+
rf"Software\Classes\{PROTOCOL}\shell", rf"Software\Classes\{PROTOCOL}",
|
|
61
|
+
rf"Software\Classes\AppUserModelId\{APP_ID}"):
|
|
62
|
+
try:
|
|
63
|
+
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path)
|
|
64
|
+
count += 1
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
pass
|
|
67
|
+
return count
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def action_uri(action, **params):
|
|
71
|
+
"""The protocol link a button carries: `smart-tool-action:<action>?<params>`; returns the link as text."""
|
|
72
|
+
query = urllib.parse.urlencode(params)
|
|
73
|
+
return f"{PROTOCOL}:{action}" + (f"?{query}" if query else "")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def toast_xml(title, lines, buttons):
|
|
77
|
+
"""`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast."""
|
|
78
|
+
text = "".join(f"<text>{escape(line)}</text>" for line in [title, *lines])
|
|
79
|
+
actions = "".join(
|
|
80
|
+
f'<action content={quoteattr(label)} arguments={quoteattr(uri)} activationType="protocol"/>'
|
|
81
|
+
for label, uri in buttons)
|
|
82
|
+
return (f'<toast><visual><binding template="ToastGeneric">{text}</binding></visual>'
|
|
83
|
+
f'<actions>{actions}</actions></toast>')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def show(title, lines, buttons, tag=None):
|
|
87
|
+
"""Shows the toast; raises RuntimeError with PowerShell's message when Windows refuses it."""
|
|
88
|
+
if sys.platform != "win32":
|
|
89
|
+
raise RuntimeError("Toast notifications are only available on Windows.")
|
|
90
|
+
register()
|
|
91
|
+
script = base64.b64encode(_SHOW_SCRIPT.encode("utf-16-le")).decode("ascii")
|
|
92
|
+
env = {**os.environ, "SMART_TOOL_TOAST_XML": toast_xml(title, lines, buttons),
|
|
93
|
+
"SMART_TOOL_TOAST_APPID": APP_ID, "SMART_TOOL_TOAST_TAG": tag or ""}
|
|
94
|
+
done = subprocess.run(
|
|
95
|
+
["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", script],
|
|
96
|
+
env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
|
|
97
|
+
creationflags=subprocess.CREATE_NO_WINDOW)
|
|
98
|
+
if done.returncode:
|
|
99
|
+
raise RuntimeError(f"Windows refused the notification: {(done.stderr or done.stdout).strip()[:500]}")
|
package/tray.py
CHANGED
|
@@ -22,11 +22,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
22
22
|
import autostart
|
|
23
23
|
import config
|
|
24
24
|
import daemon_launcher
|
|
25
|
+
import toast_notice
|
|
25
26
|
import update_check
|
|
26
27
|
import version
|
|
27
28
|
|
|
28
29
|
POLL_INTERVAL_S = 45
|
|
29
30
|
UPDATE_CHECK_INTERVAL_S = 24 * 3600
|
|
31
|
+
NOTICE_INTERVAL_S = 600
|
|
30
32
|
SETUP_URL = "http://127.0.0.1:8765/setup"
|
|
31
33
|
|
|
32
34
|
_STATE_LOCK = threading.Lock()
|
|
@@ -111,6 +113,42 @@ def _poll_loop(icon):
|
|
|
111
113
|
time.sleep(POLL_INTERVAL_S)
|
|
112
114
|
|
|
113
115
|
|
|
116
|
+
def _show_notices(icon, notices):
|
|
117
|
+
"""Shows a toast for each pending notice: the new-default-models offer and each model over the failure limit."""
|
|
118
|
+
offer = notices.get("upgrade")
|
|
119
|
+
if offer:
|
|
120
|
+
lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
|
|
121
|
+
nonce = offer["nonce"]
|
|
122
|
+
toast_notice.show("Smart Tool has new recommended models", lines, [
|
|
123
|
+
("Apply now", toast_notice.action_uri("models-apply", n=nonce)),
|
|
124
|
+
("Don't show again", toast_notice.action_uri("models-decline", n=nonce)),
|
|
125
|
+
], tag="models-upgrade")
|
|
126
|
+
for row in notices.get("failing") or []:
|
|
127
|
+
last = row.get("last_error") or "see the failure log in the settings"
|
|
128
|
+
toast_notice.show(
|
|
129
|
+
f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
|
|
130
|
+
[("Switch model", toast_notice.action_uri("open-setup", section="models")),
|
|
131
|
+
("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _notice_loop(icon):
|
|
135
|
+
"""Asks the daemon every 10 minutes whether a toast is due. The daemon decides (once a day per notice) and marks it
|
|
136
|
+
announced, so a restart of the tray never repeats one."""
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
daemon_url = (daemon_launcher.read_registry() or {}).get("url")
|
|
140
|
+
if daemon_url:
|
|
141
|
+
request = urllib.request.Request(daemon_url.rstrip("/") + "/setup/notices", data=b"{}", method="POST",
|
|
142
|
+
headers={"Content-Type": "application/json"})
|
|
143
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
144
|
+
_show_notices(icon, json.load(response))
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
if str(exc) != _state.get("notice_error"):
|
|
147
|
+
_state["notice_error"] = str(exc)
|
|
148
|
+
icon.notify(f"Could not show a Smart Tool notice: {exc}", "Smart Tool")
|
|
149
|
+
time.sleep(NOTICE_INTERVAL_S)
|
|
150
|
+
|
|
151
|
+
|
|
114
152
|
def _announce_start(icon):
|
|
115
153
|
"""Tells the user, once per start, that Smart Tool was just updated or that the last automatic update failed."""
|
|
116
154
|
updated = update_check.note_running_version()
|
|
@@ -304,6 +342,7 @@ def main():
|
|
|
304
342
|
)
|
|
305
343
|
threading.Thread(target=_poll_loop, args=(icon,), daemon=True).start()
|
|
306
344
|
threading.Thread(target=_update_loop, args=(icon,), daemon=True).start()
|
|
345
|
+
threading.Thread(target=_notice_loop, args=(icon,), daemon=True).start()
|
|
307
346
|
try:
|
|
308
347
|
icon.run()
|
|
309
348
|
finally:
|
package/version.py
CHANGED