Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
00cf6f8747
|
+6
-1
@@ -3,11 +3,16 @@ 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_BASE_URL=https://api.openai.com/v1
|
||||||
OPENAI_MODEL=gpt-5.3-codex
|
OPENAI_MODEL=gpt-5.4-mini
|
||||||
OPENAI_API_KEY=
|
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=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TraderAI
|
# TraderAI
|
||||||
|
|
||||||
Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
|
Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
@@ -25,7 +25,9 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
||||||
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.3-codex`.
|
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:
|
||||||
@@ -39,7 +41,7 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Ollama runs locally at `http://localhost:11434` by default. This app can talk to either Ollama's native chat API or OpenAI's Chat Completions API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
|
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
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.6"
|
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",
|
||||||
@@ -40,3 +40,4 @@ include = ["traderai*"]
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -217,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"))
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
|||||||
+581
-7
@@ -1,9 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -11,6 +17,7 @@ from tzlocal import get_localzone
|
|||||||
|
|
||||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
|
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
|
from traderai.version import __version__
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
||||||
@@ -19,7 +26,9 @@ Use continual plan tools when the user asks for multi-day or recurring marketpla
|
|||||||
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||||
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
||||||
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
|
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
|
||||||
|
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
|
||||||
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
||||||
|
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
|
||||||
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
||||||
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
||||||
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
||||||
@@ -41,6 +50,7 @@ class OllamaAgent:
|
|||||||
num_ctx: int | None = None,
|
num_ctx: int | None = None,
|
||||||
provider: str = "ollama",
|
provider: str = "ollama",
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
|
reasoning_effort: str = "medium",
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -50,11 +60,14 @@ class OllamaAgent:
|
|||||||
self.num_ctx = num_ctx
|
self.num_ctx = num_ctx
|
||||||
self.provider = provider.strip().casefold() or "ollama"
|
self.provider = provider.strip().casefold() or "ollama"
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
self.reasoning_effort = reasoning_effort.strip().casefold() or "medium"
|
||||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
async def health(self) -> dict[str, Any]:
|
||||||
if self.provider == "openai":
|
if self.provider == "openai":
|
||||||
return await self._openai_health()
|
return await self._openai_health()
|
||||||
|
if self.provider == "codex":
|
||||||
|
return await self._codex_health()
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=3) as client:
|
async with httpx.AsyncClient(timeout=3) as client:
|
||||||
response = await client.get(f"{self.base_url}/api/tags")
|
response = await client.get(f"{self.base_url}/api/tags")
|
||||||
@@ -83,6 +96,8 @@ class OllamaAgent:
|
|||||||
health = await self.health()
|
health = await self.health()
|
||||||
if not health["online"]:
|
if not health["online"]:
|
||||||
raise OllamaUnavailable(health["message"])
|
raise OllamaUnavailable(health["message"])
|
||||||
|
if health.get("model_available") is False:
|
||||||
|
raise OllamaUnavailable(health["message"])
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
@@ -304,6 +319,13 @@ class OllamaAgent:
|
|||||||
previous_interaction=previous_interaction,
|
previous_interaction=previous_interaction,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
)
|
)
|
||||||
|
if self.provider == "codex":
|
||||||
|
return await self._codex_chat(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
return await self._ollama_chat(
|
return await self._ollama_chat(
|
||||||
query,
|
query,
|
||||||
messages,
|
messages,
|
||||||
@@ -327,6 +349,15 @@ class OllamaAgent:
|
|||||||
):
|
):
|
||||||
yield event
|
yield event
|
||||||
return
|
return
|
||||||
|
if self.provider == "codex":
|
||||||
|
async for event in self._codex_chat_stream(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
):
|
||||||
|
yield event
|
||||||
|
return
|
||||||
async for event in self._ollama_chat_stream(
|
async for event in self._ollama_chat_stream(
|
||||||
query,
|
query,
|
||||||
messages,
|
messages,
|
||||||
@@ -410,6 +441,7 @@ class OllamaAgent:
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
),
|
),
|
||||||
"tools": self.tools.schemas,
|
"tools": self.tools.schemas,
|
||||||
|
"reasoning_effort": self.reasoning_effort,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -447,6 +479,7 @@ class OllamaAgent:
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
),
|
),
|
||||||
"tools": self.tools.schemas,
|
"tools": self.tools.schemas,
|
||||||
|
"reasoning_effort": self.reasoning_effort,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
},
|
},
|
||||||
) as response:
|
) as response:
|
||||||
@@ -487,6 +520,47 @@ class OllamaAgent:
|
|||||||
"done": True,
|
"done": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _codex_chat(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await self._codex_cli_turn(
|
||||||
|
query,
|
||||||
|
messages or self._messages_for_thread(thread_id),
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
return self._codex_structured_response(result)
|
||||||
|
|
||||||
|
async def _codex_chat_stream(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
messages: list[dict[str, Any]] | None = None,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
result = await self._codex_cli_turn(
|
||||||
|
query,
|
||||||
|
messages or self._messages_for_thread(thread_id),
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
response = self._codex_structured_response(result)
|
||||||
|
message = response["message"]
|
||||||
|
if message.get("content"):
|
||||||
|
yield {"message": {"role": "assistant", "content": message["content"]}}
|
||||||
|
yield {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": message.get("tool_calls") or [],
|
||||||
|
},
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
|
||||||
def _messages_with_context(
|
def _messages_with_context(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -511,15 +585,57 @@ class OllamaAgent:
|
|||||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||||
|
|
||||||
async def _openai_health(self) -> dict[str, Any]:
|
async def _openai_health(self) -> dict[str, Any]:
|
||||||
|
return await self._cloud_health("openai")
|
||||||
|
|
||||||
|
async def _codex_health(self) -> dict[str, Any]:
|
||||||
|
command = self._codex_command()
|
||||||
|
if not command:
|
||||||
|
return {
|
||||||
|
"online": False,
|
||||||
|
"model": self.model,
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"provider": "codex",
|
||||||
|
"model_available": False,
|
||||||
|
"models": [],
|
||||||
|
"message": "Codex CLI was not found on PATH.",
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
account, models = await self._codex_app_server_status()
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"online": False,
|
||||||
|
"model": self.model,
|
||||||
|
"base_url": command,
|
||||||
|
"provider": "codex",
|
||||||
|
"model_available": False,
|
||||||
|
"models": [],
|
||||||
|
"message": "Codex App Server is installed, but TraderAI could not connect to it.",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
logged_in = bool(account)
|
||||||
|
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
|
||||||
|
return {
|
||||||
|
"online": logged_in,
|
||||||
|
"model": self.model,
|
||||||
|
"base_url": command,
|
||||||
|
"provider": "codex",
|
||||||
|
"model_available": self.model in models if models else bool(self.model),
|
||||||
|
"models": models,
|
||||||
|
"message": "Codex App Server is online." if logged_in else "Codex CLI is installed, but not logged in with ChatGPT.",
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _cloud_health(self, provider: str) -> dict[str, Any]:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
return {
|
return {
|
||||||
"online": False,
|
"online": False,
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"base_url": self.base_url,
|
"base_url": self.base_url,
|
||||||
"provider": "openai",
|
"provider": provider,
|
||||||
"model_available": False,
|
"model_available": False,
|
||||||
"models": [],
|
"models": [],
|
||||||
"message": "OpenAI is selected, but no OpenAI API key is configured.",
|
"message": f"{self._provider_label()} is selected, but no API key is configured.",
|
||||||
"detail": "",
|
"detail": "",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -532,10 +648,10 @@ class OllamaAgent:
|
|||||||
"online": False,
|
"online": False,
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"base_url": self.base_url,
|
"base_url": self.base_url,
|
||||||
"provider": "openai",
|
"provider": provider,
|
||||||
"model_available": False,
|
"model_available": False,
|
||||||
"models": [],
|
"models": [],
|
||||||
"message": f"OpenAI is unreachable at {self.base_url} or rejected the API key.",
|
"message": f"{self._provider_label()} is unreachable at {self.base_url} or rejected the API key.",
|
||||||
"detail": str(exc),
|
"detail": str(exc),
|
||||||
}
|
}
|
||||||
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
||||||
@@ -543,10 +659,10 @@ class OllamaAgent:
|
|||||||
"online": True,
|
"online": True,
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"base_url": self.base_url,
|
"base_url": self.base_url,
|
||||||
"provider": "openai",
|
"provider": provider,
|
||||||
"model_available": self.model in models,
|
"model_available": self.model in models,
|
||||||
"models": models,
|
"models": models,
|
||||||
"message": "OpenAI is online.",
|
"message": f"{self._provider_label()} is online.",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _openai_headers(self) -> dict[str, str]:
|
def _openai_headers(self) -> dict[str, str]:
|
||||||
@@ -595,8 +711,27 @@ class OllamaAgent:
|
|||||||
normalized.append(entry)
|
normalized.append(entry)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
def _codex_tool_catalog(self) -> list[dict[str, Any]]:
|
||||||
|
tools: list[dict[str, Any]] = []
|
||||||
|
for schema in self.tools.schemas:
|
||||||
|
if schema.get("type") != "function":
|
||||||
|
continue
|
||||||
|
function = schema.get("function") or {}
|
||||||
|
tools.append(
|
||||||
|
{
|
||||||
|
"name": function.get("name", ""),
|
||||||
|
"description": function.get("description", ""),
|
||||||
|
"parameters": function.get("parameters") or {"type": "object", "properties": {}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tools
|
||||||
|
|
||||||
def _provider_label(self) -> str:
|
def _provider_label(self) -> str:
|
||||||
return "OpenAI model" if self.provider == "openai" else "local model"
|
if self.provider == "openai":
|
||||||
|
return "OpenAI model"
|
||||||
|
if self.provider == "codex":
|
||||||
|
return "Codex model"
|
||||||
|
return "local model"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
||||||
@@ -615,6 +750,431 @@ class OllamaAgent:
|
|||||||
def _ordered_tool_calls(tool_calls: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
|
def _ordered_tool_calls(tool_calls: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
return [tool_calls[index] for index in sorted(tool_calls)]
|
return [tool_calls[index] for index in sorted(tool_calls)]
|
||||||
|
|
||||||
|
async def _codex_cli_turn(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._codex_app_server_turn(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _codex_app_server_turn(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prompt = self._codex_cli_prompt(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
final_text = ""
|
||||||
|
|
||||||
|
process = await self._start_codex_app_server()
|
||||||
|
request_id = 1
|
||||||
|
|
||||||
|
async def send_request(method: str, params: dict[str, Any] | None = None, timeout: int = 120) -> 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 self._codex_app_server_write(process, payload)
|
||||||
|
while True:
|
||||||
|
message = await self._codex_app_server_read(process, timeout=timeout)
|
||||||
|
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 self._handle_codex_app_server_message(process, message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_request(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||||
|
"capabilities": {"experimentalApi": True},
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||||
|
thread = await send_request(
|
||||||
|
"thread/start",
|
||||||
|
{
|
||||||
|
"model": self.model,
|
||||||
|
"modelProvider": None,
|
||||||
|
"cwd": str(Path.cwd()),
|
||||||
|
"approvalPolicy": "never",
|
||||||
|
"sandbox": "read-only",
|
||||||
|
"baseInstructions": "You are TraderAI running through the local Codex App Server using ChatGPT OAuth.",
|
||||||
|
"developerInstructions": (
|
||||||
|
"Do not run shell commands, inspect files, or modify the workspace. "
|
||||||
|
"Answer only with JSON matching the requested output schema."
|
||||||
|
),
|
||||||
|
"ephemeral": True,
|
||||||
|
"experimentalRawEvents": False,
|
||||||
|
"persistExtendedHistory": False,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
thread_id_value = ((thread.get("thread") or {}).get("id") or thread.get("threadId") or "").strip()
|
||||||
|
if not thread_id_value:
|
||||||
|
raise RuntimeError(f"Codex App Server did not return a thread id: {thread!r}")
|
||||||
|
turn = await send_request(
|
||||||
|
"turn/start",
|
||||||
|
{
|
||||||
|
"threadId": thread_id_value,
|
||||||
|
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
||||||
|
"cwd": str(Path.cwd()),
|
||||||
|
"approvalPolicy": "never",
|
||||||
|
"sandboxPolicy": {"type": "readOnly", "access": {"type": "fullAccess"}},
|
||||||
|
"model": self.model,
|
||||||
|
"effort": self.reasoning_effort,
|
||||||
|
"summary": "none",
|
||||||
|
"outputSchema": self._codex_output_schema(),
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
turn_id = ((turn.get("turn") or {}).get("id") or "").strip()
|
||||||
|
if not turn_id:
|
||||||
|
raise RuntimeError(f"Codex App Server did not return a turn id: {turn!r}")
|
||||||
|
while True:
|
||||||
|
message = await self._codex_app_server_read(process, timeout=240)
|
||||||
|
method = message.get("method")
|
||||||
|
params = message.get("params") or {}
|
||||||
|
if method == "item/agentMessage/delta" and params.get("turnId") == turn_id:
|
||||||
|
final_text += params.get("delta") or ""
|
||||||
|
elif method == "item/completed" and params.get("turnId") == turn_id:
|
||||||
|
item = params.get("item") or {}
|
||||||
|
if item.get("type") == "agentMessage":
|
||||||
|
final_text = item.get("text") or final_text
|
||||||
|
elif method == "turn/completed" and (params.get("turn") or {}).get("id") == turn_id:
|
||||||
|
turn_status = (params.get("turn") or {}).get("status")
|
||||||
|
if turn_status != "completed":
|
||||||
|
error = (params.get("turn") or {}).get("error") or {}
|
||||||
|
raise RuntimeError(error.get("message") or f"Codex App Server turn ended with status {turn_status}.")
|
||||||
|
break
|
||||||
|
elif method == "error":
|
||||||
|
error = params.get("message") or params.get("error") or params
|
||||||
|
raise RuntimeError(f"Codex App Server error: {error}")
|
||||||
|
else:
|
||||||
|
await self._handle_codex_app_server_message(process, message)
|
||||||
|
finally:
|
||||||
|
await self._stop_codex_app_server(process)
|
||||||
|
return self._parse_codex_app_server_text(final_text)
|
||||||
|
|
||||||
|
def _codex_cli_prompt(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
) -> str:
|
||||||
|
conversation_lines: list[str] = []
|
||||||
|
for message in self._messages_with_context(
|
||||||
|
query,
|
||||||
|
messages,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
):
|
||||||
|
role = message.get("role", "unknown")
|
||||||
|
content = message.get("content", "")
|
||||||
|
suffix = ""
|
||||||
|
if role == "user" and message.get("images"):
|
||||||
|
suffix = f" [attached images: {len(message.get('images') or [])}]"
|
||||||
|
if role == "tool":
|
||||||
|
suffix = f" [tool {message.get('tool_name') or ''}]"
|
||||||
|
if role == "assistant" and message.get("tool_calls"):
|
||||||
|
suffix = f" [tool calls: {json.dumps(message.get('tool_calls'), ensure_ascii=True)}]"
|
||||||
|
conversation_lines.append(f"{role}{suffix}: {content}")
|
||||||
|
tools_json = json.dumps(self._codex_tool_catalog(), ensure_ascii=True, indent=2)
|
||||||
|
return (
|
||||||
|
"You are TraderAI running through the local Codex App Server using ChatGPT OAuth.\n"
|
||||||
|
"Do not run shell commands, inspect files, or modify the workspace.\n"
|
||||||
|
"Your only job is to decide whether to answer directly or request exactly one TraderAI tool.\n\n"
|
||||||
|
"Return JSON that matches the provided schema.\n"
|
||||||
|
"- If you can answer now, set kind to final, put the user-facing reply in message, set tool_name to an empty string, and set arguments_json to '{}'.\n"
|
||||||
|
"- If you need a tool, set kind to tool_call, set tool_name to the exact tool name, set message to an empty string, and set arguments_json to a valid JSON object string.\n"
|
||||||
|
"- Never return more than one tool call at a time.\n"
|
||||||
|
"- Prefer the TraderAI tools over guessing.\n\n"
|
||||||
|
f"Available tools:\n{tools_json}\n\n"
|
||||||
|
"Conversation transcript:\n"
|
||||||
|
+ "\n".join(conversation_lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _codex_structured_response(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if result.get("kind") == "tool_call":
|
||||||
|
tool_name = str(result.get("tool_name") or "").strip()
|
||||||
|
arguments_json = str(result.get("arguments_json") or "{}").strip() or "{}"
|
||||||
|
return {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": f"codex-{uuid.uuid4()}",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments_json,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": str(result.get("message") or ""),
|
||||||
|
"tool_calls": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _write_codex_schema(self) -> str:
|
||||||
|
schema = self._codex_output_schema()
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix="-traderai-codex-schema.json", delete=False, encoding="utf-8") as handle:
|
||||||
|
json.dump(schema, handle, ensure_ascii=True)
|
||||||
|
return handle.name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _codex_output_schema() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {"type": "string", "enum": ["final", "tool_call"]},
|
||||||
|
"message": {"type": "string"},
|
||||||
|
"tool_name": {"type": "string"},
|
||||||
|
"arguments_json": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["kind", "message", "tool_name", "arguments_json"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_codex_app_server_text(self, final_text: str) -> dict[str, Any]:
|
||||||
|
if not final_text.strip():
|
||||||
|
raise RuntimeError("Codex App Server returned an empty response.")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(final_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(f"Codex App Server returned non-JSON output: {final_text}") from exc
|
||||||
|
if parsed.get("kind") not in {"final", "tool_call"}:
|
||||||
|
raise RuntimeError(f"Codex App Server returned an invalid result kind: {parsed!r}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _parse_codex_exec_output(self, output: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
events = output.get("events") or []
|
||||||
|
final_text = ""
|
||||||
|
error_text = ""
|
||||||
|
for event in events:
|
||||||
|
if event.get("type") == "item.completed":
|
||||||
|
item = event.get("item") or {}
|
||||||
|
if item.get("type") == "agent_message":
|
||||||
|
final_text = item.get("text") or final_text
|
||||||
|
elif event.get("type") == "error":
|
||||||
|
error_text = event.get("message") or error_text
|
||||||
|
elif event.get("type") == "turn.failed":
|
||||||
|
details = event.get("error") or {}
|
||||||
|
error_text = details.get("message") or error_text
|
||||||
|
if output.get("returncode") != 0 and not final_text:
|
||||||
|
raise RuntimeError(error_text or output.get("stderr") or "Codex CLI failed.")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(final_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(f"Codex CLI returned non-JSON output: {final_text}") from exc
|
||||||
|
if parsed.get("kind") not in {"final", "tool_call"}:
|
||||||
|
raise RuntimeError(f"Codex CLI returned an invalid result kind: {parsed!r}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def _codex_command(self, required: bool = False) -> str | None:
|
||||||
|
configured = self.base_url.strip() if self.base_url else "codex"
|
||||||
|
resolved = shutil.which(configured) or configured
|
||||||
|
if required and not Path(resolved).exists() and shutil.which(resolved) is None:
|
||||||
|
raise RuntimeError("Codex CLI was not found on PATH.")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
async def _codex_app_server_status(self) -> tuple[dict[str, Any] | None, list[str]]:
|
||||||
|
process = await self._start_codex_app_server()
|
||||||
|
request_id = 1
|
||||||
|
|
||||||
|
async def send_request(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 self._codex_app_server_write(process, payload)
|
||||||
|
while True:
|
||||||
|
message = await self._codex_app_server_read(process, timeout=30)
|
||||||
|
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 self._handle_codex_app_server_message(process, message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send_request(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||||
|
"capabilities": {"experimentalApi": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||||
|
account_result = await send_request("account/read", {"refreshToken": False})
|
||||||
|
models: 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_request("model/list", params)
|
||||||
|
for item in page.get("data") or []:
|
||||||
|
model = item.get("id") or item.get("model")
|
||||||
|
if model:
|
||||||
|
models.append(model)
|
||||||
|
cursor = page.get("nextCursor")
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
return account_result.get("account"), sorted(set(models))
|
||||||
|
finally:
|
||||||
|
await self._stop_codex_app_server(process)
|
||||||
|
|
||||||
|
async def _start_codex_app_server(self) -> asyncio.subprocess.Process:
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
self._codex_command(required=True),
|
||||||
|
"app-server",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _codex_app_server_write(self, process: asyncio.subprocess.Process, 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 _codex_app_server_read(self, process: asyncio.subprocess.Process, timeout: int) -> 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 TimeoutError as exc:
|
||||||
|
raise RuntimeError("Codex App Server timed out.") 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 TimeoutError:
|
||||||
|
stderr = ""
|
||||||
|
raise RuntimeError(stderr or "Codex App Server exited without a response.")
|
||||||
|
try:
|
||||||
|
return json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(f"Codex App Server returned invalid JSON-RPC: {line!r}") from exc
|
||||||
|
|
||||||
|
async def _handle_codex_app_server_message(self, process: asyncio.subprocess.Process, message: dict[str, Any]) -> None:
|
||||||
|
if "id" not in message or "method" not in message:
|
||||||
|
return
|
||||||
|
method = message.get("method")
|
||||||
|
if method in {
|
||||||
|
"item/commandExecution/requestApproval",
|
||||||
|
"item/fileChange/requestApproval",
|
||||||
|
"applyPatchApproval",
|
||||||
|
"execCommandApproval",
|
||||||
|
}:
|
||||||
|
await self._codex_app_server_write(
|
||||||
|
process,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message["id"],
|
||||||
|
"result": {
|
||||||
|
"decision": "deny",
|
||||||
|
"message": "TraderAI does not allow Codex to run commands or change files.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._codex_app_server_write(
|
||||||
|
process,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message["id"],
|
||||||
|
"error": {"code": -32601, "message": f"TraderAI does not handle Codex App Server request {method}."},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _stop_codex_app_server(self, process: asyncio.subprocess.Process) -> None:
|
||||||
|
if process.returncode is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=3)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
async def _run_command(self, command: list[str], timeout: int = 120, stdin_text: str | None = None) -> dict[str, Any]:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = stdin_text.encode("utf-8") if stdin_text is not None else None
|
||||||
|
stdout, stderr = await asyncio.wait_for(process.communicate(payload), timeout=timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.communicate()
|
||||||
|
raise RuntimeError(f"Command timed out: {' '.join(command[:3])}")
|
||||||
|
stdout_text = stdout.decode("utf-8", errors="replace")
|
||||||
|
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||||
|
events = []
|
||||||
|
for line in stdout_text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
except ValueError:
|
||||||
|
events.append({"type": "stdout", "text": line})
|
||||||
|
return {
|
||||||
|
"returncode": process.returncode,
|
||||||
|
"stdout": stdout_text,
|
||||||
|
"stderr": stderr_text,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _codex_model_cache(self) -> 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 _runtime_context(
|
def _runtime_context(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -741,6 +1301,16 @@ class OllamaAgent:
|
|||||||
choice = (response.json().get("choices") or [{}])[0]
|
choice = (response.json().get("choices") or [{}])[0]
|
||||||
message = choice.get("message") or {}
|
message = choice.get("message") or {}
|
||||||
return self._clean_generated_title(message.get("content", ""))
|
return self._clean_generated_title(message.get("content", ""))
|
||||||
|
if self.provider == "codex":
|
||||||
|
result = await self._codex_app_server_turn(
|
||||||
|
prompt,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": "You write short chat titles."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
thread_id="title",
|
||||||
|
)
|
||||||
|
return self._clean_generated_title(result.get("message", ""))
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/api/chat",
|
f"{self.base_url}/api/chat",
|
||||||
@@ -831,6 +1401,10 @@ class OllamaAgent:
|
|||||||
"list_scmdb_versions": "Checking SCMDB versions",
|
"list_scmdb_versions": "Checking SCMDB versions",
|
||||||
"search_scmdb_missions": "Searching SCMDB missions",
|
"search_scmdb_missions": "Searching SCMDB missions",
|
||||||
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
||||||
|
"search_scwiki_pages": "Searching Star Citizen Wiki",
|
||||||
|
"get_scwiki_page": "Reading Star Citizen Wiki page",
|
||||||
|
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
|
||||||
|
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
|
||||||
"search_cornerstone_items": "Searching Cornerstone items",
|
"search_cornerstone_items": "Searching Cornerstone items",
|
||||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||||
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
||||||
|
|||||||
+18
-2
@@ -17,9 +17,14 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
|||||||
"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_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||||
"openai_model": {"env": "OPENAI_MODEL", "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},
|
"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},
|
||||||
@@ -71,10 +76,15 @@ class Settings(BaseSettings):
|
|||||||
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_base_url: str = "https://api.openai.com/v1"
|
||||||
openai_model: str = "gpt-5.3-codex"
|
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)
|
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)
|
||||||
@@ -92,7 +102,13 @@ class Settings(BaseSettings):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_model_provider(cls, value: Any) -> str:
|
def _normalize_model_provider(cls, value: Any) -> str:
|
||||||
text = str(value or "ollama").strip().casefold()
|
text = str(value or "ollama").strip().casefold()
|
||||||
return text if text in {"ollama", "openai"} else "ollama"
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+485
-41
@@ -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__
|
||||||
@@ -106,34 +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.openai_base_url if settings.model_provider == "openai" else settings.ollama_base_url,
|
provider_base_url,
|
||||||
settings.openai_model if settings.model_provider == "openai" else 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=settings.model_provider,
|
provider=current_settings.model_provider,
|
||||||
api_key=settings.openai_api_key,
|
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")
|
||||||
@@ -149,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:
|
||||||
@@ -178,9 +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,
|
||||||
"model_provider": settings.model_provider,
|
"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"],
|
||||||
@@ -193,27 +220,62 @@ 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_model_provider()
|
return await inspect_model_provider()
|
||||||
|
|
||||||
@app.get("/api/openai/models")
|
@app.get("/api/provider/models")
|
||||||
async def openai_models() -> dict:
|
async def provider_models(provider: str | None = None) -> dict:
|
||||||
status = await inspect_openai()
|
status = await inspect_provider_models(provider)
|
||||||
return {
|
return {
|
||||||
"provider": "openai",
|
"provider": status.get("provider", "openai"),
|
||||||
"configured_model": status.get("configured_model"),
|
"configured_model": status.get("configured_model"),
|
||||||
"models": status.get("models", []),
|
"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", ""),
|
"message": status.get("message", ""),
|
||||||
"detail": status.get("detail", ""),
|
"detail": status.get("detail", ""),
|
||||||
"online": status.get("online", False),
|
"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:
|
||||||
command = ollama_launch_command()
|
command = ollama_launch_command()
|
||||||
@@ -319,6 +381,7 @@ 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(
|
return await agent.chat(
|
||||||
request.message,
|
request.message,
|
||||||
@@ -330,6 +393,8 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@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(
|
async for event in agent.chat_events(
|
||||||
request.message,
|
request.message,
|
||||||
@@ -367,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")
|
||||||
@@ -393,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)
|
||||||
@@ -412,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,
|
||||||
@@ -433,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"])
|
||||||
@@ -440,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"])
|
||||||
@@ -447,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"])
|
||||||
@@ -487,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
|
||||||
@@ -509,33 +592,96 @@ async def inspect_model_provider() -> dict[str, Any]:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.model_provider == "openai":
|
if settings.model_provider == "openai":
|
||||||
return await inspect_openai()
|
return await inspect_openai()
|
||||||
|
if settings.model_provider == "codex":
|
||||||
|
return await inspect_codex()
|
||||||
return await inspect_ollama()
|
return await inspect_ollama()
|
||||||
|
|
||||||
|
|
||||||
async def inspect_openai() -> dict[str, Any]:
|
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()
|
settings = get_settings()
|
||||||
models: list[str] = []
|
models: list[str] = []
|
||||||
online = False
|
online = False
|
||||||
detail = ""
|
detail = ""
|
||||||
if not settings.openai_api_key:
|
provider_name = provider_display_name(provider)
|
||||||
|
if not api_key:
|
||||||
return {
|
return {
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"running": False,
|
"running": False,
|
||||||
"online": False,
|
"online": False,
|
||||||
"provider": "openai",
|
"provider": provider,
|
||||||
"model_available": False,
|
"model_available": False,
|
||||||
"configured_model": settings.openai_model,
|
"configured_model": model,
|
||||||
"base_url": settings.openai_base_url,
|
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||||
|
"reasoning_efforts": reasoning_effort_options(),
|
||||||
|
"base_url": base_url,
|
||||||
"models": [],
|
"models": [],
|
||||||
"message": "OpenAI is selected, but no API key is configured.",
|
"message": f"{provider_name} is selected, but no API key is configured.",
|
||||||
"detail": "",
|
"detail": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{settings.openai_base_url.rstrip('/')}/models",
|
f"{base_url.rstrip('/')}/models",
|
||||||
headers={"Authorization": f"Bearer {settings.openai_api_key}"},
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -544,17 +690,19 @@ async def inspect_openai() -> dict[str, Any]:
|
|||||||
except (httpx.HTTPError, ValueError) as exc:
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
detail = str(exc)
|
detail = str(exc)
|
||||||
|
|
||||||
model_available = settings.openai_model in models
|
model_available = model in models
|
||||||
return {
|
return {
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"running": online,
|
"running": online,
|
||||||
"online": online,
|
"online": online,
|
||||||
"provider": "openai",
|
"provider": provider,
|
||||||
"model_available": model_available,
|
"model_available": model_available,
|
||||||
"configured_model": settings.openai_model,
|
"configured_model": model,
|
||||||
"base_url": settings.openai_base_url,
|
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||||
|
"reasoning_efforts": reasoning_effort_options(),
|
||||||
|
"base_url": base_url,
|
||||||
"models": models,
|
"models": models,
|
||||||
"message": openai_status_message(online, bool(settings.openai_api_key), model_available, settings.openai_model),
|
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,6 +735,8 @@ async def inspect_ollama() -> dict[str, Any]:
|
|||||||
"provider": "ollama",
|
"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,
|
||||||
@@ -599,14 +749,15 @@ async def inspect_ollama() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def openai_status_message(running: bool, configured: bool, model_available: bool, model: str) -> str:
|
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:
|
if not configured:
|
||||||
return "OpenAI API key is not configured."
|
return f"{provider_name} API key is not configured."
|
||||||
if not running:
|
if not running:
|
||||||
return "OpenAI is not reachable with the configured key."
|
return f"{provider_name} is not reachable with the configured key."
|
||||||
if not model_available:
|
if not model_available:
|
||||||
return f'OpenAI is reachable, but model "{model}" was not returned by the API.'
|
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
|
||||||
return "OpenAI is ready."
|
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:
|
||||||
@@ -619,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"),
|
||||||
@@ -671,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()
|
||||||
|
|||||||
@@ -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
|
||||||
+285
-3
@@ -10,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
|
||||||
|
|
||||||
|
|
||||||
@@ -58,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": [
|
||||||
@@ -83,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"},
|
||||||
@@ -162,12 +171,14 @@ 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
|
||||||
@@ -178,6 +189,7 @@ class ToolRegistry:
|
|||||||
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,
|
||||||
@@ -192,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,
|
||||||
@@ -226,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",
|
||||||
@@ -261,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": {
|
||||||
@@ -480,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": {
|
||||||
@@ -965,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 [
|
||||||
@@ -1213,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,
|
||||||
@@ -1405,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."}
|
||||||
@@ -1535,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 = "",
|
||||||
@@ -2210,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")
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+318
-40
@@ -26,7 +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 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");
|
||||||
@@ -57,6 +60,7 @@ 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;
|
||||||
@@ -599,6 +603,9 @@ const ollamaFieldIds = {
|
|||||||
openai_base_url: "openai-base-url",
|
openai_base_url: "openai-base-url",
|
||||||
openai_api_key: "openai-api-key",
|
openai_api_key: "openai-api-key",
|
||||||
openai_model: "openai-model",
|
openai_model: "openai-model",
|
||||||
|
model_reasoning_effort: "model-reasoning-effort",
|
||||||
|
codex_command: "codex-command",
|
||||||
|
codex_model: "codex-model",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function refreshConfig() {
|
async function refreshConfig() {
|
||||||
@@ -636,6 +643,8 @@ function renderConfig(config) {
|
|||||||
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 = "";
|
||||||
}
|
}
|
||||||
@@ -703,18 +712,21 @@ async function refreshOllamaStatus() {
|
|||||||
function renderOllamaStatus(status) {
|
function renderOllamaStatus(status) {
|
||||||
if (!ollamaStatusEl) return;
|
if (!ollamaStatusEl) return;
|
||||||
latestOllamaStatus = status;
|
latestOllamaStatus = status;
|
||||||
const provider = status.provider === "openai" ? "OpenAI" : "Ollama";
|
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 ready = status.provider === "openai"
|
const isOpenAIProvider = status.provider === "openai";
|
||||||
|
const isCodexProvider = status.provider === "codex";
|
||||||
|
const ready = isOpenAIProvider
|
||||||
? Boolean(status.online && status.model_available)
|
? Boolean(status.online && status.model_available)
|
||||||
: Boolean(status.installed && status.running && status.model_available);
|
: Boolean(status.installed && status.running && status.model_available);
|
||||||
const pillClass = ready ? "status-pill" : "status-pill warning";
|
const pillClass = ready ? "status-pill" : "status-pill warning";
|
||||||
const detailItems = [
|
const detailItems = [
|
||||||
ollamaStatusItem("Provider", provider),
|
ollamaStatusItem("Provider", provider),
|
||||||
ollamaStatusItem("Model", status.configured_model || ""),
|
ollamaStatusItem("Model", status.configured_model || ""),
|
||||||
ollamaStatusItem("URL", status.base_url || ""),
|
ollamaStatusItem(isCodexProvider ? "Command" : "URL", status.base_url || ""),
|
||||||
];
|
];
|
||||||
if (status.provider !== "openai") {
|
if (!isOpenAIProvider && !isCodexProvider) {
|
||||||
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
||||||
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
||||||
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
||||||
@@ -728,27 +740,32 @@ function renderOllamaStatus(status) {
|
|||||||
<div class="ollama-status-grid">
|
<div class="ollama-status-grid">
|
||||||
${detailItems.join("")}
|
${detailItems.join("")}
|
||||||
</div>
|
</div>
|
||||||
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "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 = status.provider === "openai";
|
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider;
|
||||||
if (ollamaInstallButton) {
|
if (ollamaInstallButton) {
|
||||||
ollamaInstallButton.hidden = status.provider === "openai" || !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) {
|
if (ollamaLaunchButton) {
|
||||||
ollamaLaunchButton.hidden = status.provider === "openai";
|
ollamaLaunchButton.hidden = isOpenAIProvider || isCodexProvider;
|
||||||
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
||||||
}
|
}
|
||||||
if (ollamaPullButton) {
|
if (ollamaPullButton) {
|
||||||
ollamaPullButton.hidden = status.provider === "openai";
|
ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider;
|
||||||
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
||||||
}
|
}
|
||||||
|
if (codexLoginButton) {
|
||||||
|
codexLoginButton.hidden = !isCodexProvider;
|
||||||
|
codexLoginButton.disabled = Boolean(status.online);
|
||||||
|
}
|
||||||
if (openaiModelsRefreshButton) {
|
if (openaiModelsRefreshButton) {
|
||||||
openaiModelsRefreshButton.hidden = status.provider !== "openai";
|
openaiModelsRefreshButton.hidden = false;
|
||||||
openaiModelsRefreshButton.disabled = false;
|
openaiModelsRefreshButton.disabled = false;
|
||||||
}
|
}
|
||||||
renderProviderModelOptions(status.models || []);
|
renderProviderModelOptions(status.models || [], status);
|
||||||
|
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
|
||||||
updateOllamaAttention(status);
|
updateOllamaAttention(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,15 +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 = currentStatus.provider === "openai"
|
const isOpenAIProvider = currentStatus.provider === "openai";
|
||||||
|
const isCodexProvider = currentStatus.provider === "codex";
|
||||||
|
const ready = isOpenAIProvider
|
||||||
? Boolean(currentStatus.online && currentStatus.model_available)
|
? Boolean(currentStatus.online && currentStatus.model_available)
|
||||||
: Boolean(currentStatus.installed && currentStatus.running && 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.provider !== "openai" && !currentStatus.installed);
|
setOllamaButtonAttention(ollamaDownloadButton, "download", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed);
|
||||||
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install);
|
setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install);
|
||||||
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running);
|
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running);
|
||||||
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available);
|
setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available);
|
||||||
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,31 +827,137 @@ function configuredOllamaModel() {
|
|||||||
return document.getElementById("ollama-model")?.value || "";
|
return document.getElementById("ollama-model")?.value || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProviderModelOptions(models) {
|
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");
|
const datalist = document.getElementById("provider-models");
|
||||||
if (!datalist) return;
|
if (datalist) datalist.innerHTML = "";
|
||||||
datalist.innerHTML = "";
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
|
if (datalist) {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = model;
|
option.value = model;
|
||||||
datalist.appendChild(option);
|
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() {
|
async function refreshOpenAIModels() {
|
||||||
setOllamaMessage("Loading OpenAI models");
|
setOllamaMessage("Loading provider models");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/openai/models");
|
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();
|
const result = await response.json();
|
||||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||||
renderProviderModelOptions(result.models || []);
|
renderProviderModelOptions(result.models || [], {
|
||||||
setOllamaMessage(result.message || "Loaded OpenAI models");
|
provider: result.provider || provider,
|
||||||
|
configured_model: configuredProviderModel(result.provider || provider),
|
||||||
|
});
|
||||||
|
setOllamaMessage(result.message || "Loaded provider models");
|
||||||
await refreshOllamaStatus();
|
await refreshOllamaStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(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";
|
||||||
@@ -1282,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);
|
||||||
}
|
}
|
||||||
@@ -1330,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) {
|
||||||
@@ -1382,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);
|
||||||
@@ -1390,11 +1651,21 @@ 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 || {};
|
||||||
const provider = health.provider === "openai" ? "OpenAI" : "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";
|
||||||
@@ -1403,7 +1674,7 @@ async function checkHealth() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (health.model_available === false) {
|
if (health.model_available === false) {
|
||||||
const action = health.provider === "openai" ? "Load OpenAI Models." : "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}`);
|
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 {
|
||||||
@@ -1637,6 +1908,13 @@ 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", () => {
|
openaiModelsRefreshButton?.addEventListener("click", () => {
|
||||||
markOllamaActionClicked("openai-models");
|
markOllamaActionClicked("openai-models");
|
||||||
refreshOpenAIModels();
|
refreshOpenAIModels();
|
||||||
|
|||||||
+40
-15
@@ -119,22 +119,27 @@
|
|||||||
</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>Model Provider</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>Provider
|
<label>Provider
|
||||||
<select id="model-provider" name="model_provider">
|
<select id="model-provider" name="model_provider">
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Local Ollama</option>
|
||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="codex">Codex</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||||
<label>Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||||
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||||
<label>OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
|
<label data-provider-scope="openai">OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
|
||||||
<label>OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
|
<label data-provider-scope="openai">OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
|
||||||
<label>OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
|
<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>
|
<datalist id="provider-models"></datalist>
|
||||||
<button type="submit">Save Provider Config</button>
|
<button type="submit">Save Provider Config</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -144,7 +149,8 @@
|
|||||||
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
||||||
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
||||||
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
||||||
<button class="secondary small-button" id="openai-models-refresh" type="button">Load OpenAI Models</button>
|
<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>
|
||||||
@@ -157,10 +163,10 @@
|
|||||||
<i data-lucide="brain" aria-hidden="true"></i>
|
<i data-lucide="brain" aria-hidden="true"></i>
|
||||||
<span>Memory</span>
|
<span>Memory</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
|
<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();">
|
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
|
||||||
<i data-lucide="bot" aria-hidden="true"></i>
|
<i data-lucide="bot" aria-hidden="true"></i>
|
||||||
<span>Ollama</span>
|
<span>Inference</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -199,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>
|
||||||
|
|||||||
+344
-30
@@ -1039,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,10 +1106,8 @@ button.secondary {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
position: sticky;
|
padding-top: 24px;
|
||||||
bottom: -28px;
|
background: transparent;
|
||||||
padding-bottom: 28px;
|
|
||||||
background: linear-gradient(180deg, rgba(247, 241, 220, 0) 0%, var(--cream) 22%, var(--cream) 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-buttons {
|
.sidebar-tool-buttons {
|
||||||
@@ -1119,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 {
|
||||||
@@ -1208,6 +1212,11 @@ button.secondary {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-panel .section-title-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.config-form {
|
.config-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1248,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;
|
||||||
@@ -1482,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,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 {
|
||||||
@@ -1563,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;
|
||||||
@@ -1751,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user