diff --git a/addon/globalPlugins/openai/__init__.py b/addon/globalPlugins/openai/__init__.py index 84c6d49..ce8bc9b 100644 --- a/addon/globalPlugins/openai/__init__.py +++ b/addon/globalPlugins/openai/__init__.py @@ -11,21 +11,19 @@ import ui from logHandler import log from scriptHandler import script, getLastScriptRepeatCount +from . import configspec +from . import updatecheck from .apikeymanager import APIKeyManager from .consts import ( ADDON_DIR, DATA_DIR, - DEFAULT_MODEL, DEFAULT_TOP_P, DEFAULT_N, - TOP_P_MIN, TOP_P_MAX, - N_MIN, N_MAX, - TTS_VOICES, TTS_MODELS, TTS_DEFAULT_VOICE, TTS_DEFAULT_MODEL + LIBS_DIR_PY, + TTS_MODELS, TTS_VOICES ) from .recordthread import RecordThread - -additionalLibsPath = os.path.join(ADDON_DIR, "lib") -sys.path.insert(0, additionalLibsPath) +sys.path.insert(0, LIBS_DIR_PY) import mss from openai import OpenAI -sys.path.remove(additionalLibsPath) +sys.path.remove(LIBS_DIR_PY) addonHandler.initTranslation() @@ -34,40 +32,9 @@ ROOT_ADDON_DIR ).manifest -confSpecs = { - "use_org": "boolean(default=False)", - "model": f"string(default={DEFAULT_MODEL.name})", - "topP": f"integer(min={TOP_P_MIN}, max={TOP_P_MAX}, default={DEFAULT_TOP_P})", - "n": f"integer(min={N_MIN}, max={N_MAX}, default={DEFAULT_N})", - "stream": "boolean(default=True)", - "TTSModel": f"option({', '.join(TTS_MODELS)}, default={TTS_DEFAULT_MODEL})", - "TTSVoice": f"option({', '.join(TTS_VOICES)}, default={TTS_DEFAULT_VOICE})", - "blockEscapeKey": "boolean(default=False)", - "conversationMode": "boolean(default=True)", - "saveSystem": "boolean(default=true)", - "advancedMode": "boolean(default=False)", - "images": { - "maxHeight": "integer(min=0, default=720)", - "maxWidth": "integer(min=0, default=0)", - "quality": "integer(min=0, max=100, default=85)", - "resize": "boolean(default=False)", - "resizeInfoDisplayed": "boolean(default=False)", - "useCustomPrompt": "boolean(default=False)", - "customPromptText": 'string(default="")' - }, - "audio": { - "sampleRate": "integer(min=8000, max=48000, default=16000)", - "channels": "integer(min=1, max=2, default=1)", - "dtype": "string(default=int16)" - }, - "renewClient": "boolean(default=False)", - "debug": "boolean(default=False)" -} -config.conf.spec["OpenAI"] = confSpecs -conf = config.conf["OpenAI"] - NO_AUTHENTICATION_KEY_PROVIDED_MSG = _("No authentication key provided. Please set it in the Preferences dialog.") +conf = config.conf["OpenAI"] api_key_manager = APIKeyManager(DATA_DIR) class SettingsDlg(gui.settingsDialogs.SettingsPanel): @@ -76,6 +43,31 @@ class SettingsDlg(gui.settingsDialogs.SettingsPanel): def makeSettings(self, settingsSizer): sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + + updateGroupLabel = _("Update") + updateSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=updateGroupLabel) + updateBox = updateSizer.GetStaticBox() + updateGroup = gui.guiHelper.BoxSizerHelper(self, sizer=updateSizer) + + self.updateCheck = updateGroup.addItem( + wx.CheckBox( + updateBox, + label=_("Check for &updates on startup and periodically") + ) + ) + self.updateCheck.SetValue(conf["update"]["check"]) + + self.updateChannel = updateGroup.addLabeledControl( + _("&Channel:"), + wx.Choice, + choices=["stable", "dev"] + ) + self.updateChannel.SetSelection( + 1 if conf["update"]["channel"] == "dev" else 0 + ) + + sHelper.addItem(updateSizer) + APIKey = api_key_manager.get_api_key() if not APIKey: APIKey = '' APIKeyOrg = api_key_manager.get_api_key(use_org=True) @@ -224,7 +216,7 @@ def makeSettings(self, settingsSizer): self.useCustomPrompt = imageGroup.addItem( wx.CheckBox( - imageBox, + imageBox, label=_("Customize default text &prompt") ) ) @@ -265,6 +257,8 @@ def onDefaultPrompt(self, evt): self.customPromptText.Enable(False) def onSave(self): + conf["update"]["check"] = self.updateCheck.GetValue() + conf["update"]["channel"] = self.updateChannel.GetString(self.updateChannel.GetSelection()) api_key = self.APIKey.GetValue().strip() api_key_manager.save_api_key(api_key) api_key_org = self.org_key.GetValue().strip() @@ -326,6 +320,9 @@ def createMenu(self): _("Show the Open AI dialog") ) gui.mainFrame.sysTrayIcon.Bind(wx.EVT_MENU, self.onShowMainDialog, item) + + self.submenu.AppendSeparator() + item = self.submenu.Append( wx.ID_ANY, _("API &keys"), @@ -345,6 +342,19 @@ def createMenu(self): ) gui.mainFrame.sysTrayIcon.Bind(wx.EVT_MENU, self.onGitRepo, item) + self.submenu.AppendSeparator() + + item = self.submenu.Append( + wx.ID_ANY, + _("Check for &updates..."), + _("Check for updates") + ) + gui.mainFrame.sysTrayIcon.Bind( + wx.EVT_MENU, + self.onCheckForUpdates, + item + ) + addon_name = ADDON_INFO["name"] addon_version = ADDON_INFO["version"] self.submenu_item = gui.mainFrame.sysTrayIcon.menu.InsertMenu( @@ -381,6 +391,12 @@ def onDocumentation(self, evt): os.startfile(fp) break + def onCheckForUpdates(self, evt): + updatecheck.check_update( + auto=False + ) + updatecheck.update_last_check() + def terminate(self): gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove(SettingsDlg) gui.mainFrame.sysTrayIcon.menu.DestroyItem(self.submenu_item) diff --git a/addon/globalPlugins/openai/configspec.py b/addon/globalPlugins/openai/configspec.py new file mode 100644 index 0000000..5c1abd8 --- /dev/null +++ b/addon/globalPlugins/openai/configspec.py @@ -0,0 +1,49 @@ +import config +from .consts import ( + DEFAULT_MODEL, + DEFAULT_TOP_P, + DEFAULT_N, + TOP_P_MIN, + TOP_P_MAX, + N_MIN, + N_MAX, + TTS_MODELS, + TTS_DEFAULT_MODEL, + TTS_VOICES, + TTS_DEFAULT_VOICE +) + +confSpecs = { + "update": { + "check": "boolean(default=True)", + "channel": "string(default='stable')" + }, + "use_org": "boolean(default=False)", + "model": f"string(default={DEFAULT_MODEL.name})", + "topP": f"integer(min={TOP_P_MIN}, max={TOP_P_MAX}, default={DEFAULT_TOP_P})", + "n": f"integer(min={N_MIN}, max={N_MAX}, default={DEFAULT_N})", + "stream": "boolean(default=True)", + "TTSModel": f"option({', '.join(TTS_MODELS)}, default={TTS_DEFAULT_MODEL})", + "TTSVoice": f"option({', '.join(TTS_VOICES)}, default={TTS_DEFAULT_VOICE})", + "blockEscapeKey": "boolean(default=False)", + "conversationMode": "boolean(default=True)", + "saveSystem": "boolean(default=true)", + "advancedMode": "boolean(default=False)", + "images": { + "maxHeight": "integer(min=0, default=720)", + "maxWidth": "integer(min=0, default=0)", + "quality": "integer(min=0, max=100, default=85)", + "resize": "boolean(default=False)", + "resizeInfoDisplayed": "boolean(default=False)", + "useCustomPrompt": "boolean(default=False)", + "customPromptText": 'string(default="")' + }, + "audio": { + "sampleRate": "integer(min=8000, max=48000, default=16000)", + "channels": "integer(min=1, max=2, default=1)", + "dtype": "string(default=int16)" + }, + "renewClient": "boolean(default=False)", + "debug": "boolean(default=False)" +} +config.conf.spec["OpenAI"] = confSpecs diff --git a/addon/globalPlugins/openai/consts.py b/addon/globalPlugins/openai/consts.py index bdeedd8..64bae4b 100644 --- a/addon/globalPlugins/openai/consts.py +++ b/addon/globalPlugins/openai/consts.py @@ -1,4 +1,5 @@ import os +import sys import globalVars import addonHandler from .model import Model @@ -54,4 +55,13 @@ "organization of the interface. If the image does not correspond to a computer screen, just generate " "a detailed visual description. If the user sends an image alone without additional instructions in text, " "describe the image exactly as prescribed in this system prompt. Adhere strictly to the instructions in " - "this system prompt to describe images. Don’t add any additional details unless the user specifically ask you.") + "this system prompt to describe images. Don’t add any additional details unless the user specifically ask you." +) +LIBS_DIR = os.path.join(DATA_DIR, "libs") +LIBS_DIR_PY = os.path.join( + LIBS_DIR, + "lib_py%s.%s" % ( + sys.version_info.major, + sys.version_info.minor + ) +) diff --git a/addon/globalPlugins/openai/imagehelper.py b/addon/globalPlugins/openai/imagehelper.py index 02476cc..c14ecbd 100644 --- a/addon/globalPlugins/openai/imagehelper.py +++ b/addon/globalPlugins/openai/imagehelper.py @@ -2,14 +2,14 @@ import os import sys from logHandler import log -from .consts import ADDON_DIR +from .consts import ADDON_DIR, LIBS_DIR_PY -additionalLibsPath = os.path.join(ADDON_DIR, "lib") -sys.path.insert(0, additionalLibsPath) +sys.path.append(LIBS_DIR_PY) from openai import OpenAI from PIL import Image import fractions -sys.path.remove(additionalLibsPath) +sys.path.remove(LIBS_DIR_PY) + def get_image_dimensions(path): """ diff --git a/addon/globalPlugins/openai/maindialog.py b/addon/globalPlugins/openai/maindialog.py index 4feee46..21cf516 100644 --- a/addon/globalPlugins/openai/maindialog.py +++ b/addon/globalPlugins/openai/maindialog.py @@ -19,6 +19,7 @@ from logHandler import log from .consts import ( ADDON_DIR, DATA_DIR, + LIBS_DIR_PY, MODELS, MODEL_VISION, TOP_P_MIN, TOP_P_MAX, N_MIN, N_MAX, @@ -33,11 +34,10 @@ from .recordthread import RecordThread from .resultevent import ResultEvent, EVT_RESULT_ID -additionalLibsPath = os.path.join(ADDON_DIR, "lib") -sys.path.insert(0, additionalLibsPath) +sys.path.insert(0, LIBS_DIR_PY) import openai import markdown2 -sys.path.remove(additionalLibsPath) +sys.path.remove(LIBS_DIR_PY) addonHandler.initTranslation() @@ -771,12 +771,19 @@ def onModelChange(self, evt): defaultMaxOutputToken = 1024 self.maxTokens.SetValue(defaultMaxOutputToken) if self.conf["advancedMode"]: - self.temperature.SetRange(0, model.maxTemperature * 100) + self.temperature.SetRange( + 0, + int(model.maxTemperature * 100) + ) key_temperature = "temperature_%s" % model.name if key_temperature in self.data: - self.temperature.SetValue(self.data[key_temperature]) + self.temperature.SetValue( + int(self.data[key_temperature]) + ) else: - self.temperature.SetValue(model.defaultTemperature * 100) + self.temperature.SetValue( + int(model.defaultTemperature * 100) + ) def onOk(self, evt): if not self.promptText.GetValue().strip() and not self.pathList: diff --git a/addon/globalPlugins/openai/recordthread.py b/addon/globalPlugins/openai/recordthread.py index c4657cb..edd34e7 100644 --- a/addon/globalPlugins/openai/recordthread.py +++ b/addon/globalPlugins/openai/recordthread.py @@ -16,14 +16,13 @@ import core import ui -from .consts import ADDON_DIR, DATA_DIR +from .consts import ADDON_DIR, DATA_DIR, LIBS_DIR_PY from .resultevent import ResultEvent -additionalLibsPath = os.path.join(ADDON_DIR, "lib") -sys.path.insert(0, additionalLibsPath) +sys.path.insert(0, LIBS_DIR_PY) import numpy as np import sounddevice as sd -sys.path.remove(additionalLibsPath) +sys.path.remove(LIBS_DIR_PY) addonHandler.initTranslation() diff --git a/addon/globalPlugins/openai/updatecheck.py b/addon/globalPlugins/openai/updatecheck.py new file mode 100644 index 0000000..2b1f2a5 --- /dev/null +++ b/addon/globalPlugins/openai/updatecheck.py @@ -0,0 +1,225 @@ +import hashlib +import json +import os +import shutil +import sys +import time +import urllib.request +import zipfile +import gui +import wx + +import addonHandler +import config +import core +import ui +import versionInfo +from logHandler import log + +from .consts import ADDON_DIR, DATA_DIR, LIBS_DIR, LIBS_DIR_PY + +addonHandler.initTranslation() + +ROOT_ADDON_DIR = "\\".join(ADDON_DIR.split(os.sep)[:-2]) +ADDON_INFO = addonHandler.Addon(ROOT_ADDON_DIR).manifest +LAST_CHECK_FP = os.path.join(DATA_DIR, "last_check") +LIB_REV_FP = os.path.join(LIBS_DIR_PY, "libs_rev.txt") +URL_LATEST_RELEASE = "https://andreabc.net/projects/NVDA_addons/OpenAI/version.json" + +NVDA_VERSION = f"{versionInfo.version_year}.{versionInfo.version_major}.{versionInfo.version_minor}" +PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}" + +conf = config.conf["OpenAI"]["update"] + + +def ensure_dir_exists(directory: str): + """Ensure that the specified directory exists, creating it if necessary.""" + if not os.path.exists(directory): + os.mkdir(directory) + +ensure_dir_exists(DATA_DIR) + +_lib_rev = "" + +def get_lib_rev(): + """Return the current revision of the OpenAI dependencies.""" + global _lib_rev + if _lib_rev: + return _lib_rev + if os.path.exists(LIB_REV_FP): + try: + with open(LIB_REV_FP) as file: + _lib_rev = file.read().strip() + except ValueError: + pass + return _lib_rev + + +_last_check = 0 + +def get_last_check(): + """Return the timestamp for the latest update check""" + global _last_check + if _last_check: + return _last_check + if os.path.exists(LAST_CHECK_FP): + try: + with open(LAST_CHECK_FP) as file: + _last_check = float(file.read().strip()) + except ValueError: + pass + return _last_check + + +def update_last_check(): + """Update the timestamp for the last check in the configuration.""" + global _last_check + _last_check = time.time() + with open(LAST_CHECK_FP, "w") as file: + file.write(str(_last_check)) + + +def check_addon_version(data: dict, auto: bool): + """Check if the addon is up to date and notify the user accordingly.""" + local_version = ADDON_INFO["version"] + remote_version = data["addon_version"] + if local_version.split('-')[0] != remote_version: + # Translators: This is the message displayed when a new version of the add-on is available. + msg = _( + "New version available: %s. Your version is %s. " + "You can update from the add-on store " + "or from the GitHub repository." + ) % ( + remote_version, + local_version + ) + gui.messageBox( + msg, + # Translators: This is the title of the message displayed when a new version of the add-on is available. + _("OpenAI update"), + wx.OK | wx.ICON_INFORMATION + ) + elif not auto: + # Translators: This is the message displayed when the user checks for updates manually and there are no updates available. + msg = _("You have the latest version of OpenAI add-on installed.") + gui.messageBox( + msg, + # Translators: This is the title of the message displayed when the user checks for updates manually and there are no updates available. + _("OpenAI update"), + wx.OK | wx.ICON_INFORMATION + ) + + +def load_remote_data(auto: bool): + """Load and return the remote version and dependency information.""" + channel = conf["channel"] + params = { + "nvda_version": NVDA_VERSION, + "python_version": PYTHON_VERSION, + "addon_version": ADDON_INFO["version"], + "addon_channel": channel, + "lib_rev": get_lib_rev() + } + request_url = f"{URL_LATEST_RELEASE}?{urllib.parse.urlencode(params)}" + with urllib.request.urlopen(request_url) as response: + return json.loads(response.read()) + + +def handle_data_update(response_data: dict, auto: bool): + """Check the addon and dependencies version and handle updates if necessary.""" + check_addon_version(response_data, auto) + if (get_lib_rev() != response_data["libs_rev"]) or not os.path.exists(LIBS_DIR): + if offer_data_update(response_data): + update_dependency_files(response_data) + else: + update_last_check() + +def check_file_hash(file_path: str, expected_hash: str) -> bool: + """Check that the SHA256 hash of the file at file_path matches expected_hash.""" + with open(file_path, "rb") as file: + file_hash = hashlib.sha256(file.read()).hexdigest() + return file_hash == expected_hash + +def offer_data_update(data: dict) -> bool: + """Offer to update the OpenAI dependencies and return the user's choice.""" + # Translators: This is the message displayed when a new version of the OpenAI dependencies is available. + msg = _("New OpenAI dependencies revision available: %s. Your version is %s. Update now?") % ( + data["libs_rev"], + get_lib_rev() or _("unknown") + ) + + result = gui.messageBox( + msg, + # Translators: This is the title of the message displayed when a new version of the OpenAI dependencies is available. + _("OpenAI dependencies update"), + wx.YES_NO | wx.ICON_QUESTION + ) + return result == wx.YES + + +def update_dependency_files(data: dict): + """Handle the downloading and extraction of updated dependencies.""" + ui.message( + # Translators: This is the message emitted when updating the OpenAI dependencies. + _("Updating OpenAI dependencies... Please wait") + ) + try: + with urllib.request.urlopen(data["libs_download_url"]) as response: + zip_file_content = response.read() + + zip_path = os.path.join(DATA_DIR, "libs.zip") + with open(zip_path, "wb") as file: + file.write(zip_file_content) + + if not check_file_hash(zip_path, data["libs_hash"]): + raise ValueError("Libs hash mismatch") + + if os.path.exists(LIBS_DIR_PY): + shutil.rmtree(LIBS_DIR_PY) + + with zipfile.ZipFile(zip_path, "r") as zip_file: + zip_file.extractall(LIBS_DIR) + + os.remove(zip_path) + update_last_check() + gui.messageBox( + # Translators: This is the message displayed when the OpenAI dependencies are updated successfully. + _("Dependencies updated successfully. NVDA will now restart to apply the changes."), + # Translators: This is the title of the message displayed when the OpenAI dependencies are updated successfully. + _("Success"), + wx.OK | wx.ICON_INFORMATION + ) + core.restart() + + except ( + urllib.error.URLError, + zipfile.BadZipFile, + ValueError, + OSError + ) as e: + log.error(f"Error updating OpenAI dependencies: {e}") + gui.messageBox( + # Translators: This is the message displayed when the OpenAI dependencies cannot be updated. + _("Error updating dependencies. Please restart NVDA and try again."), + # Translators: This is the title of the message displayed when the OpenAI dependencies cannot be updated. + _("Error"), + wx.OK | wx.ICON_ERROR + ) + + +def check_update(auto: bool = True): + """Check for updates to OpenAI addon, including new versions and dependencies.""" + log.info("Checking for Open AI updates (auto=%s)", auto) + try: + data = load_remote_data(auto) + wx.CallAfter(handle_data_update, data, auto) + except urllib.error.URLError as e: + log.error(f"Error checking Open AI update: {e}") + + +if ( + (conf["check"] and get_last_check() + 86400 * 3 < time.time()) + or not os.path.exists(LIBS_DIR) + or not get_lib_rev() +): + check_update() diff --git a/buildVars.py b/buildVars.py index e996fe7..e148498 100644 --- a/buildVars.py +++ b/buildVars.py @@ -54,7 +54,7 @@ def _(arg): # Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) "addon_minimumNVDAVersion": "2023.1", # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) - "addon_lastTestedNVDAVersion": "2023.3", + "addon_lastTestedNVDAVersion": "2024.1", # Add-on update channel (default is None, denoting stable releases, # and for development releases, use "dev".) # Do not change unless you know what you are doing!