Compare commits
9 Commits
729f421ec8
..
0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
58a57ddc6a
|
|||
|
11adcc160a
|
|||
|
da016c23cb
|
|||
|
5850674448
|
|||
|
36c91ce500
|
|||
|
761eda6155
|
|||
|
f7ac45ddd8
|
|||
|
103f30d9c0
|
|||
|
dbc97bddee
|
@@ -0,0 +1,10 @@
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen3.5:9b
|
||||
OLLAMA_NUM_CTX=64512
|
||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||
UEX_SECRET_KEY=
|
||||
UEX_BEARER_TOKEN=
|
||||
TRADERAI_USER_NAME=
|
||||
TRADERAI_MEMORY_PATH=
|
||||
UEX_NOTIFICATION_POLL_SECONDS=60
|
||||
REQUIRE_WRITE_APPROVAL=true
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Build Release EXE
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-windows-exe:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install build dependencies
|
||||
shell: pwsh
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
|
||||
- name: Build TraderAI.exe
|
||||
shell: pwsh
|
||||
run: |
|
||||
pyinstaller TraderAI.spec --noconfirm
|
||||
if (-not (Test-Path -LiteralPath "dist\TraderAI.exe")) {
|
||||
throw "dist\TraderAI.exe was not created."
|
||||
}
|
||||
|
||||
- name: Attach EXE to release
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$event = Get-Content -LiteralPath $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json
|
||||
$releaseId = $event.release.id
|
||||
if (-not $releaseId) {
|
||||
throw "Release id was not present in the release event payload."
|
||||
}
|
||||
|
||||
$token = $env:RELEASE_TOKEN
|
||||
if ([string]::IsNullOrWhiteSpace($token)) {
|
||||
$token = $env:GITEA_TOKEN
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Set a RELEASE_TOKEN secret or enable the built-in GITHUB_TOKEN for Actions."
|
||||
}
|
||||
|
||||
$apiUrl = $env:GITHUB_API_URL
|
||||
if ([string]::IsNullOrWhiteSpace($apiUrl)) {
|
||||
$apiUrl = "$($env:GITHUB_SERVER_URL.TrimEnd('/'))/api/v1"
|
||||
}
|
||||
|
||||
$repoParts = $env:GITHUB_REPOSITORY.Split("/", 2)
|
||||
if ($repoParts.Length -ne 2) {
|
||||
throw "GITHUB_REPOSITORY must look like owner/repo. Value: $env:GITHUB_REPOSITORY"
|
||||
}
|
||||
|
||||
$owner = [uri]::EscapeDataString($repoParts[0])
|
||||
$repo = [uri]::EscapeDataString($repoParts[1])
|
||||
$assetPath = Resolve-Path -LiteralPath "dist\TraderAI.exe"
|
||||
$uploadUrl = "$apiUrl/repos/$owner/$repo/releases/$releaseId/assets?name=TraderAI.exe"
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri $uploadUrl `
|
||||
-Headers @{ Authorization = "token $token" } `
|
||||
-Form @{ attachment = Get-Item -LiteralPath $assetPath }
|
||||
+60
@@ -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
|
||||
@@ -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.
|
||||
@@ -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; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
|
||||
|
||||
## Releases And Updates
|
||||
|
||||
Change the app version before cutting a release:
|
||||
|
||||
```powershell
|
||||
.\scripts\set_version.ps1 0.2.0
|
||||
```
|
||||
|
||||
Create a Gitea release with a matching tag such as `v0.2.0`. The release workflow builds `dist\TraderAI.exe` and attaches only that exe to the release.
|
||||
|
||||
The desktop app can check `https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases` from Settings > Updates. When a newer release has a `TraderAI.exe` attachment, the packaged app downloads it to the user app data update folder, exits, replaces the current exe, and relaunches.
|
||||
|
||||
UEX marketplace posting and negotiation messages are guarded because they are account-affecting write actions. The model can draft them, but the UI approval button performs the final API call.
|
||||
|
||||
The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. 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
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||
|
||||
|
||||
a = Analysis(
|
||||
["traderai\\desktop.py"],
|
||||
pathex=[],
|
||||
binaries=webview_binaries,
|
||||
datas=[("web", "web"), *webview_datas],
|
||||
hiddenimports=[
|
||||
*webview_hiddenimports,
|
||||
"uvicorn.logging",
|
||||
"uvicorn.loops",
|
||||
"uvicorn.loops.auto",
|
||||
"uvicorn.protocols",
|
||||
"uvicorn.protocols.http",
|
||||
"uvicorn.protocols.http.auto",
|
||||
"uvicorn.protocols.websockets",
|
||||
"uvicorn.protocols.websockets.auto",
|
||||
"uvicorn.lifespan",
|
||||
"uvicorn.lifespan.on",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name="TraderAI.Debug",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
icon="web\\art\\LBC_Logo.ico",
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||
|
||||
|
||||
a = Analysis(
|
||||
["traderai\\desktop.py"],
|
||||
pathex=[],
|
||||
binaries=webview_binaries,
|
||||
datas=[("web", "web"), *webview_datas],
|
||||
hiddenimports=[
|
||||
*webview_hiddenimports,
|
||||
"uvicorn.logging",
|
||||
"uvicorn.loops",
|
||||
"uvicorn.loops.auto",
|
||||
"uvicorn.protocols",
|
||||
"uvicorn.protocols.http",
|
||||
"uvicorn.protocols.http.auto",
|
||||
"uvicorn.protocols.websockets",
|
||||
"uvicorn.protocols.websockets.auto",
|
||||
"uvicorn.lifespan",
|
||||
"uvicorn.lifespan.on",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name="TraderAI",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
icon="web\\art\\LBC_Logo.ico",
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
[project]
|
||||
name = "traderai"
|
||||
version = "0.0.2"
|
||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"pywebview>=5.4",
|
||||
"pydantic>=2.8.0",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
"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*"]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $Root
|
||||
|
||||
$Python = Join-Path $Root ".venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $Python)) {
|
||||
$Python = "python"
|
||||
}
|
||||
|
||||
& $Python -m ensurepip --upgrade
|
||||
& $Python -m pip install -e ".[dev]"
|
||||
& $Python -m PyInstaller --clean "TraderAI.spec"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Built dist\TraderAI.exe"
|
||||
@@ -0,0 +1,29 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^v?\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$')]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||
$cleanVersion = $Version.TrimStart("v")
|
||||
|
||||
$pyprojectPath = Join-Path $repoRoot "pyproject.toml"
|
||||
$versionPath = Join-Path $repoRoot "traderai\version.py"
|
||||
$lockPath = Join-Path $repoRoot "uv.lock"
|
||||
|
||||
$pyproject = Get-Content -LiteralPath $pyprojectPath -Raw
|
||||
$pyproject = $pyproject -replace '(?m)^version = "[^"]+"', "version = `"$cleanVersion`""
|
||||
Set-Content -LiteralPath $pyprojectPath -Value $pyproject -Encoding UTF8
|
||||
|
||||
$versionModule = Get-Content -LiteralPath $versionPath -Raw
|
||||
$versionModule = $versionModule -replace '__version__ = "[^"]+"', "__version__ = `"$cleanVersion`""
|
||||
Set-Content -LiteralPath $versionPath -Value $versionModule -Encoding UTF8
|
||||
|
||||
if (Test-Path -LiteralPath $lockPath) {
|
||||
$lock = Get-Content -LiteralPath $lockPath -Raw
|
||||
$lock = $lock -replace '(?s)(name = "traderai"\s+version = ")[^"]+(")', "`${1}$cleanVersion`${2}"
|
||||
Set-Content -LiteralPath $lockPath -Value $lock -Encoding UTF8
|
||||
}
|
||||
|
||||
Write-Host "TraderAI version set to $cleanVersion"
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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}]}
|
||||
@@ -0,0 +1 @@
|
||||
"""TraderAI application package."""
|
||||
@@ -0,0 +1,412 @@
|
||||
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.
|
||||
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
||||
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
|
||||
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
||||
Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise.
|
||||
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
|
||||
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
|
||||
|
||||
|
||||
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}.",
|
||||
]
|
||||
uex = getattr(self.tools, "uex", None)
|
||||
if uex:
|
||||
auth_methods = []
|
||||
if uex.secret_key:
|
||||
auth_methods.append("secret key")
|
||||
if uex.bearer_token:
|
||||
auth_methods.append("bearer token")
|
||||
if auth_methods:
|
||||
parts.append(
|
||||
"UEX API authentication is configured server-side with "
|
||||
+ " and ".join(auth_methods)
|
||||
+ "; use authenticated UEX tools directly and do not ask for tokens."
|
||||
)
|
||||
else:
|
||||
parts.append("UEX API authentication is not configured server-side.")
|
||||
if self.user_name:
|
||||
parts.append(f"Known user name/handle: {self.user_name}.")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,154 @@
|
||||
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 = 64512
|
||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||
uex_secret_key: str | None = Field(default=None)
|
||||
uex_bearer_token: str | None = Field(default=None)
|
||||
traderai_user_name: str | None = Field(default=None)
|
||||
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
||||
uex_notification_poll_seconds: int = 60
|
||||
require_write_approval: bool = True
|
||||
|
||||
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||
@classmethod
|
||||
def _blank_optional(cls, value: Any) -> Any:
|
||||
return None if value == "" else value
|
||||
|
||||
@field_validator("traderai_memory_path", mode="before")
|
||||
@classmethod
|
||||
def _blank_memory_path(cls, value: Any) -> Any:
|
||||
return str(default_memory_path()) if value == "" or value is None else value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
|
||||
current = settings or get_settings()
|
||||
values = current.model_dump()
|
||||
secrets_configured = {}
|
||||
for key, meta in CONFIG_FIELDS.items():
|
||||
if meta.get("secret"):
|
||||
secrets_configured[key] = bool(values.get(key))
|
||||
values[key] = ""
|
||||
return {
|
||||
"app_data_dir": str(ensure_app_data_dir()),
|
||||
"config_path": str(user_config_path()),
|
||||
"log_path": str(log_path()),
|
||||
"edge_profile_dir": str(edge_profile_dir()),
|
||||
"values": values,
|
||||
"fields": CONFIG_FIELDS,
|
||||
"secrets_configured": secrets_configured,
|
||||
}
|
||||
|
||||
|
||||
def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
||||
current = get_settings().model_dump()
|
||||
next_values = dict(current)
|
||||
for key, value in values.items():
|
||||
if key not in CONFIG_FIELDS:
|
||||
continue
|
||||
if CONFIG_FIELDS[key].get("secret") and value == "":
|
||||
continue
|
||||
next_values[key] = _coerce_value(key, value)
|
||||
|
||||
path = user_config_path()
|
||||
lines = [
|
||||
"# TraderAI desktop configuration",
|
||||
"# Saved by the app. Environment variables still override these values.",
|
||||
"",
|
||||
]
|
||||
for key, meta in CONFIG_FIELDS.items():
|
||||
value = next_values.get(key)
|
||||
lines.append(f"{meta['env']}={_env_value(value)}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
get_settings.cache_clear()
|
||||
return settings_payload(get_settings())
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
field_type = CONFIG_FIELDS[key]["type"]
|
||||
if value == "":
|
||||
return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||
if field_type == "integer":
|
||||
return int(value)
|
||||
if field_type == "boolean":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _env_value(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
text = str(value)
|
||||
if not text or any(char.isspace() for char in text) or "#" in text:
|
||||
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
return text
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import NoReturn
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
|
||||
from traderai.config import edge_profile_dir, log_path
|
||||
|
||||
|
||||
def resource_path(*parts: str) -> Path:
|
||||
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
_chdir_to_app_dir()
|
||||
_log("TraderAI desktop starting")
|
||||
_log(f"cwd={Path.cwd()}")
|
||||
_log(f"executable={sys.executable}")
|
||||
_log(f"frozen={getattr(sys, 'frozen', False)} meipass={getattr(sys, '_MEIPASS', '')}")
|
||||
port = _select_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
_log(f"selected_url={url}")
|
||||
if _existing_server_ready(url):
|
||||
_log("existing TraderAI backend found; opening window")
|
||||
_open_window(url)
|
||||
return
|
||||
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
||||
server_thread.start()
|
||||
_log("backend thread started")
|
||||
_wait_for_server(url)
|
||||
_log("backend health check passed")
|
||||
_open_window(url)
|
||||
_log("webview closed")
|
||||
except Exception:
|
||||
_log("fatal startup error")
|
||||
_log(traceback.format_exc())
|
||||
raise
|
||||
|
||||
|
||||
def _chdir_to_app_dir() -> None:
|
||||
if getattr(sys, "frozen", False):
|
||||
os.chdir(Path(sys.executable).resolve().parent)
|
||||
|
||||
|
||||
def _select_port() -> int:
|
||||
preferred = int(os.getenv("TRADERAI_PORT", "8765"))
|
||||
if _port_available(preferred):
|
||||
return preferred
|
||||
_log(f"preferred port {preferred} is in use")
|
||||
return _free_port()
|
||||
|
||||
|
||||
def _port_available(port: int) -> bool:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _existing_server_ready(url: str) -> bool:
|
||||
try:
|
||||
response = httpx.get(f"{url}/api/health", timeout=1)
|
||||
return response.status_code < 500 and response.headers.get("content-type", "").startswith("application/json")
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
def _run_server(port: int) -> NoReturn:
|
||||
try:
|
||||
_log(f"backend starting on port {port}")
|
||||
from traderai.server import app
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="info",
|
||||
log_config=None,
|
||||
lifespan="on",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
_log("backend server stopped")
|
||||
raise SystemExit(0)
|
||||
except BaseException:
|
||||
_log("backend thread crashed")
|
||||
_log(traceback.format_exc())
|
||||
raise
|
||||
|
||||
|
||||
def _wait_for_server(url: str) -> None:
|
||||
deadline = time.monotonic() + 30
|
||||
last_error = ""
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
response = httpx.get(f"{url}/api/health", timeout=1)
|
||||
_log(f"health probe status={response.status_code}")
|
||||
if response.status_code < 500:
|
||||
return
|
||||
except httpx.HTTPError as exc:
|
||||
last_error = str(exc)
|
||||
_log(f"health probe failed: {last_error}")
|
||||
time.sleep(0.25)
|
||||
raise RuntimeError(f"TraderAI backend did not start within 30 seconds. {last_error}")
|
||||
|
||||
|
||||
def _open_window(url: str) -> None:
|
||||
mode = os.getenv("TRADERAI_DESKTOP_UI", "edge").casefold()
|
||||
_log(f"ui_mode={mode}")
|
||||
if mode == "webview":
|
||||
_open_webview(url)
|
||||
return
|
||||
if _open_edge_app(url):
|
||||
return
|
||||
_open_browser(url)
|
||||
|
||||
|
||||
def _open_webview(url: str) -> None:
|
||||
_log("importing pywebview")
|
||||
import webview
|
||||
|
||||
_log("creating pywebview window")
|
||||
webview.create_window(
|
||||
"TraderAI",
|
||||
url,
|
||||
width=1320,
|
||||
height=860,
|
||||
min_size=(980, 680),
|
||||
text_select=True,
|
||||
icon=str(resource_path("web", "art", "LBC_Logo.ico")),
|
||||
)
|
||||
_log("starting pywebview")
|
||||
webview.start(gui="edgechromium", debug=False)
|
||||
|
||||
|
||||
def _open_edge_app(url: str) -> bool:
|
||||
edge = _edge_path()
|
||||
if not edge:
|
||||
_log("msedge not found; falling back to default browser")
|
||||
return False
|
||||
profile_dir = edge_profile_dir()
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
str(edge),
|
||||
f"--app={url}",
|
||||
f"--user-data-dir={profile_dir}",
|
||||
"--new-window",
|
||||
"--no-first-run",
|
||||
"--disable-features=Translate",
|
||||
f"--app-icon={resource_path('web', 'art', 'LBC_Logo.ico')}",
|
||||
]
|
||||
_log(f"launching edge app: {' '.join(command)}")
|
||||
process = subprocess.Popen(command)
|
||||
_log(f"edge process id={process.pid}")
|
||||
time.sleep(2)
|
||||
if process.poll() is None:
|
||||
process.wait()
|
||||
_log("edge app process exited")
|
||||
return True
|
||||
_log(f"edge app process exited early code={process.returncode}; keeping backend alive")
|
||||
_keep_alive()
|
||||
return True
|
||||
|
||||
|
||||
def _open_browser(url: str) -> None:
|
||||
import webbrowser
|
||||
|
||||
_log(f"opening default browser at {url}")
|
||||
webbrowser.open(url)
|
||||
_keep_alive()
|
||||
|
||||
|
||||
def _keep_alive() -> None:
|
||||
_log("backend staying alive; close TraderAI from Task Manager if no app window owns this process")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def _edge_path() -> Path | None:
|
||||
edge = shutil.which("msedge")
|
||||
if edge:
|
||||
return Path(edge)
|
||||
candidates = [
|
||||
Path(os.environ.get("ProgramFiles", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
||||
Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
||||
Path(os.environ.get("LocalAppData", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
try:
|
||||
log_path = _log_path()
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
with log_path.open("a", encoding="utf-8") as file:
|
||||
file.write(f"[{timestamp}] {message}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_path() -> Path:
|
||||
return log_path()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.0.2"
|
||||
|
||||
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||
|
||||
|
||||
|
||||
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 = {
|
||||
uex_base_url: "config-uex-base-url",
|
||||
uex_secret_key: "config-uex-secret-key",
|
||||
uex_bearer_token: "config-uex-bearer-token",
|
||||
traderai_user_name: "config-traderai-user-name",
|
||||
traderai_memory_path: "config-traderai-memory-path",
|
||||
uex_notification_poll_seconds: "config-uex-notification-poll-seconds",
|
||||
require_write_approval: "config-require-write-approval",
|
||||
};
|
||||
|
||||
const ollamaFieldIds = {
|
||||
ollama_base_url: "ollama-base-url",
|
||||
ollama_model: "ollama-model",
|
||||
ollama_num_ctx: "ollama-num-ctx",
|
||||
};
|
||||
|
||||
async function refreshConfig() {
|
||||
try {
|
||||
const response = await fetch("/api/config");
|
||||
const config = await response.json();
|
||||
renderConfig(config);
|
||||
} catch (error) {
|
||||
configStatusEl.textContent = `Config load failed: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConfig(config) {
|
||||
const values = config.values || {};
|
||||
const secretsConfigured = config.secrets_configured || {};
|
||||
for (const [key, id] of Object.entries(configFieldIds)) {
|
||||
const field = document.getElementById(id);
|
||||
if (!field) continue;
|
||||
if (field.type === "checkbox") {
|
||||
field.checked = Boolean(values[key]);
|
||||
} else if (field.type === "password") {
|
||||
field.value = "";
|
||||
field.placeholder = secretsConfigured[key] ? "Configured" : "";
|
||||
} else {
|
||||
field.value = values[key] ?? "";
|
||||
}
|
||||
}
|
||||
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
||||
const field = document.getElementById(id);
|
||||
if (!field) continue;
|
||||
field.value = values[key] ?? "";
|
||||
}
|
||||
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
|
||||
configStatusEl.textContent = "";
|
||||
}
|
||||
|
||||
async function saveConfig(event) {
|
||||
event.preventDefault();
|
||||
const values = {};
|
||||
for (const [key, id] of Object.entries(configFieldIds)) {
|
||||
const field = document.getElementById(id);
|
||||
if (!field) continue;
|
||||
values[key] = field.type === "checkbox" ? field.checked : field.value;
|
||||
}
|
||||
configStatusEl.textContent = "Saving";
|
||||
try {
|
||||
const response = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
const result = await response.json();
|
||||
renderConfig(result);
|
||||
configStatusEl.textContent = result.message || "Saved";
|
||||
addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply.");
|
||||
} catch (error) {
|
||||
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOllamaConfig(event) {
|
||||
event.preventDefault();
|
||||
const values = {};
|
||||
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
||||
const field = document.getElementById(id);
|
||||
if (!field) continue;
|
||||
values[key] = field.value;
|
||||
}
|
||||
setOllamaMessage("Saving Ollama config");
|
||||
try {
|
||||
const response = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
const result = await response.json();
|
||||
renderConfig(result);
|
||||
setOllamaMessage(result.message || "Saved");
|
||||
await refreshOllamaStatus();
|
||||
} catch (error) {
|
||||
setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOllamaStatus() {
|
||||
if (!ollamaStatusEl) return;
|
||||
ollamaStatusEl.textContent = "Checking Ollama";
|
||||
try {
|
||||
const response = await fetch("/api/ollama/status");
|
||||
const status = await response.json();
|
||||
renderOllamaStatus(status);
|
||||
} catch (error) {
|
||||
ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOllamaStatus(status) {
|
||||
if (!ollamaStatusEl) return;
|
||||
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
||||
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
|
||||
ollamaStatusEl.innerHTML = `
|
||||
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
|
||||
<div class="ollama-status-grid">
|
||||
${ollamaStatusItem("Installed", status.installed ? "Yes" : "No")}
|
||||
${ollamaStatusItem("Running", status.running ? "Yes" : "No")}
|
||||
${ollamaStatusItem("Model", status.configured_model || "")}
|
||||
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
|
||||
${ollamaStatusItem("URL", status.base_url || "")}
|
||||
${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")}
|
||||
</div>
|
||||
${ollamaStatusItem("Installed Models", models)}
|
||||
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
||||
`;
|
||||
if (ollamaInstallButton) ollamaInstallButton.disabled = Boolean(status.installed);
|
||||
if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
||||
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
||||
}
|
||||
|
||||
function ollamaStatusItem(label, value) {
|
||||
return `<div class="ollama-status-item"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(String(value ?? ""))}</span></div>`;
|
||||
}
|
||||
|
||||
function setOllamaMessage(message) {
|
||||
if (ollamaMessageEl) ollamaMessageEl.textContent = message || "";
|
||||
}
|
||||
|
||||
async function postOllamaAction(endpoint, options = {}) {
|
||||
setOllamaMessage("Working");
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: options.body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||
setOllamaMessage(result.message || "Done");
|
||||
await refreshOllamaStatus();
|
||||
} catch (error) {
|
||||
setOllamaMessage(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredOllamaModel() {
|
||||
return document.getElementById("ollama-model")?.value || "";
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
if (!updateStatusEl) return;
|
||||
updateStatusEl.textContent = "Checking releases";
|
||||
try {
|
||||
const response = await fetch("/api/update/check");
|
||||
const result = await response.json();
|
||||
latestUpdate = result;
|
||||
renderUpdateStatus(result);
|
||||
} catch (error) {
|
||||
updateStatusEl.textContent = `Update check failed: ${error.message}`;
|
||||
if (updateInstallButton) updateInstallButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpdateStatus(update) {
|
||||
if (!updateStatusEl) return;
|
||||
const lines = [
|
||||
`Current: ${update.current_version || "unknown"}`,
|
||||
`Latest: ${update.latest_version || "unknown"}`,
|
||||
update.message || "",
|
||||
].filter(Boolean);
|
||||
if (update.available && !update.asset_download_url) {
|
||||
lines.push("The release needs a TraderAI.exe attachment before the app can self-update.");
|
||||
}
|
||||
if (update.available && update.asset_download_url && !update.packaged) {
|
||||
lines.push("Self-update runs from the packaged desktop exe.");
|
||||
}
|
||||
updateStatusEl.textContent = lines.join("\n");
|
||||
if (updateInstallButton) {
|
||||
updateInstallButton.disabled = !update.available || !update.asset_download_url || !update.packaged;
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!updateStatusEl) return;
|
||||
updateStatusEl.textContent = "Downloading update";
|
||||
try {
|
||||
const response = await fetch("/api/update/install", { method: "POST" });
|
||||
const result = await response.json();
|
||||
latestUpdate = result;
|
||||
renderUpdateStatus(result);
|
||||
} catch (error) {
|
||||
updateStatusEl.textContent = `Update failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function openReleasesPage() {
|
||||
const url = latestUpdate?.release_url || "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases";
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
}
|
||||
|
||||
function toggleSidebarPanel(panelName) {
|
||||
const panels = {
|
||||
settings: { panel: settingsPanel, button: settingsToggle },
|
||||
memory: { panel: memoryPanel, button: memoryToggle },
|
||||
ollama: { panel: ollamaPanel, button: ollamaToggle },
|
||||
};
|
||||
const target = panels[panelName];
|
||||
if (!target?.panel || !target?.button) return;
|
||||
const shouldOpen = target.panel.hidden;
|
||||
for (const item of Object.values(panels)) {
|
||||
if (!item.panel || !item.button) continue;
|
||||
item.panel.hidden = true;
|
||||
item.button.classList.remove("active");
|
||||
item.button.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
if (shouldOpen) {
|
||||
target.panel.hidden = false;
|
||||
target.button.classList.add("active");
|
||||
target.button.setAttribute("aria-expanded", "true");
|
||||
if (panelName === "settings") {
|
||||
refreshConfig();
|
||||
checkForUpdate();
|
||||
}
|
||||
if (panelName === "memory") refreshMemory();
|
||||
if (panelName === "ollama") {
|
||||
refreshConfig();
|
||||
refreshOllamaStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
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 |
@@ -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 |
+126
@@ -0,0 +1,126 @@
|
||||
<!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>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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user