Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6bd1e81a51
|
|||
|
a5a718b3e4
|
|||
|
7b65b62f58
|
|||
|
97c751c585
|
|||
|
e2f87481d6
|
|||
|
d6c2d57fd9
|
|||
|
71638fcaed
|
|||
|
767e929bf9
|
|||
|
3b6e3c34d5
|
|||
|
58a57ddc6a
|
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
|
MODEL_PROVIDER=ollama
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
OLLAMA_MODEL=qwen3.5:9b
|
OLLAMA_MODEL=qwen3.5:9b
|
||||||
OLLAMA_NUM_CTX=64000
|
OLLAMA_NUM_CTX=64512
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-5.3-codex
|
||||||
|
OPENAI_API_KEY=
|
||||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||||
|
SCMDB_BASE_URL=https://scmdb.net
|
||||||
|
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
||||||
UEX_SECRET_KEY=
|
UEX_SECRET_KEY=
|
||||||
UEX_BEARER_TOKEN=
|
UEX_BEARER_TOKEN=
|
||||||
TRADERAI_USER_NAME=
|
TRADERAI_USER_NAME=
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# TraderAI
|
# TraderAI
|
||||||
|
|
||||||
Local Ollama-powered chat for UEX marketplace workflows.
|
Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
|
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
|
||||||
|
- Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo.
|
||||||
|
- Searches Cornerstone Universal Item Finder data so the assistant can find where in-game items are sold, including store/location, base price, and verified date.
|
||||||
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
|
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
|
||||||
- Drafts negotiation messages and marketplace listings as pending actions.
|
- Drafts negotiation messages and marketplace listings as pending actions.
|
||||||
- Requires browser approval before sending authenticated write requests to UEX.
|
- Requires browser approval before sending authenticated write requests to UEX.
|
||||||
@@ -23,6 +25,9 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
||||||
|
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.3-codex`.
|
||||||
|
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||||
|
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||||
4. Install and run:
|
4. Install and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -34,7 +39,7 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64000` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
|
Ollama runs locally at `http://localhost:11434` by default. This app can talk to either Ollama's native chat API or OpenAI's Chat Completions 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
|
## Releases And Updates
|
||||||
|
|
||||||
@@ -71,6 +76,8 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
|
|||||||
## Sources Used
|
## Sources Used
|
||||||
|
|
||||||
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
|
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
|
||||||
|
- SCMDB mission data: https://scmdb.net/
|
||||||
|
- Cornerstone Universal Item Finder: https://finder.cstone.space/
|
||||||
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=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
|
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
|
||||||
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
|
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
Add an option to use API: marketplace_trends as a tool for the AI from UEX
|
||||||
|
Closed negotiations (not open): All other negotiations show date_closed > 0 or were marked as closed by client or Riggs0. find a more relaiable way to mark them as closed/open negotations for the AI
|
||||||
|
some times when having a long tool call the assiant will not reply what it did/an awnser see ---
|
||||||
|
|
||||||
|
2026-05-06T19:41:17.786390+00:00 assistant
|
||||||
|
Looking at the historical price data for **Wikelo Favor**, here's what happened recently:
|
||||||
|
|
||||||
|
## Recent Historical Price Activity (Last Few Hours)
|
||||||
|
|
||||||
|
**Sell Orders (asking prices):**
|
||||||
|
- **1,500,000,000 UEC** (two listings at the same time)
|
||||||
|
- **1,000,000,000 UEC** (one listing)
|
||||||
|
|
||||||
|
**Buy Orders (bidding prices):**
|
||||||
|
- **300,000,000 UEC** (multiple buy orders)
|
||||||
|
|
||||||
|
**Current Active Listings:**
|
||||||
|
- **500,000,000 UEC** per unit (9 in stock)
|
||||||
|
- **1,000,000,000 UEC** per stack (5 in stock)
|
||||||
|
|
||||||
|
## Price Range Analysis
|
||||||
|
|
||||||
|
The market has been fluctuating significantly:
|
||||||
|
- **Lowest recent price:** 300M UEC (buy orders)
|
||||||
|
- **Highest recent price:** 1.5B UEC (sell orders)
|
||||||
|
- **Current active listings:** 500M - 1B UEC
|
||||||
|
|
||||||
|
**What This Means for Pricing:**
|
||||||
|
If you're looking to sell your Wikelo Favors competitively, I'd recommend pricing between **500M - 800M UEC** to match the current market, which is more realistic than the high-end 1.5B UEC listings. The 300M UEC buy orders suggest that's where many players are willing to bid, so anything between 300M-500M UEC might actually sell quickly.
|
||||||
|
|
||||||
|
Would you like me to search for more specific listings or help you create a listing at a particular price point?
|
||||||
|
2026-05-06T19:40:51.966297+00:00 user
|
||||||
|
What happened
|
||||||
|
2026-05-06T19:38:27.178306+00:00 assistant
|
||||||
|
2026-05-06T19:37:52.825713+00:00 user
|
||||||
|
I want you to use the history tools and show me what they were selling for recently so I know what to price mine at
|
||||||
|
|
||||||
|
|
||||||
|
----
|
||||||
|
I want the user to beable to see and directly respond to negotations in the app seperate the ai so it should work like this, when a negotation is refrenced the user should be able to click on it and a floating block wiht the chat messages and an area for the user to type one to the other party,
|
||||||
|
|
||||||
|
I want the message box for the user to be forced to the bottom of the viewport, right now when their are no messages if moves up to the top then its sticky on the bottom when messages happen
|
||||||
|
|
||||||
|
I want the users messages to right aligned and not have abunch of empty space to the right or left of it.
|
||||||
|
|
||||||
|
when I approve messages they dont send I see
|
||||||
|
|
||||||
|
Approval result:
|
||||||
|
|
||||||
|
{
|
||||||
|
|
||||||
|
"status": "ok",
|
||||||
|
|
||||||
|
"http_code": 200,
|
||||||
|
|
||||||
|
"data": {
|
||||||
|
|
||||||
|
"id_message": "0"
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
"message": "Sandbox mode"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
and an error that a python dll is missing from the exe.
|
||||||
|
|
||||||
|
|
||||||
|
I want a default collapsed sidebar that has chats, they are to be like chat gpt chats where memories persist across them and the chat history is confied to them. I want the users to beable to delete them with a trash can icon, and I want them saved to disk.
|
||||||
|
|
||||||
|
|
||||||
|
the wake jobs may not be working properly, I scehudled one and
|
||||||
|
I see I'll check your UEX notifications and handle your open negotiations now!
|
||||||
|
but I dont see an output for this job. to fix this I want to have an Inbox that the AI can send messages to. It should be the output of the wake job and have the ablity to continue the coversation in a new chat.
|
||||||
|
|
||||||
|
make a popup that prompts a user to update on open when a new update is avaible.
|
||||||
+9
-1
@@ -1,14 +1,22 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
import sysconfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||||
|
|
||||||
|
python_binaries = []
|
||||||
|
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
|
||||||
|
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
|
||||||
|
if dll_path.exists():
|
||||||
|
python_binaries.append((str(dll_path), "."))
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
["traderai\\desktop.py"],
|
["traderai\\desktop.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=webview_binaries,
|
binaries=[*webview_binaries, *python_binaries],
|
||||||
datas=[("web", "web"), *webview_datas],
|
datas=[("web", "web"), *webview_datas],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
*webview_hiddenimports,
|
*webview_hiddenimports,
|
||||||
|
|||||||
+9
-1
@@ -1,14 +1,22 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
import sysconfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||||
|
|
||||||
|
python_binaries = []
|
||||||
|
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
|
||||||
|
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
|
||||||
|
if dll_path.exists():
|
||||||
|
python_binaries.append((str(dll_path), "."))
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
["traderai\\desktop.py"],
|
["traderai\\desktop.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=webview_binaries,
|
binaries=[*webview_binaries, *python_binaries],
|
||||||
datas=[("web", "web"), *webview_datas],
|
datas=[("web", "web"), *webview_datas],
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
*webview_hiddenimports,
|
*webview_hiddenimports,
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.1"
|
version = "0.0.6"
|
||||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -35,3 +35,8 @@ include = ["traderai*"]
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
@@ -12,6 +13,130 @@ class EmptyTools:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class WakeTools(EmptyTools):
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def execute(self, name, arguments):
|
||||||
|
self.calls.append((name, arguments))
|
||||||
|
return {"count": 1, "notifications": [{"message": "Buyer replied"}]}
|
||||||
|
|
||||||
|
|
||||||
|
class WakeAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", WakeTools(), memory=memory)
|
||||||
|
self.responses = [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "check_uex_notifications",
|
||||||
|
"arguments": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"message": {"role": "assistant", "content": "I checked notifications: Buyer replied."}},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _ollama_chat(self, *args, **kwargs):
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class TitleAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _generate_chat_title(self, first_message):
|
||||||
|
return "UEX Market Check"
|
||||||
|
|
||||||
|
async def _ollama_chat(self, *args, **kwargs):
|
||||||
|
return {"message": {"role": "assistant", "content": "Done"}}
|
||||||
|
|
||||||
|
|
||||||
|
class ImageCaptureAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
|
||||||
|
self.last_messages = None
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _chat_once(self, query="", messages=None, **kwargs):
|
||||||
|
self.last_messages = messages
|
||||||
|
return {"message": {"role": "assistant", "content": "Seen"}}
|
||||||
|
|
||||||
|
|
||||||
|
class SlowToolTools(EmptyTools):
|
||||||
|
schemas = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "slow_tool",
|
||||||
|
"description": "Slow fake tool.",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def execute(self, name, arguments):
|
||||||
|
self.calls += 1
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return {"status": "ok", "value": "slow result"}
|
||||||
|
|
||||||
|
|
||||||
|
class SlowStreamingAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", SlowToolTools(), memory=memory)
|
||||||
|
self.stream_calls = 0
|
||||||
|
|
||||||
|
async def health(self):
|
||||||
|
return {"online": True, "model": "test", "base_url": self.base_url}
|
||||||
|
|
||||||
|
async def _ollama_chat_stream(self, *args, **kwargs):
|
||||||
|
self.stream_calls += 1
|
||||||
|
if self.stream_calls == 1:
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
yield {"message": {"role": "assistant", "content": ""}, "done": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FailingAfterToolAgent(SlowStreamingAgent):
|
||||||
|
async def _ollama_chat_stream(self, *args, **kwargs):
|
||||||
|
self.stream_calls += 1
|
||||||
|
if self.stream_calls == 1:
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
raise RuntimeError("ollama timed out")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chat_events_warns_when_ollama_offline():
|
async def test_chat_events_warns_when_ollama_offline():
|
||||||
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
|
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
|
||||||
@@ -90,3 +215,73 @@ def test_ollama_options_include_num_ctx():
|
|||||||
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
|
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
|
||||||
|
|
||||||
assert agent._ollama_options() == {"num_ctx": 64000}
|
assert agent._ollama_options() == {"num_ctx": 64000}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_response_executes_tool_calls(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = WakeAgent(memory)
|
||||||
|
|
||||||
|
response = await agent.generate_wake_response("Scheduled wake job fired. Check notifications.")
|
||||||
|
|
||||||
|
assert response == "I checked notifications: Buyer replied."
|
||||||
|
assert agent.tools.calls == [("check_uex_notifications", {})]
|
||||||
|
wake_rows = memory.recent_conversation(thread_id="wake")
|
||||||
|
assert wake_rows[-1]["content"] == "I checked notifications: Buyer replied."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_first_chat_message_generates_thread_title(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
thread = memory.create_thread()
|
||||||
|
agent = TitleAgent(memory)
|
||||||
|
|
||||||
|
result = await agent.chat("Check UEX market listings", thread_id=thread["id"])
|
||||||
|
|
||||||
|
assert result["message"] == "Done"
|
||||||
|
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_includes_pasted_images_and_memory_note(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = ImageCaptureAgent(memory)
|
||||||
|
|
||||||
|
result = await agent.chat(
|
||||||
|
"",
|
||||||
|
images=[{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["message"] == "Seen"
|
||||||
|
user_message = next(message for message in reversed(agent.last_messages) if message.get("role") == "user")
|
||||||
|
assert user_message["images"] == ["ZmFrZS1pbWFnZQ=="]
|
||||||
|
assert user_message["content"] == "Please analyze the attached image."
|
||||||
|
assert "[Attached 1 pasted image]" in memory.recent_conversation()[-2]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = SlowStreamingAgent(memory)
|
||||||
|
|
||||||
|
events = [event async for event in agent.chat_events("run a slow tool")]
|
||||||
|
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
|
||||||
|
|
||||||
|
assert agent.tools.calls == 1
|
||||||
|
assert "I completed the tool call" in text
|
||||||
|
assert "slow result" in text
|
||||||
|
assert events[-1]["type"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_events_returns_tool_result_when_model_fails_after_slow_tool(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = FailingAfterToolAgent(memory)
|
||||||
|
|
||||||
|
events = [event async for event in agent.chat_events("run a slow tool")]
|
||||||
|
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
|
||||||
|
|
||||||
|
assert agent.tools.calls == 1
|
||||||
|
assert "local model stopped after the tool call" in text
|
||||||
|
assert "slow result" in text
|
||||||
|
assert events[-1]["type"] == "done"
|
||||||
|
|||||||
@@ -25,3 +25,33 @@ def test_memory_store_clear_selected_sections(tmp_path):
|
|||||||
assert snapshot["memories"] == []
|
assert snapshot["memories"] == []
|
||||||
assert snapshot["conversations"] == []
|
assert snapshot["conversations"] == []
|
||||||
assert snapshot["profile"][0]["key"] == "configured_name"
|
assert snapshot["profile"][0]["key"] == "configured_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_separates_chat_threads_but_keeps_shared_memories(tmp_path):
|
||||||
|
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
first = store.create_thread("First")
|
||||||
|
second = store.create_thread("Second")
|
||||||
|
store.add_conversation("user", "first thread message", first["id"])
|
||||||
|
store.add_conversation("user", "second thread message", second["id"])
|
||||||
|
store.remember("preference", "Shared trading preference", importance=5)
|
||||||
|
|
||||||
|
first_rows = store.recent_conversation(thread_id=first["id"])
|
||||||
|
second_rows = store.recent_conversation(thread_id=second["id"])
|
||||||
|
|
||||||
|
assert [row["content"] for row in first_rows] == ["first thread message"]
|
||||||
|
assert [row["content"] for row in second_rows] == ["second thread message"]
|
||||||
|
assert store.recall("trading preference")[0]["content"] == "Shared trading preference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
|
||||||
|
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
thread = store.create_thread("New chat")
|
||||||
|
store.add_outbox("Wake job result")
|
||||||
|
inbox_id = store.list_outbox()[0]["id"]
|
||||||
|
|
||||||
|
renamed = store.rename_thread(thread["id"], " Market Check ")
|
||||||
|
deleted = store.delete_outbox(inbox_id)
|
||||||
|
|
||||||
|
assert renamed["title"] == "Market Check"
|
||||||
|
assert deleted is True
|
||||||
|
assert store.list_outbox() == []
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, utc_now
|
||||||
|
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||||
|
from traderai.scheduler import WakeScheduler
|
||||||
|
from traderai.tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class BuyingUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
|
async def get(self, path, params=None, authenticated=False):
|
||||||
|
if path == "marketplace_listings":
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 501,
|
||||||
|
"slug": "wikelo-panel-good",
|
||||||
|
"title": "Wikelo Idris panel",
|
||||||
|
"operation": "sell",
|
||||||
|
"type": "item",
|
||||||
|
"price": 450_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 2,
|
||||||
|
"location": "Orison",
|
||||||
|
"user_username": "seller_a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 502,
|
||||||
|
"slug": "wikelo-panel-expensive",
|
||||||
|
"title": "Wikelo Idris panel premium",
|
||||||
|
"operation": "sell",
|
||||||
|
"type": "item",
|
||||||
|
"price": 900_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 1,
|
||||||
|
"location": "Area18",
|
||||||
|
"user_username": "seller_b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
async def post(self, path, payload, authenticated=True):
|
||||||
|
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
async def delete(self, path, params=None, authenticated=True):
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlanAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.prompts = []
|
||||||
|
|
||||||
|
async def generate_wake_response(self, wake_message):
|
||||||
|
self.prompts.append(wake_message)
|
||||||
|
return "Custom plan checked notifications and found no blockers."
|
||||||
|
|
||||||
|
|
||||||
|
def plan_stack(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
store = ContinualPlanStore(memory)
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
tools = ToolRegistry(BuyingUEX(), memory=memory, scheduler=scheduler, plan_store=store)
|
||||||
|
runner = ContinualPlanRunner(store, tools, memory)
|
||||||
|
tools.plan_runner = runner
|
||||||
|
scheduler.bind_plan_runner(runner)
|
||||||
|
return memory, store, tools, runner, scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_creates_needs_input_plan(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan("Wikelo Idris", objective="Get all parts", items=[])
|
||||||
|
|
||||||
|
assert plan["status"] == "needs_input"
|
||||||
|
assert plan["items"] == []
|
||||||
|
assert plan["events"][0]["kind"] == "needs_input"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_plan_without_items_is_active(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan("Watch negotiations", kind="custom", objective="Check replies and summarize next steps", items=[])
|
||||||
|
|
||||||
|
assert plan["status"] == "active"
|
||||||
|
assert plan["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_creates_buying_checklist(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 2, "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan["status"] == "active"
|
||||||
|
assert plan["items"][0]["item_name"] == "Wikelo Idris panel"
|
||||||
|
assert plan["items"][0]["desired_quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
|
||||||
|
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1, "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.run_plan(plan["id"])
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["drafted"] == 1
|
||||||
|
assert any(candidate["listing_id"] == "501" and candidate["status"] == "drafted" for candidate in snapshot["candidates"])
|
||||||
|
assert snapshot["negotiations"][0]["status"] == "drafted"
|
||||||
|
assert len(tools.pending_actions) == 1
|
||||||
|
assert not tools.uex.posts
|
||||||
|
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plan_approval_logs_back_to_plan(tmp_path):
|
||||||
|
_, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
await runner.run_plan(plan["id"])
|
||||||
|
action_id = next(iter(tools.pending_actions))
|
||||||
|
|
||||||
|
approved = await tools.approve(action_id)
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
|
||||||
|
assert any(event["kind"] == "approved" for event in snapshot["events"])
|
||||||
|
assert any(negotiation["status"] == "approved" for negotiation in snapshot["negotiations"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_custom_runner_continues_plan_through_agent(tmp_path):
|
||||||
|
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
agent = FakePlanAgent()
|
||||||
|
runner.bind_agent(agent)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Watch open negotiations",
|
||||||
|
kind="custom",
|
||||||
|
objective="Check UEX replies and recommend next action",
|
||||||
|
constraints={"instructions": "Pay attention to stale buyer replies."},
|
||||||
|
items=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.run_plan(plan["id"])
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "Custom plan checked notifications" in result["summary"]
|
||||||
|
assert plan["id"] in agent.prompts[0]
|
||||||
|
assert any(event["kind"] == "run" for event in snapshot["events"])
|
||||||
|
assert "Custom plan checked notifications" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduler_plan_run_survives_runner_error(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
store = ContinualPlanStore(memory)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Broken plan",
|
||||||
|
objective="Test failure handling",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
class FailingRunner:
|
||||||
|
def __init__(self, store):
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
async def run_plan(self, plan_id):
|
||||||
|
self.store.add_event(plan_id, "error", "boom")
|
||||||
|
memory.add_outbox("Broken plan: boom")
|
||||||
|
return {"error": "boom", "plan": self.store.get_plan(plan_id)}
|
||||||
|
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_plan_runner(FailingRunner(store))
|
||||||
|
|
||||||
|
await scheduler._run_plan(plan["id"])
|
||||||
|
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
assert snapshot["status"] == "active"
|
||||||
|
assert snapshot["events"][0]["kind"] == "error"
|
||||||
|
assert "boom" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
|
||||||
|
memory, store, _, runner, scheduler = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Overdue plan",
|
||||||
|
objective="Check after restart",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
store.update_schedule(plan["id"], (utc_now() - timedelta(minutes=5)).isoformat())
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
try:
|
||||||
|
catchup = scheduler.scheduler.get_job(scheduler._plan_catchup_job_id(plan["id"]))
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
finally:
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
assert catchup is not None
|
||||||
|
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from traderai.agent import OllamaAgent
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
|
|
||||||
@@ -31,6 +32,102 @@ class FakeUEXNotifications:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FailingUEXNotifications:
|
||||||
|
async def get_user_notifications(self):
|
||||||
|
raise RuntimeError("bad token")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWakeAgent:
|
||||||
|
async def generate_wake_response(self, wake_message):
|
||||||
|
return f"Wake output: {wake_message}"
|
||||||
|
|
||||||
|
|
||||||
|
class ListingWakeTools:
|
||||||
|
schemas = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_marketplace_listings",
|
||||||
|
"description": "Search active UEX marketplace listings.",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.pending_actions = {}
|
||||||
|
|
||||||
|
async def execute(self, name, arguments):
|
||||||
|
self.calls.append((name, arguments))
|
||||||
|
return {
|
||||||
|
"count": 2,
|
||||||
|
"listings": [
|
||||||
|
{
|
||||||
|
"id": 100,
|
||||||
|
"title": "Wikelo Favor",
|
||||||
|
"operation": "sell",
|
||||||
|
"price": 500_000_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 9,
|
||||||
|
"advertiser": "pilot_a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"title": "Wikelo Favor stack",
|
||||||
|
"operation": "sell",
|
||||||
|
"price": 1_000_000_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 5,
|
||||||
|
"advertiser": "pilot_b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ListingWakeAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.listing_tools = ListingWakeTools()
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", self.listing_tools, memory=memory)
|
||||||
|
self.responses = [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "search_marketplace_listings",
|
||||||
|
"arguments": {
|
||||||
|
"query": "Wikelo Favor",
|
||||||
|
"operation": "sell",
|
||||||
|
"limit": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (
|
||||||
|
"Listing check complete: found 2 active Wikelo Favor sell listings. "
|
||||||
|
"Cheapest listing is 500,000,000 UEC with 9 in stock; the next listing is "
|
||||||
|
"1,000,000,000 UEC. Suggested next action: price near 500,000,000 UEC "
|
||||||
|
"if you want to move yours quickly."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _ollama_chat(self, *args, **kwargs):
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
|
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
|
||||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
@@ -45,3 +142,52 @@ async def test_poll_uex_notifications_adds_unread_once(tmp_path):
|
|||||||
assert second == []
|
assert second == []
|
||||||
assert len(outbox) == 1
|
assert len(outbox) == 1
|
||||||
assert "A buyer replied to your listing." in outbox[0]["content"]
|
assert "A buyer replied to your listing." in outbox[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_uex_notifications_reports_failures_to_outbox(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_uex_notifications(FailingUEXNotifications())
|
||||||
|
|
||||||
|
result = await scheduler.poll_uex_notifications()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
assert "bad token" in memory.inspect()["outbox"][0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_job_writes_agent_output_to_outbox_and_disables_one_shot(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_agent(FakeWakeAgent())
|
||||||
|
memory.add_job("wake-test", "check notifications", "date", "2099-01-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
await scheduler._run_job("wake-test", "check notifications")
|
||||||
|
snapshot = memory.inspect()
|
||||||
|
|
||||||
|
assert "Wake output:" in snapshot["outbox"][0]["content"]
|
||||||
|
assert snapshot["scheduled_jobs"][0]["enabled"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_job_checks_listings_and_writes_analysis_to_outbox(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
agent = ListingWakeAgent(memory)
|
||||||
|
scheduler.bind_agent(agent)
|
||||||
|
memory.add_job("wake-listings", "check Wikelo Favor listings and analyze the market", "date", "2099-01-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
await scheduler._run_job("wake-listings", "check Wikelo Favor listings and analyze the market")
|
||||||
|
snapshot = memory.inspect()
|
||||||
|
content = snapshot["outbox"][0]["content"]
|
||||||
|
|
||||||
|
assert agent.listing_tools.calls == [
|
||||||
|
(
|
||||||
|
"search_marketplace_listings",
|
||||||
|
{"query": "Wikelo Favor", "operation": "sell", "limit": 5},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert "Listing check complete" in content
|
||||||
|
assert "500,000,000 UEC" in content
|
||||||
|
assert "Suggested next action" in content
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import pytest
|
|||||||
import respx
|
import respx
|
||||||
from httpx import Response
|
from httpx import Response
|
||||||
|
|
||||||
|
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
|
|
||||||
|
|
||||||
class FakeUEX:
|
class FakeUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
async def get(self, path, params=None, authenticated=False):
|
async def get(self, path, params=None, authenticated=False):
|
||||||
if path == "commodities_prices_history":
|
if path == "commodities_prices_history":
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +117,147 @@ class FakeUEX:
|
|||||||
async def delete(self, path, params=None, authenticated=True):
|
async def delete(self, path, params=None, authenticated=True):
|
||||||
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}}
|
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}}
|
||||||
|
|
||||||
|
async def post(self, path, payload, authenticated=True):
|
||||||
|
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSCMDB:
|
||||||
|
base_url = "https://scmdb.test"
|
||||||
|
|
||||||
|
async def list_versions(self):
|
||||||
|
return [
|
||||||
|
{"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"},
|
||||||
|
{"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_data(self, version=None, channel="live"):
|
||||||
|
return {
|
||||||
|
"version": version or "4.7.2-live.1",
|
||||||
|
"factions": {
|
||||||
|
"fac-haul": {"name": "Covalex"},
|
||||||
|
"fac-bounty": {"name": "Bounty Hunters Guild"},
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"scope-rep": {"scopeName": "FactionReputation"},
|
||||||
|
},
|
||||||
|
"factionRewardsPools": [
|
||||||
|
[{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}],
|
||||||
|
[{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}],
|
||||||
|
],
|
||||||
|
"partialRewardPayoutPools": [
|
||||||
|
[],
|
||||||
|
[{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}],
|
||||||
|
],
|
||||||
|
"resourcePools": {
|
||||||
|
"res-tungsten": {"name": "Tungsten"},
|
||||||
|
},
|
||||||
|
"blueprintPools": {
|
||||||
|
"bp-pool": {
|
||||||
|
"name": "Ship Salvage Rewards",
|
||||||
|
"blueprints": [{"name": "Abrade Scraper Module"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"locationPools": {
|
||||||
|
"loc-a18": {"name": "Area18"},
|
||||||
|
"loc-baijini": {"name": "Baijini Point"},
|
||||||
|
},
|
||||||
|
"contracts": [
|
||||||
|
{
|
||||||
|
"id": "mission-haul",
|
||||||
|
"debugName": "Haul_Tungsten_Test",
|
||||||
|
"title": "Move Tungsten",
|
||||||
|
"description": "Move Tungsten to Baijini Point.",
|
||||||
|
"missionType": "Hauling",
|
||||||
|
"category": "career",
|
||||||
|
"factionGuid": "fac-haul",
|
||||||
|
"rewardUEC": 50250,
|
||||||
|
"factionRewardsIndex": 0,
|
||||||
|
"partialRewardPayoutIndex": 1,
|
||||||
|
"haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}],
|
||||||
|
"locations": ["loc-a18"],
|
||||||
|
"destinations": ["loc-baijini"],
|
||||||
|
"systems": ["Stanton"],
|
||||||
|
"illegal": False,
|
||||||
|
"canBeShared": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mission-bounty",
|
||||||
|
"debugName": "Bounty_Blueprint_Test",
|
||||||
|
"title": "Ambush Op",
|
||||||
|
"description": "Clean out targets.",
|
||||||
|
"missionType": "Bounty Hunter",
|
||||||
|
"factionGuid": "fac-bounty",
|
||||||
|
"rewardUEC": 120000,
|
||||||
|
"factionRewardsIndex": 1,
|
||||||
|
"partialRewardPayoutIndex": 0,
|
||||||
|
"itemRewards": [{"name": "Council Scrip", "amount": 5}],
|
||||||
|
"blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}],
|
||||||
|
"systems": ["Pyro"],
|
||||||
|
"illegal": True,
|
||||||
|
"canBeShared": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"legacyContracts": [
|
||||||
|
{
|
||||||
|
"id": "legacy-delivery",
|
||||||
|
"debugName": "Legacy_Delivery_Test",
|
||||||
|
"title": "Old Box Run",
|
||||||
|
"missionType": "Delivery",
|
||||||
|
"factionGuid": "fac-haul",
|
||||||
|
"rewardUEC": 1000,
|
||||||
|
"factionRewardsIndex": 0,
|
||||||
|
"partialRewardPayoutIndex": 0,
|
||||||
|
"systems": ["Stanton"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCornerstone:
|
||||||
|
base_url = "https://finder.cstone.test"
|
||||||
|
|
||||||
|
async def list_items(self):
|
||||||
|
return [
|
||||||
|
{"id": "item-abrade", "name": "Abrade Scraper Module", "sold": True},
|
||||||
|
{"id": "item-cinch", "name": "Cinch Scraper Module", "sold": True},
|
||||||
|
{"id": "item-poster", "name": "Zeus 2955 Ship Showdown Poster", "sold": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_item_page(self, item_id):
|
||||||
|
assert item_id == "item-abrade"
|
||||||
|
return {
|
||||||
|
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
||||||
|
"html": """
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
|
||||||
|
<meta property="og:image" content="/images/abrade.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
||||||
|
<tr><td>MANUFACTURER</td><td>Greycat Industrial</td></tr>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
|
<tr><td>Stanton - ArcCorp - Area18 - Dumper's Depot</td><td>21 250</td><td>2956-01-29</td></tr>
|
||||||
|
<tr><td>Stanton - microTech - Port Tressler - Platinum Bay</td><td>21 250</td><td>2956-01-04</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_image_data(self, url, max_bytes=10_000_000):
|
||||||
|
assert url == f"{self.base_url}/images/abrade.png"
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"content_type": "image/png",
|
||||||
|
"size_bytes": 12,
|
||||||
|
"image_data": "ZmFrZS1pbWFnZQ==",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_marketplace_listings_filters_locally():
|
async def test_search_marketplace_listings_filters_locally():
|
||||||
@@ -145,6 +290,19 @@ async def test_decline_pending_action_removes_without_sending():
|
|||||||
assert action_id not in registry.pending_actions
|
assert action_id not in registry.pending_actions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_negotiation_message_forces_production_send():
|
||||||
|
fake = FakeUEX()
|
||||||
|
registry = ToolRegistry(fake)
|
||||||
|
result = await registry.draft_negotiation_message(hash="abc", message="Ready to close", is_production=0)
|
||||||
|
action_id = result["pending_action"]["id"]
|
||||||
|
|
||||||
|
approved = await registry.approve(action_id)
|
||||||
|
|
||||||
|
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
|
||||||
|
assert approved["posted"]["payload"]["is_production"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_uex_client_uses_bearer_and_secret_headers():
|
def test_uex_client_uses_bearer_and_secret_headers():
|
||||||
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
|
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
|
||||||
|
|
||||||
@@ -216,6 +374,187 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
|
|||||||
assert "uex_draft_post" not in names
|
assert "uex_draft_post" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_scmdb_mission_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "list_scmdb_versions" in names
|
||||||
|
assert "search_scmdb_missions" in names
|
||||||
|
assert "get_scmdb_mission_rewards" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_cornerstone_item_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "search_cornerstone_items" in names
|
||||||
|
assert "get_cornerstone_item_locations" in names
|
||||||
|
assert "get_cornerstone_item_media" in names
|
||||||
|
assert "draft_marketplace_listing_with_cornerstone_image" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_scmdb_missions_returns_reward_summary():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling")
|
||||||
|
|
||||||
|
assert result["version"] == "4.7.2-live.1"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
mission = result["missions"][0]
|
||||||
|
assert mission["title"] == "Move Tungsten"
|
||||||
|
assert mission["rewards"]["uec"] == 50250
|
||||||
|
assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}]
|
||||||
|
assert mission["rewards"]["hauling"] == [
|
||||||
|
{"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test")
|
||||||
|
|
||||||
|
mission = result["mission"]
|
||||||
|
assert mission["title"] == "Ambush Op"
|
||||||
|
assert mission["faction"] == "Bounty Hunters Guild"
|
||||||
|
assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}]
|
||||||
|
assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_cornerstone_items_filters_sold_items():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.search_cornerstone_items(query="scraper", sold_only=True)
|
||||||
|
|
||||||
|
assert result["matched"] == 2
|
||||||
|
assert {item["name"] for item in result["items"]} == {"Abrade Scraper Module", "Cinch Scraper Module"}
|
||||||
|
assert result["items"][0]["url"].startswith("https://finder.cstone.test/Search/item-")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cornerstone_item_locations_parses_store_prices():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.get_cornerstone_item_locations(query="abrade", location="Area18")
|
||||||
|
|
||||||
|
assert result["item"]["name"] == "Abrade Scraper Module"
|
||||||
|
assert result["item"]["general"]["manufacturer"] == "Greycat Industrial"
|
||||||
|
assert result["matched_locations"] == 1
|
||||||
|
assert result["locations"] == [
|
||||||
|
{
|
||||||
|
"location": "Stanton - ArcCorp - Area18 - Dumper's Depot",
|
||||||
|
"base_price": 21250,
|
||||||
|
"base_price_display": "21 250",
|
||||||
|
"verified": "2956-01-29",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cornerstone_item_media_returns_absolute_image_urls():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.get_cornerstone_item_media(query="abrade")
|
||||||
|
|
||||||
|
assert result["media"] == [
|
||||||
|
{
|
||||||
|
"url": "https://finder.cstone.test/images/abrade.png",
|
||||||
|
"source": "og:image",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.draft_marketplace_listing_with_cornerstone_image(
|
||||||
|
item_query="abrade",
|
||||||
|
id_category=3,
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
unit="unit",
|
||||||
|
title="Abrade Scraper Module",
|
||||||
|
description="Clean module, ready for pickup.",
|
||||||
|
price=21250,
|
||||||
|
currency="UEC",
|
||||||
|
language="en_US",
|
||||||
|
source="purchased_in_game",
|
||||||
|
in_stock=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
stored = registry.pending_actions[pending["id"]]
|
||||||
|
|
||||||
|
assert pending["endpoint"] == "marketplace_advertise"
|
||||||
|
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
|
||||||
|
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
|
||||||
|
assert pending["metadata"]["cornerstone_image_status"] == "included"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_marketplace_listing_can_reuse_pasted_chat_image():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
with registry.chat_image_scope([{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}]):
|
||||||
|
result = await registry.draft_marketplace_listing(
|
||||||
|
id_category=3,
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
unit="unit",
|
||||||
|
title="Abrade Scraper Module",
|
||||||
|
description="Clean module, ready for pickup.",
|
||||||
|
price=21250,
|
||||||
|
currency="UEC",
|
||||||
|
language="en_US",
|
||||||
|
use_attached_image=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
stored = registry.pending_actions[pending["id"]]
|
||||||
|
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
|
||||||
|
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
|
||||||
|
assert pending["metadata"]["attached_chat_image_name"] == "listing.png"
|
||||||
|
assert pending["metadata"]["attached_chat_image_status"] == "included"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_cornerstone_item_page_extracts_locations():
|
||||||
|
parsed = parse_cornerstone_item_page(
|
||||||
|
"""
|
||||||
|
<html><head><title>Star Citizen - Food - Whamburger</title><meta property="og:image" content="/img/wham.png"></head>
|
||||||
|
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
|
||||||
|
<img src="https://example.test/extra.png" alt="Whamburger">
|
||||||
|
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
|
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
|
||||||
|
""",
|
||||||
|
"https://finder.cstone.test/Search/item-wham",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed["name"] == "Whamburger"
|
||||||
|
assert parsed["locations"][0]["base_price"] == 9
|
||||||
|
assert parsed["media"][0]["url"] == "https://finder.cstone.test/img/wham.png"
|
||||||
|
assert parsed["media"][1]["url"] == "https://example.test/extra.png"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@respx.mock
|
||||||
|
async def test_cornerstone_client_accepts_json_encoded_string_payload():
|
||||||
|
respx.get("https://finder.cstone.space/GetSearch").mock(
|
||||||
|
return_value=Response(
|
||||||
|
200,
|
||||||
|
json='[{"id":"item-1","name":"Abrade Scraper Module","Sold":1}]',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = CornerstoneClient("https://finder.cstone.space")
|
||||||
|
|
||||||
|
assert await client.list_items() == [{"id": "item-1", "name": "Abrade Scraper Module", "sold": True}]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_uex_api_index_finds_history_tools():
|
async def test_search_uex_api_index_finds_history_tools():
|
||||||
registry = ToolRegistry(FakeUEX())
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|||||||
+674
-83
@@ -1,23 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import nullcontext
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone
|
||||||
|
|
||||||
from traderai.memory import MemoryStore, iso_now, iso_now_in_zone, time_since
|
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
||||||
Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
|
Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
|
||||||
|
Use continual plan tools when the user asks for multi-day or recurring marketplace work, such as finding several parts, watching for deals, tracking candidates, or coordinating negotiations over time.
|
||||||
|
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.
|
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.
|
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.
|
||||||
|
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
||||||
|
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
||||||
|
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
||||||
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.
|
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.
|
Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise.
|
||||||
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
|
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
|
||||||
|
For continual plans, never invent an unknown parts checklist. If the required items cannot be derived from provided details or tools, create the plan in a needs-input state and say what item list is missing.
|
||||||
|
When a scheduled wake job fires, always write a concise Inbox-ready result that says what you checked, the key findings, and the suggested next action.
|
||||||
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
|
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +39,8 @@ class OllamaAgent:
|
|||||||
memory: MemoryStore | None = None,
|
memory: MemoryStore | None = None,
|
||||||
user_name: str | None = None,
|
user_name: str | None = None,
|
||||||
num_ctx: int | None = None,
|
num_ctx: int | None = None,
|
||||||
|
provider: str = "ollama",
|
||||||
|
api_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -37,9 +48,13 @@ class OllamaAgent:
|
|||||||
self.memory = memory
|
self.memory = memory
|
||||||
self.user_name = user_name
|
self.user_name = user_name
|
||||||
self.num_ctx = num_ctx
|
self.num_ctx = num_ctx
|
||||||
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
self.provider = provider.strip().casefold() or "ollama"
|
||||||
|
self.api_key = api_key
|
||||||
|
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
async def health(self) -> dict[str, Any]:
|
||||||
|
if self.provider == "openai":
|
||||||
|
return await self._openai_health()
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=3) as client:
|
async with httpx.AsyncClient(timeout=3) as client:
|
||||||
response = await client.get(f"{self.base_url}/api/tags")
|
response = await client.get(f"{self.base_url}/api/tags")
|
||||||
@@ -50,7 +65,7 @@ class OllamaAgent:
|
|||||||
"online": False,
|
"online": False,
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"base_url": self.base_url,
|
"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.",
|
"message": f"Ollama is offline or unreachable at {self.base_url}. Open the Ollama tab and use the recommended action.",
|
||||||
"detail": str(exc),
|
"detail": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,107 +84,275 @@ class OllamaAgent:
|
|||||||
if not health["online"]:
|
if not health["online"]:
|
||||||
raise OllamaUnavailable(health["message"])
|
raise OllamaUnavailable(health["message"])
|
||||||
|
|
||||||
async def chat(self, content: str) -> dict[str, Any]:
|
async def chat(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
images: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
await self.ensure_available()
|
await self.ensure_available()
|
||||||
previous_interaction = self.memory.last_interaction() if self.memory else None
|
resolved_thread_id = self._thread_id(thread_id)
|
||||||
|
messages = self._messages_for_thread(resolved_thread_id)
|
||||||
|
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
|
||||||
|
normalized_images = self._normalize_images(images)
|
||||||
|
prompt_text = self._prompt_text(content, len(normalized_images))
|
||||||
|
memory_content = self._conversation_content(content, len(normalized_images))
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("user", content)
|
self.memory.add_conversation("user", memory_content, resolved_thread_id)
|
||||||
self.messages.append({"role": "user", "content": content})
|
await self._title_first_message(resolved_thread_id, prompt_text, previous_interaction)
|
||||||
for _ in range(5):
|
messages.append(self._user_message(prompt_text, normalized_images))
|
||||||
response = await self._ollama_chat(content, previous_interaction=previous_interaction)
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
message = response.get("message") or {}
|
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
||||||
tool_calls = message.get("tool_calls") or []
|
with image_scope:
|
||||||
if not tool_calls:
|
for _ in range(10):
|
||||||
self.messages.append({"role": "assistant", "content": message.get("content", "")})
|
try:
|
||||||
if self.memory:
|
response = await self._chat_once(
|
||||||
self.memory.add_conversation("assistant", message.get("content", ""))
|
prompt_text,
|
||||||
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()}
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
self.messages.append(message)
|
thread_id=resolved_thread_id,
|
||||||
for call in tool_calls:
|
)
|
||||||
name, arguments = self._extract_call(call)
|
except Exception as exc:
|
||||||
result = await self.tools.execute(name, arguments)
|
if not last_tool_results:
|
||||||
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
raise
|
||||||
|
answer = self._tool_result_fallback(
|
||||||
|
last_tool_results,
|
||||||
|
f"The {self._provider_label()} stopped after the tool call: {exc}",
|
||||||
|
)
|
||||||
|
messages.append({"role": "assistant", "content": answer})
|
||||||
|
if self.memory:
|
||||||
|
self.memory.add_conversation("assistant", answer, resolved_thread_id)
|
||||||
|
return {"message": answer, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
message = response.get("message") or {}
|
||||||
|
tool_calls = message.get("tool_calls") or []
|
||||||
|
if not tool_calls:
|
||||||
|
answer = message.get("content", "")
|
||||||
|
if not answer.strip():
|
||||||
|
answer = self._empty_response_fallback(last_tool_results)
|
||||||
|
messages.append({"role": "assistant", "content": answer})
|
||||||
|
if self.memory:
|
||||||
|
self.memory.add_conversation("assistant", answer, resolved_thread_id)
|
||||||
|
return {"message": answer, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
|
||||||
|
messages.append(message)
|
||||||
|
for call in tool_calls:
|
||||||
|
name, arguments = self._extract_call(call)
|
||||||
|
result = await self.tools.execute(name, arguments)
|
||||||
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "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."
|
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})
|
messages.append({"role": "assistant", "content": fallback})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("assistant", fallback)
|
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||||
return {"message": fallback, "pending_actions": self._pending_payloads()}
|
return {"message": fallback, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
|
||||||
async def chat_events(self, content: str) -> AsyncIterator[dict[str, Any]]:
|
async def chat_events(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
images: list[dict[str, Any]] | None = None,
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
health = await self.health()
|
health = await self.health()
|
||||||
if not health["online"]:
|
if not health["online"]:
|
||||||
yield {"type": "warning", "message": health["message"]}
|
yield {"type": "warning", "message": health["message"]}
|
||||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||||
return
|
return
|
||||||
|
|
||||||
previous_interaction = self.memory.last_interaction() if self.memory else None
|
resolved_thread_id = self._thread_id(thread_id)
|
||||||
|
messages = self._messages_for_thread(resolved_thread_id)
|
||||||
|
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
|
||||||
|
normalized_images = self._normalize_images(images)
|
||||||
|
prompt_text = self._prompt_text(content, len(normalized_images))
|
||||||
|
memory_content = self._conversation_content(content, len(normalized_images))
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("user", content)
|
self.memory.add_conversation("user", memory_content, resolved_thread_id)
|
||||||
self.messages.append({"role": "user", "content": content})
|
await self._title_first_message(resolved_thread_id, prompt_text, previous_interaction)
|
||||||
|
messages.append(self._user_message(prompt_text, normalized_images))
|
||||||
yield {"type": "status", "message": "Thinking"}
|
yield {"type": "status", "message": "Thinking"}
|
||||||
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
|
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
||||||
|
with image_scope:
|
||||||
|
for _ in range(10):
|
||||||
|
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
||||||
|
tool_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for _ in range(5):
|
try:
|
||||||
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
async for event in self._chat_stream_once(
|
||||||
tool_calls: list[dict[str, Any]] = []
|
prompt_text,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=resolved_thread_id,
|
||||||
|
):
|
||||||
|
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}
|
||||||
|
except Exception as exc:
|
||||||
|
if not last_tool_results:
|
||||||
|
yield {"type": "warning", "message": f"Chat failed before any tool result was available: {exc}"}
|
||||||
|
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
return
|
||||||
|
fallback = self._tool_result_fallback(
|
||||||
|
last_tool_results,
|
||||||
|
f"The {self._provider_label()} stopped after the tool call: {exc}",
|
||||||
|
)
|
||||||
|
assistant_message["content"] = fallback
|
||||||
|
messages.append(assistant_message)
|
||||||
|
if self.memory:
|
||||||
|
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||||
|
yield {"type": "token", "content": fallback}
|
||||||
|
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
return
|
||||||
|
|
||||||
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction):
|
if not tool_calls:
|
||||||
message = event.get("message") or {}
|
if not assistant_message.get("content", "").strip():
|
||||||
chunk = message.get("content") or ""
|
fallback = self._empty_response_fallback(last_tool_results)
|
||||||
if chunk:
|
assistant_message["content"] = fallback
|
||||||
assistant_message["content"] += chunk
|
yield {"type": "token", "content": fallback}
|
||||||
yield {"type": "token", "content": chunk}
|
messages.append(assistant_message)
|
||||||
if message.get("tool_calls"):
|
if self.memory:
|
||||||
tool_calls.extend(message["tool_calls"])
|
self.memory.add_conversation("assistant", assistant_message.get("content", ""), resolved_thread_id)
|
||||||
if event.get("done"):
|
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
metrics = self._stream_metrics(event)
|
return
|
||||||
if metrics:
|
|
||||||
yield {"type": "metrics", **metrics}
|
|
||||||
|
|
||||||
if not tool_calls:
|
assistant_message["tool_calls"] = tool_calls
|
||||||
self.messages.append(assistant_message)
|
messages.append(assistant_message)
|
||||||
if self.memory:
|
for call in tool_calls:
|
||||||
self.memory.add_conversation("assistant", assistant_message.get("content", ""))
|
name, arguments = self._extract_call(call)
|
||||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
yield {"type": "status", "message": self._tool_status(name)}
|
||||||
return
|
result = await self.tools.execute(name, arguments)
|
||||||
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
assistant_message["tool_calls"] = tool_calls
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
||||||
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"}
|
|
||||||
|
|
||||||
|
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."
|
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})
|
messages.append({"role": "assistant", "content": fallback})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("assistant", fallback)
|
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||||
yield {"type": "token", "content": fallback}
|
yield {"type": "token", "content": fallback}
|
||||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
|
||||||
async def generate_wake_response(self, wake_message: str) -> str:
|
async def generate_wake_response(self, wake_message: str) -> str:
|
||||||
await self.ensure_available()
|
await self.ensure_available()
|
||||||
self.messages.append({"role": "user", "content": wake_message})
|
messages = self._messages_for_thread("wake")
|
||||||
response = await self._ollama_chat(wake_message)
|
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
||||||
message = response.get("message") or {}
|
messages.append({"role": "user", "content": wake_message})
|
||||||
content = message.get("content", "")
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
self.messages.append({"role": "assistant", "content": content})
|
for _ in range(10):
|
||||||
if self.memory:
|
try:
|
||||||
self.memory.add_conversation("system", wake_message)
|
response = await self._chat_once(
|
||||||
self.memory.add_conversation("assistant", content)
|
wake_message,
|
||||||
return content or wake_message
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id="wake",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if not last_tool_results:
|
||||||
|
raise
|
||||||
|
content = self._tool_result_fallback(
|
||||||
|
last_tool_results,
|
||||||
|
f"The {self._provider_label()} stopped after the wake-job tool call: {exc}",
|
||||||
|
)
|
||||||
|
messages.append({"role": "assistant", "content": content})
|
||||||
|
if self.memory:
|
||||||
|
self.memory.add_conversation("system", wake_message, "wake")
|
||||||
|
self.memory.add_conversation("assistant", content, "wake")
|
||||||
|
return content
|
||||||
|
message = response.get("message") or {}
|
||||||
|
tool_calls = message.get("tool_calls") or []
|
||||||
|
if not tool_calls:
|
||||||
|
content = message.get("content", "")
|
||||||
|
if not content.strip():
|
||||||
|
content = self._empty_response_fallback(last_tool_results)
|
||||||
|
messages.append({"role": "assistant", "content": content})
|
||||||
|
if self.memory:
|
||||||
|
self.memory.add_conversation("system", wake_message, "wake")
|
||||||
|
self.memory.add_conversation("assistant", content, "wake")
|
||||||
|
return content
|
||||||
|
|
||||||
async def _ollama_chat(self, query: str = "", previous_interaction: dict[str, Any] | None = None) -> dict[str, Any]:
|
messages.append(message)
|
||||||
|
for call in tool_calls:
|
||||||
|
name, arguments = self._extract_call(call)
|
||||||
|
result = await self.tools.execute(name, arguments)
|
||||||
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
||||||
|
content = "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
|
||||||
|
messages.append({"role": "assistant", "content": content})
|
||||||
|
if self.memory:
|
||||||
|
self.memory.add_conversation("system", wake_message, "wake")
|
||||||
|
self.memory.add_conversation("assistant", content, "wake")
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def _chat_once(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if self.provider == "openai":
|
||||||
|
return await self._openai_chat(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
return await self._ollama_chat(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _chat_stream_once(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
if self.provider == "openai":
|
||||||
|
async for event in self._openai_chat_stream(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
return
|
||||||
|
async for event in self._ollama_chat_stream(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
|
||||||
|
async def _ollama_chat(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/api/chat",
|
f"{self.base_url}/api/chat",
|
||||||
json={
|
json={
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
"messages": self._messages_with_context(
|
||||||
|
query,
|
||||||
|
messages or self._messages_for_thread(thread_id),
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
"tools": self.tools.schemas,
|
"tools": self.tools.schemas,
|
||||||
"options": self._ollama_options(),
|
"options": self._ollama_options(),
|
||||||
"stream": False,
|
"stream": False,
|
||||||
@@ -181,7 +364,9 @@ class OllamaAgent:
|
|||||||
async def _ollama_chat_stream(
|
async def _ollama_chat_stream(
|
||||||
self,
|
self,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
@@ -189,7 +374,12 @@ class OllamaAgent:
|
|||||||
f"{self.base_url}/api/chat",
|
f"{self.base_url}/api/chat",
|
||||||
json={
|
json={
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
"messages": self._messages_with_context(
|
||||||
|
query,
|
||||||
|
messages or self._messages_for_thread(thread_id),
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
"tools": self.tools.schemas,
|
"tools": self.tools.schemas,
|
||||||
"options": self._ollama_options(),
|
"options": self._ollama_options(),
|
||||||
"stream": True,
|
"stream": True,
|
||||||
@@ -200,21 +390,265 @@ class OllamaAgent:
|
|||||||
if line:
|
if line:
|
||||||
yield json.loads(line)
|
yield json.loads(line)
|
||||||
|
|
||||||
|
async def _openai_chat(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=self._openai_headers(),
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": self._openai_messages(
|
||||||
|
query,
|
||||||
|
messages or self._messages_for_thread(thread_id),
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
"tools": self.tools.schemas,
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
choice = (body.get("choices") or [{}])[0]
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
return {
|
||||||
|
"message": {
|
||||||
|
"role": message.get("role", "assistant"),
|
||||||
|
"content": message.get("content") or "",
|
||||||
|
"tool_calls": message.get("tool_calls") or [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _openai_chat_stream(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
tool_calls: dict[int, dict[str, Any]] = {}
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=self._openai_headers(),
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": self._openai_messages(
|
||||||
|
query,
|
||||||
|
messages or self._messages_for_thread(thread_id),
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
),
|
||||||
|
"tools": self.tools.schemas,
|
||||||
|
"stream": True,
|
||||||
|
},
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line or not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
payload = line.removeprefix("data:").strip()
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
if payload == "[DONE]":
|
||||||
|
break
|
||||||
|
event = json.loads(payload)
|
||||||
|
choice = (event.get("choices") or [{}])[0]
|
||||||
|
delta = choice.get("delta") or {}
|
||||||
|
content = delta.get("content") or ""
|
||||||
|
if content:
|
||||||
|
yield {"message": {"role": "assistant", "content": content}}
|
||||||
|
for tool_call in delta.get("tool_calls") or []:
|
||||||
|
self._merge_openai_tool_call(tool_calls, tool_call)
|
||||||
|
finish_reason = choice.get("finish_reason")
|
||||||
|
if finish_reason:
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": self._ordered_tool_calls(tool_calls),
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": self._ordered_tool_calls(tool_calls),
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
|
||||||
def _messages_with_context(
|
def _messages_with_context(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
context = self._runtime_context(query, previous_interaction=previous_interaction)
|
attached_image_count = 0
|
||||||
|
for message in reversed(messages):
|
||||||
|
if message.get("role") != "user":
|
||||||
|
continue
|
||||||
|
attached_image_count = len(message.get("images") or [])
|
||||||
|
break
|
||||||
|
context = self._runtime_context(
|
||||||
|
query,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
attached_image_count=attached_image_count,
|
||||||
|
)
|
||||||
if not context:
|
if not context:
|
||||||
return self.messages
|
return messages
|
||||||
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]]
|
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||||
|
|
||||||
def _runtime_context(self, query: str, previous_interaction: dict[str, Any] | None = None) -> str:
|
async def _openai_health(self) -> dict[str, Any]:
|
||||||
|
if not self.api_key:
|
||||||
|
return {
|
||||||
|
"online": False,
|
||||||
|
"model": self.model,
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"provider": "openai",
|
||||||
|
"model_available": False,
|
||||||
|
"models": [],
|
||||||
|
"message": "OpenAI is selected, but no OpenAI API key is configured.",
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/models", headers=self._openai_headers())
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
return {
|
||||||
|
"online": False,
|
||||||
|
"model": self.model,
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"provider": "openai",
|
||||||
|
"model_available": False,
|
||||||
|
"models": [],
|
||||||
|
"message": f"OpenAI is unreachable at {self.base_url} or rejected the API key.",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
||||||
|
return {
|
||||||
|
"online": True,
|
||||||
|
"model": self.model,
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"provider": "openai",
|
||||||
|
"model_available": self.model in models,
|
||||||
|
"models": models,
|
||||||
|
"message": "OpenAI is online.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _openai_headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.api_key or ''}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _openai_messages(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for message in self._messages_with_context(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
):
|
||||||
|
role = message.get("role")
|
||||||
|
if role not in {"system", "user", "assistant", "tool"}:
|
||||||
|
continue
|
||||||
|
entry: dict[str, Any] = {"role": role, "content": message.get("content", "")}
|
||||||
|
if role == "user" and message.get("images"):
|
||||||
|
text_content = message.get("content", "")
|
||||||
|
content_parts: list[dict[str, Any]] = []
|
||||||
|
content_types = list(message.get("image_content_types") or [])
|
||||||
|
if text_content:
|
||||||
|
content_parts.append({"type": "text", "text": text_content})
|
||||||
|
for index, image_data in enumerate(message.get("images") or []):
|
||||||
|
content_type = content_types[index] if index < len(content_types) else "image/png"
|
||||||
|
content_parts.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:{content_type};base64,{image_data}"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entry["content"] = content_parts
|
||||||
|
if role == "assistant" and message.get("tool_calls"):
|
||||||
|
entry["tool_calls"] = message["tool_calls"]
|
||||||
|
if role == "tool":
|
||||||
|
entry["tool_call_id"] = message.get("tool_call_id") or message.get("tool_name") or "tool"
|
||||||
|
normalized.append(entry)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _provider_label(self) -> str:
|
||||||
|
return "OpenAI model" if self.provider == "openai" else "local model"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
||||||
|
index = int(delta.get("index") or 0)
|
||||||
|
current = target.setdefault(index, {"id": delta.get("id"), "type": "function", "function": {"name": "", "arguments": ""}})
|
||||||
|
if delta.get("id"):
|
||||||
|
current["id"] = delta["id"]
|
||||||
|
function = delta.get("function") or {}
|
||||||
|
current_function = current.setdefault("function", {"name": "", "arguments": ""})
|
||||||
|
if function.get("name"):
|
||||||
|
current_function["name"] += function["name"]
|
||||||
|
if function.get("arguments"):
|
||||||
|
current_function["arguments"] += function["arguments"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ordered_tool_calls(tool_calls: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [tool_calls[index] for index in sorted(tool_calls)]
|
||||||
|
|
||||||
|
def _runtime_context(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
attached_image_count: int = 0,
|
||||||
|
) -> str:
|
||||||
local_zone = get_localzone()
|
local_zone = get_localzone()
|
||||||
parts = [
|
parts = [
|
||||||
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
||||||
]
|
]
|
||||||
|
if attached_image_count:
|
||||||
|
label = "image" if attached_image_count == 1 else "images"
|
||||||
|
parts.append(
|
||||||
|
f"Current user message includes {attached_image_count} pasted {label}. "
|
||||||
|
"You can inspect them visually. If the user wants one reused in a marketplace listing draft, "
|
||||||
|
"call draft_marketplace_listing or draft_marketplace_listing_with_cornerstone_image with "
|
||||||
|
"use_attached_image=true and attached_image_index when needed."
|
||||||
|
)
|
||||||
|
uex = getattr(self.tools, "uex", None)
|
||||||
|
if uex:
|
||||||
|
auth_methods = []
|
||||||
|
if uex.secret_key:
|
||||||
|
auth_methods.append("secret key")
|
||||||
|
if uex.bearer_token:
|
||||||
|
auth_methods.append("bearer token")
|
||||||
|
if auth_methods:
|
||||||
|
parts.append(
|
||||||
|
"UEX API authentication is configured server-side with "
|
||||||
|
+ " and ".join(auth_methods)
|
||||||
|
+ "; use authenticated UEX tools directly and do not ask for tokens."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parts.append("UEX API authentication is not configured server-side.")
|
||||||
if self.user_name:
|
if self.user_name:
|
||||||
parts.append(f"Known user name/handle: {self.user_name}.")
|
parts.append(f"Known user name/handle: {self.user_name}.")
|
||||||
|
|
||||||
@@ -228,7 +662,7 @@ class OllamaAgent:
|
|||||||
parts.append(identity)
|
parts.append(identity)
|
||||||
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
|
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()
|
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction(thread_id)
|
||||||
if last:
|
if last:
|
||||||
parts.append(
|
parts.append(
|
||||||
f"Previous interaction before this message: {last['created_at']} "
|
f"Previous interaction before this message: {last['created_at']} "
|
||||||
@@ -245,16 +679,103 @@ class OllamaAgent:
|
|||||||
)
|
)
|
||||||
parts.append(f"Relevant long-term memories:\n{memory_text}")
|
parts.append(f"Relevant long-term memories:\n{memory_text}")
|
||||||
|
|
||||||
recent = self.memory.recent_conversation(limit=6)
|
recent = self.memory.recent_conversation(limit=6, thread_id=thread_id)
|
||||||
if recent:
|
if recent:
|
||||||
recent_text = "\n".join(
|
recent_text = "\n".join(
|
||||||
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
|
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
|
||||||
for item in recent
|
for item in recent
|
||||||
)
|
)
|
||||||
parts.append(f"Recent conversation excerpts:\n{recent_text}")
|
parts.append(f"Recent conversation excerpts from this chat:\n{recent_text}")
|
||||||
|
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
|
||||||
|
resolved_thread_id = self._thread_id(thread_id)
|
||||||
|
if resolved_thread_id not in self.thread_messages:
|
||||||
|
messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
if self.memory:
|
||||||
|
self.memory.ensure_thread(resolved_thread_id)
|
||||||
|
for item in self.memory.recent_conversation(limit=30, thread_id=resolved_thread_id):
|
||||||
|
role = item.get("role")
|
||||||
|
if role in {"user", "assistant"} and item.get("content"):
|
||||||
|
messages.append({"role": role, "content": item["content"]})
|
||||||
|
self.thread_messages[resolved_thread_id] = messages
|
||||||
|
return self.thread_messages[resolved_thread_id]
|
||||||
|
|
||||||
|
async def _title_first_message(
|
||||||
|
self,
|
||||||
|
thread_id: str,
|
||||||
|
first_message: str,
|
||||||
|
previous_interaction: dict[str, Any] | None,
|
||||||
|
) -> None:
|
||||||
|
if self.memory is None or previous_interaction is not None:
|
||||||
|
return
|
||||||
|
thread = self.memory.get_thread(thread_id)
|
||||||
|
if not thread or thread.get("title") != "New chat":
|
||||||
|
return
|
||||||
|
title = await self._generate_chat_title(first_message)
|
||||||
|
self.memory.rename_thread(thread_id, title or MemoryStore._thread_title(first_message))
|
||||||
|
|
||||||
|
async def _generate_chat_title(self, first_message: str) -> str:
|
||||||
|
prompt = (
|
||||||
|
"Create a concise chat title for this first user message. "
|
||||||
|
"Use 2 to 6 words. No quotes, no punctuation at the end, no preamble.\n\n"
|
||||||
|
f"Message: {first_message[:800]}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if self.provider == "openai":
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=self._openai_headers(),
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You write short chat titles."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
choice = (response.json().get("choices") or [{}])[0]
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
return self._clean_generated_title(message.get("content", ""))
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You write short chat titles."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"options": self._ollama_options(),
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
message = response.json().get("message") or {}
|
||||||
|
return self._clean_generated_title(message.get("content", ""))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _thread_id(thread_id: str | None) -> str:
|
||||||
|
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_generated_title(title: str) -> str:
|
||||||
|
text = re.sub(r"[\r\n]+", " ", title).strip().strip('"').strip("'")
|
||||||
|
text = re.sub(r"^(title|chat title)\s*:\s*", "", text, flags=re.IGNORECASE).strip()
|
||||||
|
text = text.rstrip(".!?;:-").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
words = text.split()
|
||||||
|
if len(words) > 8:
|
||||||
|
text = " ".join(words[:8])
|
||||||
|
return text[:64]
|
||||||
|
|
||||||
def _pending_payloads(self) -> list[dict[str, Any]]:
|
def _pending_payloads(self) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -262,7 +783,8 @@ class OllamaAgent:
|
|||||||
"label": action.label,
|
"label": action.label,
|
||||||
"method": action.method,
|
"method": action.method,
|
||||||
"endpoint": action.endpoint,
|
"endpoint": action.endpoint,
|
||||||
"payload": action.payload,
|
"payload": self.tools._display_payload(action.payload) if hasattr(self.tools, "_display_payload") else action.payload,
|
||||||
|
"metadata": action.metadata or {},
|
||||||
}
|
}
|
||||||
for action in self.tools.pending_actions.values()
|
for action in self.tools.pending_actions.values()
|
||||||
]
|
]
|
||||||
@@ -272,6 +794,27 @@ class OllamaAgent:
|
|||||||
return {}
|
return {}
|
||||||
return {"num_ctx": self.num_ctx}
|
return {"num_ctx": self.num_ctx}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _empty_response_fallback(tool_results: list[dict[str, Any]]) -> str:
|
||||||
|
if not tool_results:
|
||||||
|
return "I did not get a usable response from the model. Please try again, or narrow the request a bit."
|
||||||
|
return OllamaAgent._tool_result_fallback(
|
||||||
|
tool_results,
|
||||||
|
"I completed the tool call, but the model did not write a final answer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_result_fallback(tool_results: list[dict[str, Any]], reason: str) -> str:
|
||||||
|
last = tool_results[-1]
|
||||||
|
text = json.dumps(last, indent=2, ensure_ascii=True)
|
||||||
|
if len(text) > 1800:
|
||||||
|
text = text[:1800] + "\n..."
|
||||||
|
return (
|
||||||
|
f"{reason} "
|
||||||
|
"Here is the last tool result so you are not left staring at a blank response:\n\n"
|
||||||
|
f"```json\n{text}\n```"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tool_status(name: str) -> str:
|
def _tool_status(name: str) -> str:
|
||||||
if name.startswith("get_uex_"):
|
if name.startswith("get_uex_"):
|
||||||
@@ -285,6 +828,12 @@ class OllamaAgent:
|
|||||||
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
|
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
|
||||||
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
|
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
|
||||||
"summarize_uex_currency_index_history": "Summarizing currency index history",
|
"summarize_uex_currency_index_history": "Summarizing currency index history",
|
||||||
|
"list_scmdb_versions": "Checking SCMDB versions",
|
||||||
|
"search_scmdb_missions": "Searching SCMDB missions",
|
||||||
|
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
||||||
|
"search_cornerstone_items": "Searching Cornerstone items",
|
||||||
|
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||||
|
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
||||||
"uex_api_catalog": "Checking UEX API catalog",
|
"uex_api_catalog": "Checking UEX API catalog",
|
||||||
"uex_get": "Fetching UEX data",
|
"uex_get": "Fetching UEX data",
|
||||||
"uex_draft_post": "Drafting UEX write for approval",
|
"uex_draft_post": "Drafting UEX write for approval",
|
||||||
@@ -295,6 +844,7 @@ class OllamaAgent:
|
|||||||
"get_negotiation_messages": "Reading negotiation messages",
|
"get_negotiation_messages": "Reading negotiation messages",
|
||||||
"draft_negotiation_message": "Drafting message for approval",
|
"draft_negotiation_message": "Drafting message for approval",
|
||||||
"draft_marketplace_listing": "Drafting listing for approval",
|
"draft_marketplace_listing": "Drafting listing for approval",
|
||||||
|
"draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image",
|
||||||
"check_uex_notifications": "Checking UEX notifications",
|
"check_uex_notifications": "Checking UEX notifications",
|
||||||
}
|
}
|
||||||
return labels.get(name, f"Running {name}")
|
return labels.get(name, f"Running {name}")
|
||||||
@@ -391,6 +941,47 @@ class OllamaAgent:
|
|||||||
arguments = json.loads(arguments or "{}")
|
arguments = json.loads(arguments or "{}")
|
||||||
return name, arguments
|
return name, arguments
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_images(images: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for image in images or []:
|
||||||
|
if not isinstance(image, dict):
|
||||||
|
continue
|
||||||
|
image_data = str(image.get("image_data") or "").strip()
|
||||||
|
if not image_data:
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"name": str(image.get("name") or "").strip() or "pasted-image.png",
|
||||||
|
"content_type": str(image.get("content_type") or "image/png").strip() or "image/png",
|
||||||
|
"image_data": image_data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prompt_text(content: str, image_count: int) -> str:
|
||||||
|
text = content.strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return "Please analyze the attached image." if image_count == 1 else "Please analyze the attached images."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _conversation_content(content: str, image_count: int) -> str:
|
||||||
|
text = content.strip()
|
||||||
|
if not image_count:
|
||||||
|
return text
|
||||||
|
note = f"[Attached {image_count} pasted image{'s' if image_count != 1 else ''}]"
|
||||||
|
return f"{text}\n\n{note}" if text else note
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_message(content: str, images: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
message: dict[str, Any] = {"role": "user", "content": content}
|
||||||
|
if images:
|
||||||
|
message["images"] = [image["image_data"] for image in images]
|
||||||
|
message["image_content_types"] = [image["content_type"] for image in images]
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
class OllamaUnavailable(RuntimeError):
|
class OllamaUnavailable(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+29
-3
@@ -11,10 +11,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
|
|
||||||
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||||
|
"model_provider": {"env": "MODEL_PROVIDER", "type": "string", "secret": False},
|
||||||
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
|
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
|
||||||
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
||||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||||
|
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
||||||
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
||||||
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "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_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||||
@@ -60,10 +66,16 @@ class Settings(BaseSettings):
|
|||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
model_provider: str = "ollama"
|
||||||
ollama_base_url: str = "http://localhost:11434"
|
ollama_base_url: str = "http://localhost:11434"
|
||||||
ollama_model: str = "qwen3.5:9b"
|
ollama_model: str = "qwen3.5:9b"
|
||||||
ollama_num_ctx: int = 64000
|
ollama_num_ctx: int = 64512
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
|
openai_model: str = "gpt-5.3-codex"
|
||||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||||
|
scmdb_base_url: str = "https://scmdb.net"
|
||||||
|
cornerstone_base_url: str = "https://finder.cstone.space"
|
||||||
|
openai_api_key: str | None = Field(default=None)
|
||||||
uex_secret_key: str | None = Field(default=None)
|
uex_secret_key: str | None = Field(default=None)
|
||||||
uex_bearer_token: str | None = Field(default=None)
|
uex_bearer_token: str | None = Field(default=None)
|
||||||
traderai_user_name: str | None = Field(default=None)
|
traderai_user_name: str | None = Field(default=None)
|
||||||
@@ -71,11 +83,17 @@ class Settings(BaseSettings):
|
|||||||
uex_notification_poll_seconds: int = 60
|
uex_notification_poll_seconds: int = 60
|
||||||
require_write_approval: bool = True
|
require_write_approval: bool = True
|
||||||
|
|
||||||
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
@field_validator("openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_optional(cls, value: Any) -> Any:
|
def _blank_optional(cls, value: Any) -> Any:
|
||||||
return None if value == "" else value
|
return None if value == "" else value
|
||||||
|
|
||||||
|
@field_validator("model_provider", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_model_provider(cls, value: Any) -> str:
|
||||||
|
text = str(value or "ollama").strip().casefold()
|
||||||
|
return text if text in {"ollama", "openai"} else "ollama"
|
||||||
|
|
||||||
@field_validator("traderai_memory_path", mode="before")
|
@field_validator("traderai_memory_path", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_memory_path(cls, value: Any) -> Any:
|
def _blank_memory_path(cls, value: Any) -> Any:
|
||||||
@@ -90,6 +108,11 @@ def get_settings() -> Settings:
|
|||||||
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
|
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
|
||||||
current = settings or get_settings()
|
current = settings or get_settings()
|
||||||
values = current.model_dump()
|
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 {
|
return {
|
||||||
"app_data_dir": str(ensure_app_data_dir()),
|
"app_data_dir": str(ensure_app_data_dir()),
|
||||||
"config_path": str(user_config_path()),
|
"config_path": str(user_config_path()),
|
||||||
@@ -97,6 +120,7 @@ def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
|
|||||||
"edge_profile_dir": str(edge_profile_dir()),
|
"edge_profile_dir": str(edge_profile_dir()),
|
||||||
"values": values,
|
"values": values,
|
||||||
"fields": CONFIG_FIELDS,
|
"fields": CONFIG_FIELDS,
|
||||||
|
"secrets_configured": secrets_configured,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +130,8 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
if key not in CONFIG_FIELDS:
|
if key not in CONFIG_FIELDS:
|
||||||
continue
|
continue
|
||||||
|
if CONFIG_FIELDS[key].get("secret") and value == "":
|
||||||
|
continue
|
||||||
next_values[key] = _coerce_value(key, value)
|
next_values[key] = _coerce_value(key, value)
|
||||||
|
|
||||||
path = user_config_path()
|
path = user_config_path()
|
||||||
@@ -125,7 +151,7 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
def _coerce_value(key: str, value: Any) -> Any:
|
def _coerce_value(key: str, value: Any) -> Any:
|
||||||
field_type = CONFIG_FIELDS[key]["type"]
|
field_type = CONFIG_FIELDS[key]["type"]
|
||||||
if value == "":
|
if value == "":
|
||||||
return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
return None if key in {"openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||||
if field_type == "integer":
|
if field_type == "integer":
|
||||||
return int(value)
|
return int(value)
|
||||||
if field_type == "boolean":
|
if field_type == "boolean":
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class CornerstoneError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CornerstoneClient:
|
||||||
|
def __init__(self, base_url: str = "https://finder.cstone.space") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._items: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
async def list_items(self) -> list[dict[str, Any]]:
|
||||||
|
if self._items is not None:
|
||||||
|
return self._items
|
||||||
|
body = await self._get_json("GetSearch")
|
||||||
|
if isinstance(body, str):
|
||||||
|
body = json.loads(body)
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise CornerstoneError("Cornerstone search response was not a list.")
|
||||||
|
self._items = [
|
||||||
|
{"id": item.get("id"), "name": item.get("name"), "sold": bool(item.get("Sold"))}
|
||||||
|
for item in body
|
||||||
|
if isinstance(item, dict) and item.get("id") and item.get("name")
|
||||||
|
]
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
async def get_item_page(self, item_id: str) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.base_url}/Search/{item_id.strip()}",
|
||||||
|
headers={"Accept": "text/html,application/xhtml+xml"},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
||||||
|
return {"url": str(response.url), "html": response.text}
|
||||||
|
|
||||||
|
async def get_image_data(self, url: str, max_bytes: int = 10_000_000) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"Accept": "image/png,image/jpeg,image/*"})
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone image HTTP {response.status_code}: {response.text[:240]}")
|
||||||
|
content_type = response.headers.get("content-type", "").split(";")[0].strip().casefold()
|
||||||
|
if content_type not in {"image/jpeg", "image/jpg", "image/png"}:
|
||||||
|
raise CornerstoneError(f"Cornerstone image was not JPG or PNG: {content_type or 'unknown content type'}")
|
||||||
|
if len(response.content) > max_bytes:
|
||||||
|
raise CornerstoneError(f"Cornerstone image is larger than {max_bytes} bytes.")
|
||||||
|
return {
|
||||||
|
"url": str(response.url),
|
||||||
|
"content_type": content_type,
|
||||||
|
"size_bytes": len(response.content),
|
||||||
|
"image_data": base64.b64encode(response.content).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_json(self, path: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CornerstoneError(f"Cornerstone returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class CornerstonePageParser(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.title = ""
|
||||||
|
self.tables: list[list[list[str]]] = []
|
||||||
|
self.images: list[dict[str, str]] = []
|
||||||
|
self._skip_depth = 0
|
||||||
|
self._in_title = False
|
||||||
|
self._current_table: list[list[str]] | None = None
|
||||||
|
self._current_row: list[str] | None = None
|
||||||
|
self._current_cell: list[str] | None = None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
tag = tag.casefold()
|
||||||
|
if tag in {"script", "style"}:
|
||||||
|
self._skip_depth += 1
|
||||||
|
return
|
||||||
|
if self._skip_depth:
|
||||||
|
return
|
||||||
|
if tag == "title":
|
||||||
|
self._in_title = True
|
||||||
|
elif tag == "meta":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
name = (attr_map.get("property") or attr_map.get("name") or "").casefold()
|
||||||
|
content = attr_map.get("content") or ""
|
||||||
|
if content and name in {"og:image", "twitter:image", "twitter:image:src"}:
|
||||||
|
self.images.append({"url": content, "source": name})
|
||||||
|
elif tag == "link":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
rel = (attr_map.get("rel") or "").casefold()
|
||||||
|
href = attr_map.get("href") or ""
|
||||||
|
if href and "image_src" in rel:
|
||||||
|
self.images.append({"url": href, "source": "link:image_src"})
|
||||||
|
elif tag == "img":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
url = attr_map.get("src") or attr_map.get("data-src") or attr_map.get("data-original") or ""
|
||||||
|
if url:
|
||||||
|
self.images.append(
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"alt": attr_map.get("alt") or "",
|
||||||
|
"source": "img",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif tag == "table":
|
||||||
|
self._current_table = []
|
||||||
|
elif tag == "tr" and self._current_table is not None:
|
||||||
|
self._current_row = []
|
||||||
|
elif tag in {"td", "th"} and self._current_row is not None:
|
||||||
|
self._current_cell = []
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag = tag.casefold()
|
||||||
|
if tag in {"script", "style"} and self._skip_depth:
|
||||||
|
self._skip_depth -= 1
|
||||||
|
return
|
||||||
|
if self._skip_depth:
|
||||||
|
return
|
||||||
|
if tag == "title":
|
||||||
|
self._in_title = False
|
||||||
|
elif tag in {"td", "th"} and self._current_cell is not None and self._current_row is not None:
|
||||||
|
text = " ".join("".join(self._current_cell).split())
|
||||||
|
self._current_row.append(text)
|
||||||
|
self._current_cell = None
|
||||||
|
elif tag == "tr" and self._current_row is not None and self._current_table is not None:
|
||||||
|
if any(cell for cell in self._current_row):
|
||||||
|
self._current_table.append(self._current_row)
|
||||||
|
self._current_row = None
|
||||||
|
elif tag == "table" and self._current_table is not None:
|
||||||
|
if self._current_table:
|
||||||
|
self.tables.append(self._current_table)
|
||||||
|
self._current_table = None
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._skip_depth:
|
||||||
|
return
|
||||||
|
if self._in_title:
|
||||||
|
self.title += data
|
||||||
|
if self._current_cell is not None:
|
||||||
|
self._current_cell.append(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
|
||||||
|
return {key.casefold(): value or "" for key, value in attrs}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
|
||||||
|
parser = CornerstonePageParser()
|
||||||
|
parser.feed(html)
|
||||||
|
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
||||||
|
general: dict[str, str] = {}
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
for table in parser.tables:
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
header = [cell.casefold() for cell in table[0]]
|
||||||
|
if len(header) >= 3 and "location" in header[0] and "price" in header[1] and "verified" in header[2]:
|
||||||
|
for row in table[1:]:
|
||||||
|
if len(row) < 3:
|
||||||
|
continue
|
||||||
|
locations.append(
|
||||||
|
{
|
||||||
|
"location": row[0],
|
||||||
|
"base_price": _parse_cornerstone_price(row[1]),
|
||||||
|
"base_price_display": row[1],
|
||||||
|
"verified": row[2],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif all(len(row) >= 2 for row in table):
|
||||||
|
for row in table:
|
||||||
|
key = row[0].strip().lower().replace(" ", "_")
|
||||||
|
value = row[1].strip()
|
||||||
|
if key and value and key not in general:
|
||||||
|
general[key] = value
|
||||||
|
|
||||||
|
info["name"] = general.get("name") or _name_from_title(info["page_title"])
|
||||||
|
media = _dedupe_media(parser.images, page_url)
|
||||||
|
if media:
|
||||||
|
info["media"] = media
|
||||||
|
if general:
|
||||||
|
info["general"] = general
|
||||||
|
info["locations"] = locations
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cornerstone_price(value: str) -> int | None:
|
||||||
|
digits = "".join(char for char in value if char.isdigit())
|
||||||
|
return int(digits) if digits else None
|
||||||
|
|
||||||
|
|
||||||
|
def _name_from_title(title: str) -> str | None:
|
||||||
|
if " - " not in title:
|
||||||
|
return title or None
|
||||||
|
return title.rsplit(" - ", 1)[-1].strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_media(images: list[dict[str, str]], page_url: str | None = None) -> list[dict[str, str]]:
|
||||||
|
media = []
|
||||||
|
seen = set()
|
||||||
|
for image in images:
|
||||||
|
raw_url = (image.get("url") or "").strip()
|
||||||
|
if not raw_url or raw_url.startswith("data:"):
|
||||||
|
continue
|
||||||
|
url = urljoin(page_url or "", raw_url)
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
item = dict(image)
|
||||||
|
item["url"] = url
|
||||||
|
media.append(item)
|
||||||
|
return media
|
||||||
+195
-16
@@ -8,6 +8,9 @@ from typing import Any
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_THREAD_ID = "default"
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
def utc_now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -65,8 +68,16 @@ class MemoryStore:
|
|||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
db.executescript(
|
db.executescript(
|
||||||
"""
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_threads (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
thread_id TEXT,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
@@ -129,27 +140,152 @@ class MemoryStore:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
self._ensure_column(db, "conversations", "thread_id", "TEXT")
|
||||||
def add_conversation(self, role: str, content: str) -> None:
|
now = iso_now()
|
||||||
with self._connect() as db:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
|
"""
|
||||||
(role, content, iso_now()),
|
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(DEFAULT_THREAD_ID, "New chat", now, now),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"UPDATE conversations SET thread_id = ? WHERE thread_id IS NULL OR thread_id = ''",
|
||||||
|
(DEFAULT_THREAD_ID,),
|
||||||
)
|
)
|
||||||
|
|
||||||
def last_interaction(self) -> dict[str, Any] | None:
|
@staticmethod
|
||||||
|
def _ensure_column(db: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
||||||
|
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
if column not in columns:
|
||||||
|
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||||
|
|
||||||
|
def ensure_thread(self, thread_id: str | None = None, title: str | None = None) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
resolved_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||||
|
resolved_title = (title or "New chat").strip() or "New chat"
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(resolved_id, resolved_title, now, now),
|
||||||
|
)
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||||
|
(resolved_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def create_thread(self, title: str | None = None) -> dict[str, Any]:
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
thread_id = f"chat-{uuid.uuid4()}"
|
||||||
|
return self.ensure_thread(thread_id, title or "New chat")
|
||||||
|
|
||||||
|
def list_threads(self) -> list[dict[str, Any]]:
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.title,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at,
|
||||||
|
COUNT(c.id) AS message_count,
|
||||||
|
MAX(c.created_at) AS last_message_at
|
||||||
|
FROM chat_threads t
|
||||||
|
LEFT JOIN conversations c ON c.thread_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY COALESCE(MAX(c.created_at), t.updated_at) DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_thread(self, thread_id: str) -> bool:
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute("DELETE FROM conversations WHERE thread_id = ?", (thread_id,))
|
||||||
|
cursor = db.execute("DELETE FROM chat_threads WHERE id = ?", (thread_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def rename_thread(self, thread_id: str, title: str) -> dict[str, Any] | None:
|
||||||
|
clean_title = self._clean_thread_title(title)
|
||||||
|
if not clean_title:
|
||||||
|
return None
|
||||||
|
now = iso_now()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE chat_threads SET title = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(clean_title, now, thread_id),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||||
|
(thread_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
|
def get_thread(self, thread_id: str) -> dict[str, Any] | None:
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
rows = db.execute(
|
row = db.execute(
|
||||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||||
(limit,),
|
(thread_id,),
|
||||||
).fetchall()
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def add_conversation(self, role: str, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> None:
|
||||||
|
resolved_thread_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||||
|
self.ensure_thread(resolved_thread_id)
|
||||||
|
now = iso_now()
|
||||||
|
with self._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO conversations(thread_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(resolved_thread_id, role, content, now),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"UPDATE chat_threads SET updated_at = ? WHERE id = ?",
|
||||||
|
(now, resolved_thread_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def last_interaction(self, thread_id: str | None = None) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
if thread_id:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT thread_id, role, content, created_at
|
||||||
|
FROM conversations
|
||||||
|
WHERE thread_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT thread_id, 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, thread_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
with self._connect() as db:
|
||||||
|
if thread_id:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, thread_id, role, content, created_at
|
||||||
|
FROM conversations
|
||||||
|
WHERE thread_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(thread_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
return [dict(row) for row in reversed(rows)]
|
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]:
|
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
@@ -222,13 +358,22 @@ class MemoryStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
conversations = db.execute(
|
conversations = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, role, content, created_at
|
SELECT id, thread_id, role, content, created_at
|
||||||
FROM conversations
|
FROM conversations
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
threads = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, title, created_at, updated_at
|
||||||
|
FROM chat_threads
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
profile_rows = db.execute(
|
profile_rows = db.execute(
|
||||||
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -252,6 +397,7 @@ class MemoryStore:
|
|||||||
return {
|
return {
|
||||||
"path": str(self.path),
|
"path": str(self.path),
|
||||||
"memories": [self._memory_row(row) for row in memories],
|
"memories": [self._memory_row(row) for row in memories],
|
||||||
|
"chat_threads": [dict(row) for row in threads],
|
||||||
"conversations": [dict(row) for row in conversations],
|
"conversations": [dict(row) for row in conversations],
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"scheduled_jobs": [dict(row) for row in jobs],
|
"scheduled_jobs": [dict(row) for row in jobs],
|
||||||
@@ -339,17 +485,38 @@ class MemoryStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
|
def mark_job_run(self, job_id: str, next_run_at: str | None = None, enabled: bool = True) -> None:
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
|
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
|
||||||
(iso_now(), next_run_at, job_id),
|
(iso_now(), next_run_at, 1 if enabled else 0, job_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_outbox(self, content: str) -> None:
|
def add_outbox(self, content: str) -> None:
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
||||||
|
|
||||||
|
def list_outbox(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def get_outbox(self, inbox_id: int) -> dict[str, Any] | None:
|
||||||
|
with self._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT id, content, created_at, delivered_at FROM outbox WHERE id = ?",
|
||||||
|
(inbox_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def delete_outbox(self, inbox_id: int) -> bool:
|
||||||
|
with self._connect() as db:
|
||||||
|
cursor = db.execute("DELETE FROM outbox WHERE id = ?", (inbox_id,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
with self._connect() as db:
|
with self._connect() as db:
|
||||||
@@ -362,6 +529,18 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _thread_title(content: str) -> str:
|
||||||
|
text = " ".join(content.strip().split())
|
||||||
|
if not text:
|
||||||
|
return "New chat"
|
||||||
|
return text[:42] + ("..." if len(text) > 42 else "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_thread_title(title: str) -> str:
|
||||||
|
text = " ".join(title.strip().strip('"').strip("'").split())
|
||||||
|
return text[:64]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fts_query(query: str) -> str:
|
def _fts_query(query: str) -> str:
|
||||||
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, iso_now
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PLAN_CADENCE = "0 */6 * * *"
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanStore:
|
||||||
|
def __init__(self, memory: MemoryStore) -> None:
|
||||||
|
self.memory = memory
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
objective TEXT NOT NULL,
|
||||||
|
constraints TEXT NOT NULL DEFAULT '{}',
|
||||||
|
cadence TEXT NOT NULL,
|
||||||
|
next_run_at TEXT,
|
||||||
|
last_run_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
item_name TEXT NOT NULL,
|
||||||
|
desired_quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_unit_price REAL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
acquired_quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_candidates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
plan_item_id INTEGER NOT NULL,
|
||||||
|
listing_id TEXT,
|
||||||
|
listing_slug TEXT,
|
||||||
|
title TEXT,
|
||||||
|
seller TEXT,
|
||||||
|
price REAL,
|
||||||
|
currency TEXT,
|
||||||
|
stock INTEGER,
|
||||||
|
location TEXT,
|
||||||
|
score REAL,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'current',
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE(plan_item_id, listing_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_negotiations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
plan_item_id INTEGER,
|
||||||
|
candidate_id INTEGER,
|
||||||
|
listing_id TEXT,
|
||||||
|
listing_slug TEXT,
|
||||||
|
negotiation_id TEXT,
|
||||||
|
negotiation_hash TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'drafted',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_plan(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
kind: str = "buying",
|
||||||
|
objective: str = "",
|
||||||
|
items: list[dict[str, Any]] | None = None,
|
||||||
|
constraints: dict[str, Any] | None = None,
|
||||||
|
cadence: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clean_items = [item for item in (items or []) if str(item.get("item_name") or item.get("name") or "").strip()]
|
||||||
|
plan_id = f"plan-{uuid.uuid4()}"
|
||||||
|
now = iso_now()
|
||||||
|
clean_kind = (kind.strip() or "buying").casefold()
|
||||||
|
resolved_status = status or ("needs_input" if clean_kind == "buying" and not clean_items else "active")
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plans(id, title, kind, status, objective, constraints, cadence, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
title.strip() or "Continual plan",
|
||||||
|
clean_kind,
|
||||||
|
resolved_status,
|
||||||
|
objective.strip() or title.strip(),
|
||||||
|
json.dumps(constraints or {}),
|
||||||
|
(cadence or DEFAULT_PLAN_CADENCE).strip() or DEFAULT_PLAN_CADENCE,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in clean_items:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_items(
|
||||||
|
plan_id, item_name, desired_quantity, max_unit_price, status,
|
||||||
|
acquired_quantity, metadata, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
str(item.get("item_name") or item.get("name")).strip(),
|
||||||
|
max(1, int(item.get("desired_quantity") or item.get("quantity") or 1)),
|
||||||
|
item.get("max_unit_price"),
|
||||||
|
max(0, int(item.get("acquired_quantity") or 0)),
|
||||||
|
json.dumps(item.get("metadata") or {}),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if clean_kind == "buying" and not clean_items:
|
||||||
|
self.add_event(plan_id, "needs_input", "Created plan, but no item checklist was provided. Add the required parts before it can run.")
|
||||||
|
elif clean_items:
|
||||||
|
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan with {len(clean_items)} checklist item(s).")
|
||||||
|
else:
|
||||||
|
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan.")
|
||||||
|
return self.get_plan(plan_id) or {}
|
||||||
|
|
||||||
|
def list_plans(self, include_inactive: bool = True) -> list[dict[str, Any]]:
|
||||||
|
where = "" if include_inactive else "WHERE status = 'active'"
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plans
|
||||||
|
{where}
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'active' THEN 0 WHEN 'needs_input' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END,
|
||||||
|
updated_at DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [self._plan_row(row) for row in rows]
|
||||||
|
|
||||||
|
def get_plan(self, plan_id: str) -> dict[str, Any] | None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
plan = db.execute("SELECT * FROM continual_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||||
|
if not plan:
|
||||||
|
return None
|
||||||
|
data = self._plan_row(plan)
|
||||||
|
data["items"] = self.list_items(plan_id)
|
||||||
|
data["candidates"] = self.list_candidates(plan_id)
|
||||||
|
data["negotiations"] = self.list_negotiations(plan_id)
|
||||||
|
data["events"] = self.list_events(plan_id)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def list_items(self, plan_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_items WHERE plan_id = ? ORDER BY id",
|
||||||
|
(plan_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_candidates(self, plan_id: str, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plan_candidates
|
||||||
|
WHERE plan_id = ?
|
||||||
|
ORDER BY status = 'current' DESC, score DESC, last_seen_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(plan_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_events(self, plan_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plan_events
|
||||||
|
WHERE plan_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(plan_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_negotiations(self, plan_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_negotiations WHERE plan_id = ? ORDER BY updated_at DESC",
|
||||||
|
(plan_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def set_status(self, plan_id: str, status: str) -> dict[str, Any] | None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE continual_plans SET status = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(status, iso_now(), plan_id),
|
||||||
|
)
|
||||||
|
self.add_event(plan_id, status, f"Plan status changed to {status}.")
|
||||||
|
return self.get_plan(plan_id)
|
||||||
|
|
||||||
|
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_events(plan_id, kind, message, metadata, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(plan_id, kind, message, json.dumps(metadata or {}), now),
|
||||||
|
)
|
||||||
|
return {"id": cursor.lastrowid, "plan_id": plan_id, "kind": kind, "message": message, "created_at": now}
|
||||||
|
|
||||||
|
def update_schedule(self, plan_id: str, next_run_at: str | None = None, last_run_at: str | None = None) -> None:
|
||||||
|
fields = ["next_run_at = ?", "updated_at = ?"]
|
||||||
|
values: list[Any] = [next_run_at, iso_now()]
|
||||||
|
if last_run_at is not None:
|
||||||
|
fields.insert(1, "last_run_at = ?")
|
||||||
|
values.insert(1, last_run_at)
|
||||||
|
values.append(plan_id)
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(f"UPDATE continual_plans SET {', '.join(fields)} WHERE id = ?", values)
|
||||||
|
|
||||||
|
def upsert_candidate(self, plan_id: str, plan_item_id: int, listing: dict[str, Any], score: float) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
listing_id = str(listing.get("id") or listing.get("listing_id") or listing.get("slug") or uuid.uuid4())
|
||||||
|
metadata = dict(listing)
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_candidates(
|
||||||
|
plan_id, plan_item_id, listing_id, listing_slug, title, seller, price, currency,
|
||||||
|
stock, location, score, first_seen_at, last_seen_at, status, metadata
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', ?)
|
||||||
|
ON CONFLICT(plan_item_id, listing_id) DO UPDATE SET
|
||||||
|
listing_slug=excluded.listing_slug,
|
||||||
|
title=excluded.title,
|
||||||
|
seller=excluded.seller,
|
||||||
|
price=excluded.price,
|
||||||
|
currency=excluded.currency,
|
||||||
|
stock=excluded.stock,
|
||||||
|
location=excluded.location,
|
||||||
|
score=excluded.score,
|
||||||
|
last_seen_at=excluded.last_seen_at,
|
||||||
|
status='current',
|
||||||
|
metadata=excluded.metadata
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
listing_id,
|
||||||
|
listing.get("slug"),
|
||||||
|
listing.get("title"),
|
||||||
|
listing.get("advertiser") or listing.get("user_username") or listing.get("seller"),
|
||||||
|
listing.get("price"),
|
||||||
|
listing.get("currency"),
|
||||||
|
listing.get("in_stock") or listing.get("stock"),
|
||||||
|
listing.get("location"),
|
||||||
|
score,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
json.dumps(metadata),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_candidates WHERE plan_item_id = ? AND listing_id = ?",
|
||||||
|
(plan_item_id, listing_id),
|
||||||
|
).fetchone()
|
||||||
|
return self._json_row(row, "metadata")
|
||||||
|
|
||||||
|
def mark_stale_candidates(self, plan_item_id: int, seen_listing_ids: set[str]) -> int:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, listing_id FROM continual_plan_candidates WHERE plan_item_id = ? AND status = 'current'",
|
||||||
|
(plan_item_id,),
|
||||||
|
).fetchall()
|
||||||
|
stale_ids = [row["id"] for row in rows if str(row["listing_id"]) not in seen_listing_ids]
|
||||||
|
if stale_ids:
|
||||||
|
placeholders = ",".join("?" for _ in stale_ids)
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE continual_plan_candidates SET status = 'stale', last_seen_at = ? WHERE id IN ({placeholders})",
|
||||||
|
(iso_now(), *stale_ids),
|
||||||
|
)
|
||||||
|
return len(stale_ids)
|
||||||
|
|
||||||
|
def mark_candidate_drafted(self, candidate_id: int) -> None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute("UPDATE continual_plan_candidates SET status = 'drafted', last_seen_at = ? WHERE id = ?", (iso_now(), candidate_id))
|
||||||
|
|
||||||
|
def add_negotiation(self, plan_id: str, plan_item_id: int | None, candidate_id: int | None, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_negotiations(
|
||||||
|
plan_id, plan_item_id, candidate_id, listing_id, listing_slug,
|
||||||
|
negotiation_id, negotiation_hash, status, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
candidate_id,
|
||||||
|
metadata.get("listing_id"),
|
||||||
|
metadata.get("listing_slug"),
|
||||||
|
metadata.get("id_negotiation"),
|
||||||
|
metadata.get("hash"),
|
||||||
|
metadata.get("status") or "drafted",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.execute("SELECT * FROM continual_plan_negotiations WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def has_negotiation_for_candidate(self, plan_id: str, plan_item_id: int, candidate: dict[str, Any]) -> bool:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM continual_plan_negotiations
|
||||||
|
WHERE plan_id = ?
|
||||||
|
AND plan_item_id = ?
|
||||||
|
AND (
|
||||||
|
candidate_id = ?
|
||||||
|
OR (listing_id IS NOT NULL AND listing_id = ?)
|
||||||
|
OR (listing_slug IS NOT NULL AND listing_slug = ?)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
candidate.get("id"),
|
||||||
|
candidate.get("listing_id"),
|
||||||
|
candidate.get("listing_slug"),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_row(row: Any, *json_fields: str) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
for field in json_fields:
|
||||||
|
try:
|
||||||
|
data[field] = json.loads(data.get(field) or "{}")
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
data[field] = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _plan_row(cls, row: Any) -> dict[str, Any]:
|
||||||
|
return cls._json_row(row, "constraints")
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanRunner:
|
||||||
|
def __init__(self, store: ContinualPlanStore, tools: Any, memory: MemoryStore, agent: Any | None = None) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent: Any) -> None:
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
async def run_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
plan = self.store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return {"error": f"Plan not found: {plan_id}"}
|
||||||
|
if plan["status"] != "active":
|
||||||
|
message = f"Skipped {plan['title']} because status is {plan['status']}."
|
||||||
|
self.store.add_event(plan_id, "skipped", message)
|
||||||
|
return {"status": "skipped", "summary": message, "plan": self.store.get_plan(plan_id)}
|
||||||
|
try:
|
||||||
|
if plan["kind"] == "buying":
|
||||||
|
result = await self._run_buying_plan(plan)
|
||||||
|
else:
|
||||||
|
result = await self._run_agent_plan(plan)
|
||||||
|
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
|
||||||
|
self.memory.add_outbox(result["summary"])
|
||||||
|
return {**result, "plan": self.store.get_plan(plan_id)}
|
||||||
|
except Exception as exc:
|
||||||
|
message = f"Continual plan failed: {exc}"
|
||||||
|
self.store.add_event(plan_id, "error", message)
|
||||||
|
self.memory.add_outbox(f"{plan['title']}: {message}")
|
||||||
|
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
|
||||||
|
return {"error": str(exc), "summary": message, "plan": self.store.get_plan(plan_id)}
|
||||||
|
|
||||||
|
async def _run_agent_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self.agent is None:
|
||||||
|
raise RuntimeError("No agent is bound to run generic continual plans.")
|
||||||
|
prompt = self._agent_plan_prompt(plan)
|
||||||
|
response = await self.agent.generate_wake_response(prompt)
|
||||||
|
summary = f"{plan['title']}: {response}"
|
||||||
|
self.store.add_event(plan["id"], "run", "Ran generic continual plan through the agent.", {"response": response})
|
||||||
|
return {"status": "ok", "summary": summary, "checked": 0, "drafted": 0}
|
||||||
|
|
||||||
|
async def _run_buying_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
items = [item for item in plan.get("items") or [] if item.get("status") != "acquired"]
|
||||||
|
if not items:
|
||||||
|
self.store.set_status(plan["id"], "completed")
|
||||||
|
summary = f"{plan['title']}: all checklist items are marked acquired."
|
||||||
|
return {"status": "completed", "summary": summary, "drafted": 0, "checked": 0}
|
||||||
|
|
||||||
|
checked = 0
|
||||||
|
drafted = 0
|
||||||
|
best_lines = []
|
||||||
|
constraints = plan.get("constraints") or {}
|
||||||
|
excluded_sellers = {str(value).casefold() for value in constraints.get("excluded_sellers") or []}
|
||||||
|
preferred_locations = [str(value).casefold() for value in constraints.get("preferred_locations") or []]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
response = await self.tools.search_marketplace_listings(
|
||||||
|
query=item["item_name"],
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
listings = response.get("listings") or response.get("data") or []
|
||||||
|
seen: set[str] = set()
|
||||||
|
candidates = []
|
||||||
|
for listing in listings:
|
||||||
|
if not isinstance(listing, dict):
|
||||||
|
continue
|
||||||
|
listing_id = str(listing.get("id") or listing.get("slug") or "")
|
||||||
|
if listing_id:
|
||||||
|
seen.add(listing_id)
|
||||||
|
if str(listing.get("advertiser") or listing.get("seller") or "").casefold() in excluded_sellers:
|
||||||
|
continue
|
||||||
|
score = self._candidate_score(listing, item, preferred_locations)
|
||||||
|
candidate = self.store.upsert_candidate(plan["id"], int(item["id"]), listing, score)
|
||||||
|
candidates.append(candidate)
|
||||||
|
stale = self.store.mark_stale_candidates(int(item["id"]), seen)
|
||||||
|
checked += 1
|
||||||
|
current_candidates = [candidate for candidate in candidates if candidate.get("status") == "current"]
|
||||||
|
current_candidates.sort(key=lambda candidate: (-float(candidate.get("score") or 0), float(candidate.get("price") or 10**18)))
|
||||||
|
best = current_candidates[0] if current_candidates else None
|
||||||
|
if not best:
|
||||||
|
best_lines.append(f"{item['item_name']}: no active matching sell listings found.")
|
||||||
|
self.store.add_event(plan["id"], "search", f"{item['item_name']}: no active candidates found.", {"stale": stale})
|
||||||
|
continue
|
||||||
|
|
||||||
|
best_lines.append(
|
||||||
|
f"{item['item_name']}: best candidate is {best.get('title') or best.get('listing_slug')} "
|
||||||
|
f"at {self._format_price(best.get('price'), best.get('currency'))} from {best.get('seller') or 'unknown seller'}."
|
||||||
|
)
|
||||||
|
self.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"search",
|
||||||
|
f"{item['item_name']}: found {len(current_candidates)} current candidate(s); {stale} stale candidate(s) marked.",
|
||||||
|
{"best_candidate_id": best.get("id")},
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.store.has_negotiation_for_candidate(plan["id"], int(item["id"]), best) or not self._within_budget(best, item, constraints):
|
||||||
|
continue
|
||||||
|
draft = await self._draft_buying_message(plan, item, best)
|
||||||
|
if "pending_action" in draft:
|
||||||
|
drafted += 1
|
||||||
|
self.store.mark_candidate_drafted(int(best["id"]))
|
||||||
|
self.store.add_negotiation(
|
||||||
|
plan["id"],
|
||||||
|
int(item["id"]),
|
||||||
|
int(best["id"]),
|
||||||
|
{
|
||||||
|
"listing_id": best.get("listing_id"),
|
||||||
|
"listing_slug": best.get("listing_slug"),
|
||||||
|
"status": "drafted",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"draft",
|
||||||
|
f"Drafted negotiation opener for {item['item_name']} candidate {best.get('listing_id')}.",
|
||||||
|
{"pending_action_id": draft["pending_action"].get("id"), "candidate_id": best.get("id")},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = f"{plan['title']}: checked {checked} item(s). " + " ".join(best_lines[:4])
|
||||||
|
if drafted:
|
||||||
|
summary += f" Drafted {drafted} negotiation message(s) for approval."
|
||||||
|
self.store.add_event(plan["id"], "run", summary, {"checked": checked, "drafted": drafted})
|
||||||
|
return {"status": "ok", "summary": summary, "checked": checked, "drafted": drafted}
|
||||||
|
|
||||||
|
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
|
||||||
|
message = (
|
||||||
|
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing "
|
||||||
|
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. "
|
||||||
|
f"Is it still available? I am trying to complete: {plan['objective']}. "
|
||||||
|
f"Tone note: {tone}."
|
||||||
|
)
|
||||||
|
return await self.tools.draft_negotiation_message(
|
||||||
|
message=message,
|
||||||
|
id_listing=self._int_or_none(candidate.get("listing_id")),
|
||||||
|
plan_id=plan["id"],
|
||||||
|
plan_item_id=int(item["id"]),
|
||||||
|
candidate_id=int(candidate["id"]),
|
||||||
|
listing_slug=candidate.get("listing_slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
|
||||||
|
price = float(listing.get("price") or 10**12)
|
||||||
|
max_price = item.get("max_unit_price")
|
||||||
|
budget_bonus = 40.0 if max_price and price <= float(max_price) else 0.0
|
||||||
|
stock = float(listing.get("in_stock") or listing.get("stock") or 1)
|
||||||
|
location = str(listing.get("location") or "").casefold()
|
||||||
|
location_bonus = 8.0 if preferred_locations and any(place in location for place in preferred_locations) else 0.0
|
||||||
|
return round(max(0.0, 50.0 - (price / 10_000_000.0)) + min(stock, 20.0) + budget_bonus + location_bonus, 4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _within_budget(candidate: dict[str, Any], item: dict[str, Any], constraints: dict[str, Any]) -> bool:
|
||||||
|
price = candidate.get("price")
|
||||||
|
if price is None:
|
||||||
|
return False
|
||||||
|
max_price = item.get("max_unit_price") or constraints.get("max_unit_price")
|
||||||
|
return max_price is None or float(price) <= float(max_price)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_price(price: Any, currency: Any) -> str:
|
||||||
|
if isinstance(price, (int, float)):
|
||||||
|
return f"{price:,.0f} {currency or 'UEC'}"
|
||||||
|
return f"unknown price {currency or 'UEC'}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _agent_plan_prompt(plan: dict[str, Any]) -> str:
|
||||||
|
recent_events = [
|
||||||
|
{
|
||||||
|
"kind": event.get("kind"),
|
||||||
|
"message": event.get("message"),
|
||||||
|
"created_at": event.get("created_at"),
|
||||||
|
}
|
||||||
|
for event in (plan.get("events") or [])[:8]
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"plan_id": plan.get("id"),
|
||||||
|
"title": plan.get("title"),
|
||||||
|
"kind": plan.get("kind"),
|
||||||
|
"objective": plan.get("objective"),
|
||||||
|
"constraints": plan.get("constraints") or {},
|
||||||
|
"items": plan.get("items") or [],
|
||||||
|
"recent_events": recent_events,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"Continual plan wake run. Continue this durable plan and write an Inbox-ready summary. "
|
||||||
|
"Use tools as needed. For any account-affecting marketplace write, only draft a pending action for approval. "
|
||||||
|
"Do not claim a message, offer, listing, or negotiation was sent unless an approved action result says it was sent. "
|
||||||
|
f"Plan JSON: {json.dumps(payload, ensure_ascii=True)}"
|
||||||
|
)
|
||||||
+90
-5
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from apscheduler.triggers.date import DateTrigger
|
|||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone
|
||||||
|
|
||||||
from traderai.memory import MemoryStore, iso_now, time_since
|
from traderai.memory import MemoryStore, iso_now, parse_iso, time_since, utc_now
|
||||||
|
|
||||||
|
|
||||||
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
|
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
|
||||||
@@ -22,11 +22,15 @@ class WakeScheduler:
|
|||||||
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
||||||
self.agent = None
|
self.agent = None
|
||||||
self.uex = None
|
self.uex = None
|
||||||
|
self.plan_runner = None
|
||||||
self.notification_poll_seconds = 60
|
self.notification_poll_seconds = 60
|
||||||
|
|
||||||
def bind_agent(self, agent: Any) -> None:
|
def bind_agent(self, agent: Any) -> None:
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner: Any) -> None:
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
||||||
self.uex = uex
|
self.uex = uex
|
||||||
self.notification_poll_seconds = max(15, poll_seconds)
|
self.notification_poll_seconds = max(15, poll_seconds)
|
||||||
@@ -37,6 +41,9 @@ class WakeScheduler:
|
|||||||
self._schedule_notification_poll()
|
self._schedule_notification_poll()
|
||||||
for job in self.memory.list_jobs():
|
for job in self.memory.list_jobs():
|
||||||
self._schedule_existing(job)
|
self._schedule_existing(job)
|
||||||
|
if self.plan_runner is not None:
|
||||||
|
for plan in self.plan_runner.store.list_plans(include_inactive=False):
|
||||||
|
self.schedule_plan(plan)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
if self.scheduler.running:
|
if self.scheduler.running:
|
||||||
@@ -59,6 +66,70 @@ class WakeScheduler:
|
|||||||
def list_jobs(self) -> list[dict[str, Any]]:
|
def list_jobs(self) -> list[dict[str, Any]]:
|
||||||
return self.memory.list_jobs()
|
return self.memory.list_jobs()
|
||||||
|
|
||||||
|
def schedule_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self.plan_runner is None or plan.get("status") != "active":
|
||||||
|
return plan
|
||||||
|
job_id = self._plan_job_id(plan["id"])
|
||||||
|
previous_next_run = plan.get("next_run_at")
|
||||||
|
trigger = CronTrigger.from_crontab(plan.get("cadence") or "0 */6 * * *")
|
||||||
|
self.scheduler.add_job(self._run_plan, trigger=trigger, id=job_id, args=[plan["id"]], replace_existing=True)
|
||||||
|
job = self.scheduler.get_job(job_id)
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.plan_runner.store.update_schedule(plan["id"], next_run.isoformat() if next_run else None)
|
||||||
|
if self._plan_is_overdue(previous_next_run):
|
||||||
|
catchup_id = self._plan_catchup_job_id(plan["id"])
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_plan,
|
||||||
|
trigger=DateTrigger(run_date=datetime.now() + timedelta(seconds=5)),
|
||||||
|
id=catchup_id,
|
||||||
|
args=[plan["id"]],
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
self.plan_runner.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"catchup_scheduled",
|
||||||
|
"Plan was overdue while the app was closed, so a one-time catch-up run was scheduled after startup.",
|
||||||
|
{"previous_next_run_at": previous_next_run},
|
||||||
|
)
|
||||||
|
return self.plan_runner.store.get_plan(plan["id"]) or plan
|
||||||
|
|
||||||
|
def unschedule_plan(self, plan_id: str) -> None:
|
||||||
|
job_id = self._plan_job_id(plan_id)
|
||||||
|
if self.scheduler.get_job(job_id):
|
||||||
|
self.scheduler.remove_job(job_id)
|
||||||
|
catchup_id = self._plan_catchup_job_id(plan_id)
|
||||||
|
if self.scheduler.get_job(catchup_id):
|
||||||
|
self.scheduler.remove_job(catchup_id)
|
||||||
|
if self.plan_runner is not None:
|
||||||
|
self.plan_runner.store.update_schedule(plan_id, None)
|
||||||
|
|
||||||
|
async def _run_plan(self, plan_id: str) -> None:
|
||||||
|
if self.plan_runner is None:
|
||||||
|
return
|
||||||
|
result = await self.plan_runner.run_plan(plan_id)
|
||||||
|
plan = result.get("plan") or self.plan_runner.store.get_plan(plan_id)
|
||||||
|
if plan and plan.get("status") == "active":
|
||||||
|
job = self.scheduler.get_job(self._plan_job_id(plan_id))
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.plan_runner.store.update_schedule(plan_id, next_run.isoformat() if next_run else None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_job_id(plan_id: str) -> str:
|
||||||
|
return f"continual-{plan_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_catchup_job_id(plan_id: str) -> str:
|
||||||
|
return f"continual-catchup-{plan_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_is_overdue(next_run_at: str | None) -> bool:
|
||||||
|
if not next_run_at:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return parse_iso(next_run_at) <= utc_now()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
||||||
if job["trigger_type"] == "cron":
|
if job["trigger_type"] == "cron":
|
||||||
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
||||||
@@ -83,11 +154,20 @@ class WakeScheduler:
|
|||||||
)
|
)
|
||||||
if self.agent is None:
|
if self.agent is None:
|
||||||
self.memory.add_outbox(wake_message)
|
self.memory.add_outbox(wake_message)
|
||||||
|
self._mark_job_finished(job_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
text = await self.agent.generate_wake_response(wake_message)
|
try:
|
||||||
|
text = await self.agent.generate_wake_response(wake_message)
|
||||||
|
except Exception as exc:
|
||||||
|
text = f"Wake job failed: {exc}. Job instruction: {prompt}"
|
||||||
self.memory.add_outbox(text)
|
self.memory.add_outbox(text)
|
||||||
self.memory.mark_job_run(job_id)
|
self._mark_job_finished(job_id)
|
||||||
|
|
||||||
|
def _mark_job_finished(self, job_id: str) -> None:
|
||||||
|
job = self.scheduler.get_job(job_id)
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.memory.mark_job_run(job_id, next_run.isoformat() if next_run else None, enabled=bool(next_run))
|
||||||
|
|
||||||
def _schedule_notification_poll(self) -> None:
|
def _schedule_notification_poll(self) -> None:
|
||||||
if self.uex is None:
|
if self.uex is None:
|
||||||
@@ -104,7 +184,12 @@ class WakeScheduler:
|
|||||||
if self.uex is None:
|
if self.uex is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
response = await self.uex.get_user_notifications()
|
try:
|
||||||
|
response = await self.uex.get_user_notifications()
|
||||||
|
except Exception as exc:
|
||||||
|
self.memory.add_outbox(f"UEX notification poll failed: {exc}")
|
||||||
|
self.memory.set_profile("uex_last_notification_error", str(exc))
|
||||||
|
return []
|
||||||
notifications = response.get("notifications") or []
|
notifications = response.get("notifications") or []
|
||||||
pending = [item for item in notifications if not item.get("date_read")]
|
pending = [item for item in notifications if not item.get("date_read")]
|
||||||
profile = self.memory.get_profile()
|
profile = self.memory.get_profile()
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class SCMDBError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SCMDBClient:
|
||||||
|
def __init__(self, base_url: str = "https://scmdb.net") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._versions: list[dict[str, Any]] | None = None
|
||||||
|
self._data_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def list_versions(self) -> list[dict[str, Any]]:
|
||||||
|
if self._versions is not None:
|
||||||
|
return self._versions
|
||||||
|
body = await self._get_json("data/versions.json")
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise SCMDBError("SCMDB versions response was not a list.")
|
||||||
|
self._versions = [
|
||||||
|
item
|
||||||
|
for item in body
|
||||||
|
if isinstance(item, dict) and item.get("version") and item.get("file")
|
||||||
|
]
|
||||||
|
return self._versions
|
||||||
|
|
||||||
|
async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||||
|
selected = await self.resolve_version(version=version, channel=channel)
|
||||||
|
cache_key = str(selected["version"])
|
||||||
|
if cache_key not in self._data_cache:
|
||||||
|
body = await self._get_json(f"data/{selected['file']}")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise SCMDBError(f"SCMDB data for {cache_key} was not an object.")
|
||||||
|
self._data_cache[cache_key] = body
|
||||||
|
return self._data_cache[cache_key]
|
||||||
|
|
||||||
|
async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||||
|
versions = await self.list_versions()
|
||||||
|
if not versions:
|
||||||
|
raise SCMDBError("SCMDB did not return any data versions.")
|
||||||
|
|
||||||
|
if version:
|
||||||
|
needle = version.casefold().strip()
|
||||||
|
for item in versions:
|
||||||
|
item_version = str(item["version"])
|
||||||
|
if item_version.casefold() == needle or needle in item_version.casefold():
|
||||||
|
return item
|
||||||
|
raise SCMDBError(f"SCMDB version not found: {version}")
|
||||||
|
|
||||||
|
channel = (channel or "live").casefold().strip()
|
||||||
|
if channel in {"latest", "any", "all"}:
|
||||||
|
return versions[0]
|
||||||
|
if channel not in {"live", "ptu"}:
|
||||||
|
raise SCMDBError("SCMDB channel must be live, ptu, or latest.")
|
||||||
|
for item in versions:
|
||||||
|
if f"-{channel}." in str(item["version"]).casefold():
|
||||||
|
return item
|
||||||
|
return versions[0]
|
||||||
|
|
||||||
|
async def _get_json(self, path: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
+279
-9
@@ -21,8 +21,11 @@ from pydantic import BaseModel
|
|||||||
from traderai.agent import OllamaAgent, OllamaUnavailable
|
from traderai.agent import OllamaAgent, OllamaUnavailable
|
||||||
from traderai.config import save_settings, settings_payload
|
from traderai.config import save_settings, settings_payload
|
||||||
from traderai.config import get_settings
|
from traderai.config import get_settings
|
||||||
from traderai.memory import MemoryStore
|
from traderai.cornerstone_client import CornerstoneClient
|
||||||
|
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||||
|
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
|
from traderai.scmdb_client import SCMDBClient
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||||
@@ -35,6 +38,26 @@ def resource_path(*parts: str) -> Path:
|
|||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID
|
||||||
|
images: list["ChatImageRequest"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ChatImageRequest(BaseModel):
|
||||||
|
name: str = "pasted-image.png"
|
||||||
|
content_type: str = "image/png"
|
||||||
|
image_data: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatThreadRequest(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RenameChatThreadRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
class DirectNegotiationMessageRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class ClearMemoryRequest(BaseModel):
|
class ClearMemoryRequest(BaseModel):
|
||||||
@@ -45,6 +68,27 @@ class ClearMemoryRequest(BaseModel):
|
|||||||
include_outbox: bool = True
|
include_outbox: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanItemRequest(BaseModel):
|
||||||
|
item_name: str
|
||||||
|
desired_quantity: int = 1
|
||||||
|
max_unit_price: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanCreateRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
objective: str
|
||||||
|
kind: str = "buying"
|
||||||
|
cadence: str | None = None
|
||||||
|
constraints: dict[str, Any] = {}
|
||||||
|
items: list[ContinualPlanItemRequest] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanEventRequest(BaseModel):
|
||||||
|
kind: str = "note"
|
||||||
|
message: str
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
class ConfigUpdateRequest(BaseModel):
|
class ConfigUpdateRequest(BaseModel):
|
||||||
values: dict
|
values: dict
|
||||||
|
|
||||||
@@ -60,18 +104,35 @@ UPDATE_ASSET_NAME = "TraderAI.exe"
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
memory = MemoryStore(settings.traderai_memory_path)
|
memory = MemoryStore(settings.traderai_memory_path)
|
||||||
|
plan_store = ContinualPlanStore(memory)
|
||||||
scheduler = WakeScheduler(memory)
|
scheduler = WakeScheduler(memory)
|
||||||
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
||||||
tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler)
|
scmdb = SCMDBClient(settings.scmdb_base_url)
|
||||||
|
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
|
||||||
|
tools = ToolRegistry(
|
||||||
|
uex,
|
||||||
|
settings.require_write_approval,
|
||||||
|
memory=memory,
|
||||||
|
scheduler=scheduler,
|
||||||
|
scmdb=scmdb,
|
||||||
|
cornerstone=cornerstone,
|
||||||
|
plan_store=plan_store,
|
||||||
|
)
|
||||||
|
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||||
|
tools.plan_runner = plan_runner
|
||||||
agent = OllamaAgent(
|
agent = OllamaAgent(
|
||||||
settings.ollama_base_url,
|
settings.openai_base_url if settings.model_provider == "openai" else settings.ollama_base_url,
|
||||||
settings.ollama_model,
|
settings.openai_model if settings.model_provider == "openai" else settings.ollama_model,
|
||||||
tools,
|
tools,
|
||||||
memory=memory,
|
memory=memory,
|
||||||
user_name=settings.traderai_user_name,
|
user_name=settings.traderai_user_name,
|
||||||
num_ctx=settings.ollama_num_ctx,
|
num_ctx=settings.ollama_num_ctx,
|
||||||
|
provider=settings.model_provider,
|
||||||
|
api_key=settings.openai_api_key,
|
||||||
)
|
)
|
||||||
|
plan_runner.bind_agent(agent)
|
||||||
scheduler.bind_agent(agent)
|
scheduler.bind_agent(agent)
|
||||||
|
scheduler.bind_plan_runner(plan_runner)
|
||||||
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
|
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
|
||||||
|
|
||||||
app = FastAPI(title="TraderAI")
|
app = FastAPI(title="TraderAI")
|
||||||
@@ -119,6 +180,7 @@ def create_app() -> FastAPI:
|
|||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {
|
return {
|
||||||
"ollama": await agent.health(),
|
"ollama": await agent.health(),
|
||||||
|
"model_provider": settings.model_provider,
|
||||||
"user": memory.get_profile(),
|
"user": memory.get_profile(),
|
||||||
"jobs": scheduler.list_jobs(),
|
"jobs": scheduler.list_jobs(),
|
||||||
"app_data_dir": settings_payload()["app_data_dir"],
|
"app_data_dir": settings_payload()["app_data_dir"],
|
||||||
@@ -138,7 +200,19 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/ollama/status")
|
@app.get("/api/ollama/status")
|
||||||
async def ollama_status() -> dict:
|
async def ollama_status() -> dict:
|
||||||
return await inspect_ollama()
|
return await inspect_model_provider()
|
||||||
|
|
||||||
|
@app.get("/api/openai/models")
|
||||||
|
async def openai_models() -> dict:
|
||||||
|
status = await inspect_openai()
|
||||||
|
return {
|
||||||
|
"provider": "openai",
|
||||||
|
"configured_model": status.get("configured_model"),
|
||||||
|
"models": status.get("models", []),
|
||||||
|
"message": status.get("message", ""),
|
||||||
|
"detail": status.get("detail", ""),
|
||||||
|
"online": status.get("online", False),
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/ollama/launch")
|
@app.post("/api/ollama/launch")
|
||||||
async def launch_ollama() -> dict:
|
async def launch_ollama() -> dict:
|
||||||
@@ -149,7 +223,7 @@ def create_app() -> FastAPI:
|
|||||||
popen_hidden(command)
|
popen_hidden(command)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
|
||||||
status = await inspect_ollama()
|
status = await inspect_model_provider()
|
||||||
status["message"] = "Ollama launch requested."
|
status["message"] = "Ollama launch requested."
|
||||||
return status
|
return status
|
||||||
|
|
||||||
@@ -166,7 +240,7 @@ def create_app() -> FastAPI:
|
|||||||
popen_hidden([str(cli), "pull", model])
|
popen_hidden([str(cli), "pull", model])
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
|
||||||
status = await inspect_ollama()
|
status = await inspect_model_provider()
|
||||||
status["message"] = f"Started installing model {model}."
|
status["message"] = f"Started installing model {model}."
|
||||||
return status
|
return status
|
||||||
|
|
||||||
@@ -246,18 +320,51 @@ def create_app() -> FastAPI:
|
|||||||
@app.post("/api/chat")
|
@app.post("/api/chat")
|
||||||
async def chat(request: ChatRequest) -> dict:
|
async def chat(request: ChatRequest) -> dict:
|
||||||
try:
|
try:
|
||||||
return await agent.chat(request.message)
|
return await agent.chat(
|
||||||
|
request.message,
|
||||||
|
thread_id=request.thread_id,
|
||||||
|
images=[image.model_dump() for image in request.images],
|
||||||
|
)
|
||||||
except OllamaUnavailable as exc:
|
except OllamaUnavailable as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.post("/api/chat/stream")
|
@app.post("/api/chat/stream")
|
||||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||||
async def events():
|
async def events():
|
||||||
async for event in agent.chat_events(request.message):
|
async for event in agent.chat_events(
|
||||||
|
request.message,
|
||||||
|
thread_id=request.thread_id,
|
||||||
|
images=[image.model_dump() for image in request.images],
|
||||||
|
):
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(events(), media_type="text/event-stream")
|
return StreamingResponse(events(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
@app.get("/api/chats")
|
||||||
|
async def chats() -> dict:
|
||||||
|
return {"chats": memory.list_threads()}
|
||||||
|
|
||||||
|
@app.post("/api/chats")
|
||||||
|
async def create_chat(request: ChatThreadRequest) -> dict:
|
||||||
|
return {"chat": memory.create_thread(request.title)}
|
||||||
|
|
||||||
|
@app.get("/api/chats/{thread_id}/messages")
|
||||||
|
async def chat_messages(thread_id: str) -> dict:
|
||||||
|
memory.ensure_thread(thread_id)
|
||||||
|
return {"thread_id": thread_id, "messages": memory.recent_conversation(limit=200, thread_id=thread_id)}
|
||||||
|
|
||||||
|
@app.delete("/api/chats/{thread_id}")
|
||||||
|
async def delete_chat(thread_id: str) -> dict:
|
||||||
|
deleted = memory.delete_thread(thread_id)
|
||||||
|
return {"deleted": deleted, "chats": memory.list_threads()}
|
||||||
|
|
||||||
|
@app.patch("/api/chats/{thread_id}")
|
||||||
|
async def rename_chat(thread_id: str, request: RenameChatThreadRequest) -> dict:
|
||||||
|
chat = memory.rename_thread(thread_id, request.title)
|
||||||
|
if not chat:
|
||||||
|
raise HTTPException(status_code=400, detail="A non-empty chat title is required.")
|
||||||
|
return {"chat": chat, "chats": memory.list_threads()}
|
||||||
|
|
||||||
@app.get("/api/pending-actions")
|
@app.get("/api/pending-actions")
|
||||||
async def pending_actions() -> dict:
|
async def pending_actions() -> dict:
|
||||||
return {"pending_actions": agent._pending_payloads()}
|
return {"pending_actions": agent._pending_payloads()}
|
||||||
@@ -266,10 +373,99 @@ def create_app() -> FastAPI:
|
|||||||
async def notifications() -> dict:
|
async def notifications() -> dict:
|
||||||
return {"notifications": memory.undelivered_outbox()}
|
return {"notifications": memory.undelivered_outbox()}
|
||||||
|
|
||||||
|
@app.get("/api/inbox")
|
||||||
|
async def inbox() -> dict:
|
||||||
|
return {"inbox": memory.list_outbox()}
|
||||||
|
|
||||||
|
@app.post("/api/inbox/{inbox_id}/continue")
|
||||||
|
async def continue_inbox(inbox_id: int) -> dict:
|
||||||
|
item = memory.get_outbox(inbox_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Inbox item not found.")
|
||||||
|
thread = memory.create_thread("Inbox follow-up")
|
||||||
|
memory.add_conversation("assistant", item["content"], thread["id"])
|
||||||
|
return {"chat": thread, "message": item}
|
||||||
|
|
||||||
|
@app.delete("/api/inbox/{inbox_id}")
|
||||||
|
async def delete_inbox(inbox_id: int) -> dict:
|
||||||
|
deleted = memory.delete_outbox(inbox_id)
|
||||||
|
return {"deleted": deleted, "inbox": memory.list_outbox()}
|
||||||
|
|
||||||
|
@app.get("/api/negotiations/{identifier}/messages")
|
||||||
|
async def negotiation_messages(identifier: str) -> dict:
|
||||||
|
params = negotiation_identifier_params(identifier)
|
||||||
|
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
||||||
|
|
||||||
|
@app.post("/api/negotiations/{identifier}/messages")
|
||||||
|
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
||||||
|
params = negotiation_identifier_params(identifier)
|
||||||
|
payload = {**params, "message": request.message, "is_production": 1}
|
||||||
|
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
||||||
|
|
||||||
@app.get("/api/wake-jobs")
|
@app.get("/api/wake-jobs")
|
||||||
async def wake_jobs() -> dict:
|
async def wake_jobs() -> dict:
|
||||||
return {"scheduled_jobs": scheduler.list_jobs()}
|
return {"scheduled_jobs": scheduler.list_jobs()}
|
||||||
|
|
||||||
|
@app.get("/api/plans")
|
||||||
|
async def continual_plans(include_inactive: bool = True) -> dict:
|
||||||
|
return {"plans": plan_store.list_plans(include_inactive=include_inactive)}
|
||||||
|
|
||||||
|
@app.post("/api/plans")
|
||||||
|
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
|
||||||
|
result = await tools.create_continual_plan(
|
||||||
|
title=request.title,
|
||||||
|
objective=request.objective,
|
||||||
|
kind=request.kind,
|
||||||
|
items=[item.model_dump() for item in request.items],
|
||||||
|
constraints=request.constraints,
|
||||||
|
cadence=request.cadence,
|
||||||
|
)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.get("/api/plans/{plan_id}")
|
||||||
|
async def continual_plan(plan_id: str) -> dict:
|
||||||
|
plan = plan_store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||||
|
return {"plan": plan}
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/pause")
|
||||||
|
async def pause_continual_plan(plan_id: str) -> dict:
|
||||||
|
result = await tools.pause_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/resume")
|
||||||
|
async def resume_continual_plan(plan_id: str) -> dict:
|
||||||
|
result = await tools.resume_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/cancel")
|
||||||
|
async def cancel_continual_plan(plan_id: str) -> dict:
|
||||||
|
result = await tools.cancel_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/run")
|
||||||
|
async def run_continual_plan(plan_id: str) -> dict:
|
||||||
|
result = await tools.run_continual_plan_now(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/events")
|
||||||
|
async def add_continual_plan_event(plan_id: str, request: ContinualPlanEventRequest) -> dict:
|
||||||
|
if not plan_store.get_plan(plan_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||||
|
event = plan_store.add_event(plan_id, request.kind, request.message, request.metadata)
|
||||||
|
return {"event": event, "plan": plan_store.get_plan(plan_id)}
|
||||||
|
|
||||||
@app.get("/api/memory")
|
@app.get("/api/memory")
|
||||||
async def inspect_memory(limit: int = 50) -> dict:
|
async def inspect_memory(limit: int = 50) -> dict:
|
||||||
return memory.inspect(max(1, min(limit, 200)))
|
return memory.inspect(max(1, min(limit, 200)))
|
||||||
@@ -300,6 +496,69 @@ def create_app() -> FastAPI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def negotiation_identifier_params(identifier: str) -> dict[str, Any]:
|
||||||
|
value = identifier.strip()
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status_code=400, detail="Negotiation id or hash is required.")
|
||||||
|
if value.isdigit():
|
||||||
|
return {"id_negotiation": int(value)}
|
||||||
|
return {"hash": value}
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_model_provider() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.model_provider == "openai":
|
||||||
|
return await inspect_openai()
|
||||||
|
return await inspect_ollama()
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_openai() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
models: list[str] = []
|
||||||
|
online = False
|
||||||
|
detail = ""
|
||||||
|
if not settings.openai_api_key:
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"running": False,
|
||||||
|
"online": False,
|
||||||
|
"provider": "openai",
|
||||||
|
"model_available": False,
|
||||||
|
"configured_model": settings.openai_model,
|
||||||
|
"base_url": settings.openai_base_url,
|
||||||
|
"models": [],
|
||||||
|
"message": "OpenAI is selected, but no API key is configured.",
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{settings.openai_base_url.rstrip('/')}/models",
|
||||||
|
headers={"Authorization": f"Bearer {settings.openai_api_key}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
online = True
|
||||||
|
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
|
||||||
|
model_available = settings.openai_model in models
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"running": online,
|
||||||
|
"online": online,
|
||||||
|
"provider": "openai",
|
||||||
|
"model_available": model_available,
|
||||||
|
"configured_model": settings.openai_model,
|
||||||
|
"base_url": settings.openai_base_url,
|
||||||
|
"models": models,
|
||||||
|
"message": openai_status_message(online, bool(settings.openai_api_key), model_available, settings.openai_model),
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def inspect_ollama() -> dict[str, Any]:
|
async def inspect_ollama() -> dict[str, Any]:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
executable = find_ollama_executable()
|
executable = find_ollama_executable()
|
||||||
@@ -325,6 +584,7 @@ async def inspect_ollama() -> dict[str, Any]:
|
|||||||
"installed": installed,
|
"installed": installed,
|
||||||
"running": online,
|
"running": online,
|
||||||
"online": online,
|
"online": online,
|
||||||
|
"provider": "ollama",
|
||||||
"model_available": model_available,
|
"model_available": model_available,
|
||||||
"configured_model": settings.ollama_model,
|
"configured_model": settings.ollama_model,
|
||||||
"base_url": settings.ollama_base_url,
|
"base_url": settings.ollama_base_url,
|
||||||
@@ -339,6 +599,16 @@ async def inspect_ollama() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def openai_status_message(running: bool, configured: bool, model_available: bool, model: str) -> str:
|
||||||
|
if not configured:
|
||||||
|
return "OpenAI API key is not configured."
|
||||||
|
if not running:
|
||||||
|
return "OpenAI is not reachable with the configured key."
|
||||||
|
if not model_available:
|
||||||
|
return f'OpenAI is reachable, but model "{model}" was not returned by the API.'
|
||||||
|
return "OpenAI is ready."
|
||||||
|
|
||||||
|
|
||||||
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
|
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
|
||||||
if not installed:
|
if not installed:
|
||||||
return "Ollama is not installed."
|
return "Ollama is not installed."
|
||||||
|
|||||||
+1111
-15
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -1,9 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.0.6"
|
||||||
|
|
||||||
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.1"
|
version = "0.0.6"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
@@ -1047,3 +1047,8 @@ wheels = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+837
-43
File diff suppressed because it is too large
Load Diff
+137
-27
@@ -9,6 +9,37 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
|
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, and inbox">
|
||||||
|
<div class="chat-rail-top">
|
||||||
|
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
||||||
|
<i data-lucide="panel-left" aria-hidden="true"></i>
|
||||||
|
<span>Menu</span>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button" id="new-chat" type="button" title="New chat">
|
||||||
|
<i data-lucide="square-pen" aria-hidden="true"></i>
|
||||||
|
<span>New chat</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-rail-content">
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading">Chats</div>
|
||||||
|
<div class="chat-list" id="chat-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading-row">
|
||||||
|
<div class="rail-heading">Plans</div>
|
||||||
|
<button class="rail-icon-button" id="plans-toggle" type="button" title="Plans" aria-expanded="false" aria-controls="plans-panel">
|
||||||
|
<i data-lucide="list-checks" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="plans-rail-list" id="plans-rail-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading">Inbox</div>
|
||||||
|
<div class="inbox-list" id="inbox-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
@@ -20,17 +51,21 @@
|
|||||||
<h1>TraderAI</h1>
|
<h1>TraderAI</h1>
|
||||||
<p>Institutional marketplace intelligence for UEX operations</p>
|
<p>Institutional marketplace intelligence for UEX operations</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="brand-short" aria-hidden="true">LBC</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="status">Ready</div>
|
<div class="status" id="status">Ready</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="warning" id="warning" hidden></div>
|
<div class="warning" id="warning" hidden></div>
|
||||||
<div class="messages" id="messages"></div>
|
<div class="messages" id="messages"></div>
|
||||||
<div class="composer-wrap">
|
<div class="composer-wrap">
|
||||||
<form class="composer" id="chat-form">
|
<form class="composer" id="chat-form">
|
||||||
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
<div class="composer-main">
|
||||||
<button type="submit">Send</button>
|
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
||||||
</form>
|
<div class="composer-images" id="composer-images" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside class="actions">
|
<aside class="actions">
|
||||||
<section class="side-section">
|
<section class="side-section">
|
||||||
@@ -38,30 +73,12 @@
|
|||||||
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="side-section sidebar-tools">
|
<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="sidebar-panel" id="settings-panel" hidden>
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<h2>Config</h2>
|
<h2>Config</h2>
|
||||||
<button class="secondary small-button" id="config-refresh" type="button">Refresh</button>
|
<button class="secondary small-button" id="config-refresh" type="button">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="config-form" id="config-form">
|
<form class="config-form" id="config-form">
|
||||||
<label>Ollama URL<input id="config-ollama-base-url" name="ollama_base_url" type="text"></label>
|
|
||||||
<label>Ollama Model<input id="config-ollama-model" name="ollama_model" type="text"></label>
|
|
||||||
<label>Context Tokens<input id="config-ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
|
||||||
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
|
<label>UEX 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 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 Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
|
||||||
@@ -102,14 +119,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sidebar-panel" id="ollama-panel" hidden>
|
<div class="sidebar-panel" id="ollama-panel" hidden>
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<h2>Ollama</h2>
|
<h2>Model Provider</h2>
|
||||||
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="config-form" id="ollama-config-form">
|
<form class="config-form" id="ollama-config-form">
|
||||||
|
<label>Provider
|
||||||
|
<select id="model-provider" name="model_provider">
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
<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>Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||||
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></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>
|
<label>OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
|
||||||
|
<label>OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
|
||||||
|
<label>OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
|
||||||
|
<datalist id="provider-models"></datalist>
|
||||||
|
<button type="submit">Save Provider Config</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="ollama-status" id="ollama-status"></div>
|
<div class="ollama-status" id="ollama-status"></div>
|
||||||
<div class="ollama-actions">
|
<div class="ollama-actions">
|
||||||
@@ -117,12 +144,95 @@
|
|||||||
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</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="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
||||||
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
||||||
|
<button class="secondary small-button" id="openai-models-refresh" type="button">Load OpenAI Models</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-status" id="ollama-message"></div>
|
<div class="config-status" id="ollama-message"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
<div class="floating-panel" id="negotiation-panel" hidden>
|
||||||
|
<div class="floating-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">UEX negotiation</p>
|
||||||
|
<h2 id="negotiation-title">Negotiation</h2>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="negotiation-messages" id="negotiation-messages"></div>
|
||||||
|
<form class="negotiation-composer" id="negotiation-form">
|
||||||
|
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
<div class="config-status" id="negotiation-status"></div>
|
||||||
|
</div>
|
||||||
|
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
|
||||||
|
<div class="floating-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Continual work</p>
|
||||||
|
<h2>Plans</h2>
|
||||||
|
</div>
|
||||||
|
<div class="floating-panel-actions">
|
||||||
|
<button class="icon-button light" id="plans-refresh" type="button" title="Refresh plans">
|
||||||
|
<i data-lucide="refresh-cw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button light" id="plans-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plans-panel-body">
|
||||||
|
<form class="config-form" id="plan-form">
|
||||||
|
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
|
||||||
|
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
|
||||||
|
<label>Kind
|
||||||
|
<select id="plan-kind">
|
||||||
|
<option value="buying">Buying</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
|
||||||
|
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
|
||||||
|
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
|
||||||
|
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
|
||||||
|
<button type="submit">Create Plan</button>
|
||||||
|
<div class="config-status" id="plans-status"></div>
|
||||||
|
</form>
|
||||||
|
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" id="update-modal" hidden>
|
||||||
|
<section class="update-modal-card">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>Update Available</h2>
|
||||||
|
<button class="icon-button light" id="update-modal-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="update-modal-copy"></p>
|
||||||
|
<div class="update-actions">
|
||||||
|
<button class="secondary small-button" id="update-modal-releases" type="button">Releases</button>
|
||||||
|
<button class="small-button" id="update-modal-install" type="button">Update</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
|
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+768
-36
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user