Compare commits
5 Commits
d6c2d57fd9
...
0.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
6bd1e81a51
|
|||
|
a5a718b3e4
|
|||
|
7b65b62f58
|
|||
|
97c751c585
|
|||
|
e2f87481d6
|
@@ -1,6 +1,10 @@
|
|||||||
|
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=64512
|
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
|
SCMDB_BASE_URL=https://scmdb.net
|
||||||
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ 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`.
|
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||||
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||||
4. Install and run:
|
4. Install and run:
|
||||||
@@ -38,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; `64512` 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
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.3"
|
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 = [
|
||||||
@@ -37,3 +37,6 @@ include = ["traderai*"]
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ class TitleAgent(OllamaAgent):
|
|||||||
return {"message": {"role": "assistant", "content": "Done"}}
|
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):
|
class SlowToolTools(EmptyTools):
|
||||||
schemas = [
|
schemas = [
|
||||||
{
|
{
|
||||||
@@ -229,6 +242,23 @@ async def test_first_chat_message_generates_thread_title(tmp_path):
|
|||||||
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
||||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
|||||||
@@ -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"])
|
||||||
+89
-3
@@ -230,7 +230,10 @@ class FakeCornerstone:
|
|||||||
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
||||||
"html": """
|
"html": """
|
||||||
<html>
|
<html>
|
||||||
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head>
|
<head>
|
||||||
|
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
|
||||||
|
<meta property="og:image" content="/images/abrade.png">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<table>
|
<table>
|
||||||
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
||||||
@@ -246,6 +249,15 @@ class FakeCornerstone:
|
|||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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():
|
||||||
@@ -379,6 +391,8 @@ def test_schemas_expose_cornerstone_item_tools():
|
|||||||
|
|
||||||
assert "search_cornerstone_items" in names
|
assert "search_cornerstone_items" in names
|
||||||
assert "get_cornerstone_item_locations" 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
|
@pytest.mark.asyncio
|
||||||
@@ -441,18 +455,90 @@ async def test_get_cornerstone_item_locations_parses_store_prices():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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():
|
def test_parse_cornerstone_item_page_extracts_locations():
|
||||||
parsed = parse_cornerstone_item_page(
|
parsed = parse_cornerstone_item_page(
|
||||||
"""
|
"""
|
||||||
<html><head><title>Star Citizen - Food - Whamburger</title></head>
|
<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>
|
<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>
|
<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>
|
<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["name"] == "Whamburger"
|
||||||
assert parsed["locations"][0]["base_price"] == 9
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+385
-30
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
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
|
||||||
@@ -14,14 +15,17 @@ 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.
|
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 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.
|
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.
|
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."""
|
||||||
|
|
||||||
@@ -35,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
|
||||||
@@ -42,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.provider = provider.strip().casefold() or "ollama"
|
||||||
|
self.api_key = api_key
|
||||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
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")
|
||||||
@@ -74,20 +84,30 @@ class OllamaAgent:
|
|||||||
if not health["online"]:
|
if not health["online"]:
|
||||||
raise OllamaUnavailable(health["message"])
|
raise OllamaUnavailable(health["message"])
|
||||||
|
|
||||||
async def chat(self, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> 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()
|
||||||
resolved_thread_id = self._thread_id(thread_id)
|
resolved_thread_id = self._thread_id(thread_id)
|
||||||
messages = self._messages_for_thread(resolved_thread_id)
|
messages = self._messages_for_thread(resolved_thread_id)
|
||||||
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
|
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, resolved_thread_id)
|
self.memory.add_conversation("user", memory_content, resolved_thread_id)
|
||||||
await self._title_first_message(resolved_thread_id, content, previous_interaction)
|
await self._title_first_message(resolved_thread_id, prompt_text, previous_interaction)
|
||||||
messages.append({"role": "user", "content": content})
|
messages.append(self._user_message(prompt_text, normalized_images))
|
||||||
last_tool_results: list[dict[str, Any]] = []
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
for _ in range(5):
|
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):
|
||||||
try:
|
try:
|
||||||
response = await self._ollama_chat(
|
response = await self._chat_once(
|
||||||
content,
|
prompt_text,
|
||||||
messages,
|
messages,
|
||||||
previous_interaction=previous_interaction,
|
previous_interaction=previous_interaction,
|
||||||
thread_id=resolved_thread_id,
|
thread_id=resolved_thread_id,
|
||||||
@@ -97,7 +117,7 @@ class OllamaAgent:
|
|||||||
raise
|
raise
|
||||||
answer = self._tool_result_fallback(
|
answer = self._tool_result_fallback(
|
||||||
last_tool_results,
|
last_tool_results,
|
||||||
f"The local model stopped after the tool call: {exc}",
|
f"The {self._provider_label()} stopped after the tool call: {exc}",
|
||||||
)
|
)
|
||||||
messages.append({"role": "assistant", "content": answer})
|
messages.append({"role": "assistant", "content": answer})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
@@ -119,15 +139,19 @@ class OllamaAgent:
|
|||||||
name, arguments = self._extract_call(call)
|
name, arguments = self._extract_call(call)
|
||||||
result = await self.tools.execute(name, arguments)
|
result = await self.tools.execute(name, arguments)
|
||||||
last_tool_results.append({"tool": name, "result": result})
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
messages.append({"role": "tool", "tool_name": name, "content": json.dumps(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."
|
||||||
messages.append({"role": "assistant", "content": fallback})
|
messages.append({"role": "assistant", "content": fallback})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||||
return {"message": fallback, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
return {"message": fallback, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||||
|
|
||||||
async def chat_events(self, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> 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"]}
|
||||||
@@ -137,20 +161,24 @@ class OllamaAgent:
|
|||||||
resolved_thread_id = self._thread_id(thread_id)
|
resolved_thread_id = self._thread_id(thread_id)
|
||||||
messages = self._messages_for_thread(resolved_thread_id)
|
messages = self._messages_for_thread(resolved_thread_id)
|
||||||
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
|
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, resolved_thread_id)
|
self.memory.add_conversation("user", memory_content, resolved_thread_id)
|
||||||
await self._title_first_message(resolved_thread_id, content, previous_interaction)
|
await self._title_first_message(resolved_thread_id, prompt_text, previous_interaction)
|
||||||
messages.append({"role": "user", "content": content})
|
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]] = []
|
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()
|
||||||
for _ in range(5):
|
with image_scope:
|
||||||
|
for _ in range(10):
|
||||||
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
||||||
tool_calls: list[dict[str, Any]] = []
|
tool_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for event in self._ollama_chat_stream(
|
async for event in self._chat_stream_once(
|
||||||
content,
|
prompt_text,
|
||||||
messages,
|
messages,
|
||||||
previous_interaction=previous_interaction,
|
previous_interaction=previous_interaction,
|
||||||
thread_id=resolved_thread_id,
|
thread_id=resolved_thread_id,
|
||||||
@@ -173,7 +201,7 @@ class OllamaAgent:
|
|||||||
return
|
return
|
||||||
fallback = self._tool_result_fallback(
|
fallback = self._tool_result_fallback(
|
||||||
last_tool_results,
|
last_tool_results,
|
||||||
f"The local model stopped after the tool call: {exc}",
|
f"The {self._provider_label()} stopped after the tool call: {exc}",
|
||||||
)
|
)
|
||||||
assistant_message["content"] = fallback
|
assistant_message["content"] = fallback
|
||||||
messages.append(assistant_message)
|
messages.append(assistant_message)
|
||||||
@@ -201,10 +229,9 @@ class OllamaAgent:
|
|||||||
yield {"type": "status", "message": self._tool_status(name)}
|
yield {"type": "status", "message": self._tool_status(name)}
|
||||||
result = await self.tools.execute(name, arguments)
|
result = await self.tools.execute(name, arguments)
|
||||||
last_tool_results.append({"tool": name, "result": result})
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "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."
|
||||||
messages.append({"role": "assistant", "content": fallback})
|
messages.append({"role": "assistant", "content": fallback})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
@@ -218,9 +245,9 @@ class OllamaAgent:
|
|||||||
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
||||||
messages.append({"role": "user", "content": wake_message})
|
messages.append({"role": "user", "content": wake_message})
|
||||||
last_tool_results: list[dict[str, Any]] = []
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
for _ in range(5):
|
for _ in range(10):
|
||||||
try:
|
try:
|
||||||
response = await self._ollama_chat(
|
response = await self._chat_once(
|
||||||
wake_message,
|
wake_message,
|
||||||
messages,
|
messages,
|
||||||
previous_interaction=previous_interaction,
|
previous_interaction=previous_interaction,
|
||||||
@@ -231,7 +258,7 @@ class OllamaAgent:
|
|||||||
raise
|
raise
|
||||||
content = self._tool_result_fallback(
|
content = self._tool_result_fallback(
|
||||||
last_tool_results,
|
last_tool_results,
|
||||||
f"The local model stopped after the wake-job tool call: {exc}",
|
f"The {self._provider_label()} stopped after the wake-job tool call: {exc}",
|
||||||
)
|
)
|
||||||
messages.append({"role": "assistant", "content": content})
|
messages.append({"role": "assistant", "content": content})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
@@ -255,8 +282,7 @@ class OllamaAgent:
|
|||||||
name, arguments = self._extract_call(call)
|
name, arguments = self._extract_call(call)
|
||||||
result = await self.tools.execute(name, arguments)
|
result = await self.tools.execute(name, arguments)
|
||||||
last_tool_results.append({"tool": name, "result": result})
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
messages.append({"role": "tool", "tool_name": name, "content": json.dumps(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."
|
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})
|
messages.append({"role": "assistant", "content": content})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
@@ -264,6 +290,51 @@ class OllamaAgent:
|
|||||||
self.memory.add_conversation("assistant", content, "wake")
|
self.memory.add_conversation("assistant", content, "wake")
|
||||||
return content
|
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(
|
async def _ollama_chat(
|
||||||
self,
|
self,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
@@ -319,6 +390,103 @@ 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,
|
||||||
@@ -326,21 +494,146 @@ class OllamaAgent:
|
|||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
context = self._runtime_context(query, previous_interaction=previous_interaction, thread_id=thread_id)
|
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 messages
|
return messages
|
||||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||||
|
|
||||||
|
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(
|
def _runtime_context(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
attached_image_count: int = 0,
|
||||||
) -> str:
|
) -> 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)
|
uex = getattr(self.tools, "uex", None)
|
||||||
if uex:
|
if uex:
|
||||||
auth_methods = []
|
auth_methods = []
|
||||||
@@ -430,6 +723,24 @@ class OllamaAgent:
|
|||||||
f"Message: {first_message[:800]}"
|
f"Message: {first_message[:800]}"
|
||||||
)
|
)
|
||||||
try:
|
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:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/api/chat",
|
f"{self.base_url}/api/chat",
|
||||||
@@ -472,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()
|
||||||
]
|
]
|
||||||
@@ -485,10 +797,10 @@ class OllamaAgent:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _empty_response_fallback(tool_results: list[dict[str, Any]]) -> str:
|
def _empty_response_fallback(tool_results: list[dict[str, Any]]) -> str:
|
||||||
if not tool_results:
|
if not tool_results:
|
||||||
return "I did not get a usable response from the local model. Please try again, or narrow the request a bit."
|
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(
|
return OllamaAgent._tool_result_fallback(
|
||||||
tool_results,
|
tool_results,
|
||||||
"I completed the tool call, but the local model did not write a final answer.",
|
"I completed the tool call, but the model did not write a final answer.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -521,6 +833,7 @@ class OllamaAgent:
|
|||||||
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
||||||
"search_cornerstone_items": "Searching Cornerstone items",
|
"search_cornerstone_items": "Searching Cornerstone items",
|
||||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
"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",
|
||||||
@@ -531,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}")
|
||||||
@@ -627,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
|
||||||
|
|||||||
+16
-2
@@ -11,12 +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},
|
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
||||||
"cornerstone_base_url": {"env": "CORNERSTONE_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},
|
||||||
@@ -62,12 +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 = 64512
|
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"
|
scmdb_base_url: str = "https://scmdb.net"
|
||||||
cornerstone_base_url: str = "https://finder.cstone.space"
|
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)
|
||||||
@@ -75,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:
|
||||||
@@ -137,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":
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -41,6 +43,23 @@ class CornerstoneClient:
|
|||||||
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
||||||
return {"url": str(response.url), "html": response.text}
|
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 def _get_json(self, path: str) -> Any:
|
||||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
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"})
|
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||||
@@ -58,6 +77,7 @@ class CornerstonePageParser(HTMLParser):
|
|||||||
super().__init__(convert_charrefs=True)
|
super().__init__(convert_charrefs=True)
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.tables: list[list[list[str]]] = []
|
self.tables: list[list[list[str]]] = []
|
||||||
|
self.images: list[dict[str, str]] = []
|
||||||
self._skip_depth = 0
|
self._skip_depth = 0
|
||||||
self._in_title = False
|
self._in_title = False
|
||||||
self._current_table: list[list[str]] | None = None
|
self._current_table: list[list[str]] | None = None
|
||||||
@@ -73,6 +93,29 @@ class CornerstonePageParser(HTMLParser):
|
|||||||
return
|
return
|
||||||
if tag == "title":
|
if tag == "title":
|
||||||
self._in_title = True
|
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":
|
elif tag == "table":
|
||||||
self._current_table = []
|
self._current_table = []
|
||||||
elif tag == "tr" and self._current_table is not None:
|
elif tag == "tr" and self._current_table is not None:
|
||||||
@@ -110,8 +153,12 @@ class CornerstonePageParser(HTMLParser):
|
|||||||
if self._current_cell is not None:
|
if self._current_cell is not None:
|
||||||
self._current_cell.append(data)
|
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) -> dict[str, Any]:
|
|
||||||
|
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
|
||||||
parser = CornerstonePageParser()
|
parser = CornerstonePageParser()
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
||||||
@@ -142,6 +189,9 @@ def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
|
|||||||
general[key] = value
|
general[key] = value
|
||||||
|
|
||||||
info["name"] = general.get("name") or _name_from_title(info["page_title"])
|
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:
|
if general:
|
||||||
info["general"] = general
|
info["general"] = general
|
||||||
info["locations"] = locations
|
info["locations"] = locations
|
||||||
@@ -157,3 +207,20 @@ def _name_from_title(title: str) -> str | None:
|
|||||||
if " - " not in title:
|
if " - " not in title:
|
||||||
return title or None
|
return title or None
|
||||||
return title.rsplit(" - ", 1)[-1].strip() 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
|
||||||
|
|||||||
@@ -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)}"
|
||||||
|
)
|
||||||
+73
-2
@@ -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"])
|
||||||
|
|||||||
+190
-7
@@ -23,6 +23,7 @@ from traderai.config import save_settings, settings_payload
|
|||||||
from traderai.config import get_settings
|
from traderai.config import get_settings
|
||||||
from traderai.cornerstone_client import CornerstoneClient
|
from traderai.cornerstone_client import CornerstoneClient
|
||||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
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.scmdb_client import SCMDBClient
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
@@ -38,6 +39,13 @@ def resource_path(*parts: str) -> Path:
|
|||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID
|
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):
|
class ChatThreadRequest(BaseModel):
|
||||||
@@ -60,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
|
||||||
|
|
||||||
@@ -75,6 +104,7 @@ 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)
|
||||||
scmdb = SCMDBClient(settings.scmdb_base_url)
|
scmdb = SCMDBClient(settings.scmdb_base_url)
|
||||||
@@ -86,16 +116,23 @@ def create_app() -> FastAPI:
|
|||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
scmdb=scmdb,
|
scmdb=scmdb,
|
||||||
cornerstone=cornerstone,
|
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")
|
||||||
@@ -143,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"],
|
||||||
@@ -162,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:
|
||||||
@@ -173,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
|
||||||
|
|
||||||
@@ -190,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
|
||||||
|
|
||||||
@@ -270,14 +320,22 @@ 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, thread_id=request.thread_id)
|
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, thread_id=request.thread_id):
|
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")
|
||||||
@@ -348,6 +406,66 @@ def create_app() -> FastAPI:
|
|||||||
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)))
|
||||||
@@ -387,6 +505,60 @@ def negotiation_identifier_params(identifier: str) -> dict[str, Any]:
|
|||||||
return {"hash": 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()
|
||||||
@@ -412,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,
|
||||||
@@ -426,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."
|
||||||
|
|||||||
+448
-11
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
@@ -148,6 +150,7 @@ class PendingAction:
|
|||||||
endpoint: str
|
endpoint: str
|
||||||
payload: dict[str, Any]
|
payload: dict[str, Any]
|
||||||
method: str = "POST"
|
method: str = "POST"
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ToolRegistry:
|
class ToolRegistry:
|
||||||
@@ -159,6 +162,8 @@ class ToolRegistry:
|
|||||||
scheduler: WakeScheduler | None = None,
|
scheduler: WakeScheduler | None = None,
|
||||||
scmdb: SCMDBClient | None = None,
|
scmdb: SCMDBClient | None = None,
|
||||||
cornerstone: CornerstoneClient | None = None,
|
cornerstone: CornerstoneClient | None = None,
|
||||||
|
plan_store: Any | None = None,
|
||||||
|
plan_runner: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.uex = uex
|
self.uex = uex
|
||||||
self.scmdb = scmdb or SCMDBClient()
|
self.scmdb = scmdb or SCMDBClient()
|
||||||
@@ -166,7 +171,10 @@ class ToolRegistry:
|
|||||||
self.require_write_approval = require_write_approval
|
self.require_write_approval = require_write_approval
|
||||||
self.memory = memory
|
self.memory = memory
|
||||||
self.scheduler = scheduler
|
self.scheduler = scheduler
|
||||||
|
self.plan_store = plan_store
|
||||||
|
self.plan_runner = plan_runner
|
||||||
self.pending_actions: dict[str, PendingAction] = {}
|
self.pending_actions: dict[str, PendingAction] = {}
|
||||||
|
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
|
||||||
self.handlers: dict[str, ToolHandler] = {
|
self.handlers: dict[str, ToolHandler] = {
|
||||||
"search_marketplace_listings": self.search_marketplace_listings,
|
"search_marketplace_listings": self.search_marketplace_listings,
|
||||||
"get_marketplace_listing": self.get_marketplace_listing,
|
"get_marketplace_listing": self.get_marketplace_listing,
|
||||||
@@ -178,12 +186,21 @@ class ToolRegistry:
|
|||||||
"recall_memory": self.recall_memory,
|
"recall_memory": self.recall_memory,
|
||||||
"schedule_wake_job": self.schedule_wake_job,
|
"schedule_wake_job": self.schedule_wake_job,
|
||||||
"list_wake_jobs": self.list_wake_jobs,
|
"list_wake_jobs": self.list_wake_jobs,
|
||||||
|
"create_continual_plan": self.create_continual_plan,
|
||||||
|
"list_continual_plans": self.list_continual_plans,
|
||||||
|
"get_continual_plan": self.get_continual_plan,
|
||||||
|
"pause_continual_plan": self.pause_continual_plan,
|
||||||
|
"resume_continual_plan": self.resume_continual_plan,
|
||||||
|
"cancel_continual_plan": self.cancel_continual_plan,
|
||||||
|
"run_continual_plan_now": self.run_continual_plan_now,
|
||||||
"check_uex_notifications": self.check_uex_notifications,
|
"check_uex_notifications": self.check_uex_notifications,
|
||||||
"list_scmdb_versions": self.list_scmdb_versions,
|
"list_scmdb_versions": self.list_scmdb_versions,
|
||||||
"search_scmdb_missions": self.search_scmdb_missions,
|
"search_scmdb_missions": self.search_scmdb_missions,
|
||||||
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
|
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
|
||||||
"search_cornerstone_items": self.search_cornerstone_items,
|
"search_cornerstone_items": self.search_cornerstone_items,
|
||||||
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
||||||
|
"get_cornerstone_item_media": self.get_cornerstone_item_media,
|
||||||
|
"draft_marketplace_listing_with_cornerstone_image": self.draft_marketplace_listing_with_cornerstone_image,
|
||||||
}
|
}
|
||||||
self.handlers["uex_api_catalog"] = self.uex_api_catalog
|
self.handlers["uex_api_catalog"] = self.uex_api_catalog
|
||||||
self.handlers["uex_get"] = self.uex_get
|
self.handlers["uex_get"] = self.uex_get
|
||||||
@@ -285,6 +302,11 @@ class ToolRegistry:
|
|||||||
"message": {"type": "string"},
|
"message": {"type": "string"},
|
||||||
"hash": {"type": "string"},
|
"hash": {"type": "string"},
|
||||||
"id_negotiation": {"type": "integer"},
|
"id_negotiation": {"type": "integer"},
|
||||||
|
"id_listing": {"type": "integer"},
|
||||||
|
"plan_id": {"type": "string"},
|
||||||
|
"plan_item_id": {"type": "integer"},
|
||||||
|
"candidate_id": {"type": "integer"},
|
||||||
|
"listing_slug": {"type": "string"},
|
||||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -294,7 +316,7 @@ class ToolRegistry:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "draft_marketplace_listing",
|
"name": "draft_marketplace_listing",
|
||||||
"description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting.",
|
"description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting. Prefer draft_marketplace_listing_with_cornerstone_image for item posts when a Cornerstone image is useful.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
|
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
|
||||||
@@ -315,8 +337,21 @@ class ToolRegistry:
|
|||||||
"source": {"type": "string"},
|
"source": {"type": "string"},
|
||||||
"availability": {"type": "string"},
|
"availability": {"type": "string"},
|
||||||
"in_stock": {"type": "integer"},
|
"in_stock": {"type": "integer"},
|
||||||
|
"durability": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||||
|
"video_url": {"type": "string"},
|
||||||
|
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
|
||||||
|
"use_attached_image": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "When true, reuse an image pasted into the current chat as the listing image_data.",
|
||||||
|
},
|
||||||
|
"attached_image_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Zero-based pasted image index to reuse when use_attached_image is true.",
|
||||||
|
},
|
||||||
"hours_expiration": {"type": "integer"},
|
"hours_expiration": {"type": "integer"},
|
||||||
"is_hidden": {"type": "integer", "enum": [0, 1]},
|
"is_hidden": {"type": "integer", "enum": [0, 1]},
|
||||||
|
"is_tv_allowed": {"type": "integer", "enum": [0, 1]},
|
||||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -376,6 +411,83 @@ class ToolRegistry:
|
|||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "create_continual_plan",
|
||||||
|
"description": "Create a durable multi-run plan. Use this for long-running marketplace work over days. kind=buying uses structured listing/candidate tracking; kind=custom continues through an agent wake prompt. All UEX writes are draft-only for approval.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "objective"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"objective": {"type": "string"},
|
||||||
|
"kind": {"type": "string", "enum": ["buying", "custom"], "default": "buying"},
|
||||||
|
"cadence": {"type": "string", "description": "Five-field cron expression, default every six hours."},
|
||||||
|
"constraints": {"type": "object", "description": "Plan-specific options such as message_tone, excluded_sellers, preferred_locations, max_unit_price, or custom instructions."},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"item_name": {"type": "string"},
|
||||||
|
"desired_quantity": {"type": "integer", "minimum": 1},
|
||||||
|
"max_unit_price": {"type": "number"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_continual_plans",
|
||||||
|
"description": "List durable continual plans and their statuses.",
|
||||||
|
"parameters": {"type": "object", "properties": {"include_inactive": {"type": "boolean", "default": True}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_continual_plan",
|
||||||
|
"description": "Get one continual plan with checklist items, candidates, negotiations, and event history.",
|
||||||
|
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "pause_continual_plan",
|
||||||
|
"description": "Pause a continual plan so scheduled runs stop.",
|
||||||
|
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "resume_continual_plan",
|
||||||
|
"description": "Resume a paused or needs-input continual plan. It only becomes active when it has checklist items.",
|
||||||
|
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "cancel_continual_plan",
|
||||||
|
"description": "Cancel a continual plan.",
|
||||||
|
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "run_continual_plan_now",
|
||||||
|
"description": "Run one continual plan immediately and put the result in the Inbox.",
|
||||||
|
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -395,18 +507,30 @@ class ToolRegistry:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"error": str(exc)}
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def chat_image_scope(self, images: list[dict[str, Any]] | None):
|
||||||
|
token = self._chat_images_var.set(self._normalize_chat_images(images))
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._chat_images_var.reset(token)
|
||||||
|
|
||||||
async def approve(self, action_id: str) -> dict[str, Any]:
|
async def approve(self, action_id: str) -> dict[str, Any]:
|
||||||
action = self.pending_actions.pop(action_id, None)
|
action = self.pending_actions.pop(action_id, None)
|
||||||
if not action:
|
if not action:
|
||||||
return {"error": f"Pending action not found: {action_id}"}
|
return {"error": f"Pending action not found: {action_id}"}
|
||||||
if action.method == "DELETE":
|
if action.method == "DELETE":
|
||||||
return await self.uex.delete(action.endpoint, action.payload, authenticated=True)
|
result = await self.uex.delete(action.endpoint, action.payload, authenticated=True)
|
||||||
return await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
|
else:
|
||||||
|
result = await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
|
||||||
|
self._record_pending_action_result(action, "approved", result)
|
||||||
|
return result
|
||||||
|
|
||||||
async def decline(self, action_id: str) -> dict[str, Any]:
|
async def decline(self, action_id: str) -> dict[str, Any]:
|
||||||
action = self.pending_actions.pop(action_id, None)
|
action = self.pending_actions.pop(action_id, None)
|
||||||
if not action:
|
if not action:
|
||||||
return {"error": f"Pending action not found: {action_id}"}
|
return {"error": f"Pending action not found: {action_id}"}
|
||||||
|
self._record_pending_action_result(action, "declined", {})
|
||||||
return {
|
return {
|
||||||
"declined": True,
|
"declined": True,
|
||||||
"pending_action": {
|
"pending_action": {
|
||||||
@@ -414,7 +538,8 @@ class ToolRegistry:
|
|||||||
"label": action.label,
|
"label": action.label,
|
||||||
"method": action.method,
|
"method": action.method,
|
||||||
"endpoint": action.endpoint,
|
"endpoint": action.endpoint,
|
||||||
"payload": action.payload,
|
"payload": self._display_payload(action.payload),
|
||||||
|
"metadata": action.metadata or {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,6 +999,70 @@ class ToolRegistry:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_cornerstone_item_media",
|
||||||
|
"description": "Fetch Cornerstone item page media, especially image URLs that can be used when drafting UEX marketplace listings.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."},
|
||||||
|
"query": {"type": "string", "description": "Item name if id is not known."},
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "draft_marketplace_listing_with_cornerstone_image",
|
||||||
|
"description": "Draft a UEX marketplace listing and source the listing image from Cornerstone. The image is downloaded as base64 image_data and included in the pending action. Nothing is posted until user approval.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["item_query", "id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
|
||||||
|
"properties": {
|
||||||
|
"item_query": {"type": "string", "description": "Cornerstone item name to source an image from."},
|
||||||
|
"cornerstone_id": {"type": "string", "description": "Cornerstone item id, if already known."},
|
||||||
|
"id_item": {"type": "integer"},
|
||||||
|
"id_star_system": {"type": "integer"},
|
||||||
|
"id_terminal": {"type": "integer"},
|
||||||
|
"id_organization": {"type": "integer"},
|
||||||
|
"id_category": {"type": "integer"},
|
||||||
|
"operation": {"type": "string", "enum": ["buy", "sell", "rent", "trade"]},
|
||||||
|
"type": {"type": "string", "enum": ["item", "service", "contract"]},
|
||||||
|
"unit": {"type": "string"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"price": {"type": "number"},
|
||||||
|
"currency": {"type": "string", "enum": ["UEC"]},
|
||||||
|
"language": {"type": "string", "default": "en_US"},
|
||||||
|
"location": {"type": "string"},
|
||||||
|
"source": {"type": "string", "enum": ["looted", "pledged", "purchased_in_game", "pirated", "gifted"]},
|
||||||
|
"availability": {"type": "string"},
|
||||||
|
"in_stock": {"type": "integer"},
|
||||||
|
"durability": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||||
|
"video_url": {"type": "string"},
|
||||||
|
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."},
|
||||||
|
"use_attached_image": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "When true, reuse an image pasted into the current chat as the listing image_data instead of sourcing from Cornerstone.",
|
||||||
|
},
|
||||||
|
"attached_image_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Zero-based pasted image index to reuse when use_attached_image is true.",
|
||||||
|
},
|
||||||
|
"hours_expiration": {"type": "integer"},
|
||||||
|
"is_hidden": {"type": "integer", "enum": [0, 1]},
|
||||||
|
"is_tv_allowed": {"type": "integer", "enum": [0, 1]},
|
||||||
|
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
||||||
|
"require_image": {"type": "boolean", "default": False, "description": "Return an error instead of drafting if no Cornerstone JPG/PNG image can be sourced."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1046,13 +1235,89 @@ class ToolRegistry:
|
|||||||
message: str,
|
message: str,
|
||||||
hash: str | None = None,
|
hash: str | None = None,
|
||||||
id_negotiation: int | None = None,
|
id_negotiation: int | None = None,
|
||||||
|
id_listing: int | None = None,
|
||||||
|
plan_id: str | None = None,
|
||||||
|
plan_item_id: int | None = None,
|
||||||
|
candidate_id: int | None = None,
|
||||||
|
listing_slug: str | None = None,
|
||||||
is_production: int = 1,
|
is_production: int = 1,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production}
|
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "id_listing": id_listing, "is_production": is_production}
|
||||||
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload)
|
metadata = {
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"plan_item_id": plan_item_id,
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"listing_id": id_listing,
|
||||||
|
"listing_slug": listing_slug,
|
||||||
|
"hash": hash,
|
||||||
|
"id_negotiation": id_negotiation,
|
||||||
|
}
|
||||||
|
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata)
|
||||||
|
|
||||||
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
|
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
|
||||||
return self._pending("Post marketplace listing", "marketplace_advertise", payload)
|
attached_image = self._attach_chat_image(payload)
|
||||||
|
if attached_image.get("error"):
|
||||||
|
return {"error": attached_image["error"]}
|
||||||
|
return self._pending(
|
||||||
|
"Post marketplace listing",
|
||||||
|
"marketplace_advertise",
|
||||||
|
payload,
|
||||||
|
metadata=attached_image.get("metadata"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def draft_marketplace_listing_with_cornerstone_image(
|
||||||
|
self,
|
||||||
|
item_query: str,
|
||||||
|
cornerstone_id: str | None = None,
|
||||||
|
**payload: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_image = bool(payload.pop("require_image", False))
|
||||||
|
attached_image = self._attach_chat_image(payload)
|
||||||
|
if attached_image.get("error"):
|
||||||
|
return {"error": attached_image["error"]}
|
||||||
|
item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query)
|
||||||
|
if not item:
|
||||||
|
return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."}
|
||||||
|
|
||||||
|
page = await self.cornerstone.get_item_page(str(item["id"]))
|
||||||
|
parsed = parse_cornerstone_item_page(page["html"], page["url"])
|
||||||
|
media = parsed.get("media") or []
|
||||||
|
image_result: dict[str, Any] | None = None
|
||||||
|
image_error = ""
|
||||||
|
for media_item in media:
|
||||||
|
try:
|
||||||
|
image_result = await self.cornerstone.get_image_data(media_item["url"])
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
image_error = str(exc)
|
||||||
|
|
||||||
|
if image_result and not payload.get("image_data"):
|
||||||
|
payload["image_data"] = image_result["image_data"]
|
||||||
|
elif require_image and not payload.get("image_data"):
|
||||||
|
return {
|
||||||
|
"error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.",
|
||||||
|
"cornerstone": {
|
||||||
|
"item": {"id": item.get("id"), "name": parsed.get("name") or item.get("name")},
|
||||||
|
"url": page["url"],
|
||||||
|
"media": media,
|
||||||
|
"image_error": image_error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.setdefault("id_item", self._int_or_none(item.get("id")))
|
||||||
|
metadata = {
|
||||||
|
"cornerstone_item_id": item.get("id"),
|
||||||
|
"cornerstone_item_name": parsed.get("name") or item.get("name"),
|
||||||
|
"cornerstone_url": page["url"],
|
||||||
|
"cornerstone_image_url": image_result.get("url") if image_result else None,
|
||||||
|
"cornerstone_image_content_type": image_result.get("content_type") if image_result else None,
|
||||||
|
"cornerstone_image_size_bytes": image_result.get("size_bytes") if image_result else None,
|
||||||
|
"cornerstone_image_status": "user_attached" if attached_image.get("metadata") else ("included" if image_result else "not_found"),
|
||||||
|
"cornerstone_image_error": image_error or None,
|
||||||
|
}
|
||||||
|
if attached_image.get("metadata"):
|
||||||
|
metadata.update(attached_image["metadata"])
|
||||||
|
return self._pending("Post marketplace listing with Cornerstone image", "marketplace_advertise", payload, metadata=metadata)
|
||||||
|
|
||||||
async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]:
|
async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]:
|
||||||
if self.memory is None:
|
if self.memory is None:
|
||||||
@@ -1083,6 +1348,68 @@ class ToolRegistry:
|
|||||||
return {"error": "Scheduler is not configured."}
|
return {"error": "Scheduler is not configured."}
|
||||||
return {"scheduled_jobs": self.scheduler.list_jobs()}
|
return {"scheduled_jobs": self.scheduler.list_jobs()}
|
||||||
|
|
||||||
|
async def create_continual_plan(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
objective: str,
|
||||||
|
kind: str = "buying",
|
||||||
|
items: list[dict[str, Any]] | None = None,
|
||||||
|
constraints: dict[str, Any] | None = None,
|
||||||
|
cadence: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if self.plan_store is None:
|
||||||
|
return {"error": "Continual plan store is not configured."}
|
||||||
|
plan = self.plan_store.create_plan(title, kind=kind, objective=objective, items=items or [], constraints=constraints or {}, cadence=cadence)
|
||||||
|
if self.scheduler is not None and plan.get("status") == "active":
|
||||||
|
self.scheduler.schedule_plan(plan)
|
||||||
|
plan = self.plan_store.get_plan(plan["id"]) or plan
|
||||||
|
return {"plan": plan}
|
||||||
|
|
||||||
|
async def list_continual_plans(self, include_inactive: bool = True) -> dict[str, Any]:
|
||||||
|
if self.plan_store is None:
|
||||||
|
return {"error": "Continual plan store is not configured."}
|
||||||
|
return {"plans": self.plan_store.list_plans(include_inactive=include_inactive)}
|
||||||
|
|
||||||
|
async def get_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
if self.plan_store is None:
|
||||||
|
return {"error": "Continual plan store is not configured."}
|
||||||
|
plan = self.plan_store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return {"error": f"Plan not found: {plan_id}"}
|
||||||
|
return {"plan": plan}
|
||||||
|
|
||||||
|
async def pause_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
if self.plan_store is None:
|
||||||
|
return {"error": "Continual plan store is not configured."}
|
||||||
|
if self.scheduler is not None:
|
||||||
|
self.scheduler.unschedule_plan(plan_id)
|
||||||
|
return {"plan": self.plan_store.set_status(plan_id, "paused")}
|
||||||
|
|
||||||
|
async def resume_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
if self.plan_store is None:
|
||||||
|
return {"error": "Continual plan store is not configured."}
|
||||||
|
plan = self.plan_store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return {"error": f"Plan not found: {plan_id}"}
|
||||||
|
next_status = "active" if plan.get("items") else "needs_input"
|
||||||
|
plan = self.plan_store.set_status(plan_id, next_status)
|
||||||
|
if self.scheduler is not None and plan and plan.get("status") == "active":
|
||||||
|
self.scheduler.schedule_plan(plan)
|
||||||
|
plan = self.plan_store.get_plan(plan_id)
|
||||||
|
return {"plan": plan}
|
||||||
|
|
||||||
|
async def cancel_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
if self.plan_store is None:
|
||||||
|
return {"error": "Continual plan store is not configured."}
|
||||||
|
if self.scheduler is not None:
|
||||||
|
self.scheduler.unschedule_plan(plan_id)
|
||||||
|
return {"plan": self.plan_store.set_status(plan_id, "canceled")}
|
||||||
|
|
||||||
|
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
if self.plan_runner is None:
|
||||||
|
return {"error": "Continual plan runner is not configured."}
|
||||||
|
return await self.plan_runner.run_plan(plan_id)
|
||||||
|
|
||||||
async def check_uex_notifications(self) -> dict[str, Any]:
|
async def check_uex_notifications(self) -> dict[str, Any]:
|
||||||
response = await self.uex.get_user_notifications()
|
response = await self.uex.get_user_notifications()
|
||||||
notifications = response.get("notifications") or []
|
notifications = response.get("notifications") or []
|
||||||
@@ -1255,7 +1582,7 @@ class ToolRegistry:
|
|||||||
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
|
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
|
||||||
|
|
||||||
page = await self.cornerstone.get_item_page(str(item["id"]))
|
page = await self.cornerstone.get_item_page(str(item["id"]))
|
||||||
parsed = parse_cornerstone_item_page(page["html"])
|
parsed = parse_cornerstone_item_page(page["html"], page["url"])
|
||||||
locations = parsed.get("locations") or []
|
locations = parsed.get("locations") or []
|
||||||
location_filter = (location or "").casefold().strip()
|
location_filter = (location or "").casefold().strip()
|
||||||
if location_filter:
|
if location_filter:
|
||||||
@@ -1280,22 +1607,132 @@ class ToolRegistry:
|
|||||||
"locations": locations[:limit],
|
"locations": locations[:limit],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
|
async def get_cornerstone_item_media(
|
||||||
|
self,
|
||||||
|
id: str | None = None,
|
||||||
|
query: str | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
item = await self._resolve_cornerstone_item(id=id, query=query)
|
||||||
|
if not item:
|
||||||
|
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
|
||||||
|
|
||||||
|
page = await self.cornerstone.get_item_page(str(item["id"]))
|
||||||
|
parsed = parse_cornerstone_item_page(page["html"], page["url"])
|
||||||
|
media = parsed.get("media") or []
|
||||||
|
limit = max(1, min(limit, 10))
|
||||||
|
return {
|
||||||
|
"source": self.cornerstone.base_url,
|
||||||
|
"url": page["url"],
|
||||||
|
"item": {
|
||||||
|
"id": item.get("id"),
|
||||||
|
"name": parsed.get("name") or item.get("name"),
|
||||||
|
"sold": bool(item.get("sold")),
|
||||||
|
"general": parsed.get("general") or {},
|
||||||
|
},
|
||||||
|
"returned": min(len(media), limit),
|
||||||
|
"truncated": len(media) > limit,
|
||||||
|
"media": media[:limit],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _pending(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
endpoint: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
method: str = "POST",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
action_id = str(uuid.uuid4())
|
action_id = str(uuid.uuid4())
|
||||||
payload = {key: value for key, value in payload.items() if value is not None}
|
payload = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
metadata = {key: value for key, value in (metadata or {}).items() if value is not None}
|
||||||
payload = self._production_payload(endpoint, payload)
|
payload = self._production_payload(endpoint, payload)
|
||||||
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method)
|
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method, metadata)
|
||||||
return {
|
return {
|
||||||
"pending_action": {
|
"pending_action": {
|
||||||
"id": action_id,
|
"id": action_id,
|
||||||
"label": label,
|
"label": label,
|
||||||
"method": method,
|
"method": method,
|
||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"payload": payload,
|
"payload": self._display_payload(payload),
|
||||||
|
"metadata": metadata,
|
||||||
"approval_required": self.require_write_approval,
|
"approval_required": self.require_write_approval,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _display_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
display = dict(payload)
|
||||||
|
image_data = display.get("image_data")
|
||||||
|
if isinstance(image_data, str) and image_data:
|
||||||
|
display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>"
|
||||||
|
return display
|
||||||
|
|
||||||
|
def _attach_chat_image(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
attached_index = payload.pop("attached_image_index", None)
|
||||||
|
use_attached_image = bool(payload.pop("use_attached_image", False) or attached_index is not None)
|
||||||
|
if payload.get("image_data") or not use_attached_image:
|
||||||
|
return {}
|
||||||
|
image = self._chat_image(attached_index or 0)
|
||||||
|
if not image:
|
||||||
|
return {"error": "No pasted chat image is available at the requested attached_image_index."}
|
||||||
|
payload["image_data"] = image["image_data"]
|
||||||
|
return {
|
||||||
|
"metadata": {
|
||||||
|
"attached_chat_image_name": image.get("name"),
|
||||||
|
"attached_chat_image_content_type": image.get("content_type"),
|
||||||
|
"attached_chat_image_index": attached_index or 0,
|
||||||
|
"attached_chat_image_status": "included",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _chat_image(self, index: int) -> dict[str, Any] | None:
|
||||||
|
images = self._chat_images_var.get()
|
||||||
|
if 0 <= index < len(images):
|
||||||
|
return images[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_chat_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 _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _record_pending_action_result(self, action: PendingAction, result_kind: str, result: dict[str, Any]) -> None:
|
||||||
|
metadata = action.metadata or {}
|
||||||
|
plan_id = metadata.get("plan_id")
|
||||||
|
if not plan_id or self.plan_store is None:
|
||||||
|
return
|
||||||
|
message = f"{action.label} {result_kind} for continual plan."
|
||||||
|
event_metadata = {"action_id": action.id, "endpoint": action.endpoint, "payload": action.payload, "result": result, **metadata}
|
||||||
|
self.plan_store.add_event(plan_id, result_kind, message, event_metadata)
|
||||||
|
if result_kind == "approved" and action.endpoint == "marketplace_negotiations_messages":
|
||||||
|
self.plan_store.add_negotiation(
|
||||||
|
plan_id,
|
||||||
|
metadata.get("plan_item_id"),
|
||||||
|
metadata.get("candidate_id"),
|
||||||
|
{**metadata, "status": "approved"},
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
|
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.0.3"
|
__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"
|
||||||
@@ -9,3 +9,6 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.3"
|
version = "0.0.6"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
@@ -1049,3 +1049,6 @@ wheels = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+458
-33
@@ -1,5 +1,6 @@
|
|||||||
const form = document.getElementById("chat-form");
|
const form = document.getElementById("chat-form");
|
||||||
const input = document.getElementById("message-input");
|
const input = document.getElementById("message-input");
|
||||||
|
const composerImagesEl = document.getElementById("composer-images");
|
||||||
const messages = document.getElementById("messages");
|
const messages = document.getElementById("messages");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const pendingEl = document.getElementById("pending-actions");
|
const pendingEl = document.getElementById("pending-actions");
|
||||||
@@ -13,9 +14,11 @@ const configStatusEl = document.getElementById("config-status");
|
|||||||
const configPathsEl = document.getElementById("config-paths");
|
const configPathsEl = document.getElementById("config-paths");
|
||||||
const settingsToggle = document.getElementById("settings-toggle");
|
const settingsToggle = document.getElementById("settings-toggle");
|
||||||
const memoryToggle = document.getElementById("memory-toggle");
|
const memoryToggle = document.getElementById("memory-toggle");
|
||||||
|
const plansToggle = document.getElementById("plans-toggle");
|
||||||
const ollamaToggle = document.getElementById("ollama-toggle");
|
const ollamaToggle = document.getElementById("ollama-toggle");
|
||||||
const settingsPanel = document.getElementById("settings-panel");
|
const settingsPanel = document.getElementById("settings-panel");
|
||||||
const memoryPanel = document.getElementById("memory-panel");
|
const memoryPanel = document.getElementById("memory-panel");
|
||||||
|
const plansPanel = document.getElementById("plans-panel");
|
||||||
const ollamaPanel = document.getElementById("ollama-panel");
|
const ollamaPanel = document.getElementById("ollama-panel");
|
||||||
const ollamaForm = document.getElementById("ollama-config-form");
|
const ollamaForm = document.getElementById("ollama-config-form");
|
||||||
const ollamaRefreshButton = document.getElementById("ollama-refresh");
|
const ollamaRefreshButton = document.getElementById("ollama-refresh");
|
||||||
@@ -23,6 +26,7 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
|
|||||||
const ollamaInstallButton = document.getElementById("ollama-install");
|
const ollamaInstallButton = document.getElementById("ollama-install");
|
||||||
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
||||||
const ollamaPullButton = document.getElementById("ollama-pull");
|
const ollamaPullButton = document.getElementById("ollama-pull");
|
||||||
|
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
|
||||||
const ollamaStatusEl = document.getElementById("ollama-status");
|
const ollamaStatusEl = document.getElementById("ollama-status");
|
||||||
const ollamaMessageEl = document.getElementById("ollama-message");
|
const ollamaMessageEl = document.getElementById("ollama-message");
|
||||||
const updateCheckButton = document.getElementById("update-check");
|
const updateCheckButton = document.getElementById("update-check");
|
||||||
@@ -47,31 +51,65 @@ const updateModalCopy = document.getElementById("update-modal-copy");
|
|||||||
const updateModalClose = document.getElementById("update-modal-close");
|
const updateModalClose = document.getElementById("update-modal-close");
|
||||||
const updateModalInstall = document.getElementById("update-modal-install");
|
const updateModalInstall = document.getElementById("update-modal-install");
|
||||||
const updateModalReleases = document.getElementById("update-modal-releases");
|
const updateModalReleases = document.getElementById("update-modal-releases");
|
||||||
|
const plansRefreshButton = document.getElementById("plans-refresh");
|
||||||
|
const plansCloseButton = document.getElementById("plans-close");
|
||||||
|
const planForm = document.getElementById("plan-form");
|
||||||
|
const plansStatusEl = document.getElementById("plans-status");
|
||||||
|
const plansDashboardEl = document.getElementById("plans-dashboard");
|
||||||
|
const plansRailListEl = document.getElementById("plans-rail-list");
|
||||||
|
|
||||||
let ollamaOnline = true;
|
let ollamaOnline = true;
|
||||||
let latestUpdate = null;
|
let latestUpdate = null;
|
||||||
let currentThreadId = "default";
|
let currentThreadId = "default";
|
||||||
let currentNegotiationId = null;
|
let currentNegotiationId = null;
|
||||||
let latestOllamaStatus = null;
|
let latestOllamaStatus = null;
|
||||||
|
let composerImages = [];
|
||||||
const clickedOllamaActions = new Set();
|
const clickedOllamaActions = new Set();
|
||||||
|
|
||||||
if (window.lucide) {
|
if (window.lucide) {
|
||||||
window.lucide.createIcons();
|
window.lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(role, text) {
|
function addMessage(role, text, options = {}) {
|
||||||
const node = document.createElement("div");
|
const node = document.createElement("div");
|
||||||
node.className = `message ${role}`;
|
node.className = `message ${role}`;
|
||||||
setMessageMarkdown(node, text);
|
setMessageMarkdown(node, text, options);
|
||||||
messages.appendChild(node);
|
messages.appendChild(node);
|
||||||
messages.scrollTop = messages.scrollHeight;
|
messages.scrollTop = messages.scrollHeight;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessageMarkdown(node, text) {
|
function setMessageMarkdown(node, text, options = {}) {
|
||||||
const body = node.querySelector(".message-body") || node;
|
const body = node.querySelector(".message-body") || node;
|
||||||
body.innerHTML = renderMarkdown(text);
|
body.innerHTML = "";
|
||||||
enhanceNegotiationLinks(body);
|
const attachedImages = options.images || [];
|
||||||
|
if (attachedImages.length) {
|
||||||
|
body.appendChild(renderImageGallery(attachedImages));
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
const markdown = document.createElement("div");
|
||||||
|
markdown.innerHTML = renderMarkdown(text);
|
||||||
|
body.appendChild(markdown);
|
||||||
|
enhanceNegotiationLinks(markdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageGallery(images) {
|
||||||
|
const gallery = document.createElement("div");
|
||||||
|
gallery.className = "message-images";
|
||||||
|
for (const image of images) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "message-image";
|
||||||
|
const preview = document.createElement("img");
|
||||||
|
preview.src = image.preview_url || `data:${image.content_type || "image/png"};base64,${image.image_data}`;
|
||||||
|
preview.alt = image.name || "Attached image";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "message-image-label";
|
||||||
|
label.textContent = image.name || "Attached image";
|
||||||
|
card.append(preview, label);
|
||||||
|
gallery.appendChild(card);
|
||||||
|
}
|
||||||
|
return gallery;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessageActivity(node, text, active = false) {
|
function setMessageActivity(node, text, active = false) {
|
||||||
@@ -451,6 +489,74 @@ function escapeHtml(text) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function composerImageId() {
|
||||||
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
|
||||||
|
return `image-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = () => reject(reader.error || new Error(`Could not read ${file.name || "image"}`));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComposerImages(files) {
|
||||||
|
const additions = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file || !String(file.type || "").startsWith("image/")) continue;
|
||||||
|
const previewUrl = await readFileAsDataUrl(file);
|
||||||
|
const [, imageData = ""] = previewUrl.split(",", 2);
|
||||||
|
if (!imageData) continue;
|
||||||
|
additions.push({
|
||||||
|
id: composerImageId(),
|
||||||
|
name: file.name || `pasted-image-${composerImages.length + additions.length + 1}.png`,
|
||||||
|
content_type: file.type || "image/png",
|
||||||
|
image_data: imageData,
|
||||||
|
preview_url: previewUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!additions.length) return;
|
||||||
|
composerImages = [...composerImages, ...additions];
|
||||||
|
renderComposerImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComposerImage(imageId) {
|
||||||
|
composerImages = composerImages.filter((image) => image.id !== imageId);
|
||||||
|
renderComposerImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearComposerImages() {
|
||||||
|
composerImages = [];
|
||||||
|
renderComposerImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComposerImages() {
|
||||||
|
if (!composerImagesEl) return;
|
||||||
|
composerImagesEl.innerHTML = "";
|
||||||
|
composerImagesEl.hidden = !composerImages.length;
|
||||||
|
for (const image of composerImages) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "composer-image";
|
||||||
|
const preview = document.createElement("img");
|
||||||
|
preview.src = image.preview_url;
|
||||||
|
preview.alt = image.name || "Pasted image";
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "composer-image-remove";
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.title = "Remove image";
|
||||||
|
remove.addEventListener("click", () => removeComposerImage(image.id));
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "composer-image-name";
|
||||||
|
label.textContent = image.name || "Pasted image";
|
||||||
|
card.append(preview, remove, label);
|
||||||
|
composerImagesEl.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatMetrics(event) {
|
function formatMetrics(event) {
|
||||||
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
|
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
|
||||||
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
|
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
|
||||||
@@ -486,9 +592,13 @@ const configFieldIds = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ollamaFieldIds = {
|
const ollamaFieldIds = {
|
||||||
|
model_provider: "model-provider",
|
||||||
ollama_base_url: "ollama-base-url",
|
ollama_base_url: "ollama-base-url",
|
||||||
ollama_model: "ollama-model",
|
ollama_model: "ollama-model",
|
||||||
ollama_num_ctx: "ollama-num-ctx",
|
ollama_num_ctx: "ollama-num-ctx",
|
||||||
|
openai_base_url: "openai-base-url",
|
||||||
|
openai_api_key: "openai-api-key",
|
||||||
|
openai_model: "openai-model",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function refreshConfig() {
|
async function refreshConfig() {
|
||||||
@@ -519,8 +629,13 @@ function renderConfig(config) {
|
|||||||
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
for (const [key, id] of Object.entries(ollamaFieldIds)) {
|
||||||
const field = document.getElementById(id);
|
const field = document.getElementById(id);
|
||||||
if (!field) continue;
|
if (!field) continue;
|
||||||
|
if (field.type === "password") {
|
||||||
|
field.value = "";
|
||||||
|
field.placeholder = secretsConfigured[key] ? "Configured" : "";
|
||||||
|
} else {
|
||||||
field.value = values[key] ?? "";
|
field.value = values[key] ?? "";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
|
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
|
||||||
configStatusEl.textContent = "";
|
configStatusEl.textContent = "";
|
||||||
}
|
}
|
||||||
@@ -557,7 +672,7 @@ async function saveOllamaConfig(event) {
|
|||||||
if (!field) continue;
|
if (!field) continue;
|
||||||
values[key] = field.value;
|
values[key] = field.value;
|
||||||
}
|
}
|
||||||
setOllamaMessage("Saving Ollama config");
|
setOllamaMessage("Saving provider config");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/config", {
|
const response = await fetch("/api/config", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -569,46 +684,71 @@ async function saveOllamaConfig(event) {
|
|||||||
setOllamaMessage(result.message || "Saved");
|
setOllamaMessage(result.message || "Saved");
|
||||||
await refreshOllamaStatus();
|
await refreshOllamaStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setOllamaMessage(`Ollama config save failed: ${fetchErrorMessage(error)}`);
|
setOllamaMessage(`Provider config save failed: ${fetchErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOllamaStatus() {
|
async function refreshOllamaStatus() {
|
||||||
if (!ollamaStatusEl) return;
|
if (!ollamaStatusEl) return;
|
||||||
ollamaStatusEl.textContent = "Checking Ollama";
|
ollamaStatusEl.textContent = "Checking provider";
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/ollama/status");
|
const response = await fetch("/api/ollama/status");
|
||||||
const status = await response.json();
|
const status = await response.json();
|
||||||
renderOllamaStatus(status);
|
renderOllamaStatus(status);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ollamaStatusEl.textContent = `Ollama status failed: ${error.message}`;
|
ollamaStatusEl.textContent = `Provider status failed: ${error.message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOllamaStatus(status) {
|
function renderOllamaStatus(status) {
|
||||||
if (!ollamaStatusEl) return;
|
if (!ollamaStatusEl) return;
|
||||||
latestOllamaStatus = status;
|
latestOllamaStatus = status;
|
||||||
|
const provider = status.provider === "openai" ? "OpenAI" : "Ollama";
|
||||||
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
||||||
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
|
const ready = status.provider === "openai"
|
||||||
|
? Boolean(status.online && status.model_available)
|
||||||
|
: Boolean(status.installed && status.running && status.model_available);
|
||||||
|
const pillClass = ready ? "status-pill" : "status-pill warning";
|
||||||
|
const detailItems = [
|
||||||
|
ollamaStatusItem("Provider", provider),
|
||||||
|
ollamaStatusItem("Model", status.configured_model || ""),
|
||||||
|
ollamaStatusItem("URL", status.base_url || ""),
|
||||||
|
];
|
||||||
|
if (status.provider !== "openai") {
|
||||||
|
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
||||||
|
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
||||||
|
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
||||||
|
if (status.can_auto_install) detailItems.push(ollamaStatusItem("Auto Install", "Available"));
|
||||||
|
if (status.num_ctx) detailItems.push(ollamaStatusItem("Context", status.num_ctx));
|
||||||
|
} else {
|
||||||
|
detailItems.splice(1, 0, ollamaStatusItem("Connected", status.online ? "Yes" : "No"));
|
||||||
|
}
|
||||||
ollamaStatusEl.innerHTML = `
|
ollamaStatusEl.innerHTML = `
|
||||||
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
|
<div class="${pillClass}">${escapeHtml(status.message || "Unknown")}</div>
|
||||||
<div class="ollama-status-grid">
|
<div class="ollama-status-grid">
|
||||||
${ollamaStatusItem("Installed", status.installed ? "Yes" : "No")}
|
${detailItems.join("")}
|
||||||
${ollamaStatusItem("Running", status.running ? "Yes" : "No")}
|
|
||||||
${ollamaStatusItem("Model", status.configured_model || "")}
|
|
||||||
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
|
|
||||||
${ollamaStatusItem("URL", status.base_url || "")}
|
|
||||||
${status.can_auto_install ? ollamaStatusItem("Auto Install", "Available") : ""}
|
|
||||||
</div>
|
</div>
|
||||||
${ollamaStatusItem("Installed Models", models)}
|
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)}
|
||||||
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
||||||
`;
|
`;
|
||||||
|
if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai";
|
||||||
if (ollamaInstallButton) {
|
if (ollamaInstallButton) {
|
||||||
ollamaInstallButton.hidden = !status.can_auto_install;
|
ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install;
|
||||||
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
|
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
|
||||||
}
|
}
|
||||||
if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
if (ollamaLaunchButton) {
|
||||||
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
ollamaLaunchButton.hidden = status.provider === "openai";
|
||||||
|
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
||||||
|
}
|
||||||
|
if (ollamaPullButton) {
|
||||||
|
ollamaPullButton.hidden = status.provider === "openai";
|
||||||
|
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
||||||
|
}
|
||||||
|
if (openaiModelsRefreshButton) {
|
||||||
|
openaiModelsRefreshButton.hidden = status.provider !== "openai";
|
||||||
|
openaiModelsRefreshButton.disabled = false;
|
||||||
|
}
|
||||||
|
renderProviderModelOptions(status.models || []);
|
||||||
updateOllamaAttention(status);
|
updateOllamaAttention(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,12 +791,15 @@ function setOllamaButtonAttention(button, action, active) {
|
|||||||
function updateOllamaAttention(status = null) {
|
function updateOllamaAttention(status = null) {
|
||||||
const currentStatus = status || latestOllamaStatus;
|
const currentStatus = status || latestOllamaStatus;
|
||||||
if (!currentStatus) return;
|
if (!currentStatus) return;
|
||||||
const ready = Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
|
const ready = currentStatus.provider === "openai"
|
||||||
|
? Boolean(currentStatus.online && currentStatus.model_available)
|
||||||
|
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
|
||||||
ollamaToggle?.classList.toggle("attention-pulse", !ready);
|
ollamaToggle?.classList.toggle("attention-pulse", !ready);
|
||||||
setOllamaButtonAttention(ollamaDownloadButton, "download", !currentStatus.installed);
|
setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed);
|
||||||
setOllamaButtonAttention(ollamaInstallButton, "install", !currentStatus.installed && currentStatus.can_auto_install);
|
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install);
|
||||||
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.installed && !currentStatus.running);
|
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running);
|
||||||
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.running && !currentStatus.model_available);
|
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available);
|
||||||
|
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available);
|
||||||
if (ready) clickedOllamaActions.clear();
|
if (ready) clickedOllamaActions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,6 +807,31 @@ function configuredOllamaModel() {
|
|||||||
return document.getElementById("ollama-model")?.value || "";
|
return document.getElementById("ollama-model")?.value || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProviderModelOptions(models) {
|
||||||
|
const datalist = document.getElementById("provider-models");
|
||||||
|
if (!datalist) return;
|
||||||
|
datalist.innerHTML = "";
|
||||||
|
for (const model of models) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = model;
|
||||||
|
datalist.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOpenAIModels() {
|
||||||
|
setOllamaMessage("Loading OpenAI models");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/openai/models");
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||||
|
renderProviderModelOptions(result.models || []);
|
||||||
|
setOllamaMessage(result.message || "Loaded OpenAI models");
|
||||||
|
await refreshOllamaStatus();
|
||||||
|
} catch (error) {
|
||||||
|
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkForUpdate(promptUser = false) {
|
async function checkForUpdate(promptUser = false) {
|
||||||
if (!updateStatusEl) return;
|
if (!updateStatusEl) return;
|
||||||
updateStatusEl.textContent = "Checking releases";
|
updateStatusEl.textContent = "Checking releases";
|
||||||
@@ -963,6 +1131,19 @@ function closeNegotiationPanel() {
|
|||||||
negotiationStatusEl.textContent = "";
|
negotiationStatusEl.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPlansPanel(openPlanId = null) {
|
||||||
|
if (!plansPanel) return;
|
||||||
|
plansPanel.hidden = false;
|
||||||
|
plansToggle?.setAttribute("aria-expanded", "true");
|
||||||
|
refreshPlans(openPlanId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlansPanel() {
|
||||||
|
if (!plansPanel) return;
|
||||||
|
plansPanel.hidden = true;
|
||||||
|
plansToggle?.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
function renderNegotiationMessages(data) {
|
function renderNegotiationMessages(data) {
|
||||||
negotiationMessagesEl.innerHTML = "";
|
negotiationMessagesEl.innerHTML = "";
|
||||||
const items = Array.isArray(data) ? data : [data].filter(Boolean);
|
const items = Array.isArray(data) ? data : [data].filter(Boolean);
|
||||||
@@ -1002,20 +1183,228 @@ async function submitNegotiationMessage(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePlanItems(text) {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [name, quantity, maxPrice] = line.split("|").map((part) => part.trim());
|
||||||
|
const item = { item_name: name };
|
||||||
|
if (quantity) item.desired_quantity = Math.max(1, Number.parseInt(quantity, 10) || 1);
|
||||||
|
if (maxPrice) item.max_unit_price = Number(maxPrice.replace(/,/g, ""));
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlan(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const title = document.getElementById("plan-title").value.trim();
|
||||||
|
const objective = document.getElementById("plan-objective").value.trim();
|
||||||
|
if (!title || !objective) return;
|
||||||
|
const tone = document.getElementById("plan-tone").value.trim();
|
||||||
|
const instructions = document.getElementById("plan-instructions").value.trim();
|
||||||
|
const constraints = {};
|
||||||
|
if (tone) constraints.message_tone = tone;
|
||||||
|
if (instructions) constraints.instructions = instructions;
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
objective,
|
||||||
|
kind: document.getElementById("plan-kind").value || "buying",
|
||||||
|
cadence: document.getElementById("plan-cadence").value.trim() || null,
|
||||||
|
constraints,
|
||||||
|
items: parsePlanItems(document.getElementById("plan-items").value || ""),
|
||||||
|
};
|
||||||
|
plansStatusEl.textContent = "Creating plan";
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/plans", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||||
|
planForm.reset();
|
||||||
|
plansStatusEl.textContent = result.plan?.status === "needs_input"
|
||||||
|
? "Plan created, but it needs an item checklist."
|
||||||
|
: "Plan created";
|
||||||
|
await refreshPlans(result.plan?.id);
|
||||||
|
} catch (error) {
|
||||||
|
plansStatusEl.textContent = `Plan create failed: ${fetchErrorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPlans(openPlanId = null) {
|
||||||
|
if (!plansDashboardEl && !plansRailListEl) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/plans");
|
||||||
|
const result = await response.json();
|
||||||
|
const plans = result.plans || [];
|
||||||
|
renderPlansRail(plans);
|
||||||
|
if (plansDashboardEl) await renderPlans(plans, openPlanId);
|
||||||
|
} catch (error) {
|
||||||
|
if (plansDashboardEl) plansDashboardEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`;
|
||||||
|
if (plansRailListEl) plansRailListEl.textContent = `Plans failed: ${fetchErrorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlansRail(plans) {
|
||||||
|
if (!plansRailListEl) return;
|
||||||
|
plansRailListEl.innerHTML = "";
|
||||||
|
if (!plans.length) {
|
||||||
|
plansRailListEl.innerHTML = '<div class="pending-empty">No plans</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const plan of plans.slice(0, 5)) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "plan-rail-item";
|
||||||
|
const title = document.createElement("span");
|
||||||
|
title.className = "plan-rail-title";
|
||||||
|
title.textContent = plan.title || "Untitled plan";
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "plan-rail-status";
|
||||||
|
status.textContent = plan.status || "plan";
|
||||||
|
row.append(title, status);
|
||||||
|
row.addEventListener("click", () => openPlansPanel(plan.id));
|
||||||
|
plansRailListEl.appendChild(row);
|
||||||
|
}
|
||||||
|
if (plans.length > 5) {
|
||||||
|
const more = document.createElement("button");
|
||||||
|
more.type = "button";
|
||||||
|
more.className = "plan-rail-item";
|
||||||
|
more.textContent = `${plans.length - 5} more`;
|
||||||
|
more.addEventListener("click", () => openPlansPanel());
|
||||||
|
plansRailListEl.appendChild(more);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPlans(plans, openPlanId = null) {
|
||||||
|
plansDashboardEl.innerHTML = "";
|
||||||
|
if (!plans.length) {
|
||||||
|
plansDashboardEl.innerHTML = '<div class="pending-empty">No continual plans</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const plan of plans) {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = `plan-card${plan.status === "active" ? " active" : ""}`;
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.textContent = plan.title || "Untitled plan";
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "plan-meta";
|
||||||
|
meta.textContent = plan.objective || "";
|
||||||
|
const pills = document.createElement("div");
|
||||||
|
pills.className = "plan-pill-row";
|
||||||
|
for (const value of [plan.status, plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) {
|
||||||
|
const pill = document.createElement("span");
|
||||||
|
pill.className = "plan-pill";
|
||||||
|
pill.textContent = value;
|
||||||
|
pills.appendChild(pill);
|
||||||
|
}
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
controls.className = "plan-controls";
|
||||||
|
controls.append(
|
||||||
|
planButton("Details", () => loadPlanDetail(plan.id, card)),
|
||||||
|
planButton("Run", () => postPlanAction(plan.id, "run")),
|
||||||
|
planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")),
|
||||||
|
planButton("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button")
|
||||||
|
);
|
||||||
|
card.append(title, meta, pills, controls);
|
||||||
|
plansDashboardEl.appendChild(card);
|
||||||
|
if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function planButton(label, onClick, className = "small-button") {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = className;
|
||||||
|
button.textContent = label;
|
||||||
|
button.addEventListener("click", onClick);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlanDetail(planId, card) {
|
||||||
|
const existing = card.querySelector(".plan-detail");
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
|
||||||
|
const result = await response.json();
|
||||||
|
const plan = result.plan;
|
||||||
|
const detail = document.createElement("div");
|
||||||
|
detail.className = "plan-detail";
|
||||||
|
detail.append(
|
||||||
|
planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)),
|
||||||
|
planSection("Best Candidates", bestCandidateLines(plan)),
|
||||||
|
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`))
|
||||||
|
);
|
||||||
|
card.appendChild(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planSection(title, lines) {
|
||||||
|
const wrapper = document.createElement("section");
|
||||||
|
const heading = document.createElement("h4");
|
||||||
|
heading.textContent = title;
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
list.className = "plan-list";
|
||||||
|
const items = lines.length ? lines : ["Empty"];
|
||||||
|
for (const line of items) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.textContent = line;
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
wrapper.append(heading, list);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestCandidateLines(plan) {
|
||||||
|
const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name]));
|
||||||
|
return (plan.candidates || [])
|
||||||
|
.filter((candidate) => candidate.status === "current" || candidate.status === "drafted")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((candidate) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postPlanAction(planId, action) {
|
||||||
|
plansStatusEl.textContent = `${action} requested`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}/${action}`, { method: "POST" });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||||
|
plansStatusEl.textContent = result.summary || `Plan ${action} complete`;
|
||||||
|
await refreshPlans(planId);
|
||||||
|
await refreshPending();
|
||||||
|
await refreshInbox();
|
||||||
|
} catch (error) {
|
||||||
|
plansStatusEl.textContent = `Plan ${action} failed: ${fetchErrorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
async function checkHealth() {
|
async function checkHealth() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/health");
|
const response = await fetch("/api/health");
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const health = result.ollama || {};
|
const health = result.ollama || {};
|
||||||
|
const provider = health.provider === "openai" ? "OpenAI" : "Ollama";
|
||||||
ollamaOnline = Boolean(health.online);
|
ollamaOnline = Boolean(health.online);
|
||||||
if (!ollamaOnline) {
|
if (!ollamaOnline) {
|
||||||
statusEl.textContent = "Offline";
|
statusEl.textContent = "Offline";
|
||||||
setWarning("Ollama needs attention. Open the Ollama tab and use the pulsing action button.");
|
setWarning(`${provider} needs attention. Open the model provider tab and use the pulsing action button.`);
|
||||||
ollamaToggle?.classList.add("attention-pulse");
|
ollamaToggle?.classList.add("attention-pulse");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (health.model_available === false) {
|
if (health.model_available === false) {
|
||||||
setWarning(`Ollama needs the configured model "${health.model}". Open the Ollama tab and use Install Model.`);
|
const action = health.provider === "openai" ? "Load OpenAI Models." : "Install Model.";
|
||||||
|
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
|
||||||
ollamaToggle?.classList.add("attention-pulse");
|
ollamaToggle?.classList.add("attention-pulse");
|
||||||
} else {
|
} else {
|
||||||
setWarning("");
|
setWarning("");
|
||||||
@@ -1026,7 +1415,7 @@ async function checkHealth() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
ollamaOnline = false;
|
ollamaOnline = false;
|
||||||
statusEl.textContent = "Offline";
|
statusEl.textContent = "Offline";
|
||||||
setWarning("Could not check Ollama health. Open the Ollama tab and use the pulsing action button.");
|
setWarning("Could not check the active model provider. Open the model provider tab and use the pulsing action button.");
|
||||||
ollamaToggle?.classList.add("attention-pulse");
|
ollamaToggle?.classList.add("attention-pulse");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1199,13 +1588,37 @@ input.addEventListener("keydown", async (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
input.addEventListener("paste", async (event) => {
|
||||||
|
const clipboardItems = [...(event.clipboardData?.items || [])];
|
||||||
|
const imageFiles = clipboardItems
|
||||||
|
.filter((item) => item.kind === "file" && String(item.type || "").startsWith("image/"))
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!imageFiles.length) return;
|
||||||
|
if (!event.clipboardData?.getData("text/plain")) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await addComposerImages(imageFiles);
|
||||||
|
} catch (error) {
|
||||||
|
setWarning(`Image paste failed: ${fetchErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
memoryRefreshButton?.addEventListener("click", refreshMemory);
|
memoryRefreshButton?.addEventListener("click", refreshMemory);
|
||||||
memoryClearButton?.addEventListener("click", clearMemory);
|
memoryClearButton?.addEventListener("click", clearMemory);
|
||||||
configRefreshButton?.addEventListener("click", refreshConfig);
|
configRefreshButton?.addEventListener("click", refreshConfig);
|
||||||
configForm?.addEventListener("submit", saveConfig);
|
configForm?.addEventListener("submit", saveConfig);
|
||||||
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
|
settingsToggle?.addEventListener("click", () => toggleSidebarPanel("settings"));
|
||||||
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
|
memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
|
||||||
|
plansToggle?.addEventListener("click", () => {
|
||||||
|
if (plansPanel?.hidden) openPlansPanel();
|
||||||
|
else closePlansPanel();
|
||||||
|
});
|
||||||
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
||||||
|
plansRefreshButton?.addEventListener("click", () => refreshPlans());
|
||||||
|
plansCloseButton?.addEventListener("click", closePlansPanel);
|
||||||
|
planForm?.addEventListener("submit", createPlan);
|
||||||
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
||||||
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
||||||
ollamaDownloadButton?.addEventListener("click", () => {
|
ollamaDownloadButton?.addEventListener("click", () => {
|
||||||
@@ -1224,6 +1637,10 @@ ollamaPullButton?.addEventListener("click", () => {
|
|||||||
markOllamaActionClicked("pull");
|
markOllamaActionClicked("pull");
|
||||||
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
||||||
});
|
});
|
||||||
|
openaiModelsRefreshButton?.addEventListener("click", () => {
|
||||||
|
markOllamaActionClicked("openai-models");
|
||||||
|
refreshOpenAIModels();
|
||||||
|
});
|
||||||
updateCheckButton?.addEventListener("click", checkForUpdate);
|
updateCheckButton?.addEventListener("click", checkForUpdate);
|
||||||
updateInstallButton?.addEventListener("click", installUpdate);
|
updateInstallButton?.addEventListener("click", installUpdate);
|
||||||
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
||||||
@@ -1237,15 +1654,22 @@ updateModalInstall?.addEventListener("click", installUpdate);
|
|||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const message = input.value.trim();
|
const message = input.value.trim();
|
||||||
if (!message || input.disabled) return;
|
const attachedImages = composerImages.map(({ name, content_type, image_data, preview_url }) => ({
|
||||||
|
name,
|
||||||
|
content_type,
|
||||||
|
image_data,
|
||||||
|
preview_url,
|
||||||
|
}));
|
||||||
|
if ((!message && !attachedImages.length) || input.disabled) return;
|
||||||
const healthy = await checkHealth();
|
const healthy = await checkHealth();
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
addMessage("assistant warning-message", "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again.");
|
addMessage("assistant warning-message", "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.value = "";
|
input.value = "";
|
||||||
|
clearComposerImages();
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
addMessage("user", message);
|
addMessage("user", message, { images: attachedImages });
|
||||||
const assistantNode = addMessage("assistant streaming", "");
|
const assistantNode = addMessage("assistant streaming", "");
|
||||||
ensureStreamingChrome(assistantNode);
|
ensureStreamingChrome(assistantNode);
|
||||||
let assistantText = "";
|
let assistantText = "";
|
||||||
@@ -1257,7 +1681,7 @@ async function sendMessage() {
|
|||||||
const response = await fetch("/api/chat/stream", {
|
const response = await fetch("/api/chat/stream", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message, thread_id: currentThreadId }),
|
body: JSON.stringify({ message, thread_id: currentThreadId, images: attachedImages }),
|
||||||
});
|
});
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -1303,7 +1727,7 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.message.includes("503")
|
const message = error.message.includes("503")
|
||||||
? "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again."
|
? "The active model provider needs attention before chat can continue. Open the model provider tab and press the pulsing action button, then try again."
|
||||||
: `Chat failed: ${error.message}`;
|
: `Chat failed: ${error.message}`;
|
||||||
setWarning(message);
|
setWarning(message);
|
||||||
setMessageMarkdown(assistantNode, message);
|
setMessageMarkdown(assistantNode, message);
|
||||||
@@ -1320,6 +1744,7 @@ async function sendMessage() {
|
|||||||
|
|
||||||
refreshPending();
|
refreshPending();
|
||||||
refreshMemory();
|
refreshMemory();
|
||||||
|
refreshPlans();
|
||||||
refreshConfig();
|
refreshConfig();
|
||||||
refreshOllamaStatus();
|
refreshOllamaStatus();
|
||||||
refreshChats().then(() => loadChatMessages(currentThreadId));
|
refreshChats().then(() => loadChatMessages(currentThreadId));
|
||||||
|
|||||||
+78
-19
@@ -9,7 +9,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox">
|
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, and inbox">
|
||||||
<div class="chat-rail-top">
|
<div class="chat-rail-top">
|
||||||
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
||||||
<i data-lucide="panel-left" aria-hidden="true"></i>
|
<i data-lucide="panel-left" aria-hidden="true"></i>
|
||||||
@@ -25,6 +25,15 @@
|
|||||||
<div class="rail-heading">Chats</div>
|
<div class="rail-heading">Chats</div>
|
||||||
<div class="chat-list" id="chat-list"></div>
|
<div class="chat-list" id="chat-list"></div>
|
||||||
</section>
|
</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">
|
<section class="chat-nav-section">
|
||||||
<div class="rail-heading">Inbox</div>
|
<div class="rail-heading">Inbox</div>
|
||||||
<div class="inbox-list" id="inbox-list"></div>
|
<div class="inbox-list" id="inbox-list"></div>
|
||||||
@@ -42,6 +51,7 @@
|
|||||||
<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>
|
||||||
@@ -49,7 +59,10 @@
|
|||||||
<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">
|
||||||
|
<div class="composer-main">
|
||||||
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
||||||
|
<div class="composer-images" id="composer-images" hidden></div>
|
||||||
|
</div>
|
||||||
<button type="submit">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,21 +73,6 @@
|
|||||||
<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>
|
||||||
@@ -121,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">
|
||||||
@@ -136,9 +144,25 @@
|
|||||||
<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>
|
||||||
@@ -159,6 +183,41 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="config-status" id="negotiation-status"></div>
|
<div class="config-status" id="negotiation-status"></div>
|
||||||
</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>
|
<div class="modal-backdrop" id="update-modal" hidden>
|
||||||
<section class="update-modal-card">
|
<section class="update-modal-card">
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
|
|||||||
+439
-35
@@ -105,7 +105,7 @@ body::before {
|
|||||||
|
|
||||||
.chat-rail-content {
|
.chat-rail-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%);
|
grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
@@ -131,8 +131,41 @@ body::before {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-heading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-heading-row .rail-heading {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff9e9;
|
||||||
|
color: var(--forest);
|
||||||
|
box-shadow: 0 8px 18px rgba(38, 58, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-icon-button svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-list,
|
.chat-list,
|
||||||
.inbox-list {
|
.inbox-list,
|
||||||
|
.plans-rail-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: calc(100% - 26px);
|
max-height: calc(100% - 26px);
|
||||||
@@ -140,7 +173,8 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-item,
|
.chat-item,
|
||||||
.inbox-item {
|
.inbox-item,
|
||||||
|
.plan-rail-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -159,13 +193,33 @@ body::before {
|
|||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-rail-item {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 250, 240, 0.78);
|
||||||
|
color: var(--brown);
|
||||||
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-rail-item:hover {
|
||||||
|
background: #edf3df;
|
||||||
|
color: var(--brown);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-item.active {
|
.chat-item.active {
|
||||||
border-color: rgba(52, 83, 38, 0.42);
|
border-color: rgba(52, 83, 38, 0.42);
|
||||||
background: #edf3df;
|
background: #edf3df;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-title,
|
.chat-title,
|
||||||
.inbox-title {
|
.inbox-title,
|
||||||
|
.plan-rail-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--brown);
|
color: var(--brown);
|
||||||
@@ -198,7 +252,25 @@ body::before {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-rail-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-rail-status {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid rgba(52, 83, 38, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #edf3df;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -230,6 +302,10 @@ body::before {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-wrap {
|
.logo-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -481,6 +557,38 @@ h2 {
|
|||||||
background: rgba(255, 250, 240, 0.96);
|
background: rgba(255, 250, 240, 0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-images {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(88, 66, 47, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image-label {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #6d5b4e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.message.warning-message {
|
.message.warning-message {
|
||||||
border-color: rgba(212, 175, 55, 0.6);
|
border-color: rgba(212, 175, 55, 0.6);
|
||||||
background: #f5eac4;
|
background: #f5eac4;
|
||||||
@@ -646,6 +754,60 @@ h2 {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-images {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(88, 66, 47, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
box-shadow: 0 12px 26px rgba(38, 58, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-name {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
color: #6d5b4e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(88, 66, 47, 0.18);
|
||||||
|
background: rgba(255, 250, 240, 0.92);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
@@ -678,7 +840,8 @@ textarea:disabled {
|
|||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="number"] {
|
input[type="number"],
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
@@ -694,7 +857,8 @@ input[type="number"] {
|
|||||||
|
|
||||||
input[type="text"]:focus,
|
input[type="text"]:focus,
|
||||||
input[type="password"]:focus,
|
input[type="password"]:focus,
|
||||||
input[type="number"]:focus {
|
input[type="number"]:focus,
|
||||||
|
select:focus {
|
||||||
border-color: var(--gold);
|
border-color: var(--gold);
|
||||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18);
|
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18);
|
||||||
}
|
}
|
||||||
@@ -867,6 +1031,26 @@ button {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-panel-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-floating-panel {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(680px, calc(100vw - 28px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-panel-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background: linear-gradient(180deg, #3d612c, #263e1b);
|
background: linear-gradient(180deg, #3d612c, #263e1b);
|
||||||
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||||
@@ -909,7 +1093,7 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-section {
|
.side-section {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-section + .side-section {
|
.side-section + .side-section {
|
||||||
@@ -918,43 +1102,94 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tools {
|
.sidebar-tools {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
margin-top: auto;
|
||||||
|
position: sticky;
|
||||||
|
bottom: -28px;
|
||||||
|
padding-bottom: 28px;
|
||||||
|
background: linear-gradient(180deg, rgba(247, 241, 220, 0) 0%, var(--cream) 22%, var(--cream) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-buttons {
|
.sidebar-tool-buttons {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-wrap: nowrap;
|
||||||
gap: 10px;
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-button {
|
.sidebar-tool-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
flex: 0 1 42px;
|
||||||
min-width: 0;
|
gap: 0;
|
||||||
min-height: 46px;
|
width: 42px;
|
||||||
padding: 10px 12px;
|
min-width: 36px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background: #fff9e9;
|
background: #fff9e9;
|
||||||
color: var(--forest);
|
color: var(--forest);
|
||||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
|
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
|
||||||
|
transition:
|
||||||
|
flex-basis 180ms ease,
|
||||||
|
width 180ms ease,
|
||||||
|
gap 180ms ease,
|
||||||
|
padding 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
background 180ms ease,
|
||||||
|
color 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-button:hover,
|
||||||
|
.sidebar-tool-button:focus-visible {
|
||||||
|
flex-basis: 108px;
|
||||||
|
width: 108px;
|
||||||
|
gap: 7px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
border-color: rgba(212, 175, 55, 0.72);
|
||||||
|
background: linear-gradient(180deg, #3d612c, #263e1b);
|
||||||
|
color: var(--ivory);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-button span {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
max-width 180ms ease,
|
||||||
|
opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-button:hover span,
|
||||||
|
.sidebar-tool-button:focus-visible span {
|
||||||
|
max-width: 70px;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-button svg {
|
.sidebar-tool-button svg {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
stroke-width: 2.3;
|
stroke-width: 2.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-image {
|
.sidebar-tool-image {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,8 +1204,8 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel {
|
.sidebar-panel {
|
||||||
padding-top: 12px;
|
padding-bottom: 12px;
|
||||||
border-top: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-form {
|
.config-form {
|
||||||
@@ -1245,6 +1480,98 @@ pre {
|
|||||||
padding: 11px;
|
padding: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans-dashboard {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 250, 240, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card.active {
|
||||||
|
border-color: rgba(52, 83, 38, 0.42);
|
||||||
|
background: #edf3df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-meta,
|
||||||
|
.plan-line {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-pill-row,
|
||||||
|
.plan-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid rgba(52, 83, 38, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 250, 240, 0.8);
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-controls button {
|
||||||
|
flex: 1 1 80px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-detail h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list li {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(221, 206, 176, 0.72);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 253, 247, 0.72);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.decline-button {
|
.decline-button {
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: #fff9e9;
|
background: #fff9e9;
|
||||||
@@ -1277,8 +1604,17 @@ pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
body {
|
||||||
|
background: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,40 +1623,104 @@ pre {
|
|||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-rail {
|
||||||
|
position: fixed;
|
||||||
|
inset: 10px auto auto 10px;
|
||||||
|
z-index: 10;
|
||||||
|
width: min(320px, calc(100vw - 20px));
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
max-height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail.collapsed {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
max-height: 48px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail.collapsed .chat-rail-top {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail.collapsed #new-chat {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
align-items: flex-start;
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
align-items: center;
|
||||||
padding: 22px;
|
justify-content: center;
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 10px 58px 10px 66px;
|
||||||
|
border-bottom-color: var(--line);
|
||||||
|
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
.brand-block {
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrap {
|
.logo-wrap {
|
||||||
width: 58px;
|
width: 28px;
|
||||||
height: 58px;
|
height: 28px;
|
||||||
flex-basis: 58px;
|
flex: 0 0 28px;
|
||||||
border-radius: 18px;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrap::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: currentColor;
|
||||||
|
-webkit-mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
|
||||||
|
mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrap img {
|
.logo-wrap img {
|
||||||
width: 45px;
|
display: none;
|
||||||
height: 45px;
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy p,
|
||||||
|
.status {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 31px;
|
color: var(--brown);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.brand-short {
|
||||||
font-size: 10px;
|
display: inline-flex;
|
||||||
letter-spacing: 0.08em;
|
align-items: center;
|
||||||
|
color: var(--brown);
|
||||||
|
font-family: "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages,
|
.messages,
|
||||||
.actions,
|
.actions {
|
||||||
.chat-rail {
|
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,4 +1750,8 @@ pre {
|
|||||||
.message-phase {
|
.message-phase {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans-panel-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user