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