2 Commits

Author SHA1 Message Date
HRiggs 00cf6f8747 feat: infrance
Build Release EXE / build-windows-exe (release) Successful in 58s
2026-06-08 20:28:06 -04:00
HRiggs 6bd1e81a51 versioning: 0.0.6, ux: move buttons, feat: add cloud providers, feat: increese tool call limit
Build Release EXE / build-windows-exe (release) Successful in 51s
2026-05-08 14:48:51 -04:00
20 changed files with 3812 additions and 298 deletions
+9
View File
@@ -1,9 +1,18 @@
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.4-mini
OPENAI_API_KEY=
MODEL_REASONING_EFFORT=medium
CODEX_COMMAND=codex
CODEX_MODEL=gpt-5.4
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
SCWIKI_BASE_URL=https://starcitizen.tools
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
UEX_SECRET_KEY= UEX_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
+5 -2
View File
@@ -1,6 +1,6 @@
# TraderAI # TraderAI
Local Ollama-powered chat for UEX marketplace workflows. Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
## What It Does ## What It Does
@@ -25,6 +25,9 @@ Local Ollama-powered chat for UEX marketplace workflows.
``` ```
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions. 3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
`MODEL_REASONING_EFFORT` controls reasoning depth for OpenAI and Codex and defaults to `medium`.
`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 +41,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 Ollama's native chat API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, 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 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "traderai" name = "traderai"
version = "0.0.5" version = "0.0.6"
description = "Local Ollama-powered assistant for UEX marketplace workflows." description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"apscheduler>=3.10.4", "apscheduler>=3.10.4",
@@ -39,3 +39,5 @@ include = ["traderai*"]
+114
View File
@@ -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 = [
{ {
@@ -204,6 +217,90 @@ def test_ollama_options_include_num_ctx():
assert agent._ollama_options() == {"num_ctx": 64000} assert agent._ollama_options() == {"num_ctx": 64000}
def test_codex_prompt_mentions_tools_and_images(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
prompt = agent._codex_cli_prompt(
"check listing",
[
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": "Look at this",
"images": ["ZmFrZQ=="],
"image_content_types": ["image/png"],
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_marketplace_listings", "arguments": "{\"commodity\":\"gold\"}"},
}
],
},
{
"role": "tool",
"tool_name": "search_marketplace_listings",
"tool_call_id": "call_123",
"content": "{\"ok\":true}",
},
],
)
assert "Available tools" in prompt
assert "attached images: 1" in prompt
assert "search_marketplace_listings" in prompt
assert "tool search_marketplace_listings" in prompt
def test_codex_structured_response_extracts_text_and_tool_calls():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
result = agent._codex_structured_response(
{
"kind": "tool_call",
"message": "",
"tool_name": "search_marketplace_listings",
"arguments_json": "{\"commodity\":\"gold\"}",
}
)
assert result["message"]["content"] == ""
assert result["message"]["tool_calls"] == [
{
"id": result["message"]["tool_calls"][0]["id"],
"type": "function",
"function": {
"name": "search_marketplace_listings",
"arguments": "{\"commodity\":\"gold\"}",
},
}
]
def test_parse_codex_exec_output_reads_final_json():
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
result = agent._parse_codex_exec_output(
{
"returncode": 0,
"stdout": "",
"stderr": "",
"events": [
{"type": "thread.started", "thread_id": "abc"},
{"type": "item.completed", "item": {"type": "agent_message", "text": "{\"kind\":\"final\",\"message\":\"hello\",\"tool_name\":\"\",\"arguments_json\":\"{}\"}"}},
{"type": "turn.completed"},
],
}
)
assert result == {"kind": "final", "message": "hello", "tool_name": "", "arguments_json": "{}"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_wake_response_executes_tool_calls(tmp_path): async def test_wake_response_executes_tool_calls(tmp_path):
memory = MemoryStore(str(tmp_path / "memory.sqlite3")) memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
@@ -229,6 +326,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"))
+25
View File
@@ -0,0 +1,25 @@
from traderai.config import Settings
def test_model_provider_accepts_codex():
settings = Settings(model_provider="codex")
assert settings.model_provider == "codex"
def test_model_provider_invalid_value_falls_back_to_ollama():
settings = Settings(model_provider="something-else")
assert settings.model_provider == "ollama"
def test_reasoning_effort_normalizes_invalid_values():
settings = Settings(model_reasoning_effort="whatever")
assert settings.model_reasoning_effort == "medium"
def test_reasoning_effort_accepts_supported_values():
settings = Settings(model_reasoning_effort="high")
assert settings.model_reasoning_effort == "high"
+36
View File
@@ -104,6 +104,26 @@ def test_continual_plan_store_creates_buying_checklist(tmp_path):
assert plan["items"][0]["desired_quantity"] == 2 assert plan["items"][0]["desired_quantity"] == 2
def test_continual_plan_store_deletes_plan_and_related_records(tmp_path):
_, store, _, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete me",
objective="Remove everything",
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}],
)
item_id = int(plan["items"][0]["id"])
candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9)
store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"})
assert store.delete_plan(plan["id"]) is True
assert store.get_plan(plan["id"]) is None
assert store.list_items(plan["id"]) == []
assert store.list_candidates(plan["id"]) == []
assert store.list_negotiations(plan["id"]) == []
assert store.list_events(plan["id"]) == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path): async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
memory, store, tools, runner, _ = plan_stack(tmp_path) memory, store, tools, runner, _ = plan_stack(tmp_path)
@@ -215,3 +235,19 @@ async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
assert catchup is not None assert catchup is not None
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"]) assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
@pytest.mark.asyncio
async def test_tools_delete_continual_plan_removes_it(tmp_path):
_, store, tools, _, _ = plan_stack(tmp_path)
plan = store.create_plan(
"Delete through tools",
objective="Remove via registry",
items=[{"item_name": "Wikelo Idris panel"}],
)
result = await tools.delete_continual_plan(plan["id"])
assert result["deleted"] is True
assert result["plan_id"] == plan["id"]
assert store.get_plan(plan["id"]) is None
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from types import SimpleNamespace
from fastapi.testclient import TestClient
import traderai.server as server
def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
state = {"settings": make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b")}
class FakeScheduler:
def __init__(self, memory):
self.memory = memory
def bind_agent(self, agent):
self.agent = agent
def bind_plan_runner(self, plan_runner):
self.plan_runner = plan_runner
def bind_uex_notifications(self, uex, poll_seconds=60):
self.uex = uex
self.poll_seconds = poll_seconds
def start(self):
return None
def shutdown(self):
return None
def list_jobs(self):
return []
class FakeUEXClient:
def __init__(self, *args, **kwargs):
pass
async def get_user(self, username=None, authenticated=False):
return {}
class FakeToolRegistry:
def __init__(self, *args, **kwargs):
self.pending_actions = {}
self.plan_runner = None
async def approve(self, action_id):
return {"approved": action_id}
async def decline(self, action_id):
return {"declined": action_id}
class FakePlanRunner:
def __init__(self, store, tools, memory, agent=None):
self.store = store
self.tools = tools
self.memory = memory
self.agent = agent
def bind_agent(self, agent):
self.agent = agent
class FakeClient:
def __init__(self, *args, **kwargs):
pass
async def fake_health(self):
return {
"online": True,
"provider": self.provider,
"model": self.model,
"model_available": True,
"message": f"{self.provider} ready",
}
async def fake_chat(self, content, thread_id=None, images=None):
return {"message": f"{self.provider}:{self.model}", "pending_actions": [], "thread_id": thread_id}
def fake_get_settings():
return state["settings"]
def fake_save_settings(values):
state["settings"] = make_settings(
tmp_path,
model_provider=values.get("model_provider", state["settings"].model_provider),
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
codex_model=values.get("codex_model", state["settings"].codex_model),
)
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
monkeypatch.setattr(server, "get_settings", fake_get_settings)
monkeypatch.setattr(server, "save_settings", fake_save_settings)
monkeypatch.setattr(
server,
"settings_payload",
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
)
monkeypatch.setattr(server.OllamaAgent, "health", fake_health)
monkeypatch.setattr(server.OllamaAgent, "chat", fake_chat)
app = server.create_app()
with TestClient(app) as client:
before = client.get("/api/health").json()
assert before["model_provider"] == "ollama"
assert before["inference"]["provider"] == "ollama"
updated = client.post(
"/api/config",
json={"values": {"model_provider": "codex", "codex_model": "gpt-5.4"}},
).json()
assert updated["restart_required"] is False
after = client.get("/api/health").json()
assert after["model_provider"] == "codex"
assert after["inference"]["provider"] == "codex"
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
assert chat["message"] == "codex:gpt-5.4"
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4"):
return SimpleNamespace(
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
model_provider=model_provider,
ollama_base_url="http://localhost:11434",
ollama_model=ollama_model,
ollama_num_ctx=64512,
openai_base_url="https://api.openai.com/v1",
openai_api_key=None,
openai_model="gpt-5.4-mini",
model_reasoning_effort="medium",
codex_command="codex",
codex_model=codex_model,
uex_base_url="https://api.uexcorp.space/2.0",
uex_secret_key=None,
uex_bearer_token=None,
traderai_user_name=None,
uex_notification_poll_seconds=60,
require_write_approval=True,
scmdb_base_url="https://scmdb.net",
cornerstone_base_url="https://finder.cstone.space",
scwiki_base_url="https://starcitizen.tools",
scwiki_api_base_url="https://api.star-citizen.wiki",
)
+243
View File
@@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient
class FakeUEX: class FakeUEX:
def __init__(self): def __init__(self):
self.posts = [] self.posts = []
self.get_calls = []
async def get(self, path, params=None, authenticated=False): async def get(self, path, params=None, authenticated=False):
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
if path == "commodities_prices_history": if path == "commodities_prices_history":
return { return {
"status": "ok", "status": "ok",
@@ -80,6 +82,34 @@ class FakeUEX:
}, },
], ],
} }
if path == "marketplace_trends":
return {
"status": "ok",
"data": [
{
"id_item": 2791,
"item_name": "\"Quantanium\" Water Bottle",
"item_slug": "quantanium-water-bottle",
"currency": "UEC",
"price_avg_sell": "937500",
"price_avg_month_sell": "1072222",
"price_min_sell": "750000",
"price_max_sell": "1200000",
"listings_count_sell": 4,
"price_avg_buy": "500000",
"price_avg_month_buy": "525000",
"price_min_buy": "450000",
"price_max_buy": "550000",
"listings_count_buy": 2,
"total_listings_count": 6,
"negotiations_count": 18,
"negotiations_open": 7,
"negotiations_success": 9,
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
}
],
}
assert path == "marketplace_listings" assert path == "marketplace_listings"
return { return {
"data": [ "data": [
@@ -259,6 +289,85 @@ class FakeCornerstone:
} }
class FakeSCWiki:
base_url = "https://starcitizen.tools"
api_base_url = "https://api.star-citizen.wiki"
async def search_pages(self, query, limit=5):
assert query == "Carrack"
return [
{
"pageid": 415,
"title": "Carrack",
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
"extract": "The Anvil Carrack is a multi-crew explorer.",
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
"url": "https://starcitizen.tools/Carrack",
}
][:limit]
async def get_page_summary(self, title=None, pageid=None, chars=700):
assert title == "Carrack" or pageid == 415
return {
"pageid": 415,
"title": "Carrack",
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
"extract": "The Anvil Carrack is a multi-crew explorer.",
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
"url": "https://starcitizen.tools/Carrack",
}
async def search_verse(self, query):
assert query == "Carrack"
return [
{
"type": "vehicles",
"label": "Vehicles",
"results": [
{
"name": "Anvil Carrack",
"class_name": "ANVL_Carrack",
"extra_label": "Exploration",
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
"api_url": "https://api.star-citizen.wiki/api/vehicles/anvl-carrack",
}
],
}
]
async def get_vehicle(self, slug):
assert slug == "anvl-carrack"
return {
"name": "Carrack",
"game_name": "Anvil Carrack",
"slug": "anvl-carrack",
"manufacturer": {"name": "Anvil Aerospace"},
"career": "Exploration",
"role": "Expedition",
"size_class": 5,
"cargo_capacity": 456,
"crew": {"min": 6, "max": 6},
"msrp": 600,
"pledge_url": "https://robertsspaceindustries.com/pledge/ships/carrack/Carrack",
"uex_prices": {
"purchase": [
{
"price_buy": 34398000,
"terminal_name": "Astro Armada - Area 18",
"starmap_location": {"name": "Area18", "parent_name": "ArcCorp", "star_system_name": "Stanton"},
"game_version": "4.8.1-LIVE.11952564",
"date_updated": "2026-05-20T18:39:37-04:00",
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
}
]
},
"description": {"en_EN": "The Anvil Carrack features reinforced fuel tanks for long-duration flight."},
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
"updated_at": "2026-06-08T00:34:00Z",
"version": "4.8.1-LIVE.11952564",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally(): async def test_search_marketplace_listings_filters_locally():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
@@ -333,6 +442,65 @@ async def test_uex_get_projects_and_limits_results():
assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}] assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}]
@pytest.mark.asyncio
async def test_uex_get_marketplace_listings_accepts_item_and_operation_filters():
fake = FakeUEX()
registry = ToolRegistry(fake)
result = await registry.execute(
"get_uex_marketplace_listings",
{
"id_item": 2791,
"operation": "sell",
"fields": ["id", "slug", "operation"],
},
)
assert result["params"] == {"id_item": 2791, "operation": "sell"}
assert fake.get_calls[-1]["path"] == "marketplace_listings"
assert fake.get_calls[-1]["params"] == {"id_item": 2791, "operation": "sell"}
@pytest.mark.asyncio
async def test_get_marketplace_trends_returns_compact_wts_wtb_and_negotiation_metrics():
fake = FakeUEX()
registry = ToolRegistry(fake)
result = await registry.get_marketplace_trends(item_name="Quantanium", currency="UEC", quality_tier=0)
assert result["status"] == "ok"
assert result["count"] == 1
assert result["filters"] == {"item_name": "Quantanium", "currency": "UEC", "quality_tier": 0}
assert fake.get_calls[-1]["path"] == "marketplace_trends"
assert fake.get_calls[-1]["params"] == {"id_item": None, "item_name": "Quantanium", "item_slug": None, "id_category": None, "currency": "UEC", "quality_tier": 0}
assert result["trends"][0] == {
"id_item": 2791,
"item_name": "\"Quantanium\" Water Bottle",
"item_slug": "quantanium-water-bottle",
"currency": "UEC",
"sell": {
"avg_price": "937500",
"avg_price_month": "1072222",
"min_price": "750000",
"max_price": "1200000",
"listings_count": 4,
},
"buy": {
"avg_price": "500000",
"avg_price_month": "525000",
"min_price": "450000",
"max_price": "550000",
"listings_count": 2,
},
"total_listings_count": 6,
"negotiations_count": 18,
"negotiations_open": 7,
"negotiations_success": 9,
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uex_api_catalog_exposes_resources_without_live_call(): async def test_uex_api_catalog_exposes_resources_without_live_call():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
@@ -368,6 +536,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
assert "get_uex_commodities_prices" in names assert "get_uex_commodities_prices" in names
assert "get_uex_vehicles" in names assert "get_uex_vehicles" in names
assert "get_marketplace_trends" in names
assert "draft_uex_marketplace_advertise" in names assert "draft_uex_marketplace_advertise" in names
assert "delete_uex_marketplace_listings" in names assert "delete_uex_marketplace_listings" in names
assert "uex_get" not in names assert "uex_get" not in names
@@ -395,6 +564,17 @@ def test_schemas_expose_cornerstone_item_tools():
assert "draft_marketplace_listing_with_cornerstone_image" in names assert "draft_marketplace_listing_with_cornerstone_image" in names
def test_schemas_expose_scwiki_tools():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
names = {schema["function"]["name"] for schema in registry.schemas}
assert "search_scwiki_pages" in names
assert "get_scwiki_page" in names
assert "search_scwiki_vehicles" in names
assert "get_scwiki_vehicle" in names
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_scmdb_missions_returns_reward_summary(): async def test_search_scmdb_missions_returns_reward_summary():
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
@@ -469,6 +649,43 @@ async def test_get_cornerstone_item_media_returns_absolute_image_urls():
] ]
@pytest.mark.asyncio
async def test_search_scwiki_pages_returns_general_knowledge_matches():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
result = await registry.search_scwiki_pages(query="Carrack")
assert result["source"] == "https://starcitizen.tools"
assert result["matched"] == 1
assert result["pages"][0]["title"] == "Carrack"
assert result["pages"][0]["url"] == "https://starcitizen.tools/Carrack"
@pytest.mark.asyncio
async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
result = await registry.get_scwiki_vehicle(query="Carrack")
assert result["source"] == "https://api.star-citizen.wiki"
vehicle = result["vehicle"]
assert vehicle["name"] == "Carrack"
assert vehicle["manufacturer"] == "Anvil Aerospace"
assert vehicle["msrp"] == 600
assert vehicle["purchase_locations"] == [
{
"price_buy": 34398000,
"terminal_name": "Astro Armada - Area 18",
"location": "Area18",
"parent_location": "ArcCorp",
"star_system": "Stanton",
"game_version": "4.8.1-LIVE.11952564",
"date_updated": "2026-05-20T18:39:37-04:00",
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display(): async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
@@ -497,6 +714,32 @@ async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_
assert pending["metadata"]["cornerstone_image_status"] == "included" 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(
""" """
+952 -29
View File
File diff suppressed because it is too large Load Diff
+32 -2
View File
@@ -11,12 +11,21 @@ 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},
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
"codex_model": {"env": "CODEX_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},
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
"scwiki_api_base_url": {"env": "SCWIKI_API_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 +71,21 @@ 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.4-mini"
model_reasoning_effort: str = "medium"
codex_command: str = "codex"
codex_model: str = "gpt-5.4"
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"
scwiki_base_url: str = "https://starcitizen.tools"
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
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 +93,23 @@ 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", "codex"} else "ollama"
@field_validator("model_reasoning_effort", mode="before")
@classmethod
def _normalize_reasoning_effort(cls, value: Any) -> str:
text = str(value or "medium").strip().casefold()
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh"} else "medium"
@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 +167,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":
+28
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import os import os
from pathlib import Path from pathlib import Path
import shutil import shutil
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
def main() -> None: def main() -> None:
try: try:
_chdir_to_app_dir() _chdir_to_app_dir()
backend_port = _backend_port_from_args()
if backend_port is not None:
_run_server(backend_port)
return
_log("TraderAI desktop starting") _log("TraderAI desktop starting")
_log(f"cwd={Path.cwd()}") _log(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}") _log(f"executable={sys.executable}")
@@ -36,6 +41,10 @@ def main() -> None:
_log("existing TraderAI backend found; opening window") _log("existing TraderAI backend found; opening window")
_open_window(url) _open_window(url)
return return
if getattr(sys, "frozen", False):
backend_process = _start_backend_process(port)
_log(f"backend process started pid={backend_process.pid}")
else:
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True) server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start() server_thread.start()
_log("backend thread started") _log("backend thread started")
@@ -62,6 +71,22 @@ def _select_port() -> int:
return _free_port() return _free_port()
def _backend_port_from_args() -> int | None:
args = sys.argv[1:]
if len(args) >= 2 and args[0] == "--backend-port":
return int(args[1])
return None
def _start_backend_process(port: int) -> subprocess.Popen:
command = [sys.executable, "--backend-port", str(port)]
_log(f"starting backend subprocess: {' '.join(command)}")
kwargs: dict[str, object] = {}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(command, **kwargs)
def _port_available(port: int) -> bool: def _port_available(port: int) -> bool:
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -88,6 +113,9 @@ def _existing_server_ready(url: str) -> bool:
def _run_server(port: int) -> NoReturn: def _run_server(port: int) -> NoReturn:
try: try:
_log(f"backend starting on port {port}") _log(f"backend starting on port {port}")
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
from traderai.server import app from traderai.server import app
config = uvicorn.Config( config = uvicorn.Config(
+11
View File
@@ -232,6 +232,17 @@ class ContinualPlanStore:
self.add_event(plan_id, status, f"Plan status changed to {status}.") self.add_event(plan_id, status, f"Plan status changed to {status}.")
return self.get_plan(plan_id) return self.get_plan(plan_id)
def delete_plan(self, plan_id: str) -> bool:
with self.memory._connect() as db:
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
if not deleted:
return False
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
return True
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
now = iso_now() now = iso_now()
with self.memory._connect() as db: with self.memory._connect() as db:
+561 -22
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import asyncio
import json import json
import shutil import shutil
import subprocess import subprocess
@@ -26,6 +27,7 @@ from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.plans import ContinualPlanRunner, ContinualPlanStore 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.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__ from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
@@ -39,6 +41,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):
@@ -99,32 +108,52 @@ def create_app() -> FastAPI:
memory = MemoryStore(settings.traderai_memory_path) memory = MemoryStore(settings.traderai_memory_path)
plan_store = ContinualPlanStore(memory) plan_store = ContinualPlanStore(memory)
scheduler = WakeScheduler(memory) scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) runtime: dict[str, Any] = {}
scmdb = SCMDBClient(settings.scmdb_base_url)
cornerstone = CornerstoneClient(settings.cornerstone_base_url) def configure_runtime(current_settings: Any) -> None:
uex = UEXClient(current_settings.uex_base_url, current_settings.uex_secret_key, current_settings.uex_bearer_token)
scmdb = SCMDBClient(current_settings.scmdb_base_url)
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
tools = ToolRegistry( tools = ToolRegistry(
uex, uex,
settings.require_write_approval, current_settings.require_write_approval,
memory=memory, memory=memory,
scheduler=scheduler, scheduler=scheduler,
scmdb=scmdb, scmdb=scmdb,
cornerstone=cornerstone, cornerstone=cornerstone,
scwiki=scwiki,
plan_store=plan_store, plan_store=plan_store,
) )
plan_runner = ContinualPlanRunner(plan_store, tools, memory) plan_runner = ContinualPlanRunner(plan_store, tools, memory)
tools.plan_runner = plan_runner tools.plan_runner = plan_runner
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
agent = OllamaAgent( agent = OllamaAgent(
settings.ollama_base_url, provider_base_url,
settings.ollama_model, provider_model,
tools, tools,
memory=memory, memory=memory,
user_name=settings.traderai_user_name, user_name=current_settings.traderai_user_name,
num_ctx=settings.ollama_num_ctx, num_ctx=current_settings.ollama_num_ctx,
provider=current_settings.model_provider,
api_key=provider_api_key,
reasoning_effort=current_settings.model_reasoning_effort,
) )
plan_runner.bind_agent(agent) plan_runner.bind_agent(agent)
scheduler.bind_agent(agent) scheduler.bind_agent(agent)
scheduler.bind_plan_runner(plan_runner) scheduler.bind_plan_runner(plan_runner)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds) scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
runtime.update(
{
"settings": current_settings,
"uex": uex,
"tools": tools,
"plan_runner": plan_runner,
"agent": agent,
}
)
configure_runtime(settings)
app = FastAPI(title="TraderAI") app = FastAPI(title="TraderAI")
static_dir = resource_path("web") static_dir = resource_path("web")
@@ -140,17 +169,20 @@ def create_app() -> FastAPI:
scheduler.shutdown() scheduler.shutdown()
async def refresh_user_profile() -> None: async def refresh_user_profile() -> None:
if settings.traderai_user_name: current_settings = get_settings()
memory.set_profile("configured_name", settings.traderai_user_name) agent = runtime["agent"]
agent.user_name = agent.user_name or settings.traderai_user_name uex = runtime["uex"]
if current_settings.traderai_user_name:
memory.set_profile("configured_name", current_settings.traderai_user_name)
agent.user_name = agent.user_name or current_settings.traderai_user_name
try: try:
response = await uex.get_user(authenticated=True) response = await uex.get_user(authenticated=True)
except Exception as exc: except Exception as exc:
memory.set_profile("uex_user_error", str(exc)) memory.set_profile("uex_user_error", str(exc))
if settings.traderai_user_name: if current_settings.traderai_user_name:
try: try:
response = await uex.get_user(username=settings.traderai_user_name) response = await uex.get_user(username=current_settings.traderai_user_name)
except Exception: except Exception:
return return
else: else:
@@ -169,8 +201,13 @@ def create_app() -> FastAPI:
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict: async def health() -> dict:
agent = runtime["agent"]
current_settings = get_settings()
inference = await agent.health()
return { return {
"ollama": await agent.health(), "inference": inference,
"ollama": inference,
"model_provider": current_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"],
@@ -183,14 +220,61 @@ def create_app() -> FastAPI:
@app.post("/api/config") @app.post("/api/config")
async def update_config(request: ConfigUpdateRequest) -> dict: async def update_config(request: ConfigUpdateRequest) -> dict:
previous_settings = get_settings()
updated = save_settings(request.values) updated = save_settings(request.values)
updated["restart_required"] = True current_settings = get_settings()
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect." configure_runtime(current_settings)
await refresh_user_profile()
restart_required = (
"traderai_memory_path" in request.values
and str(request.values.get("traderai_memory_path") or "").strip() != str(previous_settings.traderai_memory_path)
)
updated["restart_required"] = restart_required
updated["message"] = (
"Configuration saved. Restart TraderAI to switch memory databases."
if restart_required
else "Configuration saved and applied."
)
return updated return updated
@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/provider/models")
async def provider_models(provider: str | None = None) -> dict:
status = await inspect_provider_models(provider)
return {
"provider": status.get("provider", "openai"),
"configured_model": status.get("configured_model"),
"models": status.get("models", []),
"reasoning_efforts": status.get("reasoning_efforts", reasoning_effort_options()),
"configured_reasoning_effort": status.get("configured_reasoning_effort", get_settings().model_reasoning_effort),
"message": status.get("message", ""),
"detail": status.get("detail", ""),
"online": status.get("online", False),
}
@app.post("/api/codex/login")
async def launch_codex_login() -> dict:
current_settings = get_settings()
command = find_codex_cli(current_settings.codex_command)
if not command:
raise HTTPException(status_code=404, detail="Codex CLI was not found on PATH.")
try:
login = await start_codex_browser_login(command)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Codex App Server login failed: {exception_detail(exc)}") from exc
return {
"installed": True,
"running": False,
"online": False,
"provider": "codex",
"login_id": login.get("loginId"),
"auth_url": login.get("authUrl"),
"base_url": str(command),
"message": "Opened Codex App Server sign-in in your browser. Finish the flow, then TraderAI will detect the new login.",
}
@app.post("/api/ollama/launch") @app.post("/api/ollama/launch")
async def launch_ollama() -> dict: async def launch_ollama() -> dict:
@@ -201,7 +285,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
@@ -218,7 +302,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
@@ -297,15 +381,26 @@ def create_app() -> FastAPI:
@app.post("/api/chat") @app.post("/api/chat")
async def chat(request: ChatRequest) -> dict: async def chat(request: ChatRequest) -> dict:
agent = runtime["agent"]
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:
agent = runtime["agent"]
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")
@@ -337,6 +432,7 @@ def create_app() -> FastAPI:
@app.get("/api/pending-actions") @app.get("/api/pending-actions")
async def pending_actions() -> dict: async def pending_actions() -> dict:
agent = runtime["agent"]
return {"pending_actions": agent._pending_payloads()} return {"pending_actions": agent._pending_payloads()}
@app.get("/api/notifications") @app.get("/api/notifications")
@@ -363,11 +459,13 @@ def create_app() -> FastAPI:
@app.get("/api/negotiations/{identifier}/messages") @app.get("/api/negotiations/{identifier}/messages")
async def negotiation_messages(identifier: str) -> dict: async def negotiation_messages(identifier: str) -> dict:
uex = runtime["uex"]
params = negotiation_identifier_params(identifier) params = negotiation_identifier_params(identifier)
return await uex.get("marketplace_negotiations_messages", params, authenticated=True) return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
@app.post("/api/negotiations/{identifier}/messages") @app.post("/api/negotiations/{identifier}/messages")
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict: async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
uex = runtime["uex"]
params = negotiation_identifier_params(identifier) params = negotiation_identifier_params(identifier)
payload = {**params, "message": request.message, "is_production": 1} payload = {**params, "message": request.message, "is_production": 1}
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True) return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
@@ -382,6 +480,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans") @app.post("/api/plans")
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict: async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
tools = runtime["tools"]
result = await tools.create_continual_plan( result = await tools.create_continual_plan(
title=request.title, title=request.title,
objective=request.objective, objective=request.objective,
@@ -403,6 +502,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/pause") @app.post("/api/plans/{plan_id}/pause")
async def pause_continual_plan(plan_id: str) -> dict: async def pause_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.pause_continual_plan(plan_id) result = await tools.pause_continual_plan(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"]) raise HTTPException(status_code=404, detail=result["error"])
@@ -410,6 +510,7 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/resume") @app.post("/api/plans/{plan_id}/resume")
async def resume_continual_plan(plan_id: str) -> dict: async def resume_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.resume_continual_plan(plan_id) result = await tools.resume_continual_plan(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"]) raise HTTPException(status_code=404, detail=result["error"])
@@ -417,13 +518,23 @@ def create_app() -> FastAPI:
@app.post("/api/plans/{plan_id}/cancel") @app.post("/api/plans/{plan_id}/cancel")
async def cancel_continual_plan(plan_id: str) -> dict: async def cancel_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.cancel_continual_plan(plan_id) result = await tools.cancel_continual_plan(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"]) raise HTTPException(status_code=404, detail=result["error"])
return result return result
@app.delete("/api/plans/{plan_id}")
async def delete_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.delete_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") @app.post("/api/plans/{plan_id}/run")
async def run_continual_plan(plan_id: str) -> dict: async def run_continual_plan(plan_id: str) -> dict:
tools = runtime["tools"]
result = await tools.run_continual_plan_now(plan_id) result = await tools.run_continual_plan_now(plan_id)
if result.get("error"): if result.get("error"):
raise HTTPException(status_code=400, detail=result["error"]) raise HTTPException(status_code=400, detail=result["error"])
@@ -457,10 +568,12 @@ def create_app() -> FastAPI:
@app.post("/api/approve/{action_id}") @app.post("/api/approve/{action_id}")
async def approve(action_id: str) -> dict: async def approve(action_id: str) -> dict:
tools = runtime["tools"]
return await tools.approve(action_id) return await tools.approve(action_id)
@app.post("/api/decline/{action_id}") @app.post("/api/decline/{action_id}")
async def decline(action_id: str) -> dict: async def decline(action_id: str) -> dict:
tools = runtime["tools"]
return await tools.decline(action_id) return await tools.decline(action_id)
return app return app
@@ -475,6 +588,125 @@ 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()
if settings.model_provider == "codex":
return await inspect_codex()
return await inspect_ollama()
async def inspect_openai() -> dict[str, Any]:
settings = get_settings()
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
async def inspect_codex() -> dict[str, Any]:
settings = get_settings()
command = find_codex_cli(settings.codex_command)
detail = ""
online = False
models: list[str] = []
effort_map: dict[str, list[str]] = {}
if command:
try:
account, models, effort_map = await inspect_codex_app_server(command)
online = bool(account)
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
except (OSError, RuntimeError, asyncio.TimeoutError) as exc:
detail = str(exc)
configured_model = settings.codex_model
model_available = configured_model in models if models else bool(configured_model)
return {
"installed": bool(command),
"running": online,
"online": online,
"provider": "codex",
"model_available": model_available,
"configured_model": configured_model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": codex_reasoning_efforts(configured_model, effort_map),
"base_url": str(command) if command else settings.codex_command,
"models": models,
"message": codex_status_message(bool(command), online, model_available, configured_model),
"detail": detail,
}
async def inspect_cloud_provider() -> dict[str, Any]:
settings = get_settings()
if settings.model_provider == "codex":
return await inspect_codex()
return await inspect_openai()
async def inspect_provider_models(provider: str | None = None) -> dict[str, Any]:
normalized = str(provider or get_settings().model_provider).strip().casefold()
if normalized == "codex":
return await inspect_codex()
if normalized == "ollama":
return await inspect_ollama()
return await inspect_openai()
async def inspect_cloud_provider_config(
provider: str,
base_url: str,
api_key: str | None,
model: str,
) -> dict[str, Any]:
settings = get_settings()
models: list[str] = []
online = False
detail = ""
provider_name = provider_display_name(provider)
if not api_key:
return {
"installed": True,
"running": False,
"online": False,
"provider": provider,
"model_available": False,
"configured_model": model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": base_url,
"models": [],
"message": f"{provider_name} is selected, but no API key is configured.",
"detail": "",
}
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"{base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {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 = model in models
return {
"installed": True,
"running": online,
"online": online,
"provider": provider,
"model_available": model_available,
"configured_model": model,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": base_url,
"models": models,
"message": cloud_status_message(provider, online, bool(api_key), model_available, 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()
@@ -500,8 +732,11 @@ 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,
"configured_reasoning_effort": settings.model_reasoning_effort,
"reasoning_efforts": reasoning_effort_options(),
"base_url": settings.ollama_base_url, "base_url": settings.ollama_base_url,
"num_ctx": settings.ollama_num_ctx, "num_ctx": settings.ollama_num_ctx,
"models": models, "models": models,
@@ -514,6 +749,17 @@ async def inspect_ollama() -> dict[str, Any]:
} }
def cloud_status_message(provider: str, running: bool, configured: bool, model_available: bool, model: str) -> str:
provider_name = provider_display_name(provider)
if not configured:
return f"{provider_name} API key is not configured."
if not running:
return f"{provider_name} is not reachable with the configured key."
if not model_available:
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
return f"{provider_name} 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."
@@ -524,6 +770,292 @@ def ollama_status_message(installed: bool, running: bool, model_available: bool,
return "Ollama is ready." return "Ollama is ready."
def codex_status_message(installed: bool, logged_in: bool, model_available: bool, model: str) -> str:
if not installed:
return "Codex CLI is not installed."
if not logged_in:
return "Codex CLI is installed, but the Codex App Server is not logged in with ChatGPT."
if not model_available:
return f'Codex App Server is logged in, but model "{model}" was not returned by the model list.'
return "Codex App Server is ready."
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
if settings.model_provider == "openai":
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
if settings.model_provider == "codex":
return settings.codex_command, settings.codex_model, None
return settings.ollama_base_url, settings.ollama_model, None
def provider_display_name(provider: str) -> str:
return {"openai": "OpenAI", "codex": "Codex"}.get(provider, "Ollama")
def find_codex_cli(configured_command: str | None = None) -> Path | None:
candidates = [configured_command, shutil.which("codex"), os.path.join(os.environ.get("USERPROFILE", ""), ".codex", ".sandbox-bin", "codex.exe")]
for candidate in candidates:
if not candidate:
continue
resolved = shutil.which(candidate) if Path(candidate).name == candidate else candidate
if not resolved:
continue
path = Path(resolved)
if path.exists():
return path
return None
_codex_login_tasks: set[asyncio.Task] = set()
async def start_codex_browser_login(command: Path) -> dict[str, Any]:
process = await asyncio.create_subprocess_exec(
str(command),
"app-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
request_id = 1
async def write(payload: dict[str, Any]) -> None:
if process.stdin is None:
raise RuntimeError("Codex App Server stdin is unavailable.")
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
await process.stdin.drain()
async def read(timeout: int = 30) -> dict[str, Any]:
if process.stdout is None:
raise RuntimeError("Codex App Server stdout is unavailable.")
try:
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
except asyncio.TimeoutError as exc:
raise RuntimeError("Codex App Server timed out while starting browser login.") from exc
if not line:
stderr = ""
if process.stderr is not None:
try:
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
except asyncio.TimeoutError:
stderr = ""
raise RuntimeError(stderr or "Codex App Server exited before login completed.")
return json.loads(line.decode("utf-8", errors="replace"))
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
nonlocal request_id
current_id = request_id
request_id += 1
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
if params is not None:
payload["params"] = params
await write(payload)
while True:
message = await read()
if message.get("id") == current_id:
if message.get("error"):
error = message["error"]
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
return message.get("result") or {}
await answer_codex_login_server_request(write, message)
try:
await send(
"initialize",
{
"clientInfo": {"name": "TraderAI", "version": __version__},
"capabilities": {"experimentalApi": True},
},
)
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
login = await send("account/login/start", {"type": "chatgpt"})
if login.get("type") != "chatgpt" or not login.get("authUrl"):
raise RuntimeError(f"Codex App Server did not return a browser login URL: {login!r}")
task = asyncio.create_task(watch_codex_browser_login(process, read, write, login.get("loginId")))
_codex_login_tasks.add(task)
task.add_done_callback(_codex_login_tasks.discard)
return login
except Exception:
await stop_process(process)
raise
async def answer_codex_login_server_request(write: Any, message: dict[str, Any]) -> None:
if "id" not in message or "method" not in message:
return
await write(
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": "TraderAI login does not handle server requests."},
}
)
async def watch_codex_browser_login(process: asyncio.subprocess.Process, read: Any, write: Any, login_id: str | None) -> None:
try:
while True:
message = await read(timeout=300)
if message.get("method") == "account/login/completed":
params = message.get("params") or {}
if login_id is None or params.get("loginId") == login_id:
return
await answer_codex_login_server_request(write, message)
except Exception:
return
finally:
await stop_process(process)
async def stop_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
process.kill()
await process.wait()
async def inspect_codex_app_server(command: Path) -> tuple[dict[str, Any] | None, list[str], dict[str, list[str]]]:
process = await asyncio.create_subprocess_exec(
str(command),
"app-server",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
)
request_id = 1
async def write(payload: dict[str, Any]) -> None:
if process.stdin is None:
raise RuntimeError("Codex App Server stdin is unavailable.")
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
await process.stdin.drain()
async def read(timeout: int = 30) -> dict[str, Any]:
if process.stdout is None:
raise RuntimeError("Codex App Server stdout is unavailable.")
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
if not line:
stderr = ""
if process.stderr is not None:
try:
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
except asyncio.TimeoutError:
stderr = ""
raise RuntimeError(stderr or "Codex App Server exited without a response.")
return json.loads(line.decode("utf-8", errors="replace"))
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
nonlocal request_id
current_id = request_id
request_id += 1
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
if params is not None:
payload["params"] = params
await write(payload)
while True:
message = await read()
if message.get("id") == current_id:
if message.get("error"):
error = message["error"]
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
return message.get("result") or {}
if "id" in message and "method" in message:
await write(
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": "TraderAI status checks do not handle server requests."},
}
)
try:
await send(
"initialize",
{
"clientInfo": {"name": "TraderAI", "version": __version__},
"capabilities": {"experimentalApi": True},
},
)
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
account_result = await send("account/read", {"refreshToken": False})
models: list[str] = []
effort_map: dict[str, list[str]] = {}
cursor: str | None = None
for _ in range(20):
params: dict[str, Any] = {"limit": 50, "includeHidden": False}
if cursor:
params["cursor"] = cursor
page = await send("model/list", params)
for item in page.get("data") or []:
model = item.get("id") or item.get("model")
if not model:
continue
models.append(model)
efforts = [
effort.get("reasoningEffort")
for effort in item.get("supportedReasoningEfforts", [])
if effort.get("reasoningEffort")
]
if efforts:
effort_map[model] = efforts
cursor = page.get("nextCursor")
if not cursor:
break
return account_result.get("account"), sorted(set(models)), effort_map
finally:
if process.returncode is None:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=3)
except asyncio.TimeoutError:
process.kill()
await process.wait()
def codex_models() -> list[str]:
cache_path = Path.home() / ".codex" / "models_cache.json"
if not cache_path.exists():
return []
try:
body = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
models = []
for item in body.get("models", []):
slug = item.get("slug")
if slug:
models.append(slug)
return sorted(set(models))
def codex_reasoning_efforts(model: str, effort_map: dict[str, list[str]] | None = None) -> list[str]:
if effort_map and effort_map.get(model):
return effort_map[model]
cache_path = Path.home() / ".codex" / "models_cache.json"
if not cache_path.exists():
return reasoning_effort_options()
try:
body = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return reasoning_effort_options()
for item in body.get("models", []):
if item.get("slug") != model:
continue
efforts = [entry.get("effort") for entry in item.get("supported_reasoning_levels", []) if entry.get("effort")]
return efforts or reasoning_effort_options()
return reasoning_effort_options()
def reasoning_effort_options() -> list[str]:
return ["none", "minimal", "low", "medium", "high", "xhigh"]
def find_ollama_executable() -> Path | None: def find_ollama_executable() -> Path | None:
candidates = [ candidates = [
shutil.which("ollama"), shutil.which("ollama"),
@@ -576,6 +1108,13 @@ def popen_hidden(command: list[str]) -> subprocess.Popen:
return subprocess.Popen(command, **kwargs) return subprocess.Popen(command, **kwargs)
def exception_detail(exc: BaseException) -> str:
text = str(exc).strip()
if text:
return text
return f"{type(exc).__name__}: {exc!r}"
async def inspect_update() -> dict[str, Any]: async def inspect_update() -> dict[str, Any]:
try: try:
latest = await latest_release() latest = await latest_release()
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Any
from urllib.parse import quote
import httpx
class StarCitizenWikiError(RuntimeError):
pass
class StarCitizenWikiClient:
def __init__(
self,
base_url: str = "https://starcitizen.tools",
api_base_url: str = "https://api.star-citizen.wiki",
) -> None:
self.base_url = base_url.rstrip("/")
self.api_base_url = api_base_url.rstrip("/")
async def search_pages(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
body = await self._get_json(
f"{self.base_url}/api.php",
params={
"action": "query",
"generator": "prefixsearch",
"gpssearch": query,
"gpslimit": max(1, min(limit, 10)),
"prop": "description|pageimages|extracts",
"exintro": 1,
"explaintext": 1,
"exchars": 320,
"piprop": "thumbnail",
"pithumbsize": 240,
"format": "json",
},
)
pages = body.get("query", {}).get("pages", {})
ordered = sorted(
(item for item in pages.values() if isinstance(item, dict)),
key=lambda item: int(item.get("index") or 0),
)
return [
{
"pageid": item.get("pageid"),
"title": item.get("title"),
"description": item.get("description"),
"extract": item.get("extract"),
"thumbnail": (item.get("thumbnail") or {}).get("source"),
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
}
for item in ordered
if item.get("title")
]
async def get_page_summary(self, title: str | None = None, pageid: int | None = None, chars: int = 700) -> dict[str, Any] | None:
params: dict[str, Any] = {
"action": "query",
"prop": "extracts|description|pageimages",
"exintro": 1,
"explaintext": 1,
"exchars": max(120, min(chars, 1200)),
"piprop": "thumbnail",
"pithumbsize": 320,
"format": "json",
}
if pageid is not None:
params["pageids"] = pageid
elif title:
params["titles"] = title
else:
raise StarCitizenWikiError("title or pageid is required")
body = await self._get_json(f"{self.base_url}/api.php", params=params)
pages = body.get("query", {}).get("pages", {})
for item in pages.values():
if isinstance(item, dict) and item.get("pageid") and item.get("title"):
return {
"pageid": item.get("pageid"),
"title": item.get("title"),
"description": item.get("description"),
"extract": item.get("extract"),
"thumbnail": (item.get("thumbnail") or {}).get("source"),
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
}
return None
async def search_verse(self, query: str) -> list[dict[str, Any]]:
body = await self._get_json(
f"{self.api_base_url}/api/search",
params={"filter[query]": query},
)
data = body.get("data")
return data if isinstance(data, list) else []
async def get_vehicle(self, slug: str) -> dict[str, Any]:
body = await self._get_json(f"{self.api_base_url}/api/vehicles/{slug.strip('/')}")
data = body.get("data")
if not isinstance(data, dict):
raise StarCitizenWikiError(f"Vehicle response for {slug} was not an object.")
return data
async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
response = await client.get(url, params=params, headers={"Accept": "application/json"})
try:
body = response.json()
except ValueError as exc:
raise StarCitizenWikiError(f"Star Citizen Wiki returned non-JSON response: HTTP {response.status_code}") from exc
if response.status_code >= 400:
raise StarCitizenWikiError(f"Star Citizen Wiki HTTP {response.status_code}: {body}")
return body
+374 -7
View File
@@ -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
@@ -8,6 +10,7 @@ from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_ite
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient from traderai.scmdb_client import SCMDBClient
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
@@ -56,10 +59,14 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, "marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"}, "marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"},
"marketplace_listings": {"params": ["id", "slug", "username"], "auth": False, "group": "marketplace"}, "marketplace_listings": {"params": ["id", "slug", "username", "id_item", "operation"], "auth": False, "group": "marketplace"},
"marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"}, "marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"},
"marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"}, "marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"},
"marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_prices_averages": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, "marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
"marketplace_prices_history": { "marketplace_prices_history": {
"params": [ "params": [
@@ -81,7 +88,11 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
"group": "marketplace", "group": "marketplace",
"history": True, "history": True,
}, },
"marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_trends": {
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
"auth": False,
"group": "marketplace",
},
"moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"}, "orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
@@ -160,21 +171,25 @@ 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,
scwiki: StarCitizenWikiClient | None = None,
plan_store: Any | None = None, plan_store: Any | None = None,
plan_runner: 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()
self.cornerstone = cornerstone or CornerstoneClient() self.cornerstone = cornerstone or CornerstoneClient()
self.scwiki = scwiki or StarCitizenWikiClient()
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_store = plan_store
self.plan_runner = plan_runner 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,
"get_marketplace_trends": self.get_marketplace_trends,
"list_marketplace_negotiations": self.list_marketplace_negotiations, "list_marketplace_negotiations": self.list_marketplace_negotiations,
"get_negotiation_messages": self.get_negotiation_messages, "get_negotiation_messages": self.get_negotiation_messages,
"draft_negotiation_message": self.draft_negotiation_message, "draft_negotiation_message": self.draft_negotiation_message,
@@ -189,11 +204,16 @@ class ToolRegistry:
"pause_continual_plan": self.pause_continual_plan, "pause_continual_plan": self.pause_continual_plan,
"resume_continual_plan": self.resume_continual_plan, "resume_continual_plan": self.resume_continual_plan,
"cancel_continual_plan": self.cancel_continual_plan, "cancel_continual_plan": self.cancel_continual_plan,
"delete_continual_plan": self.delete_continual_plan,
"run_continual_plan_now": self.run_continual_plan_now, "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_scwiki_pages": self.search_scwiki_pages,
"get_scwiki_page": self.get_scwiki_page,
"search_scwiki_vehicles": self.search_scwiki_vehicles,
"get_scwiki_vehicle": self.get_scwiki_vehicle,
"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, "get_cornerstone_item_media": self.get_cornerstone_item_media,
@@ -223,6 +243,7 @@ class ToolRegistry:
*self._uex_post_schemas(), *self._uex_post_schemas(),
*self._uex_delete_schemas(), *self._uex_delete_schemas(),
*self._scmdb_schemas(), *self._scmdb_schemas(),
*self._scwiki_schemas(),
*self._cornerstone_schemas(), *self._cornerstone_schemas(),
{ {
"type": "function", "type": "function",
@@ -258,6 +279,24 @@ class ToolRegistry:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "get_marketplace_trends",
"description": "Fetch current UEX marketplace trend metrics for an item, including WTS and WTB averages plus negotiation counts.",
"parameters": {
"type": "object",
"properties": {
"id_item": {"type": "integer"},
"item_name": {"type": "string"},
"item_slug": {"type": "string"},
"id_category": {"type": "integer"},
"currency": {"type": "string", "description": "Optional currency filter such as UEC, WIF, or MGS."},
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 7},
},
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -337,6 +376,15 @@ class ToolRegistry:
"durability": {"type": "integer", "minimum": 0, "maximum": 100}, "durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"}, "video_url": {"type": "string"},
"image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."}, "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_tv_allowed": {"type": "integer", "enum": [0, 1]},
@@ -468,6 +516,14 @@ class ToolRegistry:
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}}, "parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
}, },
}, },
{
"type": "function",
"function": {
"name": "delete_continual_plan",
"description": "Delete a continual plan and all of its stored checklist items, candidates, negotiations, and event history.",
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -495,6 +551,14 @@ 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:
@@ -945,6 +1009,68 @@ class ToolRegistry:
}, },
] ]
@classmethod
def _scwiki_schemas(cls) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "search_scwiki_pages",
"description": "Search Star Citizen Wiki pages on starcitizen.tools and return concise summaries for general game knowledge.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Page title or topic to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_page",
"description": "Fetch one Star Citizen Wiki page summary by title or page id.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"pageid": {"type": "integer"},
"chars": {"type": "integer", "minimum": 120, "maximum": 1200, "default": 700},
},
},
},
},
{
"type": "function",
"function": {
"name": "search_scwiki_vehicles",
"description": "Search Star Citizen Wiki structured vehicle data for ships and vehicles.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ship or vehicle name to search for."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_scwiki_vehicle",
"description": "Fetch one Star Citizen Wiki vehicle summary, including MSRP and in-game purchase locations when available.",
"parameters": {
"type": "object",
"properties": {
"slug": {"type": "string", "description": "Vehicle slug such as anvl-carrack."},
"query": {"type": "string", "description": "Vehicle name if the slug is not known."},
},
},
},
},
]
@classmethod @classmethod
def _cornerstone_schemas(cls) -> list[dict[str, Any]]: def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
return [ return [
@@ -1024,6 +1150,16 @@ class ToolRegistry:
"in_stock": {"type": "integer"}, "in_stock": {"type": "integer"},
"durability": {"type": "integer", "minimum": 0, "maximum": 100}, "durability": {"type": "integer", "minimum": 0, "maximum": 100},
"video_url": {"type": "string"}, "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"}, "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_tv_allowed": {"type": "integer", "enum": [0, 1]},
@@ -1183,6 +1319,49 @@ class ToolRegistry:
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug}) response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
return {"listing": response.get("data")} return {"listing": response.get("data")}
async def get_marketplace_trends(
self,
id_item: int | None = None,
item_name: str | None = None,
item_slug: str | None = None,
id_category: int | None = None,
currency: str | None = None,
quality_tier: int | None = None,
) -> dict[str, Any]:
response = await self.uex.get(
"marketplace_trends",
{
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
},
)
trends = [
self._summarize_marketplace_trend(item)
for item in self._as_list(response.get("data"))
if isinstance(item, dict)
]
return {
"status": response.get("status"),
"count": len(trends),
"filters": {
key: value
for key, value in {
"id_item": id_item,
"item_name": item_name,
"item_slug": item_slug,
"id_category": id_category,
"currency": currency,
"quality_tier": quality_tier,
}.items()
if value is not None
},
"trends": trends,
}
async def list_marketplace_negotiations( async def list_marketplace_negotiations(
self, self,
id: int | None = None, id: int | None = None,
@@ -1225,7 +1404,15 @@ class ToolRegistry:
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload, metadata=metadata) 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( async def draft_marketplace_listing_with_cornerstone_image(
self, self,
@@ -1234,6 +1421,9 @@ class ToolRegistry:
**payload: Any, **payload: Any,
) -> dict[str, Any]: ) -> dict[str, Any]:
require_image = bool(payload.pop("require_image", False)) 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) item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query)
if not item: if not item:
return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."} return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."}
@@ -1250,9 +1440,9 @@ class ToolRegistry:
except Exception as exc: except Exception as exc:
image_error = str(exc) image_error = str(exc)
if image_result: if image_result and not payload.get("image_data"):
payload["image_data"] = image_result["image_data"] payload["image_data"] = image_result["image_data"]
elif require_image: elif require_image and not payload.get("image_data"):
return { return {
"error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.", "error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.",
"cornerstone": { "cornerstone": {
@@ -1271,9 +1461,11 @@ class ToolRegistry:
"cornerstone_image_url": image_result.get("url") if image_result else None, "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_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_size_bytes": image_result.get("size_bytes") if image_result else None,
"cornerstone_image_status": "included" if image_result else "not_found", "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, "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) 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]:
@@ -1362,6 +1554,19 @@ class ToolRegistry:
self.scheduler.unschedule_plan(plan_id) self.scheduler.unschedule_plan(plan_id)
return {"plan": self.plan_store.set_status(plan_id, "canceled")} return {"plan": self.plan_store.set_status(plan_id, "canceled")}
async def delete_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}"}
if self.scheduler is not None:
self.scheduler.unschedule_plan(plan_id)
deleted = self.plan_store.delete_plan(plan_id)
if not deleted:
return {"error": f"Plan not found: {plan_id}"}
return {"deleted": True, "plan_id": plan_id, "summary": f"Deleted plan {plan.get('title') or plan_id}."}
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]: async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
if self.plan_runner is None: if self.plan_runner is None:
return {"error": "Continual plan runner is not configured."} return {"error": "Continual plan runner is not configured."}
@@ -1492,6 +1697,49 @@ class ToolRegistry:
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True), "mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
} }
async def search_scwiki_pages(self, query: str, limit: int = 5) -> dict[str, Any]:
pages = await self.scwiki.search_pages(query, limit=limit)
return {"source": self.scwiki.base_url, "query": query, "matched": len(pages), "pages": pages}
async def get_scwiki_page(
self,
title: str | None = None,
pageid: int | None = None,
chars: int = 700,
) -> dict[str, Any]:
page = await self.scwiki.get_page_summary(title=title, pageid=pageid, chars=chars)
if not page:
return {"error": "No Star Citizen Wiki page matched."}
return {"source": self.scwiki.base_url, "page": page}
async def search_scwiki_vehicles(self, query: str, limit: int = 5) -> dict[str, Any]:
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
results = [
self._summarize_scwiki_vehicle_search(item)
for item in (vehicles_group or {}).get("results", [])[: max(1, min(limit, 10))]
if isinstance(item, dict)
]
return {"source": self.scwiki.api_base_url, "query": query, "matched": len(results), "vehicles": results}
async def get_scwiki_vehicle(self, slug: str | None = None, query: str | None = None) -> dict[str, Any]:
resolved_slug = slug
if not resolved_slug:
if not query:
return {"error": "Provide slug or query."}
groups = await self.scwiki.search_verse(query)
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
candidates = [
item
for item in (vehicles_group or {}).get("results", [])
if isinstance(item, dict) and item.get("api_url")
]
if not candidates:
return {"error": "No Star Citizen Wiki vehicle matched."}
resolved_slug = str(candidates[0]["api_url"]).rstrip("/").rsplit("/", 1)[-1]
vehicle = await self.scwiki.get_vehicle(resolved_slug)
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
async def search_cornerstone_items( async def search_cornerstone_items(
self, self,
query: str = "", query: str = "",
@@ -1625,6 +1873,48 @@ class ToolRegistry:
display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>" display["image_data"] = f"<base64 image data redacted; {len(image_data)} characters>"
return display 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 @staticmethod
def _int_or_none(value: Any) -> int | None: def _int_or_none(value: Any) -> int | None:
try: try:
@@ -2125,6 +2415,83 @@ class ToolRegistry:
"expires_at": listing.get("date_expiration"), "expires_at": listing.get("date_expiration"),
} }
@staticmethod
def _summarize_marketplace_trend(trend: dict[str, Any]) -> dict[str, Any]:
return {
"id_item": trend.get("id_item"),
"item_name": trend.get("item_name"),
"item_slug": trend.get("item_slug"),
"currency": trend.get("currency"),
"sell": {
"avg_price": trend.get("price_avg_sell"),
"avg_price_month": trend.get("price_avg_month_sell"),
"min_price": trend.get("price_min_sell"),
"max_price": trend.get("price_max_sell"),
"listings_count": trend.get("listings_count_sell"),
},
"buy": {
"avg_price": trend.get("price_avg_buy"),
"avg_price_month": trend.get("price_avg_month_buy"),
"min_price": trend.get("price_min_buy"),
"max_price": trend.get("price_max_buy"),
"listings_count": trend.get("listings_count_buy"),
},
"total_listings_count": trend.get("total_listings_count"),
"negotiations_count": trend.get("negotiations_count"),
"negotiations_open": trend.get("negotiations_open"),
"negotiations_success": trend.get("negotiations_success"),
"link_prices": trend.get("link_prices"),
"link_prices_history": trend.get("link_prices_history"),
}
@staticmethod
def _summarize_scwiki_vehicle_search(vehicle: dict[str, Any]) -> dict[str, Any]:
return {
"name": vehicle.get("name"),
"class_name": vehicle.get("class_name"),
"career": vehicle.get("extra_label"),
"api_url": vehicle.get("api_url"),
"web_url": vehicle.get("web_url"),
}
@staticmethod
def _summarize_scwiki_vehicle(vehicle: dict[str, Any]) -> dict[str, Any]:
purchases = []
for entry in ((vehicle.get("uex_prices") or {}).get("purchase") or []):
if not isinstance(entry, dict):
continue
location = entry.get("starmap_location") or {}
purchases.append(
{
"price_buy": entry.get("price_buy"),
"terminal_name": entry.get("terminal_name"),
"location": location.get("name"),
"parent_location": location.get("parent_name"),
"star_system": location.get("star_system_name"),
"game_version": entry.get("game_version"),
"date_updated": entry.get("date_updated"),
"uex_link": entry.get("uex_link"),
}
)
return {
"name": vehicle.get("name") or vehicle.get("game_name"),
"game_name": vehicle.get("game_name"),
"slug": vehicle.get("slug"),
"manufacturer": (vehicle.get("manufacturer") or {}).get("name"),
"career": vehicle.get("career"),
"role": vehicle.get("role"),
"size_class": vehicle.get("size_class"),
"cargo_capacity": vehicle.get("cargo_capacity"),
"crew": vehicle.get("crew"),
"msrp": vehicle.get("msrp"),
"pledge_url": vehicle.get("pledge_url"),
"purchase_locations": purchases,
"description": ((vehicle.get("description") or {}).get("en_EN") or (vehicle.get("game_description") or {}).get("en_EN")),
"web_url": vehicle.get("web_url"),
"updated_at": vehicle.get("updated_at"),
"version": vehicle.get("version"),
}
@classmethod @classmethod
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]: def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
summary = cls._project_item(negotiation, mode="summary") summary = cls._project_item(negotiation, mode="summary")
+3 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.0.5" __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"
@@ -11,3 +11,5 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
Generated
+3 -1
View File
@@ -755,7 +755,7 @@ wheels = [
[[package]] [[package]]
name = "traderai" name = "traderai"
version = "0.0.5" version = "0.0.6"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -1051,3 +1051,5 @@ wheels = [
+514 -46
View File
@@ -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");
@@ -25,6 +26,10 @@ 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 codexLoginButton = document.getElementById("codex-login");
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
const providerModelSelect = document.getElementById("provider-model-select");
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
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");
@@ -55,31 +60,60 @@ const planForm = document.getElementById("plan-form");
const plansStatusEl = document.getElementById("plans-status"); const plansStatusEl = document.getElementById("plans-status");
const plansDashboardEl = document.getElementById("plans-dashboard"); const plansDashboardEl = document.getElementById("plans-dashboard");
const plansRailListEl = document.getElementById("plans-rail-list"); const plansRailListEl = document.getElementById("plans-rail-list");
const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]"));
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) {
@@ -459,6 +493,74 @@ function escapeHtml(text) {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
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);
@@ -494,9 +596,16 @@ 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",
model_reasoning_effort: "model-reasoning-effort",
codex_command: "codex-command",
codex_model: "codex-model",
}; };
async function refreshConfig() { async function refreshConfig() {
@@ -527,8 +636,15 @@ 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] ?? "";
} }
}
renderReasoningEffortOptions(["none", "minimal", "low", "medium", "high", "xhigh"], values.model_reasoning_effort || "medium");
updateProviderFieldVisibility(values.model_provider || "ollama");
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 = "";
} }
@@ -565,7 +681,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",
@@ -577,46 +693,79 @@ 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;
updateProviderFieldVisibility(status.provider || "ollama");
const provider = providerDisplayName(status.provider);
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 isOpenAIProvider = status.provider === "openai";
const isCodexProvider = status.provider === "codex";
const ready = isOpenAIProvider
? 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(isCodexProvider ? "Command" : "URL", status.base_url || ""),
];
if (!isOpenAIProvider && !isCodexProvider) {
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(isOpenAIProvider || isCodexProvider ? "Available Models" : "Installed Models", models)}
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} ${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
`; `;
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider;
if (ollamaInstallButton) { if (ollamaInstallButton) {
ollamaInstallButton.hidden = !status.can_auto_install; ollamaInstallButton.hidden = isOpenAIProvider || isCodexProvider || !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 = isOpenAIProvider || isCodexProvider;
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
}
if (ollamaPullButton) {
ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider;
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
}
if (codexLoginButton) {
codexLoginButton.hidden = !isCodexProvider;
codexLoginButton.disabled = Boolean(status.online);
}
if (openaiModelsRefreshButton) {
openaiModelsRefreshButton.hidden = false;
openaiModelsRefreshButton.disabled = false;
}
renderProviderModelOptions(status.models || [], status);
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
updateOllamaAttention(status); updateOllamaAttention(status);
} }
@@ -659,12 +808,18 @@ 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 isOpenAIProvider = currentStatus.provider === "openai";
const isCodexProvider = currentStatus.provider === "codex";
const ready = isOpenAIProvider
? 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", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed);
setOllamaButtonAttention(ollamaInstallButton, "install", !currentStatus.installed && currentStatus.can_auto_install); setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install);
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.installed && !currentStatus.running); setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running);
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.running && !currentStatus.model_available); setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available);
setOllamaButtonAttention(codexLoginButton, "codex-login", isCodexProvider && !currentStatus.online);
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", isOpenAIProvider && !currentStatus.model_available);
if (ready) clickedOllamaActions.clear(); if (ready) clickedOllamaActions.clear();
} }
@@ -672,6 +827,137 @@ function configuredOllamaModel() {
return document.getElementById("ollama-model")?.value || ""; return document.getElementById("ollama-model")?.value || "";
} }
function updateProviderFieldVisibility(provider) {
for (const field of providerScopedFields) {
const scope = field.dataset.providerScope;
field.hidden = scope !== provider;
}
}
function renderProviderModelOptions(models, status = latestOllamaStatus) {
const datalist = document.getElementById("provider-models");
if (datalist) datalist.innerHTML = "";
for (const model of models) {
if (datalist) {
const option = document.createElement("option");
option.value = model;
datalist.appendChild(option);
}
}
if (!providerModelSelect) return;
const provider = status?.provider || document.getElementById("model-provider")?.value || "ollama";
const configuredModel = configuredProviderModel(provider);
providerModelSelect.innerHTML = "";
const allModels = [...new Set([configuredModel, ...models].filter(Boolean))];
if (!allModels.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = "No models detected";
providerModelSelect.appendChild(option);
providerModelSelect.disabled = true;
return;
}
providerModelSelect.disabled = false;
for (const model of allModels) {
const option = document.createElement("option");
option.value = model;
option.textContent = model;
if (model === configuredModel) option.selected = true;
providerModelSelect.appendChild(option);
}
}
async function refreshOpenAIModels() {
setOllamaMessage("Loading provider models");
try {
const provider = document.getElementById("model-provider")?.value || latestOllamaStatus?.provider || "openai";
const response = await fetch(`/api/provider/models?provider=${encodeURIComponent(provider)}`);
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
renderProviderModelOptions(result.models || [], {
provider: result.provider || provider,
configured_model: configuredProviderModel(result.provider || provider),
});
setOllamaMessage(result.message || "Loaded provider models");
await refreshOllamaStatus();
} catch (error) {
setOllamaMessage(`Provider model load failed: ${fetchErrorMessage(error)}`);
}
}
async function launchCodexLogin() {
markOllamaActionClicked("codex-login");
setOllamaMessage("Starting Codex sign-in");
try {
const response = await fetch("/api/codex/login", { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
if (result.auth_url) {
window.open(result.auth_url, "_blank", "noopener,noreferrer");
}
setOllamaMessage(result.message || "Opened Codex sign-in in your browser. Waiting for completion...");
await waitForCodexLogin();
} catch (error) {
setOllamaMessage(`Codex sign-in failed: ${fetchErrorMessage(error)}`);
}
}
async function waitForCodexLogin() {
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 1500 : 3000));
await refreshOllamaStatus();
const provider = latestOllamaStatus?.provider || "";
if (provider === "codex" && latestOllamaStatus?.online) {
setOllamaMessage("Codex sign-in complete.");
return;
}
}
setOllamaMessage("Codex sign-in opened. If you completed it, click Load Provider Models or refresh provider status.");
}
function renderReasoningEffortOptions(efforts, configured) {
if (!modelReasoningEffortSelect) return;
const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))];
modelReasoningEffortSelect.innerHTML = "";
for (const effort of options) {
const option = document.createElement("option");
option.value = effort;
option.textContent = effort;
if (effort === configured) option.selected = true;
modelReasoningEffortSelect.appendChild(option);
}
}
function configuredProviderModel(provider) {
if (provider === "openai") return document.getElementById("openai-model")?.value || "";
if (provider === "codex") return document.getElementById("codex-model")?.value || "";
return document.getElementById("ollama-model")?.value || "";
}
function syncSelectedProviderModel() {
const provider = document.getElementById("model-provider")?.value || "ollama";
const selectedModel = providerModelSelect?.value || "";
if (!selectedModel) return;
if (provider === "openai") {
const field = document.getElementById("openai-model");
if (field) field.value = selectedModel;
return;
}
if (provider === "codex") {
const field = document.getElementById("codex-model");
if (field) field.value = selectedModel;
return;
}
const field = document.getElementById("ollama-model");
if (field) field.value = selectedModel;
}
function providerDisplayName(provider) {
if (provider === "openai") return "OpenAI";
if (provider === "codex") return "Codex";
return "Local Ollama";
}
async function checkForUpdate(promptUser = false) { async function checkForUpdate(promptUser = false) {
if (!updateStatusEl) return; if (!updateStatusEl) return;
updateStatusEl.textContent = "Checking releases"; updateStatusEl.textContent = "Checking releases";
@@ -1122,34 +1408,81 @@ function renderPlansRail(plans) {
async function renderPlans(plans, openPlanId = null) { async function renderPlans(plans, openPlanId = null) {
plansDashboardEl.innerHTML = ""; plansDashboardEl.innerHTML = "";
if (!plans.length) { if (!plans.length) {
plansDashboardEl.innerHTML = '<div class="pending-empty">No continual plans</div>'; plansDashboardEl.innerHTML = `
<section class="plans-overview">
<div>
<p class="eyebrow">Plan board</p>
<h3>No plans yet</h3>
<p class="plan-overview-copy">Create a buying watchlist or a custom follow-up routine to start tracking work over time.</p>
</div>
</section>
<div class="plan-empty-state">
<h4>Nothing is running</h4>
<p>Your continual plans will appear here with status, timing, and recent activity.</p>
</div>
`;
return; return;
} }
const activeCount = plans.filter((plan) => plan.status === "active").length;
const attentionCount = plans.filter((plan) => plan.status === "needs_input" || plan.status === "paused").length;
const overview = document.createElement("section");
overview.className = "plans-overview";
overview.innerHTML = `
<div>
<p class="eyebrow">Plan board</p>
<h3>${plans.length} continual ${plans.length === 1 ? "plan" : "plans"}</h3>
<p class="plan-overview-copy">Monitor recurring work, keep candidate leads in view, and jump into details when something needs attention.</p>
</div>
<div class="plan-overview-stats">
<div class="plan-overview-stat">
<span class="plan-overview-stat-value">${activeCount}</span>
<span class="plan-overview-stat-label">active</span>
</div>
<div class="plan-overview-stat">
<span class="plan-overview-stat-value">${attentionCount}</span>
<span class="plan-overview-stat-label">needs eyes</span>
</div>
</div>
`;
plansDashboardEl.appendChild(overview);
for (const plan of plans) { for (const plan of plans) {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = `plan-card${plan.status === "active" ? " active" : ""}`; card.className = `plan-card plan-status-${slugifyPlanValue(plan.status)}${plan.status === "active" ? " active" : ""}`;
const heading = document.createElement("div");
heading.className = "plan-card-heading";
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = plan.title || "Untitled plan"; title.textContent = plan.title || "Untitled plan";
const statusBadge = document.createElement("span");
statusBadge.className = `plan-status-badge plan-status-${slugifyPlanValue(plan.status)}`;
statusBadge.textContent = humanizePlanValue(plan.status || "unknown");
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "plan-meta"; meta.className = "plan-meta";
meta.textContent = plan.objective || ""; meta.textContent = plan.objective || "";
const pills = document.createElement("div"); const pills = document.createElement("div");
pills.className = "plan-pill-row"; 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"]) { for (const value of [plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) {
const pill = document.createElement("span"); const pill = document.createElement("span");
pill.className = "plan-pill"; pill.className = "plan-pill";
pill.textContent = value; pill.textContent = humanizePlanValue(value);
pills.appendChild(pill); pills.appendChild(pill);
} }
const metrics = document.createElement("div");
metrics.className = "plan-metrics";
metrics.append(
planMetric("Checklist", String((plan.items || []).length)),
planMetric("Cadence", summarizeCadence(plan.cadence)),
planMetric("Updated", formatShortDate(plan.updated_at || plan.created_at))
);
const controls = document.createElement("div"); const controls = document.createElement("div");
controls.className = "plan-controls"; controls.className = "plan-controls";
controls.append( controls.append(
planButton("Details", () => loadPlanDetail(plan.id, card)), planButton("Details", () => loadPlanDetail(plan.id, card)),
planButton("Run", () => postPlanAction(plan.id, "run")), planButton("Run", () => postPlanAction(plan.id, "run")),
planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")), planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")),
planButton("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button") planButton("Delete", () => deletePlan(plan.id), "secondary small-button")
); );
card.append(title, meta, pills, controls); heading.append(title, statusBadge);
card.append(heading, meta, pills, metrics, controls);
plansDashboardEl.appendChild(card); plansDashboardEl.appendChild(card);
if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card); if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card);
} }
@@ -1170,41 +1503,113 @@ async function loadPlanDetail(planId, card) {
existing.remove(); existing.remove();
return; return;
} }
const loading = document.createElement("div");
loading.className = "plan-detail plan-detail-loading";
loading.textContent = "Loading plan details...";
card.appendChild(loading);
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`); const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
const result = await response.json(); const result = await response.json();
const plan = result.plan; const plan = result.plan;
loading.remove();
const detail = document.createElement("div"); const detail = document.createElement("div");
detail.className = "plan-detail"; detail.className = "plan-detail";
detail.append( 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("Checklist", checklistLines(plan), "checklist"),
planSection("Best Candidates", bestCandidateLines(plan)), planSection("Best Candidates", bestCandidateLines(plan), "candidates"),
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`)) planSection("Recent Events", recentEventLines(plan), "events")
); );
card.appendChild(detail); card.appendChild(detail);
} }
function planSection(title, lines) { function planSection(title, lines, sectionClass = "") {
const wrapper = document.createElement("section"); const wrapper = document.createElement("section");
wrapper.className = `plan-section${sectionClass ? ` plan-section-${sectionClass}` : ""}`;
const heading = document.createElement("h4"); const heading = document.createElement("h4");
heading.textContent = title; heading.textContent = title;
const list = document.createElement("ul"); const list = document.createElement("ul");
list.className = "plan-list"; list.className = "plan-list";
const items = lines.length ? lines : ["Empty"]; const items = lines.length ? lines : [planListItemData("Empty", "Nothing to show right now.")];
for (const line of items) { for (const line of items) {
const item = document.createElement("li"); const item = document.createElement("li");
if (typeof line === "string") {
item.textContent = line; item.textContent = line;
} else {
item.className = line.className || "";
const titleEl = document.createElement("div");
titleEl.className = "plan-list-title";
titleEl.textContent = line.title;
const bodyEl = document.createElement("div");
bodyEl.className = "plan-list-body";
bodyEl.textContent = line.body;
item.append(titleEl, bodyEl);
}
list.appendChild(item); list.appendChild(item);
} }
wrapper.append(heading, list); wrapper.append(heading, list);
return wrapper; return wrapper;
} }
function planMetric(label, value) {
const metric = document.createElement("div");
metric.className = "plan-metric";
const metricLabel = document.createElement("span");
metricLabel.className = "plan-metric-label";
metricLabel.textContent = label;
const metricValue = document.createElement("span");
metricValue.className = "plan-metric-value";
metricValue.textContent = value;
metric.append(metricLabel, metricValue);
return metric;
}
function summarizeCadence(cadence) {
if (!cadence) return "manual";
return cadence.replace(/\s+/g, " ").trim();
}
function slugifyPlanValue(value) {
return String(value || "unknown")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
function humanizePlanValue(value) {
return String(value || "")
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function planListItemData(title, body, className = "") {
return { title, body, className };
}
function checklistLines(plan) {
return (plan.items || []).map((item) => {
const quantity = `${item.acquired_quantity || 0}/${item.desired_quantity || 1}`;
const price = item.max_unit_price ? `Max ${Number(item.max_unit_price).toLocaleString()} UEC` : "No price cap";
return planListItemData(item.item_name, `${quantity} acquired • ${price}${humanizePlanValue(item.status || "pending")}`);
});
}
function bestCandidateLines(plan) { function bestCandidateLines(plan) {
const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name])); const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name]));
return (plan.candidates || []) return (plan.candidates || [])
.filter((candidate) => candidate.status === "current" || candidate.status === "drafted") .filter((candidate) => candidate.status === "current" || candidate.status === "drafted")
.slice(0, 6) .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})`); .map((candidate) => {
const title = byItem.get(candidate.plan_item_id) || "Item";
const listing = candidate.title || candidate.listing_slug || candidate.listing_id || "Unnamed listing";
const price = `${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"}`;
const seller = candidate.seller || "unknown seller";
return planListItemData(title, `${listing}${price}${seller}${humanizePlanValue(candidate.status || "current")}`);
});
}
function recentEventLines(plan) {
return (plan.events || [])
.slice(0, 5)
.map((event) => planListItemData(`${formatShortDate(event.created_at)}${humanizePlanValue(event.kind || "event")}`, event.message || "No details."));
} }
async function postPlanAction(planId, action) { async function postPlanAction(planId, action) {
@@ -1222,6 +1627,22 @@ async function postPlanAction(planId, action) {
} }
} }
async function deletePlan(planId) {
if (!window.confirm("Delete this plan and its stored history?")) return;
plansStatusEl.textContent = "delete requested";
try {
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
plansStatusEl.textContent = result.summary || "Plan deleted";
await refreshPlans();
await refreshPending();
await refreshInbox();
} catch (error) {
plansStatusEl.textContent = `Plan delete failed: ${fetchErrorMessage(error)}`;
}
}
function formatShortDate(value) { function formatShortDate(value) {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
@@ -1230,19 +1651,31 @@ function formatShortDate(value) {
} }
async function checkHealth() { async function checkHealth() {
try {
let health = {};
try { try {
const response = await fetch("/api/health"); const response = await fetch("/api/health");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json(); const result = await response.json();
const health = result.ollama || {}; health = result.inference || result.ollama || {};
} catch (primaryError) {
const fallbackResponse = await fetch("/api/ollama/status");
if (!fallbackResponse.ok) throw primaryError;
health = await fallbackResponse.json();
}
const provider = providerDisplayName(health.provider);
const isOpenAIProvider = health.provider === "openai";
const isCodexProvider = health.provider === "codex";
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 = isOpenAIProvider ? "Load Provider Models." : isCodexProvider ? "Sign In to Codex." : "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("");
@@ -1253,7 +1686,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;
} }
@@ -1426,6 +1859,23 @@ 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);
@@ -1458,6 +1908,17 @@ ollamaPullButton?.addEventListener("click", () => {
markOllamaActionClicked("pull"); markOllamaActionClicked("pull");
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }); postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
}); });
codexLoginButton?.addEventListener("click", launchCodexLogin);
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
document.getElementById("model-provider")?.addEventListener("change", () => {
const provider = document.getElementById("model-provider")?.value || "ollama";
updateProviderFieldVisibility(provider);
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
});
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);
@@ -1471,15 +1932,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 = "";
@@ -1491,7 +1959,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}`);
@@ -1537,7 +2005,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);
+63 -24
View File
@@ -59,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>
@@ -70,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>
@@ -131,14 +119,29 @@
</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>Inference</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>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label> <label>Provider
<label>Model<input id="ollama-model" name="ollama_model" type="text"></label> <select id="model-provider" name="model_provider">
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label> <option value="ollama">Local Ollama</option>
<button type="submit">Save Ollama Config</button> <option value="openai">OpenAI</option>
<option value="codex">Codex</option>
</select>
</label>
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
<label data-provider-scope="openai">OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
<label data-provider-scope="openai">OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
<label data-provider-scope="openai">OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
<label data-provider-scope="codex">Codex Command<input id="codex-command" name="codex_command" type="text"></label>
<label data-provider-scope="codex">Codex Model<input id="codex-model" name="codex_model" type="text" list="provider-models"></label>
<label>Available Models<select id="provider-model-select"></select></label>
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></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">
@@ -146,9 +149,26 @@
<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="codex-login" type="button">Sign In to Codex</button>
<button class="secondary small-button" id="openai-models-refresh" type="button">Load Provider 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="Inference">
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
<i data-lucide="bot" aria-hidden="true"></i>
<span>Inference</span>
</button>
</div>
</section> </section>
</aside> </aside>
</main> </main>
@@ -185,23 +205,42 @@
</div> </div>
</div> </div>
<div class="plans-panel-body"> <div class="plans-panel-body">
<form class="config-form" id="plan-form"> <aside class="plan-creator-shell">
<div class="plan-creator-card">
<div class="plan-creator-copy">
<p class="eyebrow">New continual plan</p>
<h3>Set the watch once</h3>
<p>Spin up buying runs or custom follow-up work with a title, a goal, and just enough guardrails to keep it on track.</p>
</div>
<form class="config-form plan-form-grid" id="plan-form">
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label> <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>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
<div class="plan-form-split">
<label>Kind <label>Kind
<select id="plan-kind"> <select id="plan-kind">
<option value="buying">Buying</option> <option value="buying">Buying</option>
<option value="custom">Custom</option> <option value="custom">Custom</option>
</select> </select>
</label> </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> <label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
</div>
<label>Items<textarea id="plan-items" rows="5" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
<label>Instructions<textarea id="plan-instructions" rows="4" placeholder="Extra guidance for custom or buying plans"></textarea></label>
<div class="plan-form-split">
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
<div class="plan-form-hint">
<strong>Tip</strong>
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
</div>
</div>
<button type="submit">Create Plan</button> <button type="submit">Create Plan</button>
<div class="config-status" id="plans-status"></div> <div class="config-status" id="plans-status"></div>
</form> </form>
</div>
</aside>
<section class="plans-dashboard-shell">
<div class="plans-dashboard" id="plans-dashboard"></div> <div class="plans-dashboard" id="plans-dashboard"></div>
</section>
</div> </div>
</div> </div>
<div class="modal-backdrop" id="update-modal" hidden> <div class="modal-backdrop" id="update-modal" hidden>
+438 -30
View File
@@ -269,6 +269,8 @@ body::before {
} }
.actions { .actions {
display: flex;
flex-direction: column;
padding: 28px; padding: 28px;
overflow: auto; overflow: auto;
min-height: 0; min-height: 0;
@@ -555,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;
@@ -720,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;
@@ -951,15 +1039,15 @@ button {
.plans-floating-panel { .plans-floating-panel {
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
width: min(680px, calc(100vw - 28px)); width: min(980px, calc(100vw - 28px));
} }
.plans-panel-body { .plans-panel-body {
display: grid; display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px; gap: 20px;
min-height: 0; min-height: 0;
padding: 16px; padding: 20px;
overflow: auto; overflow: auto;
} }
@@ -1005,7 +1093,7 @@ button.secondary {
} }
.side-section { .side-section {
margin-bottom: 28px; margin-bottom: 0;
} }
.side-section + .side-section { .side-section + .side-section {
@@ -1014,8 +1102,12 @@ button.secondary {
} }
.sidebar-tools { .sidebar-tools {
display: grid; display: flex;
flex-direction: column;
gap: 14px; gap: 14px;
margin-top: auto;
padding-top: 24px;
background: transparent;
} }
.sidebar-tool-buttons { .sidebar-tool-buttons {
@@ -1025,6 +1117,12 @@ button.secondary {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
gap: 8px; gap: 8px;
position: sticky;
bottom: 0;
z-index: 2;
padding-top: 14px;
padding-bottom: 2px;
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
} }
.sidebar-tool-button { .sidebar-tool-button {
@@ -1110,8 +1208,13 @@ button.secondary {
} }
.sidebar-panel { .sidebar-panel {
padding-top: 12px; padding-bottom: 12px;
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.sidebar-panel .section-title-row {
position: relative;
z-index: 1;
} }
.config-form { .config-form {
@@ -1154,6 +1257,77 @@ button.secondary {
font-weight: 800; font-weight: 800;
} }
.plan-creator-shell,
.plans-dashboard-shell {
min-height: 0;
}
.plan-creator-card {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid rgba(212, 175, 55, 0.28);
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(240, 214, 129, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(247, 241, 220, 0.94));
box-shadow: 0 20px 40px rgba(38, 58, 27, 0.08);
}
.plan-creator-copy {
display: grid;
gap: 8px;
}
.plan-creator-copy h3 {
margin: 0;
color: var(--forest);
font-family: "Playfair Display", Georgia, serif;
font-size: 28px;
line-height: 1.02;
}
.plan-creator-copy p:last-child {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.plan-form-grid {
gap: 12px;
}
.plan-form-grid textarea {
min-height: 96px;
}
.plan-form-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.plan-form-hint {
display: grid;
align-content: start;
gap: 4px;
padding: 12px 13px;
border: 1px dashed rgba(52, 83, 38, 0.24);
border-radius: 14px;
background: rgba(237, 243, 223, 0.68);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.plan-form-hint strong {
color: var(--forest);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ollama-status { .ollama-status {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1388,36 +1562,175 @@ pre {
.plans-dashboard { .plans-dashboard {
display: grid; display: grid;
gap: 12px; gap: 14px;
margin-top: 14px; min-height: 0;
}
.plans-overview {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
padding: 6px 2px 10px;
}
.plans-overview h3 {
margin: 4px 0 0;
color: var(--forest);
font-family: "Playfair Display", Georgia, serif;
font-size: 31px;
line-height: 1.04;
}
.plan-overview-copy {
max-width: 48ch;
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.plan-overview-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.plan-overview-stat {
display: grid;
gap: 2px;
min-width: 110px;
padding: 12px 14px;
border: 1px solid rgba(212, 175, 55, 0.28);
border-radius: 16px;
background: rgba(255, 250, 240, 0.78);
box-shadow: 0 12px 24px rgba(38, 58, 27, 0.06);
}
.plan-overview-stat-value {
color: var(--forest);
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.plan-overview-stat-label {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.plan-empty-state {
padding: 22px;
border: 1px dashed rgba(52, 83, 38, 0.24);
border-radius: 22px;
background: rgba(255, 253, 247, 0.72);
}
.plan-empty-state h4 {
margin: 0 0 6px;
color: var(--forest);
font-size: 18px;
}
.plan-empty-state p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
} }
.plan-card { .plan-card {
display: grid; display: grid;
gap: 10px; gap: 12px;
padding: 13px; padding: 16px;
border: 1px solid var(--line); border: 1px solid rgba(221, 206, 176, 0.92);
border-radius: 16px; border-radius: 20px;
background: rgba(255, 250, 240, 0.82); background:
linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(251, 244, 223, 0.88));
box-shadow: 0 16px 30px rgba(38, 58, 27, 0.06);
} }
.plan-card.active { .plan-card.active {
border-color: rgba(52, 83, 38, 0.42); border-color: rgba(52, 83, 38, 0.32);
background: #edf3df; background:
radial-gradient(circle at top right, rgba(190, 212, 144, 0.22), transparent 26%),
linear-gradient(180deg, rgba(247, 250, 238, 0.98), rgba(237, 243, 223, 0.96));
}
.plan-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
} }
.plan-card h3 { .plan-card h3 {
margin: 0; margin: 0;
color: var(--forest); color: var(--forest);
font-size: 16px; font-size: 19px;
line-height: 1.25; line-height: 1.18;
}
.plan-status-badge {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.plan-status-active {
border-color: rgba(52, 83, 38, 0.28);
background: rgba(237, 243, 223, 0.9);
color: var(--forest);
}
.plan-status-badge.plan-status-active {
border: 1px solid rgba(52, 83, 38, 0.24);
}
.plan-status-paused {
border-color: rgba(196, 170, 115, 0.42);
background: rgba(255, 246, 220, 0.86);
color: #7a5a18;
}
.plan-status-badge.plan-status-paused {
border: 1px solid rgba(196, 170, 115, 0.34);
}
.plan-status-needs-input {
border-color: rgba(159, 60, 50, 0.24);
background: rgba(255, 241, 237, 0.88);
color: var(--danger);
}
.plan-status-badge.plan-status-needs-input {
border: 1px solid rgba(159, 60, 50, 0.22);
}
.plan-status-canceled,
.plan-status-completed {
opacity: 0.84;
}
.plan-status-badge.plan-status-canceled,
.plan-status-badge.plan-status-completed {
border: 1px solid rgba(111, 91, 80, 0.18);
background: rgba(255, 250, 240, 0.82);
color: var(--muted);
} }
.plan-meta, .plan-meta,
.plan-line { .plan-line {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 13px;
line-height: 1.4; line-height: 1.55;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -1431,33 +1744,82 @@ pre {
.plan-pill { .plan-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 24px; min-height: 26px;
padding: 4px 8px; padding: 4px 10px;
border: 1px solid rgba(52, 83, 38, 0.24); border: 1px solid rgba(52, 83, 38, 0.14);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 250, 240, 0.8); background: rgba(255, 250, 240, 0.88);
color: var(--forest); color: var(--forest);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
} }
.plan-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.plan-metric {
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px solid rgba(221, 206, 176, 0.78);
border-radius: 14px;
background: rgba(255, 253, 247, 0.76);
}
.plan-metric-label {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.plan-metric-value {
color: var(--brown);
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.plan-controls button { .plan-controls button {
flex: 1 1 80px; flex: 1 1 80px;
min-width: 0; min-width: 0;
} }
.plan-detail { .plan-detail {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding-top: 10px;
border-top: 1px solid rgba(221, 206, 176, 0.92);
}
.plan-detail-loading {
grid-template-columns: 1fr;
padding: 16px;
border: 1px dashed rgba(52, 83, 38, 0.2);
border-radius: 16px;
background: rgba(255, 253, 247, 0.72);
color: var(--muted);
font-size: 13px;
text-align: center;
}
.plan-section {
display: grid; display: grid;
gap: 8px; gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
} }
.plan-detail h4 { .plan-detail h4 {
margin: 0; margin: 0;
color: var(--forest); color: var(--forest);
font-size: 13px; font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
} }
.plan-list { .plan-list {
@@ -1469,15 +1831,29 @@ pre {
} }
.plan-list li { .plan-list li {
padding: 8px; display: grid;
gap: 5px;
padding: 10px 11px;
border: 1px solid rgba(221, 206, 176, 0.72); border: 1px solid rgba(221, 206, 176, 0.72);
border-radius: 8px; border-radius: 12px;
background: rgba(255, 253, 247, 0.72); background: rgba(255, 253, 247, 0.8);
color: var(--brown); color: var(--brown);
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
} }
.plan-list-title {
color: var(--forest);
font-size: 12px;
font-weight: 800;
}
.plan-list-body {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.decline-button { .decline-button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
background: #fff9e9; background: #fff9e9;
@@ -1657,6 +2033,38 @@ pre {
grid-column: 1; grid-column: 1;
} }
.plans-overview {
flex-direction: column;
align-items: flex-start;
}
.plan-detail {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.plan-form-split,
.plan-metrics {
grid-template-columns: 1fr;
}
.plan-card-heading {
flex-direction: column;
}
.plan-status-badge {
align-self: flex-start;
}
}
@media (max-width: 640px) {
.plans-floating-panel {
width: min(100vw - 18px, 980px);
right: 9px;
bottom: 9px;
}
.plans-panel-body { .plans-panel-body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }