Compare commits
6 Commits
f7ac45ddd8
..
0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
58a57ddc6a
|
|||
|
11adcc160a
|
|||
|
da016c23cb
|
|||
|
5850674448
|
|||
|
36c91ce500
|
|||
|
761eda6155
|
+3
-1
@@ -1,8 +1,10 @@
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen3.5:9b
|
||||
OLLAMA_NUM_CTX=64512
|
||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||
UEX_SECRET_KEY=
|
||||
UEX_BEARER_TOKEN=
|
||||
TRADERAI_USER_NAME=
|
||||
TRADERAI_MEMORY_PATH=data/traderai.sqlite3
|
||||
TRADERAI_MEMORY_PATH=
|
||||
UEX_NOTIFICATION_POLL_SECONDS=60
|
||||
REQUIRE_WRITE_APPROVAL=true
|
||||
|
||||
@@ -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 }
|
||||
@@ -19,6 +19,7 @@ dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
pip-wheel-metadata/
|
||||
.playwright-mcp/
|
||||
|
||||
# Test and coverage output
|
||||
.pytest_cache/
|
||||
|
||||
@@ -4,13 +4,14 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
||||
|
||||
## 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.
|
||||
- Drafts negotiation messages and marketplace listings as pending actions.
|
||||
- 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.
|
||||
- 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.
|
||||
- Polls authenticated `GET /user_notifications` for unread UEX notifications and surfaces new pending alerts in the chat notification queue.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -33,11 +34,23 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
||||
|
||||
## 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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
- 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 context length docs: https://docs.ollama.com/context-length
|
||||
- SQLite FTS5 docs: https://www.sqlite.org/fts5.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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -1,12 +1,13 @@
|
||||
[project]
|
||||
name = "traderai"
|
||||
version = "0.1.0"
|
||||
version = "0.0.2"
|
||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"pywebview>=5.4",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
@@ -16,11 +17,22 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pyinstaller>=6.11.0",
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.23.8",
|
||||
"respx>=0.21.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
traderai-desktop = "traderai.desktop:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["traderai*"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from traderai.agent import OllamaAgent
|
||||
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||
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["writing_tokens"] == 30
|
||||
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}
|
||||
|
||||
@@ -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"]
|
||||
@@ -8,6 +8,74 @@ from traderai.uex_client import UEXClient
|
||||
|
||||
class FakeUEX:
|
||||
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"
|
||||
return {
|
||||
"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
|
||||
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
|
||||
|
||||
|
||||
@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():
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
@respx.mock
|
||||
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)
|
||||
|
||||
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
@@ -12,7 +12,12 @@ from traderai.tools import ToolRegistry
|
||||
|
||||
|
||||
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.
|
||||
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,
|
||||
memory: MemoryStore | None = None,
|
||||
user_name: str | None = None,
|
||||
num_ctx: int | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.tools = tools
|
||||
self.memory = memory
|
||||
self.user_name = user_name
|
||||
self.num_ctx = num_ctx
|
||||
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
@@ -165,6 +172,7 @@ class OllamaAgent:
|
||||
"model": self.model,
|
||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
||||
"tools": self.tools.schemas,
|
||||
"options": self._ollama_options(),
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
@@ -184,6 +192,7 @@ class OllamaAgent:
|
||||
"model": self.model,
|
||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
||||
"tools": self.tools.schemas,
|
||||
"options": self._ollama_options(),
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
@@ -207,6 +216,21 @@ class OllamaAgent:
|
||||
parts = [
|
||||
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:
|
||||
parts.append(f"Known user name/handle: {self.user_name}.")
|
||||
|
||||
@@ -252,21 +276,42 @@ class OllamaAgent:
|
||||
{
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
"method": action.method,
|
||||
"endpoint": action.endpoint,
|
||||
"payload": action.payload,
|
||||
}
|
||||
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
|
||||
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 = {
|
||||
"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",
|
||||
"get_marketplace_listing": "Fetching listing details",
|
||||
"list_marketplace_negotiations": "Checking negotiations",
|
||||
"get_negotiation_messages": "Reading negotiation messages",
|
||||
"draft_negotiation_message": "Drafting message for approval",
|
||||
"draft_marketplace_listing": "Drafting listing for approval",
|
||||
"check_uex_notifications": "Checking UEX notifications",
|
||||
}
|
||||
return labels.get(name, f"Running {name}")
|
||||
|
||||
|
||||
+136
-4
@@ -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
|
||||
|
||||
|
||||
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
|
||||
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
||||
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
||||
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False},
|
||||
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
|
||||
"require_write_approval": {"env": "REQUIRE_WRITE_APPROVAL", "type": "boolean", "secret": False},
|
||||
}
|
||||
|
||||
|
||||
def app_data_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
root = os.environ.get("LOCALAPPDATA")
|
||||
if root:
|
||||
return Path(root) / "TraderAI"
|
||||
return Path.home() / ".traderai"
|
||||
|
||||
|
||||
def ensure_app_data_dir() -> Path:
|
||||
path = app_data_dir()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def user_config_path() -> Path:
|
||||
return ensure_app_data_dir() / ".env"
|
||||
|
||||
|
||||
def default_memory_path() -> Path:
|
||||
return ensure_app_data_dir() / "traderai.sqlite3"
|
||||
|
||||
|
||||
def log_path() -> Path:
|
||||
return ensure_app_data_dir() / "TraderAI.log"
|
||||
|
||||
|
||||
def edge_profile_dir() -> Path:
|
||||
return ensure_app_data_dir() / "EdgeProfile"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(".env", str(user_config_path())),
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
ollama_model: str = "qwen3.5:9b"
|
||||
ollama_num_ctx: int = 64512
|
||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||
uex_secret_key: str | None = Field(default=None)
|
||||
uex_bearer_token: str | None = Field(default=None)
|
||||
traderai_user_name: str | None = Field(default=None)
|
||||
traderai_memory_path: str = "data/traderai.sqlite3"
|
||||
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
||||
uex_notification_poll_seconds: int = 60
|
||||
require_write_approval: bool = True
|
||||
|
||||
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||
@classmethod
|
||||
def _blank_optional(cls, value: Any) -> Any:
|
||||
return None if value == "" else value
|
||||
|
||||
@field_validator("traderai_memory_path", mode="before")
|
||||
@classmethod
|
||||
def _blank_memory_path(cls, value: Any) -> Any:
|
||||
return str(default_memory_path()) if value == "" or value is None else value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
|
||||
current = settings or get_settings()
|
||||
values = current.model_dump()
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -7,23 +7,34 @@ from uuid import uuid4
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from traderai.memory import MemoryStore, iso_now, time_since
|
||||
|
||||
|
||||
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
|
||||
|
||||
|
||||
class WakeScheduler:
|
||||
def __init__(self, memory: MemoryStore) -> None:
|
||||
self.memory = memory
|
||||
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
||||
self.agent = None
|
||||
self.uex = None
|
||||
self.notification_poll_seconds = 60
|
||||
|
||||
def bind_agent(self, agent: Any) -> None:
|
||||
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:
|
||||
if not self.scheduler.running:
|
||||
self.scheduler.start()
|
||||
self._schedule_notification_poll()
|
||||
for job in self.memory.list_jobs():
|
||||
self._schedule_existing(job)
|
||||
|
||||
@@ -77,3 +88,59 @@ class WakeScheduler:
|
||||
text = await self.agent.generate_wake_response(wake_message)
|
||||
self.memory.add_outbox(text)
|
||||
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
@@ -1,8 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
@@ -10,11 +19,18 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from traderai.agent import OllamaAgent, OllamaUnavailable
|
||||
from traderai.config import save_settings, settings_payload
|
||||
from traderai.config import get_settings
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||
|
||||
|
||||
def resource_path(*parts: str) -> Path:
|
||||
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
@@ -29,17 +45,37 @@ class ClearMemoryRequest(BaseModel):
|
||||
include_outbox: bool = True
|
||||
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
values: dict
|
||||
|
||||
|
||||
class OllamaModelRequest(BaseModel):
|
||||
model: str | None = None
|
||||
|
||||
|
||||
OLLAMA_DOWNLOAD_URL = "https://ollama.com/download/windows"
|
||||
UPDATE_ASSET_NAME = "TraderAI.exe"
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
memory = MemoryStore(settings.traderai_memory_path)
|
||||
scheduler = WakeScheduler(memory)
|
||||
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)
|
||||
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_uex_notifications(uex, settings.uex_notification_poll_seconds)
|
||||
|
||||
app = FastAPI(title="TraderAI")
|
||||
static_dir = Path(__file__).resolve().parent.parent / "web"
|
||||
static_dir = resource_path("web")
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -85,8 +121,128 @@ def create_app() -> FastAPI:
|
||||
"ollama": await agent.health(),
|
||||
"user": memory.get_profile(),
|
||||
"jobs": scheduler.list_jobs(),
|
||||
"app_data_dir": settings_payload()["app_data_dir"],
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
@app.get("/api/config")
|
||||
async def inspect_config() -> dict:
|
||||
return settings_payload()
|
||||
|
||||
@app.post("/api/config")
|
||||
async def update_config(request: ConfigUpdateRequest) -> dict:
|
||||
updated = save_settings(request.values)
|
||||
updated["restart_required"] = True
|
||||
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect."
|
||||
return updated
|
||||
|
||||
@app.get("/api/ollama/status")
|
||||
async def ollama_status() -> dict:
|
||||
return await inspect_ollama()
|
||||
|
||||
@app.post("/api/ollama/launch")
|
||||
async def launch_ollama() -> dict:
|
||||
command = ollama_launch_command()
|
||||
if not command:
|
||||
raise HTTPException(status_code=404, detail="Ollama is not installed or was not found on PATH.")
|
||||
try:
|
||||
popen_hidden(command)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
|
||||
status = await inspect_ollama()
|
||||
status["message"] = "Ollama launch requested."
|
||||
return status
|
||||
|
||||
@app.post("/api/ollama/pull")
|
||||
async def pull_ollama_model(request: OllamaModelRequest) -> dict:
|
||||
settings_now = get_settings()
|
||||
model = (request.model or settings_now.ollama_model).strip()
|
||||
if not model:
|
||||
raise HTTPException(status_code=400, detail="No Ollama model is configured.")
|
||||
cli = find_ollama_cli()
|
||||
if not cli:
|
||||
raise HTTPException(status_code=404, detail="Ollama CLI was not found.")
|
||||
try:
|
||||
popen_hidden([str(cli), "pull", model])
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
|
||||
status = await inspect_ollama()
|
||||
status["message"] = f"Started installing model {model}."
|
||||
return status
|
||||
|
||||
@app.post("/api/ollama/install")
|
||||
async def install_ollama() -> dict:
|
||||
winget = shutil.which("winget")
|
||||
if not winget:
|
||||
return {
|
||||
"started": False,
|
||||
"message": "winget is not available on this system. Open the download page instead.",
|
||||
"download_url": OLLAMA_DOWNLOAD_URL,
|
||||
}
|
||||
try:
|
||||
popen_hidden(
|
||||
[
|
||||
winget,
|
||||
"install",
|
||||
"-e",
|
||||
"--id",
|
||||
"Ollama.Ollama",
|
||||
"--accept-package-agreements",
|
||||
"--accept-source-agreements",
|
||||
]
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not start Ollama install: {exc}") from exc
|
||||
return {"started": True, "message": "Started Ollama install with winget.", "download_url": OLLAMA_DOWNLOAD_URL}
|
||||
|
||||
@app.post("/api/ollama/download")
|
||||
async def download_ollama() -> dict:
|
||||
webbrowser.open(OLLAMA_DOWNLOAD_URL)
|
||||
return {"opened": True, "download_url": OLLAMA_DOWNLOAD_URL, "message": "Opened the Ollama download page."}
|
||||
|
||||
@app.get("/api/update/check")
|
||||
async def check_update() -> dict:
|
||||
return await inspect_update()
|
||||
|
||||
@app.post("/api/update/install")
|
||||
async def install_update() -> dict:
|
||||
update = await inspect_update()
|
||||
if not update["available"]:
|
||||
return {**update, "message": "TraderAI is already up to date."}
|
||||
if not getattr(sys, "frozen", False):
|
||||
return {
|
||||
**update,
|
||||
"started": False,
|
||||
"message": "Update download is available, but self-update only runs from the packaged exe.",
|
||||
}
|
||||
|
||||
asset_url = update.get("asset_download_url")
|
||||
if not asset_url:
|
||||
raise HTTPException(status_code=404, detail="The latest release does not include TraderAI.exe.")
|
||||
|
||||
downloaded = await download_update_asset(asset_url, update["latest_version"])
|
||||
script = write_update_script(downloaded, Path(sys.executable))
|
||||
updater_command = [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script),
|
||||
"-ProcessId",
|
||||
str(os.getpid()),
|
||||
"-Source",
|
||||
str(downloaded),
|
||||
"-Target",
|
||||
str(Path(sys.executable)),
|
||||
]
|
||||
updater_kwargs: dict[str, Any] = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
||||
if sys.platform == "win32":
|
||||
updater_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
subprocess.Popen(updater_command, **updater_kwargs)
|
||||
threading.Thread(target=exit_after_update_response, daemon=True).start()
|
||||
return {**update, "started": True, "message": "Update downloaded. TraderAI will restart into the new version."}
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: ChatRequest) -> dict:
|
||||
try:
|
||||
@@ -137,7 +293,267 @@ def create_app() -> FastAPI:
|
||||
async def approve(action_id: str) -> dict:
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
+816
-4
@@ -12,12 +12,134 @@ from traderai.uex_client import UEXClient
|
||||
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
|
||||
class PendingAction:
|
||||
id: str
|
||||
label: str
|
||||
endpoint: str
|
||||
payload: dict[str, Any]
|
||||
method: str = "POST"
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
@@ -44,16 +166,36 @@ class ToolRegistry:
|
||||
"recall_memory": self.recall_memory,
|
||||
"schedule_wake_job": self.schedule_wake_job,
|
||||
"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
|
||||
def schemas(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
self._api_index_schema(),
|
||||
*self._uex_get_schemas(),
|
||||
*self._history_summary_schemas(),
|
||||
*self._uex_post_schemas(),
|
||||
*self._uex_delete_schemas(),
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -133,7 +275,7 @@ class ToolRegistry:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
|
||||
@@ -215,6 +357,14 @@ class ToolRegistry:
|
||||
"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]:
|
||||
@@ -230,8 +380,496 @@ class ToolRegistry:
|
||||
action = self.pending_actions.pop(action_id, None)
|
||||
if not action:
|
||||
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)
|
||||
|
||||
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(
|
||||
self,
|
||||
query: str | None = None,
|
||||
@@ -324,20 +962,194 @@ class ToolRegistry:
|
||||
return {"error": "Scheduler is not configured."}
|
||||
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())
|
||||
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 {
|
||||
"pending_action": {
|
||||
"id": action_id,
|
||||
"label": label,
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"payload": payload,
|
||||
"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
|
||||
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
@@ -42,6 +42,13 @@ class UEXClient:
|
||||
data = data[0] if data else None
|
||||
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 with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
@@ -51,6 +58,15 @@ class UEXClient:
|
||||
)
|
||||
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
|
||||
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "altgraph"
|
||||
version = "0.17.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@@ -45,6 +54,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bottle"
|
||||
version = "0.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
@@ -54,6 +72,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
@@ -66,6 +110,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clr-loader"
|
||||
version = "0.2.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -182,6 +238,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
@@ -191,6 +259,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pefile"
|
||||
version = "2024.8.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -200,6 +277,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proxy-tools"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
@@ -340,6 +432,145 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
{ name = "macholib", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pefile", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyinstaller-hooks-contrib" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyinstaller-hooks-contrib"
|
||||
version = "2026.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-core"
|
||||
version = "12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-cocoa"
|
||||
version = "12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-quartz"
|
||||
version = "12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core" },
|
||||
{ name = "pyobjc-framework-cocoa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-security"
|
||||
version = "12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core" },
|
||||
{ name = "pyobjc-framework-cocoa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/3d/8d3a39cd292d7c76ab76233498189bc7170fc80f573b415308464f68c7ee/pyobjc_framework_security-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b2d8819f0fb7b619ec7627a0d8c1cac1a57c5143579ce8ac21548165680684b", size = 41287, upload-time = "2025-11-14T10:02:54.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/66/5160c0f938fc0515fe8d9af146aac1b093f7ef285ce797fedae161b6c0e8/pyobjc_framework_security-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab42e55f5b782332be5442750fcd9637ee33247d57c7b1d5801bc0e24ee13278", size = 41280, upload-time = "2025-11-14T10:02:58.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/48/b294ed75247c5cfa00d51925a10237337d24f54961d49a179b20a4307642/pyobjc_framework_security-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:afc36661cc6eb98cd794bed1d6668791e96557d6f72d9ac70aa49022d26af1d4", size = 41284, upload-time = "2025-11-14T10:03:01.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/57/0d3ef78779cf5c3bba878b2f824137e50978ad4a21dabe65d8b5ae0fc0d1/pyobjc_framework_security-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9510c98ab56921d1d416437372605cc1c1f6c1ad8d3061ee56b17bf423dd5427", size = 42162, upload-time = "2025-11-14T10:03:05.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4d/63c15f9449c191e7448a05ff8af4a82c39a51bb627bc96dc9697586c0f79/pyobjc_framework_security-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6319a34508fd87ab6ca3cda6f54e707196197a65b792b292705af967e225438a", size = 41348, upload-time = "2025-11-14T10:03:08.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d8/5aaa2a8124ed04a9d6ca7053dc0fa64e42be51497ed8263a24b744a95598/pyobjc_framework_security-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:03d166371cefdef24908825148eb848f99ee2c0b865870a09dcbb94334dd3e0a", size = 42908, upload-time = "2025-11-14T10:03:13.01Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-uniformtypeidentifiers"
|
||||
version = "12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core" },
|
||||
{ name = "pyobjc-framework-cocoa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5f/1f10f5275b06d213c9897850f1fca9c881c741c1f9190cea6db982b71824/pyobjc_framework_uniformtypeidentifiers-12.1-py2.py3-none-any.whl", hash = "sha256:ec5411e39152304d2a7e0e426c3058fa37a00860af64e164794e0bcffee813f2", size = 4901, upload-time = "2025-11-14T10:05:51.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-webkit"
|
||||
version = "12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core" },
|
||||
{ name = "pyobjc-framework-cocoa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/37/5082a0bbe12e48d4ffa53b0c0f09c77a4a6ffcfa119e26fa8dd77c08dc1c/pyobjc_framework_webkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3db734877025614eaef4504fadc0fbbe1279f68686a6f106f2e614e89e0d1a9d", size = 49970, upload-time = "2025-11-14T10:07:01.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/67/64920c8d201a7fc27962f467c636c4e763b43845baba2e091a50a97a5d52/pyobjc_framework_webkit-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af2c7197447638b92aafbe4847c063b6dd5e1ed83b44d3ce7e71e4c9b042ab5a", size = 50084, upload-time = "2025-11-14T10:07:05.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/3d/80d36280164c69220ce99372f7736a028617c207e42cb587716009eecb88/pyobjc_framework_webkit-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1da0c428c9d9891c93e0de51c9f272bfeb96d34356cdf3136cb4ad56ce32ec2d", size = 50096, upload-time = "2025-11-14T10:07:10.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/7a/03c29c46866e266b0c705811c55c22625c349b0a80f5cf4776454b13dc4c/pyobjc_framework_webkit-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1a29e334d5a7dd4a4f0b5647481b6ccf8a107b92e67b2b3c6b368c899f571965", size = 50572, upload-time = "2025-11-14T10:07:14.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ac/924878f239c167ffe3bfc643aee4d6dd5b357e25f6b28db227e40e9e6df3/pyobjc_framework_webkit-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:99d0d28542a266a95ee2585f51765c0331794bca461aaf4d1f5091489d475179", size = 50210, upload-time = "2025-11-14T10:07:18.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/86/637cda4983dc0936b73a385f3906256953ac434537b812814cb0b6d231a2/pyobjc_framework_webkit-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1aaa3bf12c7b68e1a36c0b294d2728e06f2cc220775e6dc4541d5046290e4dc8", size = 50680, upload-time = "2025-11-14T10:07:23.331Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
@@ -378,6 +609,49 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pythonnet"
|
||||
version = "3.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "clr-loader" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywebview"
|
||||
version = "6.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bottle" },
|
||||
{ name = "proxy-tools" },
|
||||
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pythonnet", marker = "sys_platform == 'win32'" },
|
||||
{ name = "qtpy", marker = "sys_platform == 'openbsd6'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -433,6 +707,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qtpy"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.23.1"
|
||||
@@ -445,6 +731,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
@@ -460,7 +755,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "traderai"
|
||||
version = "0.1.0"
|
||||
version = "0.0.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -469,12 +764,14 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pywebview" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pyinstaller" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "respx" },
|
||||
@@ -487,9 +784,11 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "pydantic", specifier = ">=2.8.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.4.0" },
|
||||
{ name = "pyinstaller", marker = "extra == 'dev'", specifier = ">=6.11.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.8" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "pywebview", specifier = ">=5.4" },
|
||||
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.1" },
|
||||
{ name = "tzlocal", specifier = ">=5.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
@@ -745,3 +1044,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/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
@@ -7,8 +7,35 @@ const warningEl = document.getElementById("warning");
|
||||
const memoryInspectorEl = document.getElementById("memory-inspector");
|
||||
const memoryRefreshButton = document.getElementById("memory-refresh");
|
||||
const memoryClearButton = document.getElementById("memory-clear");
|
||||
const configForm = document.getElementById("config-form");
|
||||
const configRefreshButton = document.getElementById("config-refresh");
|
||||
const configStatusEl = document.getElementById("config-status");
|
||||
const configPathsEl = document.getElementById("config-paths");
|
||||
const settingsToggle = document.getElementById("settings-toggle");
|
||||
const memoryToggle = document.getElementById("memory-toggle");
|
||||
const ollamaToggle = document.getElementById("ollama-toggle");
|
||||
const settingsPanel = document.getElementById("settings-panel");
|
||||
const memoryPanel = document.getElementById("memory-panel");
|
||||
const ollamaPanel = document.getElementById("ollama-panel");
|
||||
const ollamaForm = document.getElementById("ollama-config-form");
|
||||
const ollamaRefreshButton = document.getElementById("ollama-refresh");
|
||||
const ollamaDownloadButton = document.getElementById("ollama-download");
|
||||
const ollamaInstallButton = document.getElementById("ollama-install");
|
||||
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
||||
const ollamaPullButton = document.getElementById("ollama-pull");
|
||||
const ollamaStatusEl = document.getElementById("ollama-status");
|
||||
const ollamaMessageEl = document.getElementById("ollama-message");
|
||||
const updateCheckButton = document.getElementById("update-check");
|
||||
const updateInstallButton = document.getElementById("update-install");
|
||||
const updateOpenReleasesButton = document.getElementById("update-open-releases");
|
||||
const updateStatusEl = document.getElementById("update-status");
|
||||
|
||||
let ollamaOnline = true;
|
||||
let latestUpdate = null;
|
||||
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
const node = document.createElement("div");
|
||||
@@ -27,6 +54,7 @@ function setMessageMarkdown(node, text) {
|
||||
function setMessageActivity(node, text, active = false) {
|
||||
const activity = node.querySelector(".message-activity");
|
||||
if (!activity) return;
|
||||
if (text) appendThinkingStep(node, reasoningSummaryForStatus(text), { fallback: true });
|
||||
const phase = activity.querySelector(".message-phase");
|
||||
phase.innerHTML = "";
|
||||
if (text) {
|
||||
@@ -48,6 +76,122 @@ function setMessageMetrics(node, 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) {
|
||||
if (node.querySelector(".message-activity")) return;
|
||||
node.innerHTML = "";
|
||||
@@ -57,10 +201,22 @@ function ensureStreamingChrome(node) {
|
||||
phase.className = "message-phase";
|
||||
const metrics = document.createElement("span");
|
||||
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");
|
||||
body.className = "message-body";
|
||||
activity.append(phase, metrics);
|
||||
node.append(activity, body);
|
||||
thinkingSummary.appendChild(thinkingLabel);
|
||||
thinking.append(thinkingSummary, thinkingSteps);
|
||||
node.append(activity, thinking, body);
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
@@ -252,6 +408,256 @@ function setWarning(text) {
|
||||
warningEl.textContent = text || "";
|
||||
}
|
||||
|
||||
function fetchErrorMessage(error) {
|
||||
if (error instanceof TypeError && /fetch/i.test(error.message)) {
|
||||
return "TraderAI backend is not reachable. Close this app window and launch TraderAI.exe again.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
|
||||
const configFieldIds = {
|
||||
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() {
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
@@ -299,7 +705,14 @@ function renderPending(actions) {
|
||||
const approve = document.createElement("button");
|
||||
approve.textContent = "Approve";
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
const response = await fetch("/api/pending-actions");
|
||||
const result = await response.json();
|
||||
@@ -422,8 +853,22 @@ input.addEventListener("keydown", async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
memoryRefreshButton.addEventListener("click", refreshMemory);
|
||||
memoryClearButton.addEventListener("click", clearMemory);
|
||||
memoryRefreshButton?.addEventListener("click", refreshMemory);
|
||||
memoryClearButton?.addEventListener("click", clearMemory);
|
||||
configRefreshButton?.addEventListener("click", refreshConfig);
|
||||
configForm?.addEventListener("submit", saveConfig);
|
||||
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
|
||||
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
|
||||
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
||||
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
||||
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
||||
ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download"));
|
||||
ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install"));
|
||||
ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch"));
|
||||
ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }));
|
||||
updateCheckButton?.addEventListener("click", checkForUpdate);
|
||||
updateInstallButton?.addEventListener("click", installUpdate);
|
||||
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
||||
|
||||
async function sendMessage() {
|
||||
const message = input.value.trim();
|
||||
@@ -439,6 +884,7 @@ async function sendMessage() {
|
||||
const assistantNode = addMessage("assistant streaming", "");
|
||||
ensureStreamingChrome(assistantNode);
|
||||
let assistantText = "";
|
||||
const thinkParser = createThinkTagParser(assistantNode);
|
||||
statusEl.textContent = "Working";
|
||||
setMessageActivity(assistantNode, "Thinking", true);
|
||||
setMessageMetrics(assistantNode, "");
|
||||
@@ -473,10 +919,18 @@ async function sendMessage() {
|
||||
assistantText += event.message;
|
||||
setMessageMarkdown(assistantNode, assistantText);
|
||||
} else if (event.type === "token") {
|
||||
assistantText += event.content;
|
||||
const visibleContent = thinkParser.consume(event.content);
|
||||
if (visibleContent) {
|
||||
assistantText += visibleContent;
|
||||
setMessageMarkdown(assistantNode, assistantText);
|
||||
}
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
} else if (event.type === "done") {
|
||||
const visibleContent = thinkParser.flush();
|
||||
if (visibleContent) {
|
||||
assistantText += visibleContent;
|
||||
setMessageMarkdown(assistantNode, assistantText);
|
||||
}
|
||||
renderPending(event.pending_actions || []);
|
||||
}
|
||||
}
|
||||
@@ -492,6 +946,7 @@ async function sendMessage() {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
statusEl.textContent = "Ready";
|
||||
finishThinking(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.");
|
||||
refreshPending();
|
||||
refreshMemory();
|
||||
refreshConfig();
|
||||
refreshOllamaStatus();
|
||||
checkForUpdate();
|
||||
pollNotifications();
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 201 KiB |
@@ -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
@@ -4,15 +4,22 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TraderAI</title>
|
||||
<link rel="icon" href="/static/art/LBC_Logo.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="workspace">
|
||||
<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>
|
||||
<p>Local Ollama chat for UEX marketplace work</p>
|
||||
<p>Institutional marketplace intelligence for UEX operations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status" id="status">Ready</div>
|
||||
</header>
|
||||
@@ -30,7 +37,52 @@
|
||||
<h2>Pending Approval</h2>
|
||||
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
||||
</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">
|
||||
<h2>Memory</h2>
|
||||
<button class="secondary small-button" id="memory-refresh" type="button">Refresh</button>
|
||||
@@ -44,9 +96,31 @@
|
||||
</div>
|
||||
<button class="danger-button" id="memory-clear" type="button">Clear Selected</button>
|
||||
<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>
|
||||
</aside>
|
||||
</main>
|
||||
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+701
-154
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user