feat: versioning, feat: in app configueration, feat: single exe, feat: reasoning, action: inital version, fix: config saving
Build Release EXE / build-windows-exe (release) Successful in 1m5s
Build Release EXE / build-windows-exe (release) Successful in 1m5s
This commit is contained in:
+126
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
+406
-2
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user