6 Commits

Author SHA1 Message Date
HRiggs 58a57ddc6a fix: config saving and config loading
Build Release EXE / build-windows-exe (release) Successful in 50s
2026-05-06 15:18:51 -04:00
HRiggs 11adcc160a 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
2026-05-06 15:06:15 -04:00
HRiggs da016c23cb feat: history tools 2026-05-06 13:16:27 -04:00
HRiggs 5850674448 ux: LBC Styling, feat: thinking, feat: more tools: 2026-05-06 01:15:37 -04:00
HRiggs 36c91ce500 feat: decline pending action 2026-05-05 20:14:06 -04:00
HRiggs 761eda6155 agent: look at current info, its aUEC, feat: pull up notifcations. 2026-05-05 20:05:33 -04:00
27 changed files with 3821 additions and 193 deletions
+3 -1
View File
@@ -1,8 +1,10 @@
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64512
UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_BASE_URL=https://api.uexcorp.space/2.0
UEX_SECRET_KEY= UEX_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
TRADERAI_MEMORY_PATH=data/traderai.sqlite3 TRADERAI_MEMORY_PATH=
UEX_NOTIFICATION_POLL_SECONDS=60
REQUIRE_WRITE_APPROVAL=true REQUIRE_WRITE_APPROVAL=true
+74
View File
@@ -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 }
+1
View File
@@ -19,6 +19,7 @@ dist/
*.egg-info/ *.egg-info/
.eggs/ .eggs/
pip-wheel-metadata/ pip-wheel-metadata/
.playwright-mcp/
# Test and coverage output # Test and coverage output
.pytest_cache/ .pytest_cache/
+19 -3
View File
@@ -4,13 +4,14 @@ Local Ollama-powered chat for UEX marketplace workflows.
## What It Does ## What It Does
- Searches active UEX marketplace listings through `GET /marketplace_listings/`. - Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set. - Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
- Drafts negotiation messages and marketplace listings as pending actions. - Drafts negotiation messages and marketplace listings as pending actions.
- Requires browser approval before sending authenticated write requests to UEX. - Requires browser approval before sending authenticated write requests to UEX.
- Maintains local SQLite memory with searchable recall for user facts, preferences, and prior chat context. - Maintains local SQLite memory with searchable recall for user facts, preferences, and prior chat context.
- Can create one-time or recurring wake jobs that prompt the assistant later and surface the result in the UI. - Can create one-time or recurring wake jobs that prompt the assistant later and surface the result in the UI.
- Loads the configured UEX user profile from `GET /user` so the assistant knows the current account username, display name, timezone, language, and marketplace-relevant profile details. - Loads the configured UEX user profile from `GET /user` so the assistant knows the current account username, display name, timezone, language, and marketplace-relevant profile details.
- Polls authenticated `GET /user_notifications` for unread UEX notifications and surfaces new pending alerts in the chat notification queue.
## Setup ## Setup
@@ -33,11 +34,23 @@ Local Ollama-powered chat for UEX marketplace workflows.
## Notes ## Notes
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 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; `64512` 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. 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. Memory is stored locally at `TRADERAI_MEMORY_PATH`. 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`.
Wake jobs can be created from chat, for example: Wake jobs can be created from chat, for example:
@@ -53,6 +66,8 @@ Every day at 9 AM, wake up and check whether I have marketplace followups.
The scheduler accepts one-time ISO datetimes and five-field cron expressions through the `schedule_wake_job` tool. When a wake job fires, the assistant receives context like the current time and last interaction time, then places its response into the UI notification queue. The scheduler accepts one-time ISO datetimes and five-field cron expressions through the `schedule_wake_job` tool. When a wake job fires, the assistant receives context like the current time and last interaction time, then places its response into the UI notification queue.
UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by default. New unread notifications are deduplicated locally, then displayed in the chat through the same notification queue used by wake jobs.
## Sources Used ## Sources Used
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 - UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
@@ -60,6 +75,7 @@ The scheduler accepts one-time ISO datetimes and five-field cron expressions thr
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1 - UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling - Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
- Ollama API streaming/tool-call reference: https://github.com/ollama/ollama/blob/main/docs/api.md - Ollama API streaming/tool-call reference: https://github.com/ollama/ollama/blob/main/docs/api.md
- Ollama context length docs: https://docs.ollama.com/context-length
- SQLite FTS5 docs: https://www.sqlite.org/fts5.html - SQLite FTS5 docs: https://www.sqlite.org/fts5.html
- APScheduler AsyncIO scheduler docs: https://apscheduler.readthedocs.io/en/stable/modules/schedulers/asyncio.html - APScheduler AsyncIO scheduler docs: https://apscheduler.readthedocs.io/en/stable/modules/schedulers/asyncio.html
- Letta/MemGPT memory hierarchy background: https://docs.letta.com/concepts/letta - Letta/MemGPT memory hierarchy background: https://docs.letta.com/concepts/letta
+55
View File
@@ -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,
)
+55
View File
@@ -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,
)
+13 -1
View File
@@ -1,12 +1,13 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.1.0" version = "0.0.2"
description = "Local Ollama-powered assistant for UEX marketplace workflows." description = "Local Ollama-powered assistant for UEX marketplace workflows."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"apscheduler>=3.10.4", "apscheduler>=3.10.4",
"fastapi>=0.115.0", "fastapi>=0.115.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"pywebview>=5.4",
"pydantic>=2.8.0", "pydantic>=2.8.0",
"pydantic-settings>=2.4.0", "pydantic-settings>=2.4.0",
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
@@ -16,11 +17,22 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pyinstaller>=6.11.0",
"pytest>=8.3.0", "pytest>=8.3.0",
"pytest-asyncio>=0.23.8", "pytest-asyncio>=0.23.8",
"respx>=0.21.1", "respx>=0.21.1",
] ]
[project.scripts]
traderai-desktop = "traderai.desktop:main"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."] pythonpath = ["."]
[tool.setuptools.packages.find]
include = ["traderai*"]
+16
View File
@@ -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"
+29
View File
@@ -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"
+14 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from traderai.agent import OllamaAgent from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
@@ -77,3 +77,16 @@ def test_stream_metrics_include_reading_and_writing_rates():
assert metrics["reading_tokens_per_second"] == 10 assert metrics["reading_tokens_per_second"] == 10
assert metrics["writing_tokens"] == 30 assert metrics["writing_tokens"] == 30
assert metrics["writing_tokens_per_second"] == 10 assert metrics["writing_tokens_per_second"] == 10
def test_system_prompt_prefers_current_marketplace_data():
assert "open/current" in SYSTEM_PROMPT
assert "Do not use historical sale data" in SYSTEM_PROMPT
assert "aUEC/UEC credits" in SYSTEM_PROMPT
assert "never real-world dollars" in SYSTEM_PROMPT
def test_ollama_options_include_num_ctx():
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
assert agent._ollama_options() == {"num_ctx": 64000}
+47
View File
@@ -0,0 +1,47 @@
import pytest
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
class FakeUEXNotifications:
def __init__(self):
self.calls = 0
async def get_user_notifications(self):
self.calls += 1
return {
"status": "ok",
"notifications": [
{
"id": 10,
"message": "A buyer replied to your listing.",
"redir": "/marketplace/negotiations/abc",
"code": "negotiation_reply",
"date_added": 123,
"date_read": 0,
},
{
"id": 11,
"message": "Already read.",
"date_added": 122,
"date_read": 123,
},
],
}
@pytest.mark.asyncio
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
scheduler = WakeScheduler(memory)
scheduler.bind_uex_notifications(FakeUEXNotifications())
first = await scheduler.poll_uex_notifications()
second = await scheduler.poll_uex_notifications()
outbox = memory.inspect()["outbox"]
assert len(first) == 1
assert second == []
assert len(outbox) == 1
assert "A buyer replied to your listing." in outbox[0]["content"]
+198
View File
@@ -8,6 +8,74 @@ from traderai.uex_client import UEXClient
class FakeUEX: class FakeUEX:
async def get(self, path, params=None, authenticated=False): async def get(self, path, params=None, authenticated=False):
if path == "commodities_prices_history":
return {
"status": "ok",
"data": [
{
"id": 1,
"id_terminal": 7,
"id_commodity": 3,
"commodity_name": "Gold",
"terminal_name": "Port Tressler",
"price_buy": 4000,
"price_sell": 5000,
"scu_buy": 100,
"scu_sell": 20,
"date_added": 100,
},
{
"id": 2,
"id_terminal": 7,
"id_commodity": 3,
"commodity_name": "Gold",
"terminal_name": "Port Tressler",
"price_buy": 4200,
"price_sell": 4800,
"scu_buy": 80,
"scu_sell": 30,
"date_added": 200,
},
],
}
if path == "marketplace_prices_history":
return {
"status": "ok",
"data": [
{"id": 1, "item_name": "Widget", "operation": "sell", "price": 1000, "currency": "UEC", "date_added": 100},
{"id": 2, "item_name": "Widget", "operation": "sell", "price": 1250, "currency": "UEC", "date_added": 200},
],
}
if path == "currencies_index_history":
return {
"status": "ok",
"data": [
{"id": 1, "currency": "UEC", "index_value": 100.0, "basket_value": 5000.0, "date_added": 100},
{"id": 2, "currency": "UEC", "index_value": 110.0, "basket_value": 5500.0, "date_added": 200},
],
}
if path == "commodities_prices":
return {
"status": "ok",
"data": [
{
"id": 10,
"commodity_name": "Gold",
"terminal_name": "Port Tressler",
"price_buy": 4120,
"price_sell": 5020,
"scu_buy": 1200,
"verbose_note": "x" * 300,
},
{
"id": 11,
"commodity_name": "Beryl",
"terminal_name": "Area18",
"price_buy": 2500,
"price_sell": 3100,
},
],
}
assert path == "marketplace_listings" assert path == "marketplace_listings"
return { return {
"data": [ "data": [
@@ -42,6 +110,9 @@ class FakeUEX:
] ]
} }
async def delete(self, path, params=None, authenticated=True):
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally(): async def test_search_marketplace_listings_filters_locally():
@@ -61,6 +132,19 @@ async def test_draft_message_creates_pending_action():
assert pending["id"] in registry.pending_actions assert pending["id"] in registry.pending_actions
@pytest.mark.asyncio
async def test_decline_pending_action_removes_without_sending():
registry = ToolRegistry(FakeUEX())
result = await registry.draft_negotiation_message(hash="abc", message="Would you take 4500 UEC?")
action_id = result["pending_action"]["id"]
declined = await registry.decline(action_id)
assert declined["declined"] is True
assert declined["pending_action"]["id"] == action_id
assert action_id not in registry.pending_actions
def test_uex_client_uses_bearer_and_secret_headers(): def test_uex_client_uses_bearer_and_secret_headers():
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer") client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
@@ -70,6 +154,107 @@ def test_uex_client_uses_bearer_and_secret_headers():
assert headers["Authorization"] == "Bearer bearer" assert headers["Authorization"] == "Bearer bearer"
@pytest.mark.asyncio
async def test_uex_get_projects_and_limits_results():
registry = ToolRegistry(FakeUEX())
result = await registry.execute(
"get_uex_commodities_prices",
{
"commodity_name": "Gold",
"ignored": "drop",
"fields": ["id", "commodity_name", "price_buy"],
"limit": 1,
},
)
assert result["resource"] == "commodities_prices"
assert result["params"] == {"commodity_name": "Gold"}
assert result["returned"] == 1
assert result["truncated"] is True
assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}]
@pytest.mark.asyncio
async def test_uex_api_catalog_exposes_resources_without_live_call():
registry = ToolRegistry(FakeUEX())
result = await registry.uex_api_catalog(group="vehicles")
resources = [item["resource"] for item in result["get"]["vehicles"]]
assert "vehicles" in resources
assert "vehicles_prices" in resources
assert "wallet_add" in result["post"]
@pytest.mark.asyncio
async def test_draft_delete_approves_with_delete_method():
registry = ToolRegistry(FakeUEX())
result = await registry.execute("delete_uex_marketplace_listings", {"id": 123, "label": "Remove listing"})
action_id = result["pending_action"]["id"]
approved = await registry.approve(action_id)
assert result["pending_action"]["method"] == "DELETE"
assert approved["deleted"] == {
"path": "marketplace_listings",
"params": {"id": 123},
"authenticated": True,
}
def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
registry = ToolRegistry(FakeUEX())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "get_uex_commodities_prices" in names
assert "get_uex_vehicles" in names
assert "draft_uex_marketplace_advertise" in names
assert "delete_uex_marketplace_listings" in names
assert "uex_get" not in names
assert "uex_draft_post" not in names
@pytest.mark.asyncio
async def test_search_uex_api_index_finds_history_tools():
registry = ToolRegistry(FakeUEX())
result = await registry.execute("search_uex_api_index", {"query": "history", "history_only": True})
tools = {item["tool"] for item in result["get"]}
assert "get_uex_commodities_prices_history" in tools
assert "get_uex_marketplace_prices_history" in tools
assert "get_uex_currencies_index_history" in tools
@pytest.mark.asyncio
async def test_summarize_commodity_price_history_returns_trend_metrics():
registry = ToolRegistry(FakeUEX())
result = await registry.execute(
"summarize_uex_commodity_price_history",
{"id_terminal": 7, "id_commodity": 3},
)
assert result["resource"] == "commodities_prices_history"
assert result["count"] == 2
assert result["labels"] == {"commodity_name": "Gold", "terminal_name": "Port Tressler"}
assert result["metrics"]["price_buy"]["change"] == 200
assert result["metrics"]["price_sell"]["pct_change"] == -4.0
@pytest.mark.asyncio
async def test_summarize_marketplace_and_currency_history():
registry = ToolRegistry(FakeUEX())
market = await registry.execute("summarize_uex_marketplace_price_history", {"item_name": "Widget"})
currency = await registry.execute("summarize_uex_currency_index_history", {"currency": "UEC"})
assert market["metrics"]["price"]["pct_change"] == 25.0
assert currency["metrics"]["index_value"]["change"] == 10.0
@pytest.mark.asyncio @pytest.mark.asyncio
@respx.mock @respx.mock
async def test_uex_client_get_user_normalizes_user_payload(): async def test_uex_client_get_user_normalizes_user_payload():
@@ -81,3 +266,16 @@ async def test_uex_client_get_user_normalizes_user_payload():
result = await client.get_user(authenticated=True) result = await client.get_user(authenticated=True)
assert result == {"status": "ok", "user": {"username": "pilot_hudson"}} assert result == {"status": "ok", "user": {"username": "pilot_hudson"}}
@pytest.mark.asyncio
@respx.mock
async def test_uex_client_get_user_notifications_normalizes_payload():
respx.get("https://api.uexcorp.space/2.0/user_notifications/").mock(
return_value=Response(200, json={"status": "ok", "data": {"id": 7, "message": "Reply waiting", "date_read": 0}})
)
client = UEXClient("https://api.uexcorp.space/2.0", bearer_token="bearer")
result = await client.get_user_notifications()
assert result == {"status": "ok", "notifications": [{"id": 7, "message": "Reply waiting", "date_read": 0}]}
+46 -1
View File
@@ -12,7 +12,12 @@ from traderai.tools import ToolRegistry
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work. SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
Use tools when the user asks about listings, negotiations, messages, offers, or posting ads. Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise.
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds. For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next.""" Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
@@ -25,12 +30,14 @@ class OllamaAgent:
tools: ToolRegistry, tools: ToolRegistry,
memory: MemoryStore | None = None, memory: MemoryStore | None = None,
user_name: str | None = None, user_name: str | None = None,
num_ctx: int | None = None,
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.model = model self.model = model
self.tools = tools self.tools = tools
self.memory = memory self.memory = memory
self.user_name = user_name self.user_name = user_name
self.num_ctx = num_ctx
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
async def health(self) -> dict[str, Any]: async def health(self) -> dict[str, Any]:
@@ -165,6 +172,7 @@ class OllamaAgent:
"model": self.model, "model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction), "messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas, "tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": False, "stream": False,
}, },
) )
@@ -184,6 +192,7 @@ class OllamaAgent:
"model": self.model, "model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction), "messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas, "tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": True, "stream": True,
}, },
) as response: ) as response:
@@ -207,6 +216,21 @@ class OllamaAgent:
parts = [ parts = [
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.", f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
] ]
uex = getattr(self.tools, "uex", None)
if uex:
auth_methods = []
if uex.secret_key:
auth_methods.append("secret key")
if uex.bearer_token:
auth_methods.append("bearer token")
if auth_methods:
parts.append(
"UEX API authentication is configured server-side with "
+ " and ".join(auth_methods)
+ "; use authenticated UEX tools directly and do not ask for tokens."
)
else:
parts.append("UEX API authentication is not configured server-side.")
if self.user_name: if self.user_name:
parts.append(f"Known user name/handle: {self.user_name}.") parts.append(f"Known user name/handle: {self.user_name}.")
@@ -252,21 +276,42 @@ class OllamaAgent:
{ {
"id": action.id, "id": action.id,
"label": action.label, "label": action.label,
"method": action.method,
"endpoint": action.endpoint, "endpoint": action.endpoint,
"payload": action.payload, "payload": action.payload,
} }
for action in self.tools.pending_actions.values() for action in self.tools.pending_actions.values()
] ]
def _ollama_options(self) -> dict[str, Any]:
if not self.num_ctx:
return {}
return {"num_ctx": self.num_ctx}
@staticmethod @staticmethod
def _tool_status(name: str) -> str: def _tool_status(name: str) -> str:
if name.startswith("get_uex_"):
return f"Fetching UEX {name.removeprefix('get_uex_')}"
if name.startswith("draft_uex_"):
return f"Drafting UEX {name.removeprefix('draft_uex_')} for approval"
if name.startswith("delete_uex_"):
return f"Drafting UEX {name.removeprefix('delete_uex_')} delete for approval"
labels = { labels = {
"search_uex_api_index": "Searching UEX API index",
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
"summarize_uex_currency_index_history": "Summarizing currency index history",
"uex_api_catalog": "Checking UEX API catalog",
"uex_get": "Fetching UEX data",
"uex_draft_post": "Drafting UEX write for approval",
"uex_draft_delete": "Drafting UEX delete for approval",
"search_marketplace_listings": "Searching UEX listings", "search_marketplace_listings": "Searching UEX listings",
"get_marketplace_listing": "Fetching listing details", "get_marketplace_listing": "Fetching listing details",
"list_marketplace_negotiations": "Checking negotiations", "list_marketplace_negotiations": "Checking negotiations",
"get_negotiation_messages": "Reading negotiation messages", "get_negotiation_messages": "Reading negotiation messages",
"draft_negotiation_message": "Drafting message for approval", "draft_negotiation_message": "Drafting message for approval",
"draft_marketplace_listing": "Drafting listing for approval", "draft_marketplace_listing": "Drafting listing for approval",
"check_uex_notifications": "Checking UEX notifications",
} }
return labels.get(name, f"Running {name}") return labels.get(name, f"Running {name}")
+136 -4
View File
@@ -1,22 +1,154 @@
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 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): 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_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b" ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64512
uex_base_url: str = "https://api.uexcorp.space/2.0" uex_base_url: str = "https://api.uexcorp.space/2.0"
uex_secret_key: str | None = Field(default=None) uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None)
traderai_user_name: 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 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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() return Settings()
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
current = settings or get_settings()
values = current.model_dump()
secrets_configured = {}
for key, meta in CONFIG_FIELDS.items():
if meta.get("secret"):
secrets_configured[key] = bool(values.get(key))
values[key] = ""
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,
"secrets_configured": secrets_configured,
}
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
if CONFIG_FIELDS[key].get("secret") and value == "":
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
+229
View File
@@ -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()
+67
View File
@@ -7,23 +7,34 @@ from uuid import uuid4
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from tzlocal import get_localzone from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, time_since from traderai.memory import MemoryStore, iso_now, time_since
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
class WakeScheduler: class WakeScheduler:
def __init__(self, memory: MemoryStore) -> None: def __init__(self, memory: MemoryStore) -> None:
self.memory = memory self.memory = memory
self.scheduler = AsyncIOScheduler(timezone=get_localzone()) self.scheduler = AsyncIOScheduler(timezone=get_localzone())
self.agent = None self.agent = None
self.uex = None
self.notification_poll_seconds = 60
def bind_agent(self, agent: Any) -> None: def bind_agent(self, agent: Any) -> None:
self.agent = agent self.agent = agent
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
self.uex = uex
self.notification_poll_seconds = max(15, poll_seconds)
def start(self) -> None: def start(self) -> None:
if not self.scheduler.running: if not self.scheduler.running:
self.scheduler.start() self.scheduler.start()
self._schedule_notification_poll()
for job in self.memory.list_jobs(): for job in self.memory.list_jobs():
self._schedule_existing(job) self._schedule_existing(job)
@@ -77,3 +88,59 @@ class WakeScheduler:
text = await self.agent.generate_wake_response(wake_message) text = await self.agent.generate_wake_response(wake_message)
self.memory.add_outbox(text) self.memory.add_outbox(text)
self.memory.mark_job_run(job_id) self.memory.mark_job_run(job_id)
def _schedule_notification_poll(self) -> None:
if self.uex is None:
return
self.scheduler.add_job(
self.poll_uex_notifications,
trigger=IntervalTrigger(seconds=self.notification_poll_seconds),
id=UEX_NOTIFICATION_JOB_ID,
replace_existing=True,
next_run_time=datetime.now(),
)
async def poll_uex_notifications(self) -> list[dict[str, Any]]:
if self.uex is None:
return []
response = await self.uex.get_user_notifications()
notifications = response.get("notifications") or []
pending = [item for item in notifications if not item.get("date_read")]
profile = self.memory.get_profile()
seen = set(profile.get("uex_seen_notification_keys") or [])
new_pending = [item for item in pending if self._notification_key(item) not in seen]
if new_pending:
for item in new_pending:
self.memory.add_outbox(self._notification_text(item))
seen.update(self._notification_key(item) for item in new_pending)
self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
self.memory.set_profile("uex_last_notification_check", iso_now())
elif notifications:
seen.update(self._notification_key(item) for item in pending)
self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
self.memory.set_profile("uex_last_notification_check", iso_now())
return new_pending
@staticmethod
def _notification_key(item: dict[str, Any]) -> str:
for key in ("code", "id"):
value = item.get(key)
if value not in (None, ""):
return f"{key}:{value}"
return f"notification:{item.get('date_added')}:{item.get('message')}"
@staticmethod
def _notification_text(item: dict[str, Any]) -> str:
message = item.get("message") or "You have a pending UEX notification."
redir = item.get("redir")
code = item.get("code")
details = []
if code:
details.append(f"code `{code}`")
if redir:
details.append(f"path `{redir}`")
suffix = f" ({', '.join(details)})" if details else ""
return f"UEX notification: {message}{suffix}"
+419 -3
View File
@@ -1,8 +1,17 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path import os
import json 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 FastAPI
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
@@ -10,11 +19,18 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from traderai.agent import OllamaAgent, OllamaUnavailable from traderai.agent import OllamaAgent, OllamaUnavailable
from traderai.config import save_settings, settings_payload
from traderai.config import get_settings from traderai.config import get_settings
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient 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): class ChatRequest(BaseModel):
@@ -29,17 +45,37 @@ class ClearMemoryRequest(BaseModel):
include_outbox: bool = True 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: def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
memory = MemoryStore(settings.traderai_memory_path) memory = MemoryStore(settings.traderai_memory_path)
scheduler = WakeScheduler(memory) scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler) tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler)
agent = OllamaAgent(settings.ollama_base_url, settings.ollama_model, tools, memory=memory, user_name=settings.traderai_user_name) agent = OllamaAgent(
settings.ollama_base_url,
settings.ollama_model,
tools,
memory=memory,
user_name=settings.traderai_user_name,
num_ctx=settings.ollama_num_ctx,
)
scheduler.bind_agent(agent) scheduler.bind_agent(agent)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
app = FastAPI(title="TraderAI") 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.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.on_event("startup") @app.on_event("startup")
@@ -85,8 +121,128 @@ def create_app() -> FastAPI:
"ollama": await agent.health(), "ollama": await agent.health(),
"user": memory.get_profile(), "user": memory.get_profile(),
"jobs": scheduler.list_jobs(), "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") @app.post("/api/chat")
async def chat(request: ChatRequest) -> dict: async def chat(request: ChatRequest) -> dict:
try: try:
@@ -137,7 +293,267 @@ def create_app() -> FastAPI:
async def approve(action_id: str) -> dict: async def approve(action_id: str) -> dict:
return await tools.approve(action_id) return await tools.approve(action_id)
@app.post("/api/decline/{action_id}")
async def decline(action_id: str) -> dict:
return await tools.decline(action_id)
return app 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() app = create_app()
+816 -4
View File
@@ -12,12 +12,134 @@ from traderai.uex_client import UEXClient
ToolHandler = Callable[..., Awaitable[dict[str, Any]]] ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"categories": {"params": ["type", "section"], "auth": False, "group": "reference"},
"categories_attributes": {"params": ["id_category", "category_name", "category_type"], "auth": False, "group": "reference"},
"cities": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"commodities": {"params": ["id", "name", "code", "slug"], "auth": False, "group": "trade"},
"commodities_alerts": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"},
"commodities_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"},
"commodities_prices": {
"params": ["id_terminal", "id_commodity", "terminal_name", "terminal_code", "terminal_slug", "commodity_name", "commodity_code", "commodity_slug"],
"auth": False,
"group": "trade",
},
"commodities_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True},
"commodities_prices_history": {"params": ["id_terminal", "id_commodity", "game_version"], "auth": False, "group": "trade", "history": True},
"commodities_ranking": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"},
"commodities_raw_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "mining"},
"commodities_raw_prices": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"},
"commodities_raw_prices_all": {"params": [], "auth": False, "group": "mining", "heavy": True},
"commodities_routes": {"params": ["id_terminal_origin", "id_terminal_destination", "id_commodity", "terminal_origin_name", "terminal_destination_name", "commodity_name"], "auth": False, "group": "trade"},
"commodities_status": {"params": [], "auth": False, "group": "trade"},
"companies": {"params": ["id", "name", "code"], "auth": False, "group": "reference"},
"contacts": {"params": ["id", "name"], "auth": False, "group": "reference"},
"contracts": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"},
"crew": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"},
"currencies_index": {"params": ["code"], "auth": False, "group": "reference"},
"currencies_index_history": {"params": ["currency", "date_from", "date_to"], "auth": False, "group": "reference", "history": True},
"data_extract": {"params": ["table"], "auth": False, "group": "data"},
"data_parameters": {"params": ["endpoint"], "auth": False, "group": "data"},
"factions": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"},
"fleet": {"params": ["username"], "auth": False, "group": "vehicles"},
"fuel_prices": {"params": ["id_terminal", "terminal_name", "terminal_code", "terminal_slug"], "auth": False, "group": "trade"},
"fuel_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True},
"game_versions": {"params": [], "auth": False, "group": "reference"},
"items": {"params": ["id", "id_category", "name", "uuid", "slug"], "auth": False, "group": "items"},
"items_attributes": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "items"},
"items_prices": {"params": ["id_item", "id_terminal", "item_name", "terminal_name"], "auth": False, "group": "items"},
"items_prices_all": {"params": [], "auth": False, "group": "items", "heavy": True},
"jump_points": {"params": ["id", "name", "slug"], "auth": False, "group": "locations"},
"jurisdictions": {"params": ["id", "name"], "auth": False, "group": "locations"},
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"},
"marketplace_listings": {"params": ["id", "slug", "username"], "auth": False, "group": "marketplace"},
"marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"},
"marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"},
"marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_prices_history": {
"params": [
"id_item",
"id_listing",
"id_terminal",
"id_star_system",
"id_category",
"item_uuid",
"item_name",
"operation",
"quality_tier",
"currency",
"game_version",
"date_start",
"date_end",
],
"auth": False,
"group": "marketplace",
"history": True,
},
"marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
"organizations": {"params": ["sid", "name"], "auth": False, "group": "reference"},
"outposts": {"params": ["id", "id_moon", "id_planet", "name", "slug"], "auth": False, "group": "locations"},
"planets": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"poi": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"refineries_audits": {"params": ["id_terminal", "terminal_name"], "auth": False, "group": "mining"},
"refineries_capacities": {"params": ["id_terminal", "terminal_name"], "auth": False, "group": "mining"},
"refineries_methods": {"params": ["id", "name"], "auth": False, "group": "mining"},
"refineries_yields": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"},
"release_notes": {"params": [], "auth": False, "group": "reference"},
"space_stations": {"params": ["id", "id_star_system", "id_planet", "id_moon", "name", "slug"], "auth": False, "group": "locations"},
"star_systems": {"params": ["id", "name", "code", "slug"], "auth": False, "group": "locations"},
"terminals": {"params": ["id", "id_star_system", "name", "code", "slug"], "auth": False, "group": "locations"},
"terminals_distances": {"params": ["id_terminal_origin", "id_terminal_destination"], "auth": False, "group": "locations"},
"user": {"params": ["username"], "auth": False, "group": "user"},
"user_notifications": {"params": [], "auth": True, "group": "user"},
"user_refineries_jobs": {"params": ["id"], "auth": True, "group": "user"},
"user_trades": {"params": ["id"], "auth": True, "group": "user"},
"vehicles": {"params": ["id", "name", "slug", "uuid"], "auth": False, "group": "vehicles"},
"vehicles_loaners": {"params": ["id_vehicle", "vehicle_name", "vehicle_slug"], "auth": False, "group": "vehicles"},
"vehicles_prices": {"params": ["id_vehicle", "vehicle_name", "vehicle_slug"], "auth": False, "group": "vehicles"},
"vehicles_purchases_prices": {"params": ["id_vehicle", "id_terminal", "vehicle_name", "terminal_name"], "auth": False, "group": "vehicles"},
"vehicles_purchases_prices_all": {"params": [], "auth": False, "group": "vehicles", "heavy": True},
"vehicles_rentals_prices": {"params": ["id_vehicle", "id_terminal", "vehicle_name", "terminal_name"], "auth": False, "group": "vehicles"},
"vehicles_rentals_prices_all": {"params": [], "auth": False, "group": "vehicles", "heavy": True},
"wallet_balance": {"params": [], "auth": True, "group": "user"},
}
UEX_POST_RESOURCES = {
"data_submit",
"marketplace_advertise",
"marketplace_negotiations_messages",
"user_refineries_jobs_add",
"user_trades_add",
"user_trades_edit",
"wallet_add",
}
UEX_DELETE_RESOURCES = {
"marketplace_listings",
"user_refineries_jobs_remove",
"user_trades_remove",
}
UEX_RESOURCE_DESCRIPTIONS = {
"commodities_prices_history": "Historical commodity prices at a terminal. Requires id_terminal and id_commodity; accepts game_version. UEX limits this to 500 rows.",
"marketplace_prices_history": "Historical marketplace price snapshots, one row per listing per price change. Requires at least one filter; supports date_start/date_end and up to 1000 records.",
"currencies_index_history": "Historical UEX currency index snapshots with basket component detail. Supports currency, date_from, and date_to timestamps.",
}
@dataclass @dataclass
class PendingAction: class PendingAction:
id: str id: str
label: str label: str
endpoint: str endpoint: str
payload: dict[str, Any] payload: dict[str, Any]
method: str = "POST"
class ToolRegistry: class ToolRegistry:
@@ -44,16 +166,36 @@ class ToolRegistry:
"recall_memory": self.recall_memory, "recall_memory": self.recall_memory,
"schedule_wake_job": self.schedule_wake_job, "schedule_wake_job": self.schedule_wake_job,
"list_wake_jobs": self.list_wake_jobs, "list_wake_jobs": self.list_wake_jobs,
"check_uex_notifications": self.check_uex_notifications,
} }
self.handlers["uex_api_catalog"] = self.uex_api_catalog
self.handlers["uex_get"] = self.uex_get
self.handlers["uex_draft_post"] = self.uex_draft_post
self.handlers["uex_draft_delete"] = self.uex_draft_delete
self.handlers["search_uex_api_index"] = self.search_uex_api_index
self.handlers["summarize_uex_commodity_price_history"] = self.summarize_uex_commodity_price_history
self.handlers["summarize_uex_marketplace_price_history"] = self.summarize_uex_marketplace_price_history
self.handlers["summarize_uex_currency_index_history"] = self.summarize_uex_currency_index_history
for resource in UEX_GET_RESOURCES:
self.handlers[self._get_tool_name(resource)] = self._make_get_handler(resource)
for resource in UEX_POST_RESOURCES:
self.handlers[self._post_tool_name(resource)] = self._make_post_handler(resource)
for resource in UEX_DELETE_RESOURCES:
self.handlers[self._delete_tool_name(resource)] = self._make_delete_handler(resource)
@property @property
def schemas(self) -> list[dict[str, Any]]: def schemas(self) -> list[dict[str, Any]]:
return [ return [
self._api_index_schema(),
*self._uex_get_schemas(),
*self._history_summary_schemas(),
*self._uex_post_schemas(),
*self._uex_delete_schemas(),
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "search_marketplace_listings", "name": "search_marketplace_listings",
"description": "Search active UEX marketplace listings. UEX returns up to 100 active listings; filters are applied locally.", "description": "Search active/current UEX marketplace listings only. Prices are in-game aUEC/UEC credits, not real-world dollars. Do not use this as historical sale or completed-sale information. UEX returns up to 100 active listings; filters are applied locally.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -133,7 +275,7 @@ class ToolRegistry:
"type": "function", "type": "function",
"function": { "function": {
"name": "draft_marketplace_listing", "name": "draft_marketplace_listing",
"description": "Draft a new UEX marketplace listing. This creates a pending action that must be approved before posting.", "description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"], "required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
@@ -215,6 +357,14 @@ class ToolRegistry:
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
}, },
}, },
{
"type": "function",
"function": {
"name": "check_uex_notifications",
"description": "Check authenticated UEX user notifications and return unread pending notifications.",
"parameters": {"type": "object", "properties": {}},
},
},
] ]
async def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: async def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
@@ -230,8 +380,496 @@ class ToolRegistry:
action = self.pending_actions.pop(action_id, None) action = self.pending_actions.pop(action_id, None)
if not action: if not action:
return {"error": f"Pending action not found: {action_id}"} return {"error": f"Pending action not found: {action_id}"}
if action.method == "DELETE":
return await self.uex.delete(action.endpoint, action.payload, authenticated=True)
return await self.uex.post(action.endpoint, action.payload, authenticated=True) return await self.uex.post(action.endpoint, action.payload, authenticated=True)
async def decline(self, action_id: str) -> dict[str, Any]:
action = self.pending_actions.pop(action_id, None)
if not action:
return {"error": f"Pending action not found: {action_id}"}
return {
"declined": True,
"pending_action": {
"id": action.id,
"label": action.label,
"method": action.method,
"endpoint": action.endpoint,
"payload": action.payload,
},
}
async def uex_api_catalog(self, group: str | None = None, resource: str | None = None) -> dict[str, Any]:
if resource:
key = self._validate_resource(resource, UEX_GET_RESOURCES)
info = UEX_GET_RESOURCES[key]
return {
"resource": key,
"method": "GET",
"group": info["group"],
"authenticated": info["auth"],
"heavy": bool(info.get("heavy")),
"params": info["params"],
"write_resources": {
"post": sorted(UEX_POST_RESOURCES),
"delete": sorted(UEX_DELETE_RESOURCES),
},
}
grouped: dict[str, list[dict[str, Any]]] = {}
for name, info in sorted(UEX_GET_RESOURCES.items()):
if group and info["group"] != group:
continue
grouped.setdefault(info["group"], []).append(
{
"resource": name,
"params": info["params"],
"auth": info["auth"],
"heavy": bool(info.get("heavy")),
}
)
return {
"get": grouped,
"post": sorted(UEX_POST_RESOURCES),
"delete": sorted(UEX_DELETE_RESOURCES),
"usage": "Call uex_get(resource, params, fields, limit, mode). Use fields and limit to keep responses small.",
}
async def uex_get(
self,
resource: str,
params: dict[str, Any] | None = None,
fields: list[str] | None = None,
search: str | None = None,
limit: int = 10,
offset: int = 0,
mode: str = "summary",
) -> dict[str, Any]:
resource = self._validate_resource(resource, UEX_GET_RESOURCES)
info = UEX_GET_RESOURCES[resource]
cleaned_params = self._filter_params(params or {}, info["params"])
response = await self.uex.get(resource, cleaned_params, authenticated=bool(info["auth"]))
data = response.get("data")
items = self._as_list(data)
total = len(items)
if search:
needle = search.casefold()
items = [item for item in items if needle in self._search_text(item)]
filtered_total = len(items)
offset = max(0, offset)
limit = max(1, min(limit, 100))
window = items[offset : offset + limit]
compacted = [
self._project_item(item, fields=fields, mode=mode)
for item in window
]
return {
"status": response.get("status"),
"resource": resource,
"params": cleaned_params,
"total": total,
"matched": filtered_total,
"returned": len(compacted),
"offset": offset,
"truncated": offset + len(compacted) < filtered_total,
"items": compacted,
}
async def uex_draft_post(self, resource: str, payload: dict[str, Any], label: str | None = None) -> dict[str, Any]:
resource = self._validate_resource(resource, UEX_POST_RESOURCES)
return self._pending(label or f"POST {resource}", resource, payload, method="POST")
async def uex_draft_delete(
self,
resource: str,
params: dict[str, Any] | None = None,
label: str | None = None,
) -> dict[str, Any]:
resource = self._validate_resource(resource, UEX_DELETE_RESOURCES)
return self._pending(label or f"DELETE {resource}", resource, params or {}, method="DELETE")
async def search_uex_api_index(
self,
query: str = "",
group: str | None = None,
history_only: bool = False,
limit: int = 20,
) -> dict[str, Any]:
needle = query.casefold().strip()
matches = []
for resource, info in sorted(UEX_GET_RESOURCES.items()):
if group and info["group"] != group:
continue
if history_only and not info.get("history"):
continue
haystack = " ".join(
[
resource,
info["group"],
" ".join(info["params"]),
UEX_RESOURCE_DESCRIPTIONS.get(resource, ""),
]
).casefold()
if needle and needle not in haystack:
continue
matches.append(self._resource_index_entry("GET", resource, info))
if len(matches) >= max(1, min(limit, 50)):
break
post_matches = []
if not history_only:
for resource in sorted(UEX_POST_RESOURCES):
if group and group != "write":
continue
if needle and needle not in resource.casefold():
continue
post_matches.append(
{
"method": "POST",
"resource": resource,
"tool": self._post_tool_name(resource),
"approval_required": True,
"docs_url": self._docs_url("post", resource),
}
)
delete_matches = []
if not history_only:
for resource in sorted(UEX_DELETE_RESOURCES):
if group and group != "write":
continue
if needle and needle not in resource.casefold():
continue
delete_matches.append(
{
"method": "DELETE",
"resource": resource,
"tool": self._delete_tool_name(resource),
"approval_required": True,
"docs_url": self._docs_url("delete", resource),
}
)
return {
"count": len(matches) + len(post_matches) + len(delete_matches),
"get": matches,
"post": post_matches[: max(0, min(limit, 50) - len(matches))],
"delete": delete_matches[: max(0, min(limit, 50) - len(matches) - len(post_matches))],
}
async def summarize_uex_commodity_price_history(
self,
id_terminal: int,
id_commodity: int,
game_version: str | None = None,
limit: int = 100,
) -> dict[str, Any]:
return await self._history_summary(
"commodities_prices_history",
{"id_terminal": id_terminal, "id_commodity": id_commodity, "game_version": game_version},
value_fields=["price_buy", "price_sell", "scu_buy", "scu_sell", "scu_sell_stock"],
label_fields=["commodity_name", "terminal_name", "game_version"],
limit=limit,
)
async def summarize_uex_marketplace_price_history(
self,
id_item: str | int | None = None,
id_listing: int | None = None,
id_terminal: int | None = None,
id_star_system: int | None = None,
id_category: int | None = None,
item_uuid: str | None = None,
item_name: str | None = None,
operation: str | None = None,
quality_tier: int | None = None,
currency: str | None = None,
game_version: str | None = None,
date_start: str | None = None,
date_end: str | None = None,
limit: int = 250,
) -> dict[str, Any]:
params = {
"id_item": id_item,
"id_listing": id_listing,
"id_terminal": id_terminal,
"id_star_system": id_star_system,
"id_category": id_category,
"item_uuid": item_uuid,
"item_name": item_name,
"operation": operation,
"quality_tier": quality_tier,
"currency": currency,
"game_version": game_version,
"date_start": date_start,
"date_end": date_end,
}
return await self._history_summary(
"marketplace_prices_history",
params,
value_fields=["price", "quality"],
label_fields=["item_name", "operation", "currency", "terminal_name", "game_version"],
limit=limit,
)
async def summarize_uex_currency_index_history(
self,
currency: str | None = None,
date_from: int | None = None,
date_to: int | None = None,
limit: int = 365,
) -> dict[str, Any]:
return await self._history_summary(
"currencies_index_history",
{"currency": currency, "date_from": date_from, "date_to": date_to},
value_fields=["index_value", "basket_value", "data_window_days"],
label_fields=["currency", "methodology"],
limit=limit,
)
def _make_get_handler(self, resource: str) -> ToolHandler:
async def handler(**arguments: Any) -> dict[str, Any]:
fields = arguments.pop("fields", None)
search = arguments.pop("search", None)
limit = arguments.pop("limit", 10)
offset = arguments.pop("offset", 0)
mode = arguments.pop("mode", "summary")
return await self.uex_get(
resource,
params=arguments,
fields=fields,
search=search,
limit=limit,
offset=offset,
mode=mode,
)
return handler
def _make_post_handler(self, resource: str) -> ToolHandler:
async def handler(payload: dict[str, Any], label: str | None = None) -> dict[str, Any]:
return await self.uex_draft_post(resource, payload, label=label)
return handler
def _make_delete_handler(self, resource: str) -> ToolHandler:
async def handler(label: str | None = None, **params: Any) -> dict[str, Any]:
return await self.uex_draft_delete(resource, params, label=label)
return handler
@classmethod
def _uex_get_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": cls._get_tool_name(resource),
"description": cls._get_tool_description(resource, info),
"parameters": cls._get_tool_parameters(info["params"]),
},
}
for resource, info in sorted(UEX_GET_RESOURCES.items())
]
@classmethod
def _api_index_schema(cls) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": "search_uex_api_index",
"description": "Search the indexed UEX API tool catalog by topic, resource, parameter, or group. Use to discover exact tool names, especially history tools.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"group": {
"type": "string",
"enum": ["trade", "marketplace", "items", "vehicles", "locations", "mining", "user", "reference", "data", "write"],
},
"history_only": {"type": "boolean", "default": False},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
},
},
},
}
@classmethod
def _history_summary_schemas(cls) -> list[dict[str, Any]]:
controls = {
"limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 250},
}
return [
{
"type": "function",
"function": {
"name": "summarize_uex_commodity_price_history",
"description": "Summarize historical commodity price and inventory changes for one commodity at one terminal.",
"parameters": {
"type": "object",
"required": ["id_terminal", "id_commodity"],
"properties": {
"id_terminal": {"type": "integer"},
"id_commodity": {"type": "integer"},
"game_version": {"type": "string"},
**controls,
},
},
},
},
{
"type": "function",
"function": {
"name": "summarize_uex_marketplace_price_history",
"description": "Summarize marketplace historical price snapshots for an item, listing, terminal, category, system, or date range.",
"parameters": {
"type": "object",
"properties": {
"id_item": {"oneOf": [{"type": "integer"}, {"type": "string"}]},
"id_listing": {"type": "integer"},
"id_terminal": {"type": "integer"},
"id_star_system": {"type": "integer"},
"id_category": {"type": "integer"},
"item_uuid": {"type": "string"},
"item_name": {"type": "string"},
"operation": {"type": "string", "enum": ["buy", "sell"]},
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 4},
"currency": {"type": "string"},
"game_version": {"type": "string"},
"date_start": {"type": "string", "description": "YYYY-MM-DD"},
"date_end": {"type": "string", "description": "YYYY-MM-DD"},
**controls,
},
},
},
},
{
"type": "function",
"function": {
"name": "summarize_uex_currency_index_history",
"description": "Summarize historical UEX currency index snapshots and basket value changes.",
"parameters": {
"type": "object",
"properties": {
"currency": {"type": "string"},
"date_from": {"type": "integer", "description": "Unix timestamp."},
"date_to": {"type": "integer", "description": "Unix timestamp."},
**controls,
},
},
},
},
]
@classmethod
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": cls._post_tool_name(resource),
"description": f"Draft UEX POST /{resource}/ for user approval. Nothing is sent until approval.",
"parameters": {
"type": "object",
"required": ["payload"],
"properties": {
"payload": {"type": "object", "description": f"JSON body for UEX POST /{resource}/."},
"label": {"type": "string", "description": "Short approval label."},
},
},
},
}
for resource in sorted(UEX_POST_RESOURCES)
]
@classmethod
def _uex_delete_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": cls._delete_tool_name(resource),
"description": f"Draft UEX DELETE /{resource}/ for user approval. Nothing is deleted until approval.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"label": {"type": "string", "description": "Short approval label."},
},
},
},
}
for resource in sorted(UEX_DELETE_RESOURCES)
]
@classmethod
def _get_tool_parameters(cls, endpoint_params: list[str]) -> dict[str, Any]:
properties = {
param: cls._query_param_schema(param)
for param in endpoint_params
}
properties.update(
{
"fields": {
"type": "array",
"items": {"type": "string"},
"description": "Fields to keep in each result row.",
},
"search": {"type": "string", "description": "Local text filter after UEX returns data."},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 10},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"mode": {"type": "string", "enum": ["summary", "full"], "default": "summary"},
}
)
return {"type": "object", "properties": properties}
@staticmethod
def _query_param_schema(param: str) -> dict[str, Any]:
if param == "id" or param.startswith("id_") or param in {"date_from", "date_to", "quality_tier"}:
return {"type": "integer"}
return {"type": "string"}
@staticmethod
def _get_tool_description(resource: str, info: dict[str, Any]) -> str:
auth = " Authenticated." if info["auth"] else ""
heavy = " Heavy endpoint; use fields and limit." if info.get("heavy") else ""
history = " History endpoint." if info.get("history") else ""
description = UEX_RESOURCE_DESCRIPTIONS.get(resource)
if description:
return f"GET UEX /{resource}/ with compact, token-limited results. {description}{auth}{heavy}"
return f"GET UEX /{resource}/ with compact, token-limited results.{history}{auth}{heavy}"
@staticmethod
def _get_tool_name(resource: str) -> str:
return f"get_uex_{resource}"
@staticmethod
def _post_tool_name(resource: str) -> str:
return f"draft_uex_{resource}"
@staticmethod
def _delete_tool_name(resource: str) -> str:
return f"delete_uex_{resource}"
@classmethod
def _resource_index_entry(cls, method: str, resource: str, info: dict[str, Any]) -> dict[str, Any]:
return {
"method": method,
"resource": resource,
"tool": cls._get_tool_name(resource),
"group": info["group"],
"params": info["params"],
"authenticated": info["auth"],
"history": bool(info.get("history")),
"heavy": bool(info.get("heavy")),
"description": UEX_RESOURCE_DESCRIPTIONS.get(resource, ""),
"docs_url": cls._docs_url("get", resource),
}
@staticmethod
def _docs_url(method: str, resource: str) -> str:
return f"https://uexcorp.space/api/documentation/id/{method}_{resource}/"
async def search_marketplace_listings( async def search_marketplace_listings(
self, self,
query: str | None = None, query: str | None = None,
@@ -324,20 +962,194 @@ class ToolRegistry:
return {"error": "Scheduler is not configured."} return {"error": "Scheduler is not configured."}
return {"scheduled_jobs": self.scheduler.list_jobs()} return {"scheduled_jobs": self.scheduler.list_jobs()}
def _pending(self, label: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: async def check_uex_notifications(self) -> dict[str, Any]:
response = await self.uex.get_user_notifications()
notifications = response.get("notifications") or []
pending = [item for item in notifications if not item.get("date_read")]
return {"count": len(pending), "notifications": pending}
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
action_id = str(uuid.uuid4()) action_id = str(uuid.uuid4())
payload = {key: value for key, value in payload.items() if value is not None} payload = {key: value for key, value in payload.items() if value is not None}
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload) self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method)
return { return {
"pending_action": { "pending_action": {
"id": action_id, "id": action_id,
"label": label, "label": label,
"method": method,
"endpoint": endpoint, "endpoint": endpoint,
"payload": payload, "payload": payload,
"approval_required": self.require_write_approval, "approval_required": self.require_write_approval,
} }
} }
@staticmethod
def _validate_resource(resource: str, allowed: dict[str, Any] | set[str]) -> str:
normalized = resource.strip().strip("/").casefold()
if normalized not in allowed:
choices = sorted(allowed.keys() if isinstance(allowed, dict) else allowed)
near = [name for name in choices if normalized in name or name in normalized][:8]
hint = f" Did you mean: {', '.join(near)}?" if near else ""
raise ValueError(f"Unsupported UEX resource: {resource}.{hint}")
return normalized
@staticmethod
def _filter_params(params: dict[str, Any], allowed_params: list[str]) -> dict[str, Any]:
if not allowed_params:
return {key: value for key, value in params.items() if value is not None}
allowed = set(allowed_params)
return {key: value for key, value in params.items() if key in allowed and value is not None}
@staticmethod
def _as_list(data: Any) -> list[Any]:
if data is None:
return []
if isinstance(data, list):
return data
return [data]
@classmethod
def _project_item(cls, item: Any, fields: list[str] | None = None, mode: str = "summary") -> Any:
if not isinstance(item, dict):
return item
if fields:
return {field: cls._compact_scalar(item.get(field)) for field in fields if field in item}
if mode == "full":
return {key: cls._compact_scalar(value) for key, value in item.items()}
priority = [
"id",
"uuid",
"code",
"slug",
"name",
"title",
"type",
"section",
"operation",
"price",
"currency",
"unit",
"location",
"terminal_name",
"commodity_name",
"item_name",
"vehicle_name",
"price_buy",
"price_sell",
"scu_buy",
"scu_sell",
"scu_sell_stock",
"status_buy",
"status_sell",
"date_modified",
"date_added",
]
selected: dict[str, Any] = {}
for key in priority:
if key in item and item[key] not in (None, ""):
selected[key] = cls._compact_scalar(item[key])
for key, value in item.items():
if len(selected) >= 16:
break
if key in selected or value in (None, ""):
continue
if isinstance(value, (str, int, float, bool)):
selected[key] = cls._compact_scalar(value)
return selected
@staticmethod
def _compact_scalar(value: Any) -> Any:
if isinstance(value, str) and len(value) > 240:
return value[:237] + "..."
if isinstance(value, list):
return value[:5]
if isinstance(value, dict):
return {key: nested_value for key, nested_value in list(value.items())[:12]}
return value
@classmethod
def _search_text(cls, item: Any) -> str:
if isinstance(item, dict):
return " ".join(str(value) for value in item.values() if isinstance(value, (str, int, float))).casefold()
return str(item).casefold()
async def _history_summary(
self,
resource: str,
params: dict[str, Any],
value_fields: list[str],
label_fields: list[str],
limit: int,
) -> dict[str, Any]:
info = UEX_GET_RESOURCES[resource]
cleaned_params = self._filter_params(params, info["params"])
response = await self.uex.get(resource, cleaned_params, authenticated=bool(info["auth"]))
rows = [
row
for row in self._as_list(response.get("data"))
if isinstance(row, dict)
][: max(1, min(limit, 1000))]
rows_sorted = sorted(rows, key=lambda row: int(row.get("date_added") or 0))
latest = rows_sorted[-1] if rows_sorted else {}
earliest = rows_sorted[0] if rows_sorted else {}
summaries = {
field: self._numeric_history_summary(rows_sorted, field)
for field in value_fields
if any(self._is_number(row.get(field)) for row in rows_sorted)
}
labels = {
field: latest.get(field)
for field in label_fields
if latest.get(field) not in (None, "")
}
sample_fields = ["id", "date_added", *label_fields, *value_fields]
recent = [
self._project_item(row, fields=sample_fields, mode="summary")
for row in list(reversed(rows_sorted[-5:]))
]
return {
"status": response.get("status"),
"resource": resource,
"params": cleaned_params,
"count": len(rows),
"date_start": earliest.get("date_added"),
"date_end": latest.get("date_added"),
"labels": labels,
"metrics": summaries,
"recent": recent,
"docs_url": self._docs_url("get", resource),
}
@classmethod
def _numeric_history_summary(cls, rows: list[dict[str, Any]], field: str) -> dict[str, Any]:
points = [
(int(row.get("date_added") or 0), float(row[field]))
for row in rows
if cls._is_number(row.get(field))
]
values = [value for _, value in points]
first_date, first_value = points[0]
last_date, last_value = points[-1]
change = last_value - first_value
pct_change = (change / first_value * 100) if first_value else None
return {
"first": first_value,
"first_date": first_date,
"latest": last_value,
"latest_date": last_date,
"min": min(values),
"max": max(values),
"avg": round(sum(values) / len(values), 4),
"change": round(change, 4),
"pct_change": round(pct_change, 4) if pct_change is not None else None,
"points": len(points),
}
@staticmethod
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
@staticmethod @staticmethod
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]: def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
return { return {
+16
View File
@@ -42,6 +42,13 @@ class UEXClient:
data = data[0] if data else None data = data[0] if data else None
return {"status": body.get("status"), "user": data} return {"status": body.get("status"), "user": data}
async def get_user_notifications(self) -> dict[str, Any]:
body = await self.get("user_notifications", authenticated=True)
data = body.get("data") or []
if isinstance(data, dict):
data = [data]
return {"status": body.get("status"), "notifications": data}
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]: async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
response = await client.post( response = await client.post(
@@ -51,6 +58,15 @@ class UEXClient:
) )
return self._handle_response(response) return self._handle_response(response)
async def delete(self, path: str, params: dict[str, Any] | None = None, authenticated: bool = True) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.delete(
f"{self.base_url}/{path.strip('/')}/",
params={k: v for k, v in (params or {}).items() if v is not None},
headers=self._headers(authenticated),
)
return self._handle_response(response)
@staticmethod @staticmethod
def _handle_response(response: httpx.Response) -> dict[str, Any]: def _handle_response(response: httpx.Response) -> dict[str, Any]:
try: try:
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
__version__ = "0.0.2"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
Generated
+304 -1
View File
@@ -2,6 +2,15 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.11" 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]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.4" 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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2026.4.22" 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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.3.3" 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" }, { 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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" 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" }, { 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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" 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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.3" 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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" 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" }, { 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" 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" }, { 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]] [[package]]
name = "respx" name = "respx"
version = "0.23.1" 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" }, { 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]] [[package]]
name = "starlette" name = "starlette"
version = "1.0.0" version = "1.0.0"
@@ -460,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.1.0" version = "0.0.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -469,12 +764,14 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "pywebview" },
{ name = "tzlocal" }, { name = "tzlocal" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "pyinstaller" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "respx" }, { name = "respx" },
@@ -487,9 +784,11 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic", specifier = ">=2.8.0" },
{ name = "pydantic-settings", specifier = ">=2.4.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", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.8" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.8" },
{ name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "pywebview", specifier = ">=5.4" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.1" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.1" },
{ name = "tzlocal", specifier = ">=5.2" }, { name = "tzlocal", specifier = ">=5.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
@@ -745,3 +1044,7 @@ 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/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" }, { 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" },
] ]
+463 -5
View File
@@ -7,8 +7,35 @@ const warningEl = document.getElementById("warning");
const memoryInspectorEl = document.getElementById("memory-inspector"); const memoryInspectorEl = document.getElementById("memory-inspector");
const memoryRefreshButton = document.getElementById("memory-refresh"); const memoryRefreshButton = document.getElementById("memory-refresh");
const memoryClearButton = document.getElementById("memory-clear"); 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 ollamaOnline = true;
let latestUpdate = null;
if (window.lucide) {
window.lucide.createIcons();
}
function addMessage(role, text) { function addMessage(role, text) {
const node = document.createElement("div"); const node = document.createElement("div");
@@ -27,6 +54,7 @@ function setMessageMarkdown(node, text) {
function setMessageActivity(node, text, active = false) { function setMessageActivity(node, text, active = false) {
const activity = node.querySelector(".message-activity"); const activity = node.querySelector(".message-activity");
if (!activity) return; if (!activity) return;
if (text) appendThinkingStep(node, reasoningSummaryForStatus(text), { fallback: true });
const phase = activity.querySelector(".message-phase"); const phase = activity.querySelector(".message-phase");
phase.innerHTML = ""; phase.innerHTML = "";
if (text) { if (text) {
@@ -48,6 +76,122 @@ function setMessageMetrics(node, metrics) {
metricsEl.textContent = metrics || ""; metricsEl.textContent = metrics || "";
} }
function appendThinkingStep(node, text, options = {}) {
const steps = node.querySelector(".thinking-steps");
if (!steps || !text) return;
const previous = steps.lastElementChild?.textContent;
if (previous === text) return;
const item = document.createElement("li");
if (options.fallback) item.dataset.fallback = "true";
item.textContent = text;
steps.appendChild(item);
}
function appendThinkingText(node, text) {
const steps = node.querySelector(".thinking-steps");
if (!steps || !text) return;
node.querySelectorAll(".thinking-steps [data-fallback='true']").forEach((item) => item.remove());
node.dataset.hasModelThinking = "true";
let item = steps.querySelector(".thinking-raw-step");
if (!item) {
item = document.createElement("li");
item.className = "thinking-raw-step";
steps.appendChild(item);
}
item.textContent += text;
}
function createThinkTagParser(node) {
let buffer = "";
let inThinking = false;
const partialTagLength = (text) => {
const lower = text.toLowerCase();
const tags = ["<think>", "</think>"];
for (const tag of tags) {
for (let length = tag.length - 1; length > 0; length -= 1) {
if (lower.endsWith(tag.slice(0, length))) return length;
}
}
return 0;
};
const consume = (content, flush = false) => {
buffer += content;
let visible = "";
while (buffer) {
const lower = buffer.toLowerCase();
if (inThinking) {
const closeIndex = lower.indexOf("</think>");
if (closeIndex === -1) {
if (flush) {
appendThinkingText(node, buffer);
buffer = "";
} else {
const keep = partialTagLength(buffer);
appendThinkingText(node, buffer.slice(0, buffer.length - keep));
buffer = buffer.slice(buffer.length - keep);
}
break;
}
appendThinkingText(node, buffer.slice(0, closeIndex));
buffer = buffer.slice(closeIndex + "</think>".length);
inThinking = false;
continue;
}
const openIndex = lower.indexOf("<think>");
if (openIndex === -1) {
const keep = flush ? 0 : partialTagLength(buffer);
visible += buffer.slice(0, buffer.length - keep);
buffer = buffer.slice(buffer.length - keep);
break;
}
visible += buffer.slice(0, openIndex);
buffer = buffer.slice(openIndex + "<think>".length);
inThinking = true;
}
return visible;
};
return {
consume,
flush: () => consume("", true),
};
}
function reasoningSummaryForStatus(text) {
const summaries = {
Thinking: "Reading your request and deciding whether I need current UEX data, memory, or a draft action before answering.",
"Searching UEX listings": "Checking current UEX marketplace listings so the answer is grounded in live item data instead of stale memory.",
"Fetching listing details": "Opening the specific listing details to avoid guessing about price, seller, quantity, or status.",
"Checking negotiations": "Looking through active negotiations because replies and offers can change what the next move should be.",
"Reading negotiation messages": "Reading the negotiation thread so any drafted reply matches the actual conversation.",
"Drafting message for approval": "Preparing the exact message as a pending action because marketplace writes need your approval first.",
"Drafting listing for approval": "Preparing the listing payload as a pending action so you can review it before anything is posted.",
"Checking UEX notifications": "Checking notifications for fresh replies or alerts that could change the recommendation.",
"Writing response": "Turning the gathered context into a concise response with the relevant details and next action.",
};
if (summaries[text]) return summaries[text];
if (text.startsWith("Running ")) {
return `Using ${text.replace(/^Running\s+/, "")} to gather the missing context before answering.`;
}
return text;
}
function finishThinking(node) {
const thinking = node.querySelector(".thinking-log");
const label = node.querySelector(".thinking-summary-label");
if (!thinking || !label) return;
const startedAt = Number(thinking.dataset.startedAt || Date.now());
const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
label.textContent = `Thought for ${elapsedSeconds}s`;
thinking.classList.remove("thinking-active");
}
function ensureStreamingChrome(node) { function ensureStreamingChrome(node) {
if (node.querySelector(".message-activity")) return; if (node.querySelector(".message-activity")) return;
node.innerHTML = ""; node.innerHTML = "";
@@ -57,10 +201,22 @@ function ensureStreamingChrome(node) {
phase.className = "message-phase"; phase.className = "message-phase";
const metrics = document.createElement("span"); const metrics = document.createElement("span");
metrics.className = "message-metrics"; metrics.className = "message-metrics";
const thinking = document.createElement("details");
thinking.className = "thinking-log";
thinking.classList.add("thinking-active");
thinking.dataset.startedAt = String(Date.now());
const thinkingSummary = document.createElement("summary");
const thinkingLabel = document.createElement("span");
thinkingLabel.className = "thinking-summary-label";
thinkingLabel.textContent = "Thinking...";
const thinkingSteps = document.createElement("ol");
thinkingSteps.className = "thinking-steps";
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "message-body"; body.className = "message-body";
activity.append(phase, metrics); activity.append(phase, metrics);
node.append(activity, body); thinkingSummary.appendChild(thinkingLabel);
thinking.append(thinkingSummary, thinkingSteps);
node.append(activity, thinking, body);
} }
function renderMarkdown(text) { function renderMarkdown(text) {
@@ -252,6 +408,256 @@ function setWarning(text) {
warningEl.textContent = 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 = {
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 || {};
const secretsConfigured = config.secrets_configured || {};
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 if (field.type === "password") {
field.value = "";
field.placeholder = secretsConfigured[key] ? "Configured" : "";
} 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 = `
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
<div class="ollama-status-grid">
${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")}
</div>
${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 `<div class="ollama-status-item"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(String(value ?? ""))}</span></div>`;
}
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 || "";
}
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() { async function checkHealth() {
try { try {
const response = await fetch("/api/health"); const response = await fetch("/api/health");
@@ -299,7 +705,14 @@ function renderPending(actions) {
const approve = document.createElement("button"); const approve = document.createElement("button");
approve.textContent = "Approve"; approve.textContent = "Approve";
approve.addEventListener("click", () => approveAction(action.id)); approve.addEventListener("click", () => approveAction(action.id));
card.append(title, endpoint, payload, approve); const decline = document.createElement("button");
decline.className = "decline-button";
decline.textContent = "Decline";
decline.addEventListener("click", () => declineAction(action.id));
const controls = document.createElement("div");
controls.className = "pending-controls";
controls.append(decline, approve);
card.append(title, endpoint, payload, controls);
pendingEl.appendChild(card); pendingEl.appendChild(card);
} }
} }
@@ -318,6 +731,24 @@ async function approveAction(id) {
} }
} }
async function declineAction(id) {
statusEl.textContent = "Declining";
try {
const response = await fetch(`/api/decline/${id}`, { method: "POST" });
const result = await response.json();
if (result.error) {
addMessage("assistant warning-message", `Decline failed: ${result.error}`);
} else {
addMessage("assistant", `Declined pending action: ${result.pending_action?.label || id}`);
}
await refreshPending();
} catch (error) {
addMessage("assistant warning-message", `Decline failed: ${error.message}`);
} finally {
statusEl.textContent = "Ready";
}
}
async function refreshPending() { async function refreshPending() {
const response = await fetch("/api/pending-actions"); const response = await fetch("/api/pending-actions");
const result = await response.json(); const result = await response.json();
@@ -422,8 +853,22 @@ input.addEventListener("keydown", async (event) => {
} }
}); });
memoryRefreshButton.addEventListener("click", refreshMemory); memoryRefreshButton?.addEventListener("click", refreshMemory);
memoryClearButton.addEventListener("click", clearMemory); 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() { async function sendMessage() {
const message = input.value.trim(); const message = input.value.trim();
@@ -439,6 +884,7 @@ async function sendMessage() {
const assistantNode = addMessage("assistant streaming", ""); const assistantNode = addMessage("assistant streaming", "");
ensureStreamingChrome(assistantNode); ensureStreamingChrome(assistantNode);
let assistantText = ""; let assistantText = "";
const thinkParser = createThinkTagParser(assistantNode);
statusEl.textContent = "Working"; statusEl.textContent = "Working";
setMessageActivity(assistantNode, "Thinking", true); setMessageActivity(assistantNode, "Thinking", true);
setMessageMetrics(assistantNode, ""); setMessageMetrics(assistantNode, "");
@@ -473,10 +919,18 @@ async function sendMessage() {
assistantText += event.message; assistantText += event.message;
setMessageMarkdown(assistantNode, assistantText); setMessageMarkdown(assistantNode, assistantText);
} else if (event.type === "token") { } else if (event.type === "token") {
assistantText += event.content; const visibleContent = thinkParser.consume(event.content);
if (visibleContent) {
assistantText += visibleContent;
setMessageMarkdown(assistantNode, assistantText); setMessageMarkdown(assistantNode, assistantText);
}
messages.scrollTop = messages.scrollHeight; messages.scrollTop = messages.scrollHeight;
} else if (event.type === "done") { } else if (event.type === "done") {
const visibleContent = thinkParser.flush();
if (visibleContent) {
assistantText += visibleContent;
setMessageMarkdown(assistantNode, assistantText);
}
renderPending(event.pending_actions || []); renderPending(event.pending_actions || []);
} }
} }
@@ -492,6 +946,7 @@ async function sendMessage() {
input.disabled = false; input.disabled = false;
input.focus(); input.focus();
statusEl.textContent = "Ready"; statusEl.textContent = "Ready";
finishThinking(assistantNode);
setMessageActivity(assistantNode, ""); setMessageActivity(assistantNode, "");
} }
} }
@@ -499,6 +954,9 @@ async function sendMessage() {
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything."); addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
refreshPending(); refreshPending();
refreshMemory(); refreshMemory();
refreshConfig();
refreshOllamaStatus();
checkForUpdate();
pollNotifications(); pollNotifications();
checkHealth(); checkHealth();
setInterval(checkHealth, 30000); setInterval(checkHealth, 30000);
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill-rule="evenodd" clip-rule="evenodd" d="M168.64 23.253c4.608 1.814 8.768 4.8 12.544 8.747 6.293 6.528 11.605 15.872 15.659 26.944 4.074 11.136 6.72 23.467 7.722 35.84a107.824 107.824 0 0143.712-13.568l1.088-.085c18.56-1.494 36.907 1.856 52.907 10.112a103.091 103.091 0 016.336 3.626c1.067-12.138 3.669-24.192 7.68-35.072 4.053-11.093 9.365-20.416 15.637-26.965a35.628 35.628 0 0112.566-8.747c5.482-2.133 11.306-2.517 16.981-.896 8.555 2.432 15.893 7.851 21.675 15.723 5.29 7.19 9.258 16.405 11.968 27.456 4.906 19.925 5.76 46.144 2.453 77.76l1.131.853.554.406c16.15 12.288 27.392 29.802 33.344 50.133 9.28 31.723 4.608 67.307-11.392 87.211l-.384.448.043.064c8.896 16.256 14.293 33.429 15.445 51.2l.043.64c1.365 22.72-4.267 45.589-17.365 68.053l-.15.213.214.512c10.069 24.683 13.226 49.536 9.344 74.368l-.128.832a13.888 13.888 0 01-15.936 11.435 13.83 13.83 0 01-11.31-10.43 13.828 13.828 0 01-.21-5.399c3.562-22.038.213-44.139-10.24-66.624a13.713 13.713 0 01.853-13.163l.085-.128c12.886-19.712 18.219-39.04 17.067-58.027-.981-16.618-6.933-32.938-17.067-48.49a13.737 13.737 0 013.84-18.902l.192-.128c5.184-3.392 9.963-12.053 12.374-23.893a90.218 90.218 0 00-2.027-42.112c-4.373-14.933-12.373-27.392-23.573-35.904-12.694-9.685-29.504-14.357-50.774-13.013a13.93 13.93 0 01-13.482-7.915c-6.699-14.187-16.47-24.341-28.651-30.635a70.145 70.145 0 00-37.803-7.082c-26.56 2.112-49.984 17.088-56.96 35.968a13.91 13.91 0 01-13.013 9.066c-22.763.043-40.384 5.376-53.269 14.998-11.136 8.32-18.731 19.946-22.742 33.877a86.824 86.824 0 00-1.45 40.235c2.389 11.904 7.061 21.76 12.416 27.072l.17.149c4.523 4.416 5.483 11.307 2.326 16.747-7.68 13.269-13.419 33.045-14.358 52.053-1.066 21.717 3.968 40.576 15.339 54.101l.341.406a13.711 13.711 0 012.027 14.72c-12.288 26.368-16.064 48.042-11.989 65.109a13.91 13.91 0 01-27.072 6.357c-5.184-21.717-1.664-46.592 10.09-74.624l.299-.746-.17-.256a92.574 92.574 0 01-12.758-27.926l-.107-.405a122.965 122.965 0 01-3.776-38.08c.939-19.413 5.931-39.296 13.27-55.253l.256-.555-.043-.043c-6.25-8.917-10.88-20.33-13.44-32.96l-.107-.512a114.176 114.176 0 011.984-53.12c5.59-19.52 16.576-36.288 32.768-48.405 1.28-.96 2.624-1.92 3.968-2.816-3.392-31.851-2.538-58.24 2.39-78.293 2.709-11.051 6.698-20.267 11.989-27.456 5.76-7.851 13.099-13.27 21.653-15.723 5.675-1.621 11.52-1.259 17.003.896v.021zm87.808 193.92c19.968 0 38.4 6.678 52.181 18.24 13.44 11.243 21.44 26.347 21.44 41.387 0 18.944-8.661 33.707-24.17 43.136-13.227 8-30.955 11.883-51.264 11.883-21.526 0-39.915-5.526-53.184-15.659-13.163-10.027-20.544-24.107-20.544-39.36 0-15.083 8.49-30.229 22.528-41.515 14.25-11.456 33.066-18.112 53.013-18.112zm0 19.115a65.498 65.498 0 00-40.875 13.867c-9.834 7.893-15.402 17.813-15.402 26.666 0 9.131 4.48 17.686 13.013 24.192 9.707 7.403 23.979 11.691 41.451 11.691 17.045 0 31.424-3.136 41.216-9.088 9.877-5.973 14.933-14.635 14.933-26.816 0-9.024-5.248-18.987-14.571-26.795-10.325-8.64-24.32-13.717-39.765-13.717zm14.123 25.813l.085.086a7.431 7.431 0 01-1.195 10.453l-6.229 4.907v9.514a7.999 7.999 0 01-8.021 7.958 8.004 8.004 0 01-8.022-7.958v-9.813l-5.781-4.651a7.4 7.4 0 01-1.109-10.453 7.53 7.53 0 0110.538-1.088l4.587 3.669 4.693-3.712a7.533 7.533 0 0110.454 1.088zm-107.52-40.938c10.197 0 18.496 8.32 18.496 18.581a18.564 18.564 0 01-18.518 18.581 18.559 18.559 0 01-18.496-18.56 18.565 18.565 0 015.399-13.129 18.609 18.609 0 0113.119-5.473zm185.728 0c10.24 0 18.517 8.32 18.517 18.581a18.559 18.559 0 01-18.517 18.581 18.56 18.56 0 01-18.496-18.56 18.56 18.56 0 0118.496-18.602zM158.72 49.067l-.064.042a14.06 14.06 0 00-6.08 5.078l-.107.128c-2.944 4.032-5.504 9.962-7.424 17.749-3.626 14.763-4.608 34.795-2.645 59.349 9.173-2.73 19.179-4.437 29.952-5.056l.213-.021.406-.725a69.41 69.41 0 013.157-5.099c2.624-16.448.469-36.096-5.397-52.139-2.859-7.765-6.336-13.866-9.664-17.344a13.403 13.403 0 00-2.283-1.92l-.064-.042zm195.712.853l-.043.021a13.396 13.396 0 00-2.282 1.92c-3.328 3.478-6.827 9.6-9.664 17.366-6.187 16.938-8.256 37.888-4.907 54.869l1.237 2.069.171.299h.64a110.599 110.599 0 0131.275 4.523c1.834-23.979.81-43.584-2.731-58.07-1.92-7.786-4.48-13.717-7.445-17.749l-.086-.128a14.054 14.054 0 00-6.08-5.099h-.085v-.021z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+77 -3
View File
@@ -4,15 +4,22 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>TraderAI</title> <title>TraderAI</title>
<link rel="icon" href="/static/art/LBC_Logo.ico" sizes="any">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
<section class="workspace"> <section class="workspace">
<header class="topbar"> <header class="topbar">
<div> <div class="brand-block">
<div class="logo-wrap" aria-hidden="true">
<img src="/static/art/LBC_Logo.png" alt="">
</div>
<div class="brand-copy">
<p class="eyebrow">Lambda Banking Conglomerate</p>
<h1>TraderAI</h1> <h1>TraderAI</h1>
<p>Local Ollama chat for UEX marketplace work</p> <p>Institutional marketplace intelligence for UEX operations</p>
</div>
</div> </div>
<div class="status" id="status">Ready</div> <div class="status" id="status">Ready</div>
</header> </header>
@@ -30,7 +37,52 @@
<h2>Pending Approval</h2> <h2>Pending Approval</h2>
<div id="pending-actions" class="pending-empty">No pending actions</div> <div id="pending-actions" class="pending-empty">No pending actions</div>
</section> </section>
<section class="side-section"> <section class="side-section sidebar-tools">
<div class="sidebar-tool-buttons" role="tablist" aria-label="Sidebar panels">
<button class="sidebar-tool-button" id="settings-toggle" type="button" aria-expanded="false" aria-controls="settings-panel" title="Settings">
<i data-lucide="settings" aria-hidden="true"></i>
<span>Settings</span>
</button>
<button class="sidebar-tool-button" id="memory-toggle" type="button" aria-expanded="false" aria-controls="memory-panel" title="Memory">
<i data-lucide="brain" aria-hidden="true"></i>
<span>Memory</span>
</button>
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
<i data-lucide="bot" aria-hidden="true"></i>
<span>Ollama</span>
</button>
</div>
<div class="sidebar-panel" id="settings-panel" hidden>
<div class="section-title-row">
<h2>Config</h2>
<button class="secondary small-button" id="config-refresh" type="button">Refresh</button>
</div>
<form class="config-form" id="config-form">
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
<label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label>
<label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label>
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
<label class="config-check"><input id="config-require-write-approval" name="require_write_approval" type="checkbox"> Require write approval</label>
<div class="config-paths" id="config-paths"></div>
<button type="submit">Save Config</button>
<div class="config-status" id="config-status"></div>
</form>
<div class="update-box">
<div class="section-title-row">
<h2>Updates</h2>
<button class="secondary small-button" id="update-check" type="button">Check</button>
</div>
<div class="update-status" id="update-status"></div>
<div class="update-actions">
<button class="secondary small-button" id="update-open-releases" type="button">Releases</button>
<button class="small-button" id="update-install" type="button">Update</button>
</div>
</div>
</div>
<div class="sidebar-panel" id="memory-panel" hidden>
<div class="section-title-row"> <div class="section-title-row">
<h2>Memory</h2> <h2>Memory</h2>
<button class="secondary small-button" id="memory-refresh" type="button">Refresh</button> <button class="secondary small-button" id="memory-refresh" type="button">Refresh</button>
@@ -44,9 +96,31 @@
</div> </div>
<button class="danger-button" id="memory-clear" type="button">Clear Selected</button> <button class="danger-button" id="memory-clear" type="button">Clear Selected</button>
<div id="memory-inspector" class="memory-inspector"></div> <div id="memory-inspector" class="memory-inspector"></div>
</div>
<div class="sidebar-panel" id="ollama-panel" hidden>
<div class="section-title-row">
<h2>Ollama</h2>
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
</div>
<form class="config-form" id="ollama-config-form">
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label>Model<input id="ollama-model" name="ollama_model" type="text"></label>
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<button type="submit">Save Ollama Config</button>
</form>
<div class="ollama-status" id="ollama-status"></div>
<div class="ollama-actions">
<button class="secondary small-button" id="ollama-download" type="button">Download</button>
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
</div>
<div class="config-status" id="ollama-message"></div>
</div>
</section> </section>
</aside> </aside>
</main> </main>
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+701 -154
View File
File diff suppressed because it is too large Load Diff