8 Commits

31 changed files with 7141 additions and 2 deletions
+10
View File
@@ -0,0 +1,10 @@
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64000
UEX_BASE_URL=https://api.uexcorp.space/2.0
UEX_SECRET_KEY=
UEX_BEARER_TOKEN=
TRADERAI_USER_NAME=
TRADERAI_MEMORY_PATH=
UEX_NOTIFICATION_POLL_SECONDS=60
REQUIRE_WRITE_APPROVAL=true
+74
View File
@@ -0,0 +1,74 @@
name: Build Release EXE
on:
release:
types: [published]
jobs:
build-windows-exe:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies
shell: pwsh
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Build TraderAI.exe
shell: pwsh
run: |
pyinstaller TraderAI.spec --noconfirm
if (-not (Test-Path -LiteralPath "dist\TraderAI.exe")) {
throw "dist\TraderAI.exe was not created."
}
- name: Attach EXE to release
shell: pwsh
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
$event = Get-Content -LiteralPath $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json
$releaseId = $event.release.id
if (-not $releaseId) {
throw "Release id was not present in the release event payload."
}
$token = $env:RELEASE_TOKEN
if ([string]::IsNullOrWhiteSpace($token)) {
$token = $env:GITEA_TOKEN
}
if ([string]::IsNullOrWhiteSpace($token)) {
throw "Set a RELEASE_TOKEN secret or enable the built-in GITHUB_TOKEN for Actions."
}
$apiUrl = $env:GITHUB_API_URL
if ([string]::IsNullOrWhiteSpace($apiUrl)) {
$apiUrl = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1"
}
$repoParts = $env:GITHUB_REPOSITORY.Split("/", 2)
if ($repoParts.Length -ne 2) {
throw "GITHUB_REPOSITORY must look like owner/repo. Value: $env:GITHUB_REPOSITORY"
}
$owner = [uri]::EscapeDataString($repoParts[0])
$repo = [uri]::EscapeDataString($repoParts[1])
$assetPath = Resolve-Path -LiteralPath "dist\TraderAI.exe"
$uploadUrl = "$apiUrl/repos/$owner/$repo/releases/$releaseId/assets?name=TraderAI.exe"
Invoke-RestMethod `
-Method Post `
-Uri $uploadUrl `
-Headers @{ Authorization = "token $token" } `
-Form @{ attachment = Get-Item -LiteralPath $assetPath }
+60
View File
@@ -0,0 +1,60 @@
# Environment and secrets
.env
.env.*
!.env.example
# Python
__pycache__/
*.py[cod]
*$py.class
.python-version
.venv/
venv/
env/
ENV/
# Python packaging and build output
build/
dist/
*.egg-info/
.eggs/
pip-wheel-metadata/
.playwright-mcp/
# Test and coverage output
.pytest_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
# Type checker and linter caches
.mypy_cache/
.ruff_cache/
.pyre/
.pytype/
# FastAPI/Uvicorn/runtime files
*.log
*.pid
data/
*.sqlite3
*.sqlite3-*
# Frontend dependencies and build artifacts
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
web/dist/
web/build/
# IDE and OS files
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
desktop.ini
-2
View File
@@ -1,2 +0,0 @@
Use the UEX API spefications at https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1#/Static/get_data_parameters.
I want a ollama powered chat bot that can search marketplace listings on UEX, respond to messages regarding them, make offers etc. I will use a local model for this.
+81
View File
@@ -0,0 +1,81 @@
# TraderAI
Local Ollama-powered chat for UEX marketplace workflows.
## What It Does
- 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
1. Install Python 3.11+.
2. Install Ollama and pull a tool-capable model, for example:
```powershell
ollama pull qwen3.5:9b
```
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
4. Install and run:
```powershell
pip install -e .
uvicorn traderai.server:app --reload
```
5. Open `http://127.0.0.1:8000`.
## 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_NUM_CTX` controls the per-request Ollama context window; `64000` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
## Releases And Updates
Change the app version before cutting a release:
```powershell
.\scripts\set_version.ps1 0.2.0
```
Create a Gitea release with a matching tag such as `v0.2.0`. The release workflow builds `dist\TraderAI.exe` and attaches only that exe to the release.
The desktop app can check `https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases` from Settings > Updates. When a newer release has a `TraderAI.exe` attachment, the packaged app downloads it to the user app data update folder, exits, replaces the current exe, and relaunches.
UEX marketplace posting and negotiation messages are guarded because they are account-affecting write actions. The model can draft them, but the UI approval button performs the final API call.
The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. It is instructed to prefer open/current marketplace data, avoid historical sale information unless explicitly requested, and treat UEX prices as in-game aUEC/UEC credits rather than real-world dollars. Memory is stored locally at `TRADERAI_MEMORY_PATH`.
Wake jobs can be created from chat, for example:
```text
At 9 PM remind yourself to check my open Polaris Bit negotiations.
```
or:
```text
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
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
- Ollama 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
+55
View File
@@ -0,0 +1,55 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
a = Analysis(
["traderai\\desktop.py"],
pathex=[],
binaries=webview_binaries,
datas=[("web", "web"), *webview_datas],
hiddenimports=[
*webview_hiddenimports,
"uvicorn.logging",
"uvicorn.loops",
"uvicorn.loops.auto",
"uvicorn.protocols",
"uvicorn.protocols.http",
"uvicorn.protocols.http.auto",
"uvicorn.protocols.websockets",
"uvicorn.protocols.websockets.auto",
"uvicorn.lifespan",
"uvicorn.lifespan.on",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="TraderAI.Debug",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
icon="web\\art\\LBC_Logo.ico",
codesign_identity=None,
entitlements_file=None,
)
+55
View File
@@ -0,0 +1,55 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
a = Analysis(
["traderai\\desktop.py"],
pathex=[],
binaries=webview_binaries,
datas=[("web", "web"), *webview_datas],
hiddenimports=[
*webview_hiddenimports,
"uvicorn.logging",
"uvicorn.loops",
"uvicorn.loops.auto",
"uvicorn.protocols",
"uvicorn.protocols.http",
"uvicorn.protocols.http.auto",
"uvicorn.protocols.websockets",
"uvicorn.protocols.websockets.auto",
"uvicorn.lifespan",
"uvicorn.lifespan.on",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="TraderAI",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
icon="web\\art\\LBC_Logo.ico",
codesign_identity=None,
entitlements_file=None,
)
+37
View File
@@ -0,0 +1,37 @@
[project]
name = "traderai"
version = "0.0.1"
description = "Local Ollama-powered assistant for UEX marketplace workflows."
requires-python = ">=3.11"
dependencies = [
"apscheduler>=3.10.4",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"pywebview>=5.4",
"pydantic>=2.8.0",
"pydantic-settings>=2.4.0",
"python-dotenv>=1.0.1",
"tzlocal>=5.2",
"uvicorn[standard]>=0.30.0",
]
[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*"]
+16
View File
@@ -0,0 +1,16 @@
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
$Python = Join-Path $Root ".venv\Scripts\python.exe"
if (-not (Test-Path $Python)) {
$Python = "python"
}
& $Python -m ensurepip --upgrade
& $Python -m pip install -e ".[dev]"
& $Python -m PyInstaller --clean "TraderAI.spec"
Write-Host ""
Write-Host "Built dist\TraderAI.exe"
+29
View File
@@ -0,0 +1,29 @@
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^v?\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$')]
[string]$Version
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
$cleanVersion = $Version.TrimStart("v")
$pyprojectPath = Join-Path $repoRoot "pyproject.toml"
$versionPath = Join-Path $repoRoot "traderai\version.py"
$lockPath = Join-Path $repoRoot "uv.lock"
$pyproject = Get-Content -LiteralPath $pyprojectPath -Raw
$pyproject = $pyproject -replace '(?m)^version = "[^"]+"', "version = `"$cleanVersion`""
Set-Content -LiteralPath $pyprojectPath -Value $pyproject -Encoding UTF8
$versionModule = Get-Content -LiteralPath $versionPath -Raw
$versionModule = $versionModule -replace '__version__ = "[^"]+"', "__version__ = `"$cleanVersion`""
Set-Content -LiteralPath $versionPath -Value $versionModule -Encoding UTF8
if (Test-Path -LiteralPath $lockPath) {
$lock = Get-Content -LiteralPath $lockPath -Raw
$lock = $lock -replace '(?s)(name = "traderai"\s+version = ")[^"]+(")', "`${1}$cleanVersion`${2}"
Set-Content -LiteralPath $lockPath -Value $lock -Encoding UTF8
}
Write-Host "TraderAI version set to $cleanVersion"
+92
View File
@@ -0,0 +1,92 @@
import pytest
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore
class EmptyTools:
schemas = []
@property
def pending_actions(self):
return {}
@pytest.mark.asyncio
async def test_chat_events_warns_when_ollama_offline():
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
events = []
async for event in agent.chat_events("hello"):
events.append(event)
assert events[0]["type"] == "warning"
assert "Ollama is offline" in events[0]["message"]
assert events[-1]["type"] == "done"
def test_runtime_context_uses_previous_interaction_not_current_message(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.add_conversation("assistant", "older answer")
previous = memory.last_interaction()
assert previous is not None
memory.add_conversation("user", "current question")
current = memory.last_interaction()
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
context = agent._runtime_context("current question", previous_interaction=previous)
assert f"Previous interaction before this message: {previous['created_at']}" in context
assert f"Previous interaction before this message: {current['created_at']}" not in context
assert "Current local date/time:" in context
def test_runtime_context_includes_uex_user_identity(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
memory.set_profile(
"uex_user",
{
"username": "pilot_hudson",
"name": "Hudson",
"email": "hudson@example.test",
"timezone": "America/New_York",
"specializations": "trading,hauling",
},
)
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
context = agent._runtime_context("")
assert "You are speaking with UEX user pilot_hudson (Hudson)." in context
assert "timezone: America/New_York" in context
assert "specializations: trading,hauling" in context
assert "hudson@example.test" not in context
def test_stream_metrics_include_reading_and_writing_rates():
metrics = OllamaAgent._stream_metrics(
{
"prompt_eval_count": 20,
"prompt_eval_duration": 2_000_000_000,
"eval_count": 30,
"eval_duration": 3_000_000_000,
}
)
assert metrics["reading_tokens"] == 20
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}
+27
View File
@@ -0,0 +1,27 @@
from traderai.memory import MemoryStore
def test_memory_store_recalls_saved_fact(tmp_path):
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
store.remember("preference", "The user prefers Polaris Bits searches to include barter listings.", importance=5)
results = store.recall("Polaris barter")
assert results
assert "Polaris Bits" in results[0]["content"]
def test_memory_store_clear_selected_sections(tmp_path):
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
store.remember("note", "Forgettable note")
store.add_conversation("user", "hello")
store.set_profile("configured_name", "Hudson")
deleted = store.clear(include_profile=False)
snapshot = store.inspect()
assert deleted["memories"] == 1
assert deleted["conversations"] == 1
assert snapshot["memories"] == []
assert snapshot["conversations"] == []
assert snapshot["profile"][0]["key"] == "configured_name"
+47
View File
@@ -0,0 +1,47 @@
import pytest
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
class FakeUEXNotifications:
def __init__(self):
self.calls = 0
async def get_user_notifications(self):
self.calls += 1
return {
"status": "ok",
"notifications": [
{
"id": 10,
"message": "A buyer replied to your listing.",
"redir": "/marketplace/negotiations/abc",
"code": "negotiation_reply",
"date_added": 123,
"date_read": 0,
},
{
"id": 11,
"message": "Already read.",
"date_added": 122,
"date_read": 123,
},
],
}
@pytest.mark.asyncio
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
scheduler = WakeScheduler(memory)
scheduler.bind_uex_notifications(FakeUEXNotifications())
first = await scheduler.poll_uex_notifications()
second = await scheduler.poll_uex_notifications()
outbox = memory.inspect()["outbox"]
assert len(first) == 1
assert second == []
assert len(outbox) == 1
assert "A buyer replied to your listing." in outbox[0]["content"]
+281
View File
@@ -0,0 +1,281 @@
import pytest
import respx
from httpx import Response
from traderai.tools import ToolRegistry
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": [
{
"id": 1,
"slug": "gold-haul",
"title": "Gold haul escort",
"description": "Escort service",
"operation": "sell",
"type": "service",
"price": 5000,
"currency": "UEC",
"unit": "run",
"location": "Port Tressler",
"user_username": "pilot_a",
"date_expiration": 123,
},
{
"id": 2,
"slug": "armor-set",
"title": "Armor set",
"description": "Clean set",
"operation": "sell",
"type": "item",
"price": 15000,
"currency": "UEC",
"unit": "set",
"location": "Area18",
"user_username": "pilot_b",
"date_expiration": 456,
},
]
}
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():
registry = ToolRegistry(FakeUEX())
result = await registry.search_marketplace_listings(query="gold", type="service", max_price=6000)
assert result["count"] == 1
assert result["listings"][0]["slug"] == "gold-haul"
@pytest.mark.asyncio
async def test_draft_message_creates_pending_action():
registry = ToolRegistry(FakeUEX())
result = await registry.draft_negotiation_message(hash="abc", message="Would you take 4500 UEC?")
pending = result["pending_action"]
assert pending["endpoint"] == "marketplace_negotiations_messages"
assert pending["payload"]["message"] == "Would you take 4500 UEC?"
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")
headers = client._headers(authenticated=True)
assert headers["secret-key"] == "secret"
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():
respx.get("https://api.uexcorp.space/2.0/user/").mock(
return_value=Response(200, json={"status": "ok", "data": [{"username": "pilot_hudson"}]})
)
client = UEXClient("https://api.uexcorp.space/2.0", bearer_token="bearer")
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}]}
+1
View File
@@ -0,0 +1 @@
"""TraderAI application package."""
+396
View File
@@ -0,0 +1,396 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from typing import Any
import httpx
from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, iso_now_in_zone, time_since
from traderai.tools import ToolRegistry
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
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."""
class OllamaAgent:
def __init__(
self,
base_url: str,
model: str,
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]:
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"{self.base_url}/api/tags")
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
return {
"online": False,
"model": self.model,
"base_url": self.base_url,
"message": f"Ollama is offline or unreachable at {self.base_url}. Start Ollama and make sure the model is pulled.",
"detail": str(exc),
}
models = [model.get("name") or model.get("model") for model in body.get("models", [])]
return {
"online": True,
"model": self.model,
"base_url": self.base_url,
"model_available": self.model in models,
"models": models,
"message": "Ollama is online.",
}
async def ensure_available(self) -> None:
health = await self.health()
if not health["online"]:
raise OllamaUnavailable(health["message"])
async def chat(self, content: str) -> dict[str, Any]:
await self.ensure_available()
previous_interaction = self.memory.last_interaction() if self.memory else None
if self.memory:
self.memory.add_conversation("user", content)
self.messages.append({"role": "user", "content": content})
for _ in range(5):
response = await self._ollama_chat(content, previous_interaction=previous_interaction)
message = response.get("message") or {}
tool_calls = message.get("tool_calls") or []
if not tool_calls:
self.messages.append({"role": "assistant", "content": message.get("content", "")})
if self.memory:
self.memory.add_conversation("assistant", message.get("content", ""))
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()}
self.messages.append(message)
for call in tool_calls:
name, arguments = self._extract_call(call)
result = await self.tools.execute(name, arguments)
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
self.messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback)
return {"message": fallback, "pending_actions": self._pending_payloads()}
async def chat_events(self, content: str) -> AsyncIterator[dict[str, Any]]:
health = await self.health()
if not health["online"]:
yield {"type": "warning", "message": health["message"]}
yield {"type": "done", "pending_actions": self._pending_payloads()}
return
previous_interaction = self.memory.last_interaction() if self.memory else None
if self.memory:
self.memory.add_conversation("user", content)
self.messages.append({"role": "user", "content": content})
yield {"type": "status", "message": "Thinking"}
for _ in range(5):
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
tool_calls: list[dict[str, Any]] = []
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction):
message = event.get("message") or {}
chunk = message.get("content") or ""
if chunk:
assistant_message["content"] += chunk
yield {"type": "token", "content": chunk}
if message.get("tool_calls"):
tool_calls.extend(message["tool_calls"])
if event.get("done"):
metrics = self._stream_metrics(event)
if metrics:
yield {"type": "metrics", **metrics}
if not tool_calls:
self.messages.append(assistant_message)
if self.memory:
self.memory.add_conversation("assistant", assistant_message.get("content", ""))
yield {"type": "done", "pending_actions": self._pending_payloads()}
return
assistant_message["tool_calls"] = tool_calls
self.messages.append(assistant_message)
for call in tool_calls:
name, arguments = self._extract_call(call)
yield {"type": "status", "message": self._tool_status(name)}
result = await self.tools.execute(name, arguments)
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
yield {"type": "status", "message": "Writing response"}
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
self.messages.append({"role": "assistant", "content": fallback})
if self.memory:
self.memory.add_conversation("assistant", fallback)
yield {"type": "token", "content": fallback}
yield {"type": "done", "pending_actions": self._pending_payloads()}
async def generate_wake_response(self, wake_message: str) -> str:
await self.ensure_available()
self.messages.append({"role": "user", "content": wake_message})
response = await self._ollama_chat(wake_message)
message = response.get("message") or {}
content = message.get("content", "")
self.messages.append({"role": "assistant", "content": content})
if self.memory:
self.memory.add_conversation("system", wake_message)
self.memory.add_conversation("assistant", content)
return content or wake_message
async def _ollama_chat(self, query: str = "", previous_interaction: dict[str, Any] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self.base_url}/api/chat",
json={
"model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": False,
},
)
response.raise_for_status()
return response.json()
async def _ollama_chat_stream(
self,
query: str = "",
previous_interaction: dict[str, Any] | None = None,
) -> AsyncIterator[dict[str, Any]]:
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
f"{self.base_url}/api/chat",
json={
"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:
response.raise_for_status()
async for line in response.aiter_lines():
if line:
yield json.loads(line)
def _messages_with_context(
self,
query: str,
previous_interaction: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
context = self._runtime_context(query, previous_interaction=previous_interaction)
if not context:
return self.messages
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]]
def _runtime_context(self, query: str, previous_interaction: dict[str, Any] | None = None) -> str:
local_zone = get_localzone()
parts = [
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
]
if self.user_name:
parts.append(f"Known user name/handle: {self.user_name}.")
if self.memory is None:
return "\n".join(parts)
profile = self.memory.get_profile()
if profile:
identity = self._profile_identity(profile)
if identity:
parts.append(identity)
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction()
if last:
parts.append(
f"Previous interaction before this message: {last['created_at']} "
f"({time_since(last['created_at'])}, role {last['role']})."
)
else:
parts.append("Previous interaction before this message: none recorded.")
memories = self.memory.recall(query, limit=6)
if memories:
memory_text = "\n".join(
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
for item in memories
)
parts.append(f"Relevant long-term memories:\n{memory_text}")
recent = self.memory.recent_conversation(limit=6)
if recent:
recent_text = "\n".join(
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
for item in recent
)
parts.append(f"Recent conversation excerpts:\n{recent_text}")
return "\n".join(parts)
def _pending_payloads(self) -> list[dict[str, Any]]:
return [
{
"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}")
@staticmethod
def _stream_metrics(event: dict[str, Any]) -> dict[str, Any]:
prompt_tokens = int(event.get("prompt_eval_count") or 0)
prompt_duration = int(event.get("prompt_eval_duration") or 0)
output_tokens = int(event.get("eval_count") or 0)
output_duration = int(event.get("eval_duration") or 0)
def rate(tokens: int, duration_ns: int) -> float | None:
if not tokens or not duration_ns:
return None
return tokens / (duration_ns / 1_000_000_000)
return {
"reading_tokens": prompt_tokens,
"reading_tokens_per_second": rate(prompt_tokens, prompt_duration),
"writing_tokens": output_tokens,
"writing_tokens_per_second": rate(output_tokens, output_duration),
}
@staticmethod
def _profile_identity(profile: dict[str, Any]) -> str:
user = profile.get("uex_user")
if not isinstance(user, dict):
configured = profile.get("configured_name")
return f"You are speaking with {configured}." if configured else ""
username = user.get("username") or user.get("user_username")
name = user.get("name")
fields = []
if username and name and username != name:
fields.append(f"You are speaking with UEX user {username} ({name}).")
elif username or name:
fields.append(f"You are speaking with UEX user {username or name}.")
details = []
for key, label in [
("timezone", "timezone"),
("language", "preferred language"),
("specializations", "specializations"),
("languages", "languages"),
("archetypes", "archetypes"),
]:
value = user.get(key)
if value:
details.append(f"{label}: {value}")
if details:
fields.append("UEX profile details: " + "; ".join(details) + ".")
return " ".join(fields)
@staticmethod
def _profile_for_prompt(profile: dict[str, Any]) -> dict[str, Any]:
user = profile.get("uex_user")
if not isinstance(user, dict):
return profile
useful_user_fields = [
"id",
"name",
"username",
"avatar",
"bio",
"website_url",
"timezone",
"language",
"day_availability",
"time_availability",
"specializations",
"languages",
"archetypes",
"is_datarunner",
"is_staff",
"is_away_game",
"date_rsi_verified",
"date_twitch_verified",
]
prompt_profile = dict(profile)
prompt_profile["uex_user"] = {
key: user[key]
for key in useful_user_fields
if key in user and user[key] not in (None, "")
}
return prompt_profile
@staticmethod
def _extract_call(call: dict[str, Any]) -> tuple[str, dict[str, Any]]:
function = call.get("function") or {}
name = function.get("name") or call.get("name")
arguments = function.get("arguments") or call.get("arguments") or {}
if isinstance(arguments, str):
arguments = json.loads(arguments or "{}")
return name, arguments
class OllamaUnavailable(RuntimeError):
pass
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
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", 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 = 64000
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 = Field(default_factory=lambda: str(default_memory_path()))
uex_notification_poll_seconds: int = 60
require_write_approval: bool = True
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
@classmethod
def _blank_optional(cls, value: Any) -> Any:
return None if value == "" else value
@field_validator("traderai_memory_path", mode="before")
@classmethod
def _blank_memory_path(cls, value: Any) -> Any:
return str(default_memory_path()) if value == "" or value is None else value
@lru_cache
def get_settings() -> Settings:
return Settings()
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
current = settings or get_settings()
values = current.model_dump()
return {
"app_data_dir": str(ensure_app_data_dir()),
"config_path": str(user_config_path()),
"log_path": str(log_path()),
"edge_profile_dir": str(edge_profile_dir()),
"values": values,
"fields": CONFIG_FIELDS,
}
def save_settings(values: dict[str, Any]) -> dict[str, Any]:
current = get_settings().model_dump()
next_values = dict(current)
for key, value in values.items():
if key not in CONFIG_FIELDS:
continue
next_values[key] = _coerce_value(key, value)
path = user_config_path()
lines = [
"# TraderAI desktop configuration",
"# Saved by the app. Environment variables still override these values.",
"",
]
for key, meta in CONFIG_FIELDS.items():
value = next_values.get(key)
lines.append(f"{meta['env']}={_env_value(value)}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
get_settings.cache_clear()
return settings_payload(get_settings())
def _coerce_value(key: str, value: Any) -> Any:
field_type = CONFIG_FIELDS[key]["type"]
if value == "":
return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
if field_type == "integer":
return int(value)
if field_type == "boolean":
if isinstance(value, bool):
return value
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
return str(value)
def _env_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
text = str(value)
if not text or any(char.isspace() for char in text) or "#" in text:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
return text
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import threading
import time
import traceback
from typing import NoReturn
import httpx
import uvicorn
from traderai.config import edge_profile_dir, log_path
def resource_path(*parts: str) -> Path:
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
return base.joinpath(*parts)
def main() -> None:
try:
_chdir_to_app_dir()
_log("TraderAI desktop starting")
_log(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}")
_log(f"frozen={getattr(sys, 'frozen', False)} meipass={getattr(sys, '_MEIPASS', '')}")
port = _select_port()
url = f"http://127.0.0.1:{port}"
_log(f"selected_url={url}")
if _existing_server_ready(url):
_log("existing TraderAI backend found; opening window")
_open_window(url)
return
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start()
_log("backend thread started")
_wait_for_server(url)
_log("backend health check passed")
_open_window(url)
_log("webview closed")
except Exception:
_log("fatal startup error")
_log(traceback.format_exc())
raise
def _chdir_to_app_dir() -> None:
if getattr(sys, "frozen", False):
os.chdir(Path(sys.executable).resolve().parent)
def _select_port() -> int:
preferred = int(os.getenv("TRADERAI_PORT", "8765"))
if _port_available(preferred):
return preferred
_log(f"preferred port {preferred} is in use")
return _free_port()
def _port_available(port: int) -> bool:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _existing_server_ready(url: str) -> bool:
try:
response = httpx.get(f"{url}/api/health", timeout=1)
return response.status_code < 500 and response.headers.get("content-type", "").startswith("application/json")
except httpx.HTTPError:
return False
def _run_server(port: int) -> NoReturn:
try:
_log(f"backend starting on port {port}")
from traderai.server import app
config = uvicorn.Config(
app,
host="127.0.0.1",
port=port,
log_level="info",
log_config=None,
lifespan="on",
)
server = uvicorn.Server(config)
server.run()
_log("backend server stopped")
raise SystemExit(0)
except BaseException:
_log("backend thread crashed")
_log(traceback.format_exc())
raise
def _wait_for_server(url: str) -> None:
deadline = time.monotonic() + 30
last_error = ""
while time.monotonic() < deadline:
try:
response = httpx.get(f"{url}/api/health", timeout=1)
_log(f"health probe status={response.status_code}")
if response.status_code < 500:
return
except httpx.HTTPError as exc:
last_error = str(exc)
_log(f"health probe failed: {last_error}")
time.sleep(0.25)
raise RuntimeError(f"TraderAI backend did not start within 30 seconds. {last_error}")
def _open_window(url: str) -> None:
mode = os.getenv("TRADERAI_DESKTOP_UI", "edge").casefold()
_log(f"ui_mode={mode}")
if mode == "webview":
_open_webview(url)
return
if _open_edge_app(url):
return
_open_browser(url)
def _open_webview(url: str) -> None:
_log("importing pywebview")
import webview
_log("creating pywebview window")
webview.create_window(
"TraderAI",
url,
width=1320,
height=860,
min_size=(980, 680),
text_select=True,
icon=str(resource_path("web", "art", "LBC_Logo.ico")),
)
_log("starting pywebview")
webview.start(gui="edgechromium", debug=False)
def _open_edge_app(url: str) -> bool:
edge = _edge_path()
if not edge:
_log("msedge not found; falling back to default browser")
return False
profile_dir = edge_profile_dir()
profile_dir.mkdir(parents=True, exist_ok=True)
command = [
str(edge),
f"--app={url}",
f"--user-data-dir={profile_dir}",
"--new-window",
"--no-first-run",
"--disable-features=Translate",
f"--app-icon={resource_path('web', 'art', 'LBC_Logo.ico')}",
]
_log(f"launching edge app: {' '.join(command)}")
process = subprocess.Popen(command)
_log(f"edge process id={process.pid}")
time.sleep(2)
if process.poll() is None:
process.wait()
_log("edge app process exited")
return True
_log(f"edge app process exited early code={process.returncode}; keeping backend alive")
_keep_alive()
return True
def _open_browser(url: str) -> None:
import webbrowser
_log(f"opening default browser at {url}")
webbrowser.open(url)
_keep_alive()
def _keep_alive() -> None:
_log("backend staying alive; close TraderAI from Task Manager if no app window owns this process")
while True:
time.sleep(60)
def _edge_path() -> Path | None:
edge = shutil.which("msedge")
if edge:
return Path(edge)
candidates = [
Path(os.environ.get("ProgramFiles", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
Path(os.environ.get("LocalAppData", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _log(message: str) -> None:
try:
log_path = _log_path()
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
with log_path.open("a", encoding="utf-8") as file:
file.write(f"[{timestamp}] {message}\n")
except Exception:
pass
def _log_path() -> Path:
return log_path()
if __name__ == "__main__":
main()
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def iso_now() -> str:
return utc_now().isoformat()
def iso_now_in_zone(zone: ZoneInfo) -> str:
return utc_now().astimezone(zone).isoformat()
def parse_iso(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def time_since(value: str, now: datetime | None = None) -> str:
then = parse_iso(value)
current = now or utc_now()
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
seconds = max(0, int((current - then).total_seconds()))
if seconds < 60:
return f"{seconds} seconds ago"
minutes = seconds // 60
if minutes < 60:
return _plural(minutes, "minute") + " ago"
hours = minutes // 60
if hours < 24:
return _plural(hours, "hour") + " ago"
days = hours // 24
return _plural(days, "day") + " ago"
def _plural(value: int, unit: str) -> str:
suffix = "" if value == 1 else "s"
return f"{value} {unit}{suffix}"
class MemoryStore:
def __init__(self, path: str) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _init_db(self) -> None:
with self._connect() as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
content TEXT NOT NULL,
importance INTEGER NOT NULL DEFAULT 3,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
content,
kind UNINDEXED,
content='memories',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, content, kind) VALUES (new.id, new.content, new.kind);
END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, kind)
VALUES('delete', old.id, old.content, old.kind);
END;
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, content, kind)
VALUES('delete', old.id, old.content, old.kind);
INSERT INTO memories_fts(rowid, content, kind) VALUES (new.id, new.content, new.kind);
END;
CREATE TABLE IF NOT EXISTS user_profile (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scheduled_jobs (
id TEXT PRIMARY KEY,
prompt TEXT NOT NULL,
trigger_type TEXT NOT NULL,
trigger_value TEXT NOT NULL,
next_run_at TEXT,
created_at TEXT NOT NULL,
last_run_at TEXT,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
delivered_at TEXT
);
"""
)
def add_conversation(self, role: str, content: str) -> None:
with self._connect() as db:
db.execute(
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
(role, content, iso_now()),
)
def last_interaction(self) -> dict[str, Any] | None:
with self._connect() as db:
row = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in reversed(rows)]
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now()
with self._connect() as db:
cursor = db.execute(
"""
INSERT INTO memories(kind, content, importance, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(kind, content, importance, json.dumps(metadata or {}), now, now),
)
memory_id = cursor.lastrowid
return {"id": memory_id, "kind": kind, "content": content, "importance": importance, "created_at": now}
def recall(self, query: str, limit: int = 6) -> list[dict[str, Any]]:
if not query.strip():
return self.top_memories(limit)
with self._connect() as db:
try:
rows = db.execute(
"""
SELECT m.id, m.kind, m.content, m.importance, m.metadata, m.created_at,
bm25(memories_fts) AS rank
FROM memories_fts
JOIN memories m ON m.id = memories_fts.rowid
WHERE memories_fts MATCH ?
ORDER BY rank, m.importance DESC
LIMIT ?
""",
(self._fts_query(query), limit),
).fetchall()
except sqlite3.OperationalError:
rows = db.execute(
"""
SELECT id, kind, content, importance, metadata, created_at
FROM memories
WHERE content LIKE ?
ORDER BY importance DESC, id DESC
LIMIT ?
""",
(f"%{query}%", limit),
).fetchall()
return [self._memory_row(row) for row in rows]
def top_memories(self, limit: int = 6) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
SELECT id, kind, content, importance, metadata, created_at
FROM memories
ORDER BY importance DESC, updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [self._memory_row(row) for row in rows]
def inspect(self, limit: int = 50) -> dict[str, Any]:
with self._connect() as db:
memories = db.execute(
"""
SELECT id, kind, content, importance, metadata, created_at, updated_at
FROM memories
ORDER BY importance DESC, updated_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
conversations = db.execute(
"""
SELECT id, role, content, created_at
FROM conversations
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
profile_rows = db.execute(
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
).fetchall()
jobs = db.execute(
"SELECT * FROM scheduled_jobs ORDER BY enabled DESC, next_run_at"
).fetchall()
outbox = db.execute(
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
profile = []
for row in profile_rows:
item = dict(row)
try:
item["value"] = json.loads(item["value"])
except json.JSONDecodeError:
pass
profile.append(item)
return {
"path": str(self.path),
"memories": [self._memory_row(row) for row in memories],
"conversations": [dict(row) for row in conversations],
"profile": profile,
"scheduled_jobs": [dict(row) for row in jobs],
"outbox": [dict(row) for row in outbox],
}
def clear(
self,
include_memories: bool = True,
include_conversations: bool = True,
include_profile: bool = False,
include_jobs: bool = False,
include_outbox: bool = True,
) -> dict[str, int]:
deleted: dict[str, int] = {}
with self._connect() as db:
if include_memories:
deleted["memories"] = db.execute("DELETE FROM memories").rowcount
db.execute("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')")
if include_conversations:
deleted["conversations"] = db.execute("DELETE FROM conversations").rowcount
if include_profile:
deleted["profile"] = db.execute("DELETE FROM user_profile").rowcount
if include_jobs:
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
if include_outbox:
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
return deleted
def set_profile(self, key: str, value: Any) -> None:
with self._connect() as db:
db.execute(
"""
INSERT INTO user_profile(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, json.dumps(value), iso_now()),
)
def get_profile(self) -> dict[str, Any]:
with self._connect() as db:
rows = db.execute("SELECT key, value FROM user_profile").fetchall()
profile = {}
for row in rows:
try:
profile[row["key"]] = json.loads(row["value"])
except json.JSONDecodeError:
profile[row["key"]] = row["value"]
return profile
def add_job(
self,
job_id: str,
prompt: str,
trigger_type: str,
trigger_value: str,
next_run_at: str | None = None,
) -> dict[str, Any]:
with self._connect() as db:
db.execute(
"""
INSERT INTO scheduled_jobs(id, prompt, trigger_type, trigger_value, next_run_at, created_at, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(id) DO UPDATE SET
prompt=excluded.prompt,
trigger_type=excluded.trigger_type,
trigger_value=excluded.trigger_value,
next_run_at=excluded.next_run_at,
enabled=1
""",
(job_id, prompt, trigger_type, trigger_value, next_run_at, iso_now()),
)
return {
"id": job_id,
"prompt": prompt,
"trigger_type": trigger_type,
"trigger_value": trigger_value,
"next_run_at": next_run_at,
}
def list_jobs(self) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"SELECT * FROM scheduled_jobs WHERE enabled = 1 ORDER BY next_run_at IS NULL, next_run_at"
).fetchall()
return [dict(row) for row in rows]
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
with self._connect() as db:
db.execute(
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
(iso_now(), next_run_at, job_id),
)
def add_outbox(self, content: str) -> None:
with self._connect() as db:
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
def undelivered_outbox(self) -> list[dict[str, Any]]:
now = iso_now()
with self._connect() as db:
rows = db.execute(
"SELECT id, content, created_at FROM outbox WHERE delivered_at IS NULL ORDER BY id"
).fetchall()
db.execute(
"UPDATE outbox SET delivered_at = ? WHERE delivered_at IS NULL",
(now,),
)
return [dict(row) for row in rows]
@staticmethod
def _fts_query(query: str) -> str:
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
return " OR ".join(f'"{token}"' for token in tokens) or '""'
@staticmethod
def _memory_row(row: sqlite3.Row) -> dict[str, Any]:
data = dict(row)
if "metadata" in data:
try:
data["metadata"] = json.loads(data["metadata"])
except json.JSONDecodeError:
data["metadata"] = {}
return data
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
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)
def shutdown(self) -> None:
if self.scheduler.running:
self.scheduler.shutdown(wait=False)
def schedule_date(self, run_at: str, prompt: str, job_id: str | None = None) -> dict[str, Any]:
parsed = datetime.fromisoformat(run_at)
job_id = job_id or f"wake-{uuid4()}"
trigger = DateTrigger(run_date=parsed)
self.scheduler.add_job(self._run_job, trigger=trigger, id=job_id, args=[job_id, prompt], replace_existing=True)
return self.memory.add_job(job_id, prompt, "date", run_at, parsed.isoformat())
def schedule_cron(self, cron: str, prompt: str, job_id: str | None = None) -> dict[str, Any]:
job_id = job_id or f"wake-{uuid4()}"
trigger = CronTrigger.from_crontab(cron)
self.scheduler.add_job(self._run_job, trigger=trigger, id=job_id, args=[job_id, prompt], replace_existing=True)
next_run = self.scheduler.get_job(job_id).next_run_time
return self.memory.add_job(job_id, prompt, "cron", cron, next_run.isoformat() if next_run else None)
def list_jobs(self) -> list[dict[str, Any]]:
return self.memory.list_jobs()
def _schedule_existing(self, job: dict[str, Any]) -> None:
if job["trigger_type"] == "cron":
trigger = CronTrigger.from_crontab(job["trigger_value"])
elif job["trigger_type"] == "date":
trigger = DateTrigger(run_date=datetime.fromisoformat(job["trigger_value"]))
else:
return
self.scheduler.add_job(
self._run_job,
trigger=trigger,
id=job["id"],
args=[job["id"], job["prompt"]],
replace_existing=True,
)
async def _run_job(self, job_id: str, prompt: str) -> None:
last = self.memory.last_interaction()
last_text = f"{last['created_at']} ({time_since(last['created_at'])})" if last else "never"
wake_message = (
f"Scheduled wake job fired. Current time is {iso_now()}. "
f"The last chat interaction was {last_text}. Job instruction: {prompt}"
)
if self.agent is None:
self.memory.add_outbox(wake_message)
return
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}"
+559
View File
@@ -0,0 +1,559 @@
from __future__ import annotations
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
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):
message: str
class ClearMemoryRequest(BaseModel):
include_memories: bool = True
include_conversations: bool = True
include_profile: bool = False
include_jobs: bool = False
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,
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 = resource_path("web")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.on_event("startup")
async def startup() -> None:
await refresh_user_profile()
scheduler.start()
@app.on_event("shutdown")
async def shutdown() -> None:
scheduler.shutdown()
async def refresh_user_profile() -> None:
if settings.traderai_user_name:
memory.set_profile("configured_name", settings.traderai_user_name)
agent.user_name = agent.user_name or settings.traderai_user_name
try:
response = await uex.get_user(authenticated=True)
except Exception as exc:
memory.set_profile("uex_user_error", str(exc))
if settings.traderai_user_name:
try:
response = await uex.get_user(username=settings.traderai_user_name)
except Exception:
return
else:
return
data = response.get("user")
if data:
memory.set_profile("uex_user", data)
username = data.get("username") or data.get("user_username") or data.get("name")
if username:
agent.user_name = username
@app.get("/")
async def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/api/health")
async def health() -> dict:
return {
"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:
return await agent.chat(request.message)
except OllamaUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest) -> StreamingResponse:
async def events():
async for event in agent.chat_events(request.message):
yield f"data: {json.dumps(event)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream")
@app.get("/api/pending-actions")
async def pending_actions() -> dict:
return {"pending_actions": agent._pending_payloads()}
@app.get("/api/notifications")
async def notifications() -> dict:
return {"notifications": memory.undelivered_outbox()}
@app.get("/api/wake-jobs")
async def wake_jobs() -> dict:
return {"scheduled_jobs": scheduler.list_jobs()}
@app.get("/api/memory")
async def inspect_memory(limit: int = 50) -> dict:
return memory.inspect(max(1, min(limit, 200)))
@app.post("/api/memory/clear")
async def clear_memory(request: ClearMemoryRequest) -> dict:
if request.include_jobs:
scheduler.shutdown()
deleted = memory.clear(
include_memories=request.include_memories,
include_conversations=request.include_conversations,
include_profile=request.include_profile,
include_jobs=request.include_jobs,
include_outbox=request.include_outbox,
)
if request.include_jobs:
scheduler.start()
return {"deleted": deleted, "memory": memory.inspect(50)}
@app.post("/api/approve/{action_id}")
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()
+1169
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
import httpx
class UEXError(RuntimeError):
pass
class UEXClient:
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.secret_key = secret_key
self.bearer_token = bearer_token
def _headers(self, authenticated: bool = False) -> dict[str, str]:
headers = {"Accept": "application/json"}
if authenticated:
if not self.secret_key and not self.bearer_token:
raise UEXError("UEX_SECRET_KEY or UEX_BEARER_TOKEN is required for this action.")
if self.secret_key:
headers["secret-key"] = self.secret_key
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
return headers
async def get(self, path: str, params: dict[str, Any] | None = None, authenticated: bool = False) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
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)
async def get_user(self, username: str | None = None, authenticated: bool = False) -> dict[str, Any]:
body = await self.get("user", {"username": username}, authenticated=authenticated)
data = body.get("data")
if isinstance(data, list):
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(
f"{self.base_url}/{path.strip('/')}/",
json=payload,
headers=self._headers(authenticated),
)
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:
body = response.json()
except ValueError as exc:
raise UEXError(f"UEX returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise UEXError(f"UEX HTTP {response.status_code}: {body}")
return body
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
__version__ = "0.0.1"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
Generated
+1049
View File
File diff suppressed because it is too large Load Diff
+962
View File
@@ -0,0 +1,962 @@
const form = document.getElementById("chat-form");
const input = document.getElementById("message-input");
const messages = document.getElementById("messages");
const statusEl = document.getElementById("status");
const pendingEl = document.getElementById("pending-actions");
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");
node.className = `message ${role}`;
setMessageMarkdown(node, text);
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
return node;
}
function setMessageMarkdown(node, text) {
const body = node.querySelector(".message-body") || node;
body.innerHTML = renderMarkdown(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) {
const label = document.createElement("span");
label.textContent = text;
phase.appendChild(label);
}
if (active) {
const dots = document.createElement("span");
dots.className = "working-dots";
dots.innerHTML = "<i></i><i></i><i></i>";
phase.appendChild(dots);
}
}
function setMessageMetrics(node, metrics) {
const metricsEl = node.querySelector(".message-metrics");
if (!metricsEl) return;
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 = "";
const activity = document.createElement("div");
activity.className = "message-activity";
const phase = document.createElement("span");
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);
thinkingSummary.appendChild(thinkingLabel);
thinking.append(thinkingSummary, thinkingSteps);
node.append(activity, thinking, body);
}
function renderMarkdown(text) {
const lines = text.replace(/\r\n/g, "\n").split(/\n/);
const output = [];
let inList = false;
let inOrderedList = false;
let inCode = false;
let codeLines = [];
const closeLists = () => {
if (inList) {
output.push("</ul>");
inList = false;
}
if (inOrderedList) {
output.push("</ol>");
inOrderedList = false;
}
};
const flushCode = () => {
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
codeLines = [];
inCode = false;
};
const isTableAt = (index) => {
if (index + 1 >= lines.length) return false;
return isTableRow(lines[index]) && isTableDivider(lines[index + 1]);
};
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
if (inCode) {
flushCode();
} else {
closeLists();
inCode = true;
}
continue;
}
if (inCode) {
codeLines.push(line);
continue;
}
if (isTableAt(index)) {
closeLists();
const headers = splitTableRow(lines[index]);
const aligns = splitTableRow(lines[index + 1]).map((cell) => tableAlignment(cell));
const rows = [];
index += 2;
while (index < lines.length && isTableRow(lines[index])) {
rows.push(splitTableRow(lines[index]));
index += 1;
}
index -= 1;
output.push(renderTable(headers, aligns, rows));
continue;
}
const unorderedItem = line.match(/^(\s*)[-*+]\s+(.+)$/);
if (unorderedItem) {
if (inOrderedList) {
output.push("</ol>");
inOrderedList = false;
}
if (!inList) {
output.push("<ul>");
inList = true;
}
const nestedClass = unorderedItem[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${inlineMarkdown(unorderedItem[2])}</li>`);
continue;
}
const orderedItem = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (orderedItem) {
if (inList) {
output.push("</ul>");
inList = false;
}
if (!inOrderedList) {
output.push("<ol>");
inOrderedList = true;
}
const nestedClass = orderedItem[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${inlineMarkdown(orderedItem[2])}</li>`);
continue;
}
closeLists();
if (/^>\s?/.test(trimmed)) {
output.push(`<blockquote>${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}</blockquote>`);
} else if (/^---+$/.test(trimmed)) {
output.push("<hr>");
} else if (/^#{1,6}\s+/.test(trimmed)) {
const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2);
output.push(`<h${level}>${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}</h${level}>`);
} else if (trimmed) {
output.push(`<p>${inlineMarkdown(line)}</p>`);
} else {
output.push("<br>");
}
}
if (inCode) flushCode();
closeLists();
return output.join("");
}
function inlineMarkdown(text) {
return escapeHtml(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
function isTableRow(line) {
const trimmed = line.trim();
return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed);
}
function isTableDivider(line) {
return splitTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function splitTableRow(line) {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim());
}
function tableAlignment(cell) {
const trimmed = cell.trim();
if (trimmed.startsWith(":") && trimmed.endsWith(":")) return "center";
if (trimmed.endsWith(":")) return "right";
return "left";
}
function renderTable(headers, aligns, rows) {
const head = headers
.map((cell, index) => `<th style="text-align:${aligns[index] || "left"}">${inlineMarkdown(cell)}</th>`)
.join("");
const body = rows
.map((row) => {
const cells = headers
.map((_, index) => `<td style="text-align:${aligns[index] || "left"}">${inlineMarkdown(row[index] || "")}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function formatMetrics(event) {
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | ");
}
function formatTokenMetric(tokens, speed) {
if (!tokens) return "";
const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : "";
return `${tokens} tok${speedText}`;
}
function setWarning(text) {
warningEl.hidden = !text;
warningEl.textContent = text || "";
}
function fetchErrorMessage(error) {
if (error instanceof TypeError && /fetch/i.test(error.message)) {
return "TraderAI backend is not reachable. Close this app window and launch TraderAI.exe again.";
}
return error.message;
}
const configFieldIds = {
ollama_base_url: "config-ollama-base-url",
ollama_model: "config-ollama-model",
ollama_num_ctx: "config-ollama-num-ctx",
uex_base_url: "config-uex-base-url",
uex_secret_key: "config-uex-secret-key",
uex_bearer_token: "config-uex-bearer-token",
traderai_user_name: "config-traderai-user-name",
traderai_memory_path: "config-traderai-memory-path",
uex_notification_poll_seconds: "config-uex-notification-poll-seconds",
require_write_approval: "config-require-write-approval",
};
const ollamaFieldIds = {
ollama_base_url: "ollama-base-url",
ollama_model: "ollama-model",
ollama_num_ctx: "ollama-num-ctx",
};
async function refreshConfig() {
try {
const response = await fetch("/api/config");
const config = await response.json();
renderConfig(config);
} catch (error) {
configStatusEl.textContent = `Config load failed: ${fetchErrorMessage(error)}`;
}
}
function renderConfig(config) {
const values = config.values || {};
for (const [key, id] of Object.entries(configFieldIds)) {
const field = document.getElementById(id);
if (!field) continue;
if (field.type === "checkbox") {
field.checked = Boolean(values[key]);
} else {
field.value = values[key] ?? "";
}
}
for (const [key, id] of Object.entries(ollamaFieldIds)) {
const field = document.getElementById(id);
if (!field) continue;
field.value = values[key] ?? "";
}
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
configStatusEl.textContent = "";
}
async function saveConfig(event) {
event.preventDefault();
const values = {};
for (const [key, id] of Object.entries(configFieldIds)) {
const field = document.getElementById(id);
if (!field) continue;
values[key] = field.type === "checkbox" ? field.checked : field.value;
}
configStatusEl.textContent = "Saving";
try {
const response = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values }),
});
const result = await response.json();
renderConfig(result);
configStatusEl.textContent = result.message || "Saved";
addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply.");
} catch (error) {
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
}
}
async function saveOllamaConfig(event) {
event.preventDefault();
const values = {};
for (const [key, id] of Object.entries(ollamaFieldIds)) {
const field = document.getElementById(id);
if (!field) continue;
values[key] = field.value;
}
setOllamaMessage("Saving Ollama config");
try {
const response = await fetch("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values }),
});
const result = await response.json();
renderConfig(result);
setOllamaMessage(result.message || "Saved");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`);
}
}
async function refreshOllamaStatus() {
if (!ollamaStatusEl) return;
ollamaStatusEl.textContent = "Checking Ollama";
try {
const response = await fetch("/api/ollama/status");
const status = await response.json();
renderOllamaStatus(status);
} catch (error) {
ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`;
}
}
function renderOllamaStatus(status) {
if (!ollamaStatusEl) return;
const models = status.models?.length ? status.models.join(", ") : "None detected";
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
ollamaStatusEl.innerHTML = `
<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 || document.getElementById("config-ollama-model")?.value || "";
}
async function checkForUpdate() {
if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases";
try {
const response = await fetch("/api/update/check");
const result = await response.json();
latestUpdate = result;
renderUpdateStatus(result);
} catch (error) {
updateStatusEl.textContent = `Update check failed: ${error.message}`;
if (updateInstallButton) updateInstallButton.disabled = true;
}
}
function renderUpdateStatus(update) {
if (!updateStatusEl) return;
const lines = [
`Current: ${update.current_version || "unknown"}`,
`Latest: ${update.latest_version || "unknown"}`,
update.message || "",
].filter(Boolean);
if (update.available && !update.asset_download_url) {
lines.push("The release needs a TraderAI.exe attachment before the app can self-update.");
}
if (update.available && update.asset_download_url && !update.packaged) {
lines.push("Self-update runs from the packaged desktop exe.");
}
updateStatusEl.textContent = lines.join("\n");
if (updateInstallButton) {
updateInstallButton.disabled = !update.available || !update.asset_download_url || !update.packaged;
}
}
async function installUpdate() {
if (!updateStatusEl) return;
updateStatusEl.textContent = "Downloading update";
try {
const response = await fetch("/api/update/install", { method: "POST" });
const result = await response.json();
latestUpdate = result;
renderUpdateStatus(result);
} catch (error) {
updateStatusEl.textContent = `Update failed: ${error.message}`;
}
}
function openReleasesPage() {
const url = latestUpdate?.release_url || "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases";
window.open(url, "_blank", "noreferrer");
}
function toggleSidebarPanel(panelName) {
const panels = {
settings: { panel: settingsPanel, button: settingsToggle },
memory: { panel: memoryPanel, button: memoryToggle },
ollama: { panel: ollamaPanel, button: ollamaToggle },
};
const target = panels[panelName];
if (!target?.panel || !target?.button) return;
const shouldOpen = target.panel.hidden;
for (const item of Object.values(panels)) {
if (!item.panel || !item.button) continue;
item.panel.hidden = true;
item.button.classList.remove("active");
item.button.setAttribute("aria-expanded", "false");
}
if (shouldOpen) {
target.panel.hidden = false;
target.button.classList.add("active");
target.button.setAttribute("aria-expanded", "true");
if (panelName === "settings") {
refreshConfig();
checkForUpdate();
}
if (panelName === "memory") refreshMemory();
if (panelName === "ollama") {
refreshConfig();
refreshOllamaStatus();
}
}
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
const result = await response.json();
const health = result.ollama || {};
ollamaOnline = Boolean(health.online);
if (!ollamaOnline) {
statusEl.textContent = "Offline";
setWarning(health.message || "Ollama is offline. Start Ollama before chatting.");
return false;
}
if (health.model_available === false) {
setWarning(`Ollama is online, but model "${health.model}" is not pulled. Run: ollama pull ${health.model}`);
} else {
setWarning("");
}
statusEl.textContent = "Ready";
return true;
} catch (error) {
ollamaOnline = false;
statusEl.textContent = "Offline";
setWarning(`Could not check Ollama health: ${error.message}`);
return false;
}
}
function renderPending(actions) {
pendingEl.innerHTML = "";
if (!actions.length) {
pendingEl.className = "pending-empty";
pendingEl.textContent = "No pending actions";
return;
}
pendingEl.className = "";
for (const action of actions) {
const card = document.createElement("div");
card.className = "pending-card";
const title = document.createElement("strong");
title.textContent = action.label;
const endpoint = document.createElement("p");
endpoint.className = "muted";
endpoint.textContent = action.endpoint;
const payload = document.createElement("pre");
payload.textContent = JSON.stringify(action.payload, null, 2);
const approve = document.createElement("button");
approve.textContent = "Approve";
approve.addEventListener("click", () => approveAction(action.id));
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);
}
}
async function approveAction(id) {
statusEl.textContent = "Sending";
try {
const response = await fetch(`/api/approve/${id}`, { method: "POST" });
const result = await response.json();
addMessage("assistant", `Approval result:\n${JSON.stringify(result, null, 2)}`);
await refreshPending();
} catch (error) {
addMessage("assistant", `Approval failed: ${error.message}`);
} finally {
statusEl.textContent = "Ready";
}
}
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();
renderPending(result.pending_actions || []);
}
function renderMemory(data) {
memoryInspectorEl.innerHTML = "";
const counts = document.createElement("div");
counts.className = "memory-counts";
counts.textContent = `${data.memories.length} memories | ${data.conversations.length} chat rows | ${data.profile.length} profile keys | ${data.scheduled_jobs.length} jobs`;
memoryInspectorEl.appendChild(counts);
const path = document.createElement("div");
path.className = "memory-path";
path.textContent = data.path;
memoryInspectorEl.appendChild(path);
memoryInspectorEl.appendChild(memoryGroup("Memories", data.memories, (item) => `${item.kind} (${item.importance})\n${item.content}`));
memoryInspectorEl.appendChild(memoryGroup("Profile", data.profile, (item) => `${item.key}\n${JSON.stringify(item.value, null, 2)}`));
memoryInspectorEl.appendChild(memoryGroup("Recent Chat", data.conversations, (item) => `${item.created_at} ${item.role}\n${item.content}`));
memoryInspectorEl.appendChild(memoryGroup("Wake Jobs", data.scheduled_jobs, (item) => `${item.id}\n${item.trigger_type}: ${item.trigger_value}\n${item.prompt}`));
}
function memoryGroup(title, items, formatter) {
const group = document.createElement("details");
group.className = "memory-group";
const summary = document.createElement("summary");
summary.textContent = `${title} (${items.length})`;
group.appendChild(summary);
if (!items.length) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = "Empty";
group.appendChild(empty);
return group;
}
for (const item of items.slice(0, 20)) {
const entry = document.createElement("pre");
entry.textContent = formatter(item);
group.appendChild(entry);
}
return group;
}
async function refreshMemory() {
try {
const response = await fetch("/api/memory?limit=50");
const data = await response.json();
renderMemory(data);
} catch (error) {
memoryInspectorEl.textContent = `Memory load failed: ${error.message}`;
}
}
async function clearMemory() {
const payload = {
include_memories: document.getElementById("clear-memories").checked,
include_conversations: document.getElementById("clear-conversations").checked,
include_profile: document.getElementById("clear-profile").checked,
include_jobs: document.getElementById("clear-jobs").checked,
include_outbox: document.getElementById("clear-outbox").checked,
};
if (!Object.values(payload).some(Boolean)) return;
const confirmed = window.confirm("Clear the selected TraderAI memory? This cannot be undone.");
if (!confirmed) return;
try {
const response = await fetch("/api/memory/clear", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
renderMemory(result.memory);
addMessage("assistant", `Memory cleared:\n${JSON.stringify(result.deleted, null, 2)}`);
} catch (error) {
addMessage("assistant warning-message", `Memory clear failed: ${error.message}`);
}
}
async function pollNotifications() {
try {
const response = await fetch("/api/notifications");
const result = await response.json();
for (const notification of result.notifications || []) {
addMessage("assistant", notification.content);
}
} catch {
// Notification polling should never interrupt chat.
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
await sendMessage();
});
input.addEventListener("keydown", async (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
await sendMessage();
}
});
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();
if (!message || input.disabled) return;
const healthy = await checkHealth();
if (!healthy) {
addMessage("assistant warning-message", "Ollama is offline. Start Ollama, then try again.");
return;
}
input.value = "";
input.disabled = true;
addMessage("user", message);
const assistantNode = addMessage("assistant streaming", "");
ensureStreamingChrome(assistantNode);
let assistantText = "";
const thinkParser = createThinkTagParser(assistantNode);
statusEl.textContent = "Working";
setMessageActivity(assistantNode, "Thinking", true);
setMessageMetrics(assistantNode, "");
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const rawEvent of events) {
const line = rawEvent.split("\n").find((entry) => entry.startsWith("data: "));
if (!line) continue;
const event = JSON.parse(line.slice(6));
if (event.type === "status") {
setMessageActivity(assistantNode, event.message, true);
} else if (event.type === "metrics") {
setMessageMetrics(assistantNode, formatMetrics(event));
} else if (event.type === "warning") {
setWarning(event.message);
assistantText += event.message;
setMessageMarkdown(assistantNode, assistantText);
} else if (event.type === "token") {
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 || []);
}
}
}
} catch (error) {
const message = error.message.includes("503")
? "Ollama is offline or unreachable. Start Ollama, then try again."
: `Chat failed: ${error.message}`;
setWarning(message);
setMessageMarkdown(assistantNode, message);
} finally {
assistantNode.classList.remove("streaming");
input.disabled = false;
input.focus();
statusEl.textContent = "Ready";
finishThinking(assistantNode);
setMessageActivity(assistantNode, "");
}
}
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);
setInterval(pollNotifications, 15000);
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<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 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>Institutional marketplace intelligence for UEX operations</p>
</div>
</div>
<div class="status" id="status">Ready</div>
</header>
<div class="warning" id="warning" hidden></div>
<div class="messages" id="messages"></div>
<div class="composer-wrap">
<form class="composer" id="chat-form">
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
<button type="submit">Send</button>
</form>
</div>
</section>
<aside class="actions">
<section class="side-section">
<h2>Pending Approval</h2>
<div id="pending-actions" class="pending-empty">No pending actions</div>
</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>Ollama URL<input id="config-ollama-base-url" name="ollama_base_url" type="text"></label>
<label>Ollama Model<input id="config-ollama-model" name="ollama_model" type="text"></label>
<label>Context Tokens<input id="config-ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<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>
</div>
<div class="memory-controls">
<label><input type="checkbox" id="clear-memories" checked> Memories</label>
<label><input type="checkbox" id="clear-conversations" checked> Chat</label>
<label><input type="checkbox" id="clear-outbox" checked> Notices</label>
<label><input type="checkbox" id="clear-profile"> Profile</label>
<label><input type="checkbox" id="clear-jobs"> Jobs</label>
</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>
+1025
View File
File diff suppressed because it is too large Load Diff