diff --git a/.env.example b/.env.example index b0ba249..43e69da 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,6 @@ UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_SECRET_KEY= UEX_BEARER_TOKEN= TRADERAI_USER_NAME= -TRADERAI_MEMORY_PATH=data/traderai.sqlite3 +TRADERAI_MEMORY_PATH= UEX_NOTIFICATION_POLL_SECONDS=60 REQUIRE_WRITE_APPROVAL=true diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..709a01c --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,74 @@ +name: Build Release EXE + +on: + release: + types: [published] + +jobs: + build-windows-exe: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Build TraderAI.exe + shell: pwsh + run: | + pyinstaller TraderAI.spec --noconfirm + if (-not (Test-Path -LiteralPath "dist\TraderAI.exe")) { + throw "dist\TraderAI.exe was not created." + } + + - name: Attach EXE to release + shell: pwsh + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $ErrorActionPreference = "Stop" + $event = Get-Content -LiteralPath $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json + $releaseId = $event.release.id + if (-not $releaseId) { + throw "Release id was not present in the release event payload." + } + + $token = $env:RELEASE_TOKEN + if ([string]::IsNullOrWhiteSpace($token)) { + $token = $env:GITEA_TOKEN + } + if ([string]::IsNullOrWhiteSpace($token)) { + throw "Set a RELEASE_TOKEN secret or enable the built-in GITHUB_TOKEN for Actions." + } + + $apiUrl = $env:GITHUB_API_URL + if ([string]::IsNullOrWhiteSpace($apiUrl)) { + $apiUrl = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1" + } + + $repoParts = $env:GITHUB_REPOSITORY.Split("/", 2) + if ($repoParts.Length -ne 2) { + throw "GITHUB_REPOSITORY must look like owner/repo. Value: $env:GITHUB_REPOSITORY" + } + + $owner = [uri]::EscapeDataString($repoParts[0]) + $repo = [uri]::EscapeDataString($repoParts[1]) + $assetPath = Resolve-Path -LiteralPath "dist\TraderAI.exe" + $uploadUrl = "$apiUrl/repos/$owner/$repo/releases/$releaseId/assets?name=TraderAI.exe" + + Invoke-RestMethod ` + -Method Post ` + -Uri $uploadUrl ` + -Headers @{ Authorization = "token $token" } ` + -Form @{ attachment = Get-Item -LiteralPath $assetPath } diff --git a/.gitignore b/.gitignore index 13b0a04..b4d162b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ *.egg-info/ .eggs/ pip-wheel-metadata/ +.playwright-mcp/ # Test and coverage output .pytest_cache/ diff --git a/README.md b/README.md index a550d96..61df174 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,18 @@ Local Ollama-powered chat for UEX marketplace workflows. Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64000` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. +## Releases And Updates + +Change the app version before cutting a release: + +```powershell +.\scripts\set_version.ps1 0.2.0 +``` + +Create a Gitea release with a matching tag such as `v0.2.0`. The release workflow builds `dist\TraderAI.exe` and attaches only that exe to the release. + +The desktop app can check `https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases` from Settings > Updates. When a newer release has a `TraderAI.exe` attachment, the packaged app downloads it to the user app data update folder, exits, replaces the current exe, and relaunches. + UEX marketplace posting and negotiation messages are guarded because they are account-affecting write actions. The model can draft them, but the UI approval button performs the final API call. The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. It is instructed to prefer open/current marketplace data, avoid historical sale information unless explicitly requested, and treat UEX prices as in-game aUEC/UEC credits rather than real-world dollars. Memory is stored locally at `TRADERAI_MEMORY_PATH`. diff --git a/TraderAI.Debug.spec b/TraderAI.Debug.spec new file mode 100644 index 0000000..0b67f3c --- /dev/null +++ b/TraderAI.Debug.spec @@ -0,0 +1,55 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_all + +webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview") + + +a = Analysis( + ["traderai\\desktop.py"], + pathex=[], + binaries=webview_binaries, + datas=[("web", "web"), *webview_datas], + hiddenimports=[ + *webview_hiddenimports, + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="TraderAI.Debug", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + icon="web\\art\\LBC_Logo.ico", + codesign_identity=None, + entitlements_file=None, +) diff --git a/TraderAI.spec b/TraderAI.spec new file mode 100644 index 0000000..e77f5b9 --- /dev/null +++ b/TraderAI.spec @@ -0,0 +1,55 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_all + +webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview") + + +a = Analysis( + ["traderai\\desktop.py"], + pathex=[], + binaries=webview_binaries, + datas=[("web", "web"), *webview_datas], + hiddenimports=[ + *webview_hiddenimports, + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.lifespan", + "uvicorn.lifespan.on", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="TraderAI", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + icon="web\\art\\LBC_Logo.ico", + codesign_identity=None, + entitlements_file=None, +) diff --git a/pyproject.toml b/pyproject.toml index 1c770a7..01b1f45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "traderai" -version = "0.1.0" +version = "0.0.1" description = "Local Ollama-powered assistant for UEX marketplace workflows." requires-python = ">=3.11" dependencies = [ "apscheduler>=3.10.4", "fastapi>=0.115.0", "httpx>=0.27.0", + "pywebview>=5.4", "pydantic>=2.8.0", "pydantic-settings>=2.4.0", "python-dotenv>=1.0.1", @@ -16,14 +17,21 @@ dependencies = [ [project.optional-dependencies] dev = [ + "pyinstaller>=6.11.0", "pytest>=8.3.0", "pytest-asyncio>=0.23.8", "respx>=0.21.1", ] +[project.scripts] +traderai-desktop = "traderai.desktop:main" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] [tool.setuptools.packages.find] include = ["traderai*"] + + + diff --git a/scripts/build_windows_exe.ps1 b/scripts/build_windows_exe.ps1 new file mode 100644 index 0000000..2b50327 --- /dev/null +++ b/scripts/build_windows_exe.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +Set-Location $Root + +$Python = Join-Path $Root ".venv\Scripts\python.exe" +if (-not (Test-Path $Python)) { + $Python = "python" +} + +& $Python -m ensurepip --upgrade +& $Python -m pip install -e ".[dev]" +& $Python -m PyInstaller --clean "TraderAI.spec" + +Write-Host "" +Write-Host "Built dist\TraderAI.exe" diff --git a/scripts/set_version.ps1 b/scripts/set_version.ps1 new file mode 100644 index 0000000..b5d7bde --- /dev/null +++ b/scripts/set_version.ps1 @@ -0,0 +1,29 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^v?\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$')] + [string]$Version +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..") +$cleanVersion = $Version.TrimStart("v") + +$pyprojectPath = Join-Path $repoRoot "pyproject.toml" +$versionPath = Join-Path $repoRoot "traderai\version.py" +$lockPath = Join-Path $repoRoot "uv.lock" + +$pyproject = Get-Content -LiteralPath $pyprojectPath -Raw +$pyproject = $pyproject -replace '(?m)^version = "[^"]+"', "version = `"$cleanVersion`"" +Set-Content -LiteralPath $pyprojectPath -Value $pyproject -Encoding UTF8 + +$versionModule = Get-Content -LiteralPath $versionPath -Raw +$versionModule = $versionModule -replace '__version__ = "[^"]+"', "__version__ = `"$cleanVersion`"" +Set-Content -LiteralPath $versionPath -Value $versionModule -Encoding UTF8 + +if (Test-Path -LiteralPath $lockPath) { + $lock = Get-Content -LiteralPath $lockPath -Raw + $lock = $lock -replace '(?s)(name = "traderai"\s+version = ")[^"]+(")', "`${1}$cleanVersion`${2}" + Set-Content -LiteralPath $lockPath -Value $lock -Encoding UTF8 +} + +Write-Host "TraderAI version set to $cleanVersion" diff --git a/traderai/config.py b/traderai/config.py index 6fdf584..34a0582 100644 --- a/traderai/config.py +++ b/traderai/config.py @@ -1,11 +1,64 @@ -from functools import lru_cache +from __future__ import annotations -from pydantic import Field +from functools import lru_cache +import os +from pathlib import Path +import sys +from typing import Any + +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +CONFIG_FIELDS: dict[str, dict[str, Any]] = { + "ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False}, + "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, + "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, + "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, + "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, + "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, + "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, + "traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False}, + "uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False}, + "require_write_approval": {"env": "REQUIRE_WRITE_APPROVAL", "type": "boolean", "secret": False}, +} + + +def app_data_dir() -> Path: + if sys.platform == "win32": + root = os.environ.get("LOCALAPPDATA") + if root: + return Path(root) / "TraderAI" + return Path.home() / ".traderai" + + +def ensure_app_data_dir() -> Path: + path = app_data_dir() + path.mkdir(parents=True, exist_ok=True) + return path + + +def user_config_path() -> Path: + return ensure_app_data_dir() / ".env" + + +def default_memory_path() -> Path: + return ensure_app_data_dir() / "traderai.sqlite3" + + +def log_path() -> Path: + return ensure_app_data_dir() / "TraderAI.log" + + +def edge_profile_dir() -> Path: + return ensure_app_data_dir() / "EdgeProfile" + + class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict( + env_file=(".env", str(user_config_path())), + env_file_encoding="utf-8", + ) ollama_base_url: str = "http://localhost:11434" ollama_model: str = "qwen3.5:9b" @@ -14,11 +67,80 @@ class Settings(BaseSettings): uex_secret_key: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None) - traderai_memory_path: str = "data/traderai.sqlite3" + traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path())) uex_notification_poll_seconds: int = 60 require_write_approval: bool = True + @field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before") + @classmethod + def _blank_optional(cls, value: Any) -> Any: + return None if value == "" else value + + @field_validator("traderai_memory_path", mode="before") + @classmethod + def _blank_memory_path(cls, value: Any) -> Any: + return str(default_memory_path()) if value == "" or value is None else value + @lru_cache def get_settings() -> Settings: return Settings() + + +def settings_payload(settings: Settings | None = None) -> dict[str, Any]: + current = settings or get_settings() + values = current.model_dump() + return { + "app_data_dir": str(ensure_app_data_dir()), + "config_path": str(user_config_path()), + "log_path": str(log_path()), + "edge_profile_dir": str(edge_profile_dir()), + "values": values, + "fields": CONFIG_FIELDS, + } + + +def save_settings(values: dict[str, Any]) -> dict[str, Any]: + current = get_settings().model_dump() + next_values = dict(current) + for key, value in values.items(): + if key not in CONFIG_FIELDS: + continue + next_values[key] = _coerce_value(key, value) + + path = user_config_path() + lines = [ + "# TraderAI desktop configuration", + "# Saved by the app. Environment variables still override these values.", + "", + ] + for key, meta in CONFIG_FIELDS.items(): + value = next_values.get(key) + lines.append(f"{meta['env']}={_env_value(value)}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + get_settings.cache_clear() + return settings_payload(get_settings()) + + +def _coerce_value(key: str, value: Any) -> Any: + field_type = CONFIG_FIELDS[key]["type"] + if value == "": + return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else "" + if field_type == "integer": + return int(value) + if field_type == "boolean": + if isinstance(value, bool): + return value + return str(value).strip().casefold() in {"1", "true", "yes", "on"} + return str(value) + + +def _env_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + text = str(value) + if not text or any(char.isspace() for char in text) or "#" in text: + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + return text diff --git a/traderai/desktop.py b/traderai/desktop.py new file mode 100644 index 0000000..2a3d5b4 --- /dev/null +++ b/traderai/desktop.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import threading +import time +import traceback +from typing import NoReturn + +import httpx +import uvicorn + +from traderai.config import edge_profile_dir, log_path + + +def resource_path(*parts: str) -> Path: + base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent)) + return base.joinpath(*parts) + + +def main() -> None: + try: + _chdir_to_app_dir() + _log("TraderAI desktop starting") + _log(f"cwd={Path.cwd()}") + _log(f"executable={sys.executable}") + _log(f"frozen={getattr(sys, 'frozen', False)} meipass={getattr(sys, '_MEIPASS', '')}") + port = _select_port() + url = f"http://127.0.0.1:{port}" + _log(f"selected_url={url}") + if _existing_server_ready(url): + _log("existing TraderAI backend found; opening window") + _open_window(url) + return + server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True) + server_thread.start() + _log("backend thread started") + _wait_for_server(url) + _log("backend health check passed") + _open_window(url) + _log("webview closed") + except Exception: + _log("fatal startup error") + _log(traceback.format_exc()) + raise + + +def _chdir_to_app_dir() -> None: + if getattr(sys, "frozen", False): + os.chdir(Path(sys.executable).resolve().parent) + + +def _select_port() -> int: + preferred = int(os.getenv("TRADERAI_PORT", "8765")) + if _port_available(preferred): + return preferred + _log(f"preferred port {preferred} is in use") + return _free_port() + + +def _port_available(port: int) -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", port)) + return True + except OSError: + return False + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _existing_server_ready(url: str) -> bool: + try: + response = httpx.get(f"{url}/api/health", timeout=1) + return response.status_code < 500 and response.headers.get("content-type", "").startswith("application/json") + except httpx.HTTPError: + return False + + +def _run_server(port: int) -> NoReturn: + try: + _log(f"backend starting on port {port}") + from traderai.server import app + + config = uvicorn.Config( + app, + host="127.0.0.1", + port=port, + log_level="info", + log_config=None, + lifespan="on", + ) + server = uvicorn.Server(config) + server.run() + _log("backend server stopped") + raise SystemExit(0) + except BaseException: + _log("backend thread crashed") + _log(traceback.format_exc()) + raise + + +def _wait_for_server(url: str) -> None: + deadline = time.monotonic() + 30 + last_error = "" + while time.monotonic() < deadline: + try: + response = httpx.get(f"{url}/api/health", timeout=1) + _log(f"health probe status={response.status_code}") + if response.status_code < 500: + return + except httpx.HTTPError as exc: + last_error = str(exc) + _log(f"health probe failed: {last_error}") + time.sleep(0.25) + raise RuntimeError(f"TraderAI backend did not start within 30 seconds. {last_error}") + + +def _open_window(url: str) -> None: + mode = os.getenv("TRADERAI_DESKTOP_UI", "edge").casefold() + _log(f"ui_mode={mode}") + if mode == "webview": + _open_webview(url) + return + if _open_edge_app(url): + return + _open_browser(url) + + +def _open_webview(url: str) -> None: + _log("importing pywebview") + import webview + + _log("creating pywebview window") + webview.create_window( + "TraderAI", + url, + width=1320, + height=860, + min_size=(980, 680), + text_select=True, + icon=str(resource_path("web", "art", "LBC_Logo.ico")), + ) + _log("starting pywebview") + webview.start(gui="edgechromium", debug=False) + + +def _open_edge_app(url: str) -> bool: + edge = _edge_path() + if not edge: + _log("msedge not found; falling back to default browser") + return False + profile_dir = edge_profile_dir() + profile_dir.mkdir(parents=True, exist_ok=True) + command = [ + str(edge), + f"--app={url}", + f"--user-data-dir={profile_dir}", + "--new-window", + "--no-first-run", + "--disable-features=Translate", + f"--app-icon={resource_path('web', 'art', 'LBC_Logo.ico')}", + ] + _log(f"launching edge app: {' '.join(command)}") + process = subprocess.Popen(command) + _log(f"edge process id={process.pid}") + time.sleep(2) + if process.poll() is None: + process.wait() + _log("edge app process exited") + return True + _log(f"edge app process exited early code={process.returncode}; keeping backend alive") + _keep_alive() + return True + + +def _open_browser(url: str) -> None: + import webbrowser + + _log(f"opening default browser at {url}") + webbrowser.open(url) + _keep_alive() + + +def _keep_alive() -> None: + _log("backend staying alive; close TraderAI from Task Manager if no app window owns this process") + while True: + time.sleep(60) + + +def _edge_path() -> Path | None: + edge = shutil.which("msedge") + if edge: + return Path(edge) + candidates = [ + Path(os.environ.get("ProgramFiles", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe", + Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe", + Path(os.environ.get("LocalAppData", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _log(message: str) -> None: + try: + log_path = _log_path() + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + with log_path.open("a", encoding="utf-8") as file: + file.write(f"[{timestamp}] {message}\n") + except Exception: + pass + + +def _log_path() -> Path: + return log_path() + + +if __name__ == "__main__": + main() diff --git a/traderai/server.py b/traderai/server.py index 094a118..bfeb229 100644 --- a/traderai/server.py +++ b/traderai/server.py @@ -1,8 +1,17 @@ from __future__ import annotations -from pathlib import Path +import os import json +import shutil +import subprocess +import sys +import threading +import time +import webbrowser +from pathlib import Path +from typing import Any +import httpx from fastapi import FastAPI from fastapi import HTTPException from fastapi.responses import FileResponse, StreamingResponse @@ -10,11 +19,18 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from traderai.agent import OllamaAgent, OllamaUnavailable +from traderai.config import save_settings, settings_payload from traderai.config import get_settings from traderai.memory import MemoryStore from traderai.scheduler import WakeScheduler from traderai.tools import ToolRegistry from traderai.uex_client import UEXClient +from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__ + + +def resource_path(*parts: str) -> Path: + base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent)) + return base.joinpath(*parts) class ChatRequest(BaseModel): @@ -29,6 +45,18 @@ class ClearMemoryRequest(BaseModel): include_outbox: bool = True +class ConfigUpdateRequest(BaseModel): + values: dict + + +class OllamaModelRequest(BaseModel): + model: str | None = None + + +OLLAMA_DOWNLOAD_URL = "https://ollama.com/download/windows" +UPDATE_ASSET_NAME = "TraderAI.exe" + + def create_app() -> FastAPI: settings = get_settings() memory = MemoryStore(settings.traderai_memory_path) @@ -47,7 +75,7 @@ def create_app() -> FastAPI: scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds) app = FastAPI(title="TraderAI") - static_dir = Path(__file__).resolve().parent.parent / "web" + static_dir = resource_path("web") app.mount("/static", StaticFiles(directory=static_dir), name="static") @app.on_event("startup") @@ -93,8 +121,128 @@ def create_app() -> FastAPI: "ollama": await agent.health(), "user": memory.get_profile(), "jobs": scheduler.list_jobs(), + "app_data_dir": settings_payload()["app_data_dir"], + "version": __version__, } + @app.get("/api/config") + async def inspect_config() -> dict: + return settings_payload() + + @app.post("/api/config") + async def update_config(request: ConfigUpdateRequest) -> dict: + updated = save_settings(request.values) + updated["restart_required"] = True + updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect." + return updated + + @app.get("/api/ollama/status") + async def ollama_status() -> dict: + return await inspect_ollama() + + @app.post("/api/ollama/launch") + async def launch_ollama() -> dict: + command = ollama_launch_command() + if not command: + raise HTTPException(status_code=404, detail="Ollama is not installed or was not found on PATH.") + try: + popen_hidden(command) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc + status = await inspect_ollama() + status["message"] = "Ollama launch requested." + return status + + @app.post("/api/ollama/pull") + async def pull_ollama_model(request: OllamaModelRequest) -> dict: + settings_now = get_settings() + model = (request.model or settings_now.ollama_model).strip() + if not model: + raise HTTPException(status_code=400, detail="No Ollama model is configured.") + cli = find_ollama_cli() + if not cli: + raise HTTPException(status_code=404, detail="Ollama CLI was not found.") + try: + popen_hidden([str(cli), "pull", model]) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc + status = await inspect_ollama() + status["message"] = f"Started installing model {model}." + return status + + @app.post("/api/ollama/install") + async def install_ollama() -> dict: + winget = shutil.which("winget") + if not winget: + return { + "started": False, + "message": "winget is not available on this system. Open the download page instead.", + "download_url": OLLAMA_DOWNLOAD_URL, + } + try: + popen_hidden( + [ + winget, + "install", + "-e", + "--id", + "Ollama.Ollama", + "--accept-package-agreements", + "--accept-source-agreements", + ] + ) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not start Ollama install: {exc}") from exc + return {"started": True, "message": "Started Ollama install with winget.", "download_url": OLLAMA_DOWNLOAD_URL} + + @app.post("/api/ollama/download") + async def download_ollama() -> dict: + webbrowser.open(OLLAMA_DOWNLOAD_URL) + return {"opened": True, "download_url": OLLAMA_DOWNLOAD_URL, "message": "Opened the Ollama download page."} + + @app.get("/api/update/check") + async def check_update() -> dict: + return await inspect_update() + + @app.post("/api/update/install") + async def install_update() -> dict: + update = await inspect_update() + if not update["available"]: + return {**update, "message": "TraderAI is already up to date."} + if not getattr(sys, "frozen", False): + return { + **update, + "started": False, + "message": "Update download is available, but self-update only runs from the packaged exe.", + } + + asset_url = update.get("asset_download_url") + if not asset_url: + raise HTTPException(status_code=404, detail="The latest release does not include TraderAI.exe.") + + downloaded = await download_update_asset(asset_url, update["latest_version"]) + script = write_update_script(downloaded, Path(sys.executable)) + updater_command = [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script), + "-ProcessId", + str(os.getpid()), + "-Source", + str(downloaded), + "-Target", + str(Path(sys.executable)), + ] + updater_kwargs: dict[str, Any] = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if sys.platform == "win32": + updater_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + subprocess.Popen(updater_command, **updater_kwargs) + threading.Thread(target=exit_after_update_response, daemon=True).start() + return {**update, "started": True, "message": "Update downloaded. TraderAI will restart into the new version."} + @app.post("/api/chat") async def chat(request: ChatRequest) -> dict: try: @@ -152,4 +300,260 @@ def create_app() -> FastAPI: return app +async def inspect_ollama() -> dict[str, Any]: + settings = get_settings() + executable = find_ollama_executable() + cli = find_ollama_cli() + models: list[str] = [] + online = False + detail = "" + + try: + async with httpx.AsyncClient(timeout=3) as client: + response = await client.get(f"{settings.ollama_base_url.rstrip('/')}/api/tags") + response.raise_for_status() + body = response.json() + online = True + models = [item.get("name") or item.get("model") for item in body.get("models", [])] + models = [model for model in models if model] + except (httpx.HTTPError, ValueError) as exc: + detail = str(exc) + + installed = bool(executable or cli) + model_available = settings.ollama_model in models + return { + "installed": installed, + "running": online, + "online": online, + "model_available": model_available, + "configured_model": settings.ollama_model, + "base_url": settings.ollama_base_url, + "num_ctx": settings.ollama_num_ctx, + "models": models, + "executable": str(executable) if executable else None, + "cli": str(cli) if cli else None, + "can_auto_install": bool(shutil.which("winget")), + "download_url": OLLAMA_DOWNLOAD_URL, + "message": ollama_status_message(installed, online, model_available, settings.ollama_model), + "detail": detail, + } + + +def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str: + if not installed: + return "Ollama is not installed." + if not running: + return "Ollama is installed but not running." + if not model_available: + return f'Ollama is running, but model "{model}" is not installed.' + return "Ollama is ready." + + +def find_ollama_executable() -> Path | None: + candidates = [ + shutil.which("ollama"), + os.environ.get("OLLAMA_EXE"), + os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "Ollama.exe"), + os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "ollama.exe"), + os.path.join(os.environ.get("ProgramFiles", ""), "Ollama", "Ollama.exe"), + os.path.join(os.environ.get("ProgramFiles", ""), "Ollama", "ollama.exe"), + os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Ollama", "Ollama.exe"), + os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Ollama", "ollama.exe"), + ] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate) + if path.exists(): + return path + return None + + +def find_ollama_cli() -> Path | None: + candidates = [ + shutil.which("ollama"), + os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "ollama.exe"), + os.path.join(os.environ.get("ProgramFiles", ""), "Ollama", "ollama.exe"), + os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Ollama", "ollama.exe"), + ] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate) + if path.exists(): + return path + return None + + +def ollama_launch_command() -> list[str] | None: + executable = find_ollama_executable() + if not executable: + return None + if executable.name == "Ollama.exe": + return [str(executable)] + return [str(executable), "serve"] + + +def popen_hidden(command: list[str]) -> subprocess.Popen: + kwargs: dict[str, Any] = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + return subprocess.Popen(command, **kwargs) + + +async def inspect_update() -> dict[str, Any]: + try: + latest = await latest_release() + except (httpx.HTTPError, ValueError) as exc: + return { + "current_version": __version__, + "latest_version": None, + "available": False, + "release_url": RELEASES_URL, + "message": f"Could not check releases: {exc}", + } + if not latest: + return { + "current_version": __version__, + "latest_version": None, + "available": False, + "release_url": RELEASES_URL, + "message": "No releases were found.", + } + + latest_version = normalize_version(latest.get("tag_name") or latest.get("name") or "") + asset = release_asset(latest, UPDATE_ASSET_NAME) + available = latest_version is not None and compare_versions(latest_version, __version__) > 0 + return { + "current_version": __version__, + "latest_version": latest_version, + "available": available, + "release_name": latest.get("name") or latest.get("tag_name"), + "release_url": latest.get("html_url") or RELEASES_URL, + "asset_name": asset.get("name") if asset else None, + "asset_download_url": asset.get("browser_download_url") if asset else None, + "packaged": bool(getattr(sys, "frozen", False)), + "message": update_message(available, latest_version, bool(asset)), + } + + +async def latest_release() -> dict[str, Any] | None: + async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client: + response = await client.get(RELEASES_API_URL) + response.raise_for_status() + releases = response.json() + if not isinstance(releases, list): + return None + candidates = [ + release + for release in releases + if isinstance(release, dict) and not release.get("draft") and not release.get("prerelease") + ] + if not candidates: + candidates = [release for release in releases if isinstance(release, dict) and not release.get("draft")] + if not candidates: + return None + return max(candidates, key=lambda release: version_parts(str(release.get("tag_name") or release.get("name") or "0"))) + + +def release_asset(release: dict[str, Any], name: str) -> dict[str, Any] | None: + assets = release.get("assets") or [] + if not isinstance(assets, list): + return None + for asset in assets: + if isinstance(asset, dict) and str(asset.get("name", "")).casefold() == name.casefold(): + return asset + for asset in assets: + download_url = str(asset.get("browser_download_url", "")) if isinstance(asset, dict) else "" + if download_url.casefold().endswith(f"/{name.casefold()}"): + return asset + return None + + +async def download_update_asset(url: str, version: str) -> Path: + updates_dir = Path(settings_payload()["app_data_dir"]) / "updates" + updates_dir.mkdir(parents=True, exist_ok=True) + path = updates_dir / f"TraderAI-{version}.exe" + async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + with path.open("wb") as file: + async for chunk in response.aiter_bytes(): + file.write(chunk) + return path + + +def write_update_script(source: Path, target: Path) -> Path: + updates_dir = Path(settings_payload()["app_data_dir"]) / "updates" + updates_dir.mkdir(parents=True, exist_ok=True) + script = updates_dir / "apply-update.ps1" + script.write_text( + "\n".join( + [ + "param(", + " [Parameter(Mandatory=$true)][int]$ProcessId,", + " [Parameter(Mandatory=$true)][string]$Source,", + " [Parameter(Mandatory=$true)][string]$Target", + ")", + "$ErrorActionPreference = 'Stop'", + "try { Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue } catch {}", + "Start-Sleep -Seconds 1", + "Copy-Item -LiteralPath $Source -Destination $Target -Force", + "Start-Process -FilePath $Target", + ] + ) + + "\n", + encoding="utf-8", + ) + return script + + +def exit_after_update_response() -> None: + time.sleep(1.5) + os._exit(0) + + +def update_message(available: bool, latest_version: str | None, has_asset: bool) -> str: + if not latest_version: + return "Could not determine the latest release version." + if not available: + return f"TraderAI {__version__} is up to date." + if not has_asset: + return f"TraderAI {latest_version} is available, but the release has no {UPDATE_ASSET_NAME} asset." + return f"TraderAI {latest_version} is available." + + +def normalize_version(value: str) -> str | None: + text = value.strip() + if text.startswith("v"): + text = text[1:] + parts = text.split(".") + if len(parts) < 2: + return None + return text + + +def compare_versions(left: str, right: str) -> int: + left_parts = version_parts(left) + right_parts = version_parts(right) + max_len = max(len(left_parts), len(right_parts)) + left_parts.extend([0] * (max_len - len(left_parts))) + right_parts.extend([0] * (max_len - len(right_parts))) + return (left_parts > right_parts) - (left_parts < right_parts) + + +def version_parts(version: str) -> list[int]: + text = normalize_version(version) or "0" + core = text.replace("-", ".").replace("+", ".").split(".") + parts: list[int] = [] + for item in core: + digits = "" + for char in item: + if not char.isdigit(): + break + digits += char + parts.append(int(digits or 0)) + return parts + + app = create_app() diff --git a/traderai/version.py b/traderai/version.py new file mode 100644 index 0000000..c358f15 --- /dev/null +++ b/traderai/version.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +__version__ = "0.0.1" + +RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases" +RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases" + + + diff --git a/uv.lock b/uv.lock index 2144faf..0ed8993 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -45,6 +54,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -54,6 +72,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -66,6 +110,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] +[[package]] +name = "clr-loader" +version = "0.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -182,6 +238,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -191,6 +259,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -200,6 +277,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "proxy-tools" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -340,6 +432,145 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, +] + +[[package]] +name = "pyobjc-framework-security" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/3d/8d3a39cd292d7c76ab76233498189bc7170fc80f573b415308464f68c7ee/pyobjc_framework_security-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b2d8819f0fb7b619ec7627a0d8c1cac1a57c5143579ce8ac21548165680684b", size = 41287, upload-time = "2025-11-14T10:02:54.491Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/5160c0f938fc0515fe8d9af146aac1b093f7ef285ce797fedae161b6c0e8/pyobjc_framework_security-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab42e55f5b782332be5442750fcd9637ee33247d57c7b1d5801bc0e24ee13278", size = 41280, upload-time = "2025-11-14T10:02:58.097Z" }, + { url = "https://files.pythonhosted.org/packages/32/48/b294ed75247c5cfa00d51925a10237337d24f54961d49a179b20a4307642/pyobjc_framework_security-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:afc36661cc6eb98cd794bed1d6668791e96557d6f72d9ac70aa49022d26af1d4", size = 41284, upload-time = "2025-11-14T10:03:01.722Z" }, + { url = "https://files.pythonhosted.org/packages/ef/57/0d3ef78779cf5c3bba878b2f824137e50978ad4a21dabe65d8b5ae0fc0d1/pyobjc_framework_security-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9510c98ab56921d1d416437372605cc1c1f6c1ad8d3061ee56b17bf423dd5427", size = 42162, upload-time = "2025-11-14T10:03:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/66/4d/63c15f9449c191e7448a05ff8af4a82c39a51bb627bc96dc9697586c0f79/pyobjc_framework_security-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6319a34508fd87ab6ca3cda6f54e707196197a65b792b292705af967e225438a", size = 41348, upload-time = "2025-11-14T10:03:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d8/5aaa2a8124ed04a9d6ca7053dc0fa64e42be51497ed8263a24b744a95598/pyobjc_framework_security-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:03d166371cefdef24908825148eb848f99ee2c0b865870a09dcbb94334dd3e0a", size = 42908, upload-time = "2025-11-14T10:03:13.01Z" }, +] + +[[package]] +name = "pyobjc-framework-uniformtypeidentifiers" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5f/1f10f5275b06d213c9897850f1fca9c881c741c1f9190cea6db982b71824/pyobjc_framework_uniformtypeidentifiers-12.1-py2.py3-none-any.whl", hash = "sha256:ec5411e39152304d2a7e0e426c3058fa37a00860af64e164794e0bcffee813f2", size = 4901, upload-time = "2025-11-14T10:05:51.532Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/37/5082a0bbe12e48d4ffa53b0c0f09c77a4a6ffcfa119e26fa8dd77c08dc1c/pyobjc_framework_webkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3db734877025614eaef4504fadc0fbbe1279f68686a6f106f2e614e89e0d1a9d", size = 49970, upload-time = "2025-11-14T10:07:01.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/64920c8d201a7fc27962f467c636c4e763b43845baba2e091a50a97a5d52/pyobjc_framework_webkit-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af2c7197447638b92aafbe4847c063b6dd5e1ed83b44d3ce7e71e4c9b042ab5a", size = 50084, upload-time = "2025-11-14T10:07:05.868Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/80d36280164c69220ce99372f7736a028617c207e42cb587716009eecb88/pyobjc_framework_webkit-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1da0c428c9d9891c93e0de51c9f272bfeb96d34356cdf3136cb4ad56ce32ec2d", size = 50096, upload-time = "2025-11-14T10:07:10.027Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7a/03c29c46866e266b0c705811c55c22625c349b0a80f5cf4776454b13dc4c/pyobjc_framework_webkit-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1a29e334d5a7dd4a4f0b5647481b6ccf8a107b92e67b2b3c6b368c899f571965", size = 50572, upload-time = "2025-11-14T10:07:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/924878f239c167ffe3bfc643aee4d6dd5b357e25f6b28db227e40e9e6df3/pyobjc_framework_webkit-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:99d0d28542a266a95ee2585f51765c0331794bca461aaf4d1f5091489d475179", size = 50210, upload-time = "2025-11-14T10:07:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/637cda4983dc0936b73a385f3906256953ac434537b812814cb0b6d231a2/pyobjc_framework_webkit-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1aaa3bf12c7b68e1a36c0b294d2728e06f2cc220775e6dc4541d5046290e4dc8", size = 50680, upload-time = "2025-11-14T10:07:23.331Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -378,6 +609,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pythonnet" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" }, +] + +[[package]] +name = "pywebview" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -433,6 +707,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + [[package]] name = "respx" version = "0.23.1" @@ -445,6 +731,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -460,7 +755,7 @@ wheels = [ [[package]] name = "traderai" -version = "0.1.0" +version = "0.0.1" source = { virtual = "." } dependencies = [ { name = "apscheduler" }, @@ -469,12 +764,14 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "pywebview" }, { name = "tzlocal" }, { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] dev = [ + { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "respx" }, @@ -487,9 +784,11 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic-settings", specifier = ">=2.4.0" }, + { name = "pyinstaller", marker = "extra == 'dev'", specifier = ">=6.11.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.8" }, { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "pywebview", specifier = ">=5.4" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.1" }, { name = "tzlocal", specifier = ">=5.2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, @@ -745,3 +1044,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + + + diff --git a/web/app.js b/web/app.js index 32c69aa..01815b7 100644 --- a/web/app.js +++ b/web/app.js @@ -7,8 +7,35 @@ const warningEl = document.getElementById("warning"); const memoryInspectorEl = document.getElementById("memory-inspector"); const memoryRefreshButton = document.getElementById("memory-refresh"); const memoryClearButton = document.getElementById("memory-clear"); +const configForm = document.getElementById("config-form"); +const configRefreshButton = document.getElementById("config-refresh"); +const configStatusEl = document.getElementById("config-status"); +const configPathsEl = document.getElementById("config-paths"); +const settingsToggle = document.getElementById("settings-toggle"); +const memoryToggle = document.getElementById("memory-toggle"); +const ollamaToggle = document.getElementById("ollama-toggle"); +const settingsPanel = document.getElementById("settings-panel"); +const memoryPanel = document.getElementById("memory-panel"); +const ollamaPanel = document.getElementById("ollama-panel"); +const ollamaForm = document.getElementById("ollama-config-form"); +const ollamaRefreshButton = document.getElementById("ollama-refresh"); +const ollamaDownloadButton = document.getElementById("ollama-download"); +const ollamaInstallButton = document.getElementById("ollama-install"); +const ollamaLaunchButton = document.getElementById("ollama-launch"); +const ollamaPullButton = document.getElementById("ollama-pull"); +const ollamaStatusEl = document.getElementById("ollama-status"); +const ollamaMessageEl = document.getElementById("ollama-message"); +const updateCheckButton = document.getElementById("update-check"); +const updateInstallButton = document.getElementById("update-install"); +const updateOpenReleasesButton = document.getElementById("update-open-releases"); +const updateStatusEl = document.getElementById("update-status"); let ollamaOnline = true; +let latestUpdate = null; + +if (window.lucide) { + window.lucide.createIcons(); +} function addMessage(role, text) { const node = document.createElement("div"); @@ -381,6 +408,255 @@ function setWarning(text) { warningEl.textContent = text || ""; } +function fetchErrorMessage(error) { + if (error instanceof TypeError && /fetch/i.test(error.message)) { + return "TraderAI backend is not reachable. Close this app window and launch TraderAI.exe again."; + } + return error.message; +} + +const configFieldIds = { + ollama_base_url: "config-ollama-base-url", + ollama_model: "config-ollama-model", + ollama_num_ctx: "config-ollama-num-ctx", + uex_base_url: "config-uex-base-url", + uex_secret_key: "config-uex-secret-key", + uex_bearer_token: "config-uex-bearer-token", + traderai_user_name: "config-traderai-user-name", + traderai_memory_path: "config-traderai-memory-path", + uex_notification_poll_seconds: "config-uex-notification-poll-seconds", + require_write_approval: "config-require-write-approval", +}; + +const ollamaFieldIds = { + ollama_base_url: "ollama-base-url", + ollama_model: "ollama-model", + ollama_num_ctx: "ollama-num-ctx", +}; + +async function refreshConfig() { + try { + const response = await fetch("/api/config"); + const config = await response.json(); + renderConfig(config); + } catch (error) { + configStatusEl.textContent = `Config load failed: ${fetchErrorMessage(error)}`; + } +} + +function renderConfig(config) { + const values = config.values || {}; + for (const [key, id] of Object.entries(configFieldIds)) { + const field = document.getElementById(id); + if (!field) continue; + if (field.type === "checkbox") { + field.checked = Boolean(values[key]); + } else { + field.value = values[key] ?? ""; + } + } + for (const [key, id] of Object.entries(ollamaFieldIds)) { + const field = document.getElementById(id); + if (!field) continue; + field.value = values[key] ?? ""; + } + configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`; + configStatusEl.textContent = ""; +} + +async function saveConfig(event) { + event.preventDefault(); + const values = {}; + for (const [key, id] of Object.entries(configFieldIds)) { + const field = document.getElementById(id); + if (!field) continue; + values[key] = field.type === "checkbox" ? field.checked : field.value; + } + configStatusEl.textContent = "Saving"; + try { + const response = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }); + const result = await response.json(); + renderConfig(result); + configStatusEl.textContent = result.message || "Saved"; + addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply."); + } catch (error) { + configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`; + } +} + +async function saveOllamaConfig(event) { + event.preventDefault(); + const values = {}; + for (const [key, id] of Object.entries(ollamaFieldIds)) { + const field = document.getElementById(id); + if (!field) continue; + values[key] = field.value; + } + setOllamaMessage("Saving Ollama config"); + try { + const response = await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }); + const result = await response.json(); + renderConfig(result); + setOllamaMessage(result.message || "Saved"); + await refreshOllamaStatus(); + } catch (error) { + setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`); + } +} + +async function refreshOllamaStatus() { + if (!ollamaStatusEl) return; + ollamaStatusEl.textContent = "Checking Ollama"; + try { + const response = await fetch("/api/ollama/status"); + const status = await response.json(); + renderOllamaStatus(status); + } catch (error) { + ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`; + } +} + +function renderOllamaStatus(status) { + if (!ollamaStatusEl) return; + const models = status.models?.length ? status.models.join(", ") : "None detected"; + const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning"; + ollamaStatusEl.innerHTML = ` +
${escapeHtml(status.message || "Unknown")}
+
+ ${ollamaStatusItem("Installed", status.installed ? "Yes" : "No")} + ${ollamaStatusItem("Running", status.running ? "Yes" : "No")} + ${ollamaStatusItem("Model", status.configured_model || "")} + ${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")} + ${ollamaStatusItem("URL", status.base_url || "")} + ${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")} +
+ ${ollamaStatusItem("Installed Models", models)} + ${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} + `; + if (ollamaInstallButton) ollamaInstallButton.disabled = Boolean(status.installed); + if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running); + if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available); +} + +function ollamaStatusItem(label, value) { + return `
${escapeHtml(label)}${escapeHtml(String(value ?? ""))}
`; +} + +function setOllamaMessage(message) { + if (ollamaMessageEl) ollamaMessageEl.textContent = message || ""; +} + +async function postOllamaAction(endpoint, options = {}) { + setOllamaMessage("Working"); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: options.body ? { "Content-Type": "application/json" } : undefined, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + setOllamaMessage(result.message || "Done"); + await refreshOllamaStatus(); + } catch (error) { + setOllamaMessage(error.message); + } +} + +function configuredOllamaModel() { + return document.getElementById("ollama-model")?.value || document.getElementById("config-ollama-model")?.value || ""; +} + +async function checkForUpdate() { + if (!updateStatusEl) return; + updateStatusEl.textContent = "Checking releases"; + try { + const response = await fetch("/api/update/check"); + const result = await response.json(); + latestUpdate = result; + renderUpdateStatus(result); + } catch (error) { + updateStatusEl.textContent = `Update check failed: ${error.message}`; + if (updateInstallButton) updateInstallButton.disabled = true; + } +} + +function renderUpdateStatus(update) { + if (!updateStatusEl) return; + const lines = [ + `Current: ${update.current_version || "unknown"}`, + `Latest: ${update.latest_version || "unknown"}`, + update.message || "", + ].filter(Boolean); + if (update.available && !update.asset_download_url) { + lines.push("The release needs a TraderAI.exe attachment before the app can self-update."); + } + if (update.available && update.asset_download_url && !update.packaged) { + lines.push("Self-update runs from the packaged desktop exe."); + } + updateStatusEl.textContent = lines.join("\n"); + if (updateInstallButton) { + updateInstallButton.disabled = !update.available || !update.asset_download_url || !update.packaged; + } +} + +async function installUpdate() { + if (!updateStatusEl) return; + updateStatusEl.textContent = "Downloading update"; + try { + const response = await fetch("/api/update/install", { method: "POST" }); + const result = await response.json(); + latestUpdate = result; + renderUpdateStatus(result); + } catch (error) { + updateStatusEl.textContent = `Update failed: ${error.message}`; + } +} + +function openReleasesPage() { + const url = latestUpdate?.release_url || "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"; + window.open(url, "_blank", "noreferrer"); +} + +function toggleSidebarPanel(panelName) { + const panels = { + settings: { panel: settingsPanel, button: settingsToggle }, + memory: { panel: memoryPanel, button: memoryToggle }, + ollama: { panel: ollamaPanel, button: ollamaToggle }, + }; + const target = panels[panelName]; + if (!target?.panel || !target?.button) return; + const shouldOpen = target.panel.hidden; + for (const item of Object.values(panels)) { + if (!item.panel || !item.button) continue; + item.panel.hidden = true; + item.button.classList.remove("active"); + item.button.setAttribute("aria-expanded", "false"); + } + if (shouldOpen) { + target.panel.hidden = false; + target.button.classList.add("active"); + target.button.setAttribute("aria-expanded", "true"); + if (panelName === "settings") { + refreshConfig(); + checkForUpdate(); + } + if (panelName === "memory") refreshMemory(); + if (panelName === "ollama") { + refreshConfig(); + refreshOllamaStatus(); + } + } +} + async function checkHealth() { try { const response = await fetch("/api/health"); @@ -576,8 +852,22 @@ input.addEventListener("keydown", async (event) => { } }); -memoryRefreshButton.addEventListener("click", refreshMemory); -memoryClearButton.addEventListener("click", clearMemory); +memoryRefreshButton?.addEventListener("click", refreshMemory); +memoryClearButton?.addEventListener("click", clearMemory); +configRefreshButton?.addEventListener("click", refreshConfig); +configForm?.addEventListener("submit", saveConfig); +settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings")); +memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory")); +ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama")); +ollamaForm?.addEventListener("submit", saveOllamaConfig); +ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus); +ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download")); +ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install")); +ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch")); +ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } })); +updateCheckButton?.addEventListener("click", checkForUpdate); +updateInstallButton?.addEventListener("click", installUpdate); +updateOpenReleasesButton?.addEventListener("click", openReleasesPage); async function sendMessage() { const message = input.value.trim(); @@ -663,6 +953,9 @@ async function sendMessage() { addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything."); refreshPending(); refreshMemory(); +refreshConfig(); +refreshOllamaStatus(); +checkForUpdate(); pollNotifications(); checkHealth(); setInterval(checkHealth, 30000); diff --git a/web/art/LBC_Logo.ico b/web/art/LBC_Logo.ico new file mode 100644 index 0000000..5fd8e56 Binary files /dev/null and b/web/art/LBC_Logo.ico differ diff --git a/web/art/ollama-icon.svg b/web/art/ollama-icon.svg new file mode 100644 index 0000000..fc66c26 --- /dev/null +++ b/web/art/ollama-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/index.html b/web/index.html index f4b30a3..f0de222 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,7 @@ TraderAI + @@ -36,23 +37,93 @@

Pending Approval

No pending actions
-
-
-

Memory

- + + diff --git a/web/styles.css b/web/styles.css index 2fd3f82..f2987b0 100644 --- a/web/styles.css +++ b/web/styles.css @@ -205,7 +205,7 @@ h2 { .messages { overflow: auto; min-height: 0; - padding: 28px; + padding: 28px 28px 18px; background: radial-gradient(circle at 50% 12%, rgba(212, 175, 55, 0.1), transparent 34%), linear-gradient(180deg, rgba(247, 241, 220, 0.58), rgba(255, 250, 240, 0.64)); @@ -360,8 +360,12 @@ h2 { } .composer-wrap { + position: sticky; + bottom: 0; + z-index: 5; border-top: 1px solid var(--line); background: rgba(255, 250, 240, 0.88); + box-shadow: 0 -16px 34px rgba(38, 58, 27, 0.12); } .message-activity { @@ -543,6 +547,29 @@ textarea:disabled { opacity: 0.66; } +input[type="text"], +input[type="password"], +input[type="number"] { + width: 100%; + min-height: 38px; + padding: 9px 11px; + border: 1px solid var(--line-strong); + border-radius: 10px; + outline: none; + background: #fffdf7; + color: var(--brown); + font: inherit; + font-size: 13px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +input[type="text"]:focus, +input[type="password"]:focus, +input[type="number"]:focus { + border-color: var(--gold); + box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18); +} + button { min-width: 104px; border: 0; @@ -605,6 +632,200 @@ button.secondary { border-top: 1px solid var(--line); } +.sidebar-tools { + display: grid; + gap: 14px; +} + +.sidebar-tool-buttons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.sidebar-tool-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + min-height: 46px; + padding: 10px 12px; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: #fff9e9; + color: var(--forest); + font-family: Inter, "Segoe UI", Arial, sans-serif; + font-size: 13px; + font-weight: 800; + box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08); +} + +.sidebar-tool-button svg { + width: 18px; + height: 18px; + stroke-width: 2.3; +} + +.sidebar-tool-image { + width: 18px; + height: 18px; + object-fit: contain; +} + +.sidebar-tool-image + svg { + display: none; +} + +.sidebar-tool-button.active { + border-color: rgba(212, 175, 55, 0.72); + background: linear-gradient(180deg, #345326, #1f3416); + color: var(--ivory); +} + +.sidebar-panel { + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.config-form { + display: grid; + gap: 10px; +} + +.config-form label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.config-check { + display: flex !important; + grid-template-columns: none; + align-items: center; + gap: 8px !important; +} + +.config-form button { + width: 100%; + min-height: 42px; + margin-top: 2px; +} + +.config-paths, +.config-status { + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.config-status { + color: var(--forest); + font-weight: 800; +} + +.ollama-status { + display: grid; + gap: 8px; + margin: 14px 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 250, 240, 0.78); + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.ollama-status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.ollama-status-item { + display: grid; + gap: 2px; + min-width: 0; +} + +.ollama-status-item strong { + color: var(--forest); + font-size: 11px; + text-transform: uppercase; +} + +.ollama-status-item span { + overflow-wrap: anywhere; +} + +.status-pill { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 24px; + padding: 4px 8px; + border: 1px solid rgba(52, 83, 38, 0.28); + border-radius: 999px; + background: #edf3df; + color: var(--forest); + font-weight: 800; +} + +.status-pill.warning { + border-color: rgba(159, 60, 50, 0.26); + background: #fff2e5; + color: var(--danger); +} + +.ollama-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 10px; +} + +.ollama-actions button { + width: 100%; +} + +.update-box { + display: grid; + gap: 10px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.update-box h2 { + margin-bottom: 0; +} + +.update-status { + padding: 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 250, 240, 0.78); + color: var(--muted); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.update-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.update-actions button { + width: 100%; +} + .section-title-row { display: flex; align-items: center;