Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cf0693f319
|
|||
|
8fac3d2bae
|
|||
|
454bb57484
|
|||
|
00cf6f8747
|
+11
-2
@@ -3,14 +3,23 @@ OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen3.5:9b
|
||||
OLLAMA_NUM_CTX=64512
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-5.3-codex
|
||||
OPENAI_MODEL=gpt-5.4-mini
|
||||
OPENAI_API_KEY=
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
DEEPSEEK_API_KEY=
|
||||
MODEL_REASONING_EFFORT=medium
|
||||
CODEX_COMMAND=codex
|
||||
CODEX_MODEL=gpt-5.4
|
||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||
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
|
||||
UEX_SECRET_KEY=
|
||||
UEX_BEARER_TOKEN=
|
||||
UEX_NEGOTIATION_CLOSE_ENDPOINT=marketplace_negotiations_close
|
||||
TRADERAI_USER_NAME=
|
||||
TRADERAI_MEMORY_PATH=
|
||||
UEX_NOTIFICATION_POLL_SECONDS=60
|
||||
UEX_NOTIFICATION_POLL_SECONDS=300
|
||||
REQUIRE_WRITE_APPROVAL=true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TraderAI
|
||||
|
||||
Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
|
||||
Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -25,7 +25,10 @@ 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.
|
||||
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 the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
|
||||
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 DeepSeek, OpenAI, and Codex and defaults to `medium`.
|
||||
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||
4. Install and run:
|
||||
@@ -39,7 +42,7 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows.
|
||||
|
||||
## 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, DeepSeek's OpenAI-compatible Chat Completions 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. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
|
||||
|
||||
## Releases And Updates
|
||||
|
||||
|
||||
+6
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "traderai"
|
||||
version = "0.0.6"
|
||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
||||
version = "0.0.9"
|
||||
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"apscheduler>=3.10.4",
|
||||
@@ -40,3 +40,7 @@ include = ["traderai*"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import itertools
|
||||
|
||||
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||
from traderai.memory import MemoryStore
|
||||
@@ -217,6 +218,150 @@ def test_ollama_options_include_num_ctx():
|
||||
assert agent._ollama_options() == {"num_ctx": 64000}
|
||||
|
||||
|
||||
def test_deepseek_tool_rounds_are_not_capped_at_ten():
|
||||
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||
|
||||
rounds = list(itertools.islice(agent._tool_rounds(), 12))
|
||||
|
||||
assert len(rounds) == 12
|
||||
|
||||
|
||||
def test_plan_draft_normalization_extracts_json_and_defaults():
|
||||
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
|
||||
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
|
||||
|
||||
draft = OllamaAgent._normalize_plan_draft(raw, seed)
|
||||
|
||||
assert draft["title"] == "Wikelo Polaris Parts"
|
||||
assert draft["cadence"] == "0 */3 * * *"
|
||||
assert draft["constraints"]["message_tone"] == "casual"
|
||||
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||
assert draft["items"][0]["desired_quantity"] == 2
|
||||
|
||||
|
||||
def test_plan_draft_heuristic_fills_in_basic_instructions():
|
||||
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
|
||||
|
||||
draft = OllamaAgent._heuristic_plan_draft(seed)
|
||||
|
||||
assert draft["kind"] == "custom"
|
||||
assert draft["cadence"] == "0 */4 * * *"
|
||||
assert "summarize" in draft["constraints"]["instructions"].casefold()
|
||||
assert draft["constraints"]["message_tone"] == "friendly and direct"
|
||||
|
||||
|
||||
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_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
|
||||
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||
|
||||
messages = agent._openai_messages(
|
||||
"check listing",
|
||||
[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": "Check this listing"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "I should check the current listing first.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
|
||||
],
|
||||
)
|
||||
|
||||
assistant_turn = next(message for message in messages if message["role"] == "assistant")
|
||||
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
|
||||
|
||||
|
||||
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
|
||||
async def test_wake_response_executes_tool_calls(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from traderai.config import Settings
|
||||
|
||||
|
||||
def test_model_provider_codex_falls_back_to_ollama():
|
||||
settings = Settings(model_provider="codex")
|
||||
|
||||
assert settings.model_provider == "ollama"
|
||||
|
||||
|
||||
def test_model_provider_openai_falls_back_to_ollama():
|
||||
settings = Settings(model_provider="openai")
|
||||
|
||||
assert settings.model_provider == "ollama"
|
||||
|
||||
|
||||
def test_model_provider_accepts_deepseek():
|
||||
settings = Settings(model_provider="deepseek")
|
||||
|
||||
assert settings.model_provider == "deepseek"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_reasoning_effort_accepts_max():
|
||||
settings = Settings(model_reasoning_effort="max")
|
||||
|
||||
assert settings.model_reasoning_effort == "max"
|
||||
@@ -55,3 +55,22 @@ def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
|
||||
assert renamed["title"] == "Market Check"
|
||||
assert deleted is True
|
||||
assert store.list_outbox() == []
|
||||
|
||||
|
||||
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
|
||||
original_cwd = tmp_path / "start"
|
||||
original_cwd.mkdir()
|
||||
monkeypatch.chdir(original_cwd)
|
||||
|
||||
store = MemoryStore("data/memory.sqlite3")
|
||||
|
||||
moved_cwd = tmp_path / "moved"
|
||||
moved_cwd.mkdir()
|
||||
monkeypatch.chdir(moved_cwd)
|
||||
|
||||
store.add_outbox("Notification survived cwd change")
|
||||
|
||||
snapshot = store.inspect()
|
||||
|
||||
assert store.path.is_absolute()
|
||||
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import pytest
|
||||
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.negotiations import NegotiationSyncService, extract_negotiation_hash
|
||||
from traderai.tools import ToolRegistry
|
||||
|
||||
|
||||
class FakeNegotiationUEX:
|
||||
def __init__(self):
|
||||
self.list_calls = []
|
||||
self.message_calls = []
|
||||
self.posts = []
|
||||
|
||||
async def list_negotiations(self, id=None, id_listing=None, hash=None):
|
||||
self.list_calls.append({"id": id, "id_listing": id_listing, "hash": hash})
|
||||
data = [
|
||||
{
|
||||
"id": 11,
|
||||
"hash": "open-hash",
|
||||
"id_listing": 101,
|
||||
"listing_slug": "rgl-open",
|
||||
"listing_title": "RGL Set",
|
||||
"advertiser_username": "seller_a",
|
||||
"client_username": "pilot_hudson",
|
||||
"date_modified": 1_780_975_053,
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"hash": "closed-recent",
|
||||
"id_listing": 102,
|
||||
"listing_slug": "rgl-closed",
|
||||
"listing_title": "Closed Deal",
|
||||
"advertiser_username": "seller_b",
|
||||
"client_username": "pilot_hudson",
|
||||
"date_modified": 1_780_975_053,
|
||||
"date_closed": 1_780_975_054,
|
||||
},
|
||||
]
|
||||
if hash:
|
||||
data = [item for item in data if item["hash"] == hash]
|
||||
return {"status": "ok", "negotiations": data}
|
||||
|
||||
async def get_negotiation_messages(self, hash=None, id_negotiation=None):
|
||||
self.message_calls.append({"hash": hash, "id_negotiation": id_negotiation})
|
||||
return {
|
||||
"status": "ok",
|
||||
"messages": [
|
||||
{
|
||||
"id": 201,
|
||||
"negotiation_hash": hash,
|
||||
"user_username": "seller_a" if hash == "open-hash" else "seller_b",
|
||||
"user_name": "Seller",
|
||||
"message": "Still available.",
|
||||
"date_added": 1_780_975_053,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def send_negotiation_message(self, **payload):
|
||||
self.posts.append({"kind": "message", **payload})
|
||||
return {"status": "ok", "posted": self.posts[-1]}
|
||||
|
||||
async def close_negotiation(self, **payload):
|
||||
self.posts.append({"kind": "close", **payload})
|
||||
return {"status": "ok", "posted": self.posts[-1]}
|
||||
|
||||
|
||||
def test_extract_negotiation_hash_handles_uex_redirects():
|
||||
assert extract_negotiation_hash("https://uexcorp.space/marketplace/negotiate/hash/abc-123") == "abc-123"
|
||||
assert extract_negotiation_hash("/marketplace/negotiate/hash/def-456") == "def-456"
|
||||
assert extract_negotiation_hash("/marketplace/item/info/foo") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_sync_keeps_open_and_recent_threads(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||
service = NegotiationSyncService(memory, FakeNegotiationUEX())
|
||||
|
||||
result = await service.startup_sync()
|
||||
negotiations = memory.list_negotiations(limit=10)
|
||||
|
||||
assert result["count"] == 2
|
||||
assert {item["hash"] for item in negotiations} == {"open-hash", "closed-recent"}
|
||||
detail = memory.get_negotiation("open-hash")
|
||||
assert detail["messages"][0]["body"] == "Still available."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_refresh_targets_only_changed_negotiation(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||
fake = FakeNegotiationUEX()
|
||||
service = NegotiationSyncService(memory, fake)
|
||||
await service.startup_sync()
|
||||
fake.message_calls.clear()
|
||||
|
||||
await service.handle_notifications(
|
||||
[
|
||||
{
|
||||
"id": 99,
|
||||
"message": "seller_a: ping",
|
||||
"redir": "https://uexcorp.space/marketplace/negotiate/hash/open-hash",
|
||||
"date_added": 1_780_975_060,
|
||||
"date_read": 0,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert fake.message_calls == [{"hash": "open-hash", "id_negotiation": None}]
|
||||
assert memory.get_negotiation("open-hash")["unread_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_send_refreshes_local_thread(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||
fake = FakeNegotiationUEX()
|
||||
service = NegotiationSyncService(memory, fake)
|
||||
await service.startup_sync()
|
||||
|
||||
result = await service.manual_send_message("open-hash", "I can buy tonight.")
|
||||
|
||||
assert result["posted"]["kind"] == "message"
|
||||
assert fake.message_calls[-1]["hash"] == "open-hash"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_negotiation_close_creates_pending_action(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
registry = ToolRegistry(FakeNegotiationUEX(), memory=memory)
|
||||
|
||||
result = await registry.draft_negotiation_close(
|
||||
hash="open-hash",
|
||||
deal_closed=True,
|
||||
deal_value=1_000_000,
|
||||
currency="UEC",
|
||||
clarity_rating=5,
|
||||
speed_rating=5,
|
||||
respect_rating=5,
|
||||
fairness_rating=4,
|
||||
comment="Smooth trade",
|
||||
)
|
||||
|
||||
pending = result["pending_action"]
|
||||
assert pending["endpoint"] == "marketplace_negotiations_close"
|
||||
assert pending["payload"]["deal_closed"] == 1
|
||||
assert pending["payload"]["is_production"] == 1
|
||||
@@ -104,6 +104,26 @@ def test_continual_plan_store_creates_buying_checklist(tmp_path):
|
||||
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
|
||||
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
|
||||
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||
@@ -122,6 +142,9 @@ async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
|
||||
assert len(tools.pending_actions) == 1
|
||||
assert not tools.uex.posts
|
||||
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
|
||||
pending = next(iter(tools.pending_actions.values()))
|
||||
assert "Tone note" not in pending.payload["message"]
|
||||
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -215,3 +238,19 @@ async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
|
||||
|
||||
assert catchup is not None
|
||||
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,258 @@
|
||||
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),
|
||||
deepseek_model=values.get("deepseek_model", state["settings"].deepseek_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": "deepseek", "deepseek_model": "deepseek-v4-flash"}},
|
||||
).json()
|
||||
assert updated["restart_required"] is False
|
||||
|
||||
after = client.get("/api/health").json()
|
||||
assert after["model_provider"] == "deepseek"
|
||||
assert after["inference"]["provider"] == "deepseek"
|
||||
|
||||
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
|
||||
assert chat["message"] == "deepseek:deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_plan_draft_endpoint_returns_agent_draft(monkeypatch, tmp_path):
|
||||
state = {"settings": make_settings(tmp_path)}
|
||||
|
||||
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
|
||||
|
||||
def fake_get_settings():
|
||||
return state["settings"]
|
||||
|
||||
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,
|
||||
"settings_payload",
|
||||
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
|
||||
)
|
||||
|
||||
async def fake_generate_plan_draft(self, title="", objective="", kind="buying", constraints=None, items=None):
|
||||
return {
|
||||
"title": title or "Draft title",
|
||||
"objective": objective or "Draft objective",
|
||||
"kind": kind,
|
||||
"cadence": "0 */3 * * *",
|
||||
"constraints": {"message_tone": "friendly and direct", "instructions": "Start with the best listings."},
|
||||
"items": [{"item_name": "RCMBNT-RGL-1", "desired_quantity": 1, "max_unit_price": None}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(server.OllamaAgent, "generate_plan_draft", fake_generate_plan_draft)
|
||||
|
||||
app = server.create_app()
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/plans/draft",
|
||||
json={"title": "Polaris parts", "objective": "Find the required parts", "kind": "buying", "constraints": {}, "items": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
draft = response.json()["draft"]
|
||||
assert draft["cadence"] == "0 */3 * * *"
|
||||
assert draft["constraints"]["instructions"] == "Start with the best listings."
|
||||
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||
|
||||
|
||||
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4", deepseek_model="deepseek-v4-flash"):
|
||||
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",
|
||||
deepseek_base_url="https://api.deepseek.com",
|
||||
deepseek_api_key=None,
|
||||
deepseek_model=deepseek_model,
|
||||
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,
|
||||
uex_negotiation_close_endpoint="marketplace_negotiations_close",
|
||||
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:
|
||||
def __init__(self):
|
||||
self.posts = []
|
||||
self.get_calls = []
|
||||
|
||||
async def get(self, path, params=None, authenticated=False):
|
||||
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
|
||||
if path == "commodities_prices_history":
|
||||
return {
|
||||
"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"
|
||||
return {
|
||||
"data": [
|
||||
@@ -259,6 +289,116 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
class FakeWikelo:
|
||||
base_url = "https://wikelo-projects.test"
|
||||
|
||||
async def list_ship_projects(self):
|
||||
return [
|
||||
{
|
||||
"id": "ship-1",
|
||||
"ship_name": "Polaris Wikelo Special",
|
||||
"description": "Now make Polaris. Short Time Deal",
|
||||
"status": "planning",
|
||||
"privacy": "public",
|
||||
"owner_name": "Chimpanz33",
|
||||
"required_materials": [
|
||||
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
|
||||
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "ship-2",
|
||||
"ship_name": "Guardian",
|
||||
"description": "Guardian Fight Mod",
|
||||
"status": "planning",
|
||||
"privacy": "public",
|
||||
"owner_name": "Chimpanz33",
|
||||
"required_materials": [
|
||||
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_marketplace_listings_filters_locally():
|
||||
registry = ToolRegistry(FakeUEX())
|
||||
@@ -312,6 +452,17 @@ def test_uex_client_uses_bearer_and_secret_headers():
|
||||
assert headers["Authorization"] == "Bearer bearer"
|
||||
|
||||
|
||||
def test_uex_client_uses_configured_close_endpoint():
|
||||
client = UEXClient(
|
||||
"https://api.uexcorp.space/2.0",
|
||||
secret_key="secret",
|
||||
bearer_token="bearer",
|
||||
negotiation_close_endpoint="custom_close_endpoint",
|
||||
)
|
||||
|
||||
assert client.negotiation_close_endpoint == "custom_close_endpoint"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uex_get_projects_and_limits_results():
|
||||
registry = ToolRegistry(FakeUEX())
|
||||
@@ -333,6 +484,65 @@ async def test_uex_get_projects_and_limits_results():
|
||||
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
|
||||
async def test_uex_api_catalog_exposes_resources_without_live_call():
|
||||
registry = ToolRegistry(FakeUEX())
|
||||
@@ -368,6 +578,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
|
||||
|
||||
assert "get_uex_commodities_prices" in names
|
||||
assert "get_uex_vehicles" in names
|
||||
assert "get_marketplace_trends" in names
|
||||
assert "draft_uex_marketplace_advertise" in names
|
||||
assert "delete_uex_marketplace_listings" in names
|
||||
assert "uex_get" not in names
|
||||
@@ -395,6 +606,17 @@ def test_schemas_expose_cornerstone_item_tools():
|
||||
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
|
||||
async def test_search_scmdb_missions_returns_reward_summary():
|
||||
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||
@@ -469,6 +691,72 @@ 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
|
||||
async def test_search_wikelo_ship_projects_returns_material_matches():
|
||||
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||
|
||||
result = await registry.search_wikelo_ship_projects(query="Polaris")
|
||||
|
||||
assert result["source"] == "https://wikelo-projects.test/Ships"
|
||||
assert result["matched"] == 1
|
||||
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
|
||||
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wikelo_ship_project_returns_full_requirements():
|
||||
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||
|
||||
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
|
||||
|
||||
assert result["project"]["ship_name"] == "Guardian"
|
||||
assert result["project"]["materials_count"] == 1
|
||||
assert result["project"]["required_materials"] == [
|
||||
{
|
||||
"material_name": "Wikelo Favor",
|
||||
"quantity_needed": 20,
|
||||
"quantity_collected": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
|
||||
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||
|
||||
+453
File diff suppressed because one or more lines are too long
+981
-42
File diff suppressed because it is too large
Load Diff
+29
-5
@@ -17,12 +17,21 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
||||
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
|
||||
"deepseek_model": {"env": "DEEPSEEK_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},
|
||||
"scmdb_base_url": {"env": "SCMDB_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},
|
||||
"deepseek_api_key": {"env": "DEEPSEEK_API_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_negotiation_close_endpoint": {"env": "UEX_NEGOTIATION_CLOSE_ENDPOINT", "type": "string", "secret": False},
|
||||
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False},
|
||||
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
|
||||
@@ -71,19 +80,28 @@ class Settings(BaseSettings):
|
||||
ollama_model: str = "qwen3.5:9b"
|
||||
ollama_num_ctx: int = 64512
|
||||
openai_base_url: str = "https://api.openai.com/v1"
|
||||
openai_model: str = "gpt-5.3-codex"
|
||||
openai_model: str = "gpt-5.4-mini"
|
||||
deepseek_base_url: str = "https://api.deepseek.com"
|
||||
deepseek_model: str = "deepseek-v4-flash"
|
||||
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"
|
||||
scmdb_base_url: str = "https://scmdb.net"
|
||||
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)
|
||||
deepseek_api_key: str | None = Field(default=None)
|
||||
uex_secret_key: str | None = Field(default=None)
|
||||
uex_bearer_token: str | None = Field(default=None)
|
||||
uex_negotiation_close_endpoint: str = "marketplace_negotiations_close"
|
||||
traderai_user_name: str | None = Field(default=None)
|
||||
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
||||
uex_notification_poll_seconds: int = 60
|
||||
uex_notification_poll_seconds: int = 300
|
||||
require_write_approval: bool = True
|
||||
|
||||
@field_validator("openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||
@field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||
@classmethod
|
||||
def _blank_optional(cls, value: Any) -> Any:
|
||||
return None if value == "" else value
|
||||
@@ -92,7 +110,13 @@ class Settings(BaseSettings):
|
||||
@classmethod
|
||||
def _normalize_model_provider(cls, value: Any) -> str:
|
||||
text = str(value or "ollama").strip().casefold()
|
||||
return text if text in {"ollama", "openai"} else "ollama"
|
||||
return text if text in {"ollama", "deepseek"} 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", "max"} else "medium"
|
||||
|
||||
@field_validator("traderai_memory_path", mode="before")
|
||||
@classmethod
|
||||
@@ -151,7 +175,7 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
field_type = CONFIG_FIELDS[key]["type"]
|
||||
if value == "":
|
||||
return None if key in {"openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||
return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||
if field_type == "integer":
|
||||
return int(value)
|
||||
if field_type == "boolean":
|
||||
|
||||
+31
-3
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
|
||||
def main() -> None:
|
||||
try:
|
||||
_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(f"cwd={Path.cwd()}")
|
||||
_log(f"executable={sys.executable}")
|
||||
@@ -36,9 +41,13 @@ def main() -> None:
|
||||
_log("existing TraderAI backend found; opening window")
|
||||
_open_window(url)
|
||||
return
|
||||
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
||||
server_thread.start()
|
||||
_log("backend thread started")
|
||||
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.start()
|
||||
_log("backend thread started")
|
||||
_wait_for_server(url)
|
||||
_log("backend health check passed")
|
||||
_open_window(url)
|
||||
@@ -62,6 +71,22 @@ def _select_port() -> int:
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
try:
|
||||
_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
|
||||
|
||||
config = uvicorn.Config(
|
||||
|
||||
+530
-1
@@ -30,6 +30,16 @@ def parse_iso(value: str) -> datetime:
|
||||
return parsed
|
||||
|
||||
|
||||
def unix_to_iso(value: Any) -> str | None:
|
||||
try:
|
||||
timestamp = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if timestamp <= 0:
|
||||
return None
|
||||
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def time_since(value: str, now: datetime | None = None) -> str:
|
||||
then = parse_iso(value)
|
||||
current = now or utc_now()
|
||||
@@ -55,7 +65,7 @@ def _plural(value: int, unit: str) -> str:
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = Path(path)
|
||||
self.path = Path(path).expanduser().resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
@@ -138,6 +148,56 @@ class MemoryStore:
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_threads (
|
||||
negotiation_hash TEXT PRIMARY KEY,
|
||||
uex_negotiation_id INTEGER,
|
||||
listing_id INTEGER,
|
||||
listing_slug TEXT,
|
||||
title TEXT,
|
||||
counterparty_username TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
last_message_at TEXT,
|
||||
last_synced_at TEXT NOT NULL,
|
||||
last_notification_id INTEGER,
|
||||
last_notification_at TEXT,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
closed_at TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
negotiation_hash TEXT NOT NULL,
|
||||
uex_message_id INTEGER,
|
||||
author TEXT,
|
||||
author_username TEXT,
|
||||
is_me INTEGER NOT NULL DEFAULT 0,
|
||||
body TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
source TEXT,
|
||||
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_ratings (
|
||||
negotiation_hash TEXT PRIMARY KEY,
|
||||
deal_closed INTEGER NOT NULL,
|
||||
deal_value REAL,
|
||||
currency TEXT,
|
||||
clarity_rating INTEGER,
|
||||
speed_rating INTEGER,
|
||||
respect_rating INTEGER,
|
||||
fairness_rating INTEGER,
|
||||
comment TEXT,
|
||||
submitted_at TEXT NOT NULL,
|
||||
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS negotiation_sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_column(db, "conversations", "thread_id", "TEXT")
|
||||
@@ -384,6 +444,24 @@ class MemoryStore:
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
negotiation_threads = db.execute(
|
||||
"""
|
||||
SELECT negotiation_hash, title, counterparty_username, status, unread_count, last_message_at, last_synced_at
|
||||
FROM negotiation_threads
|
||||
ORDER BY COALESCE(last_message_at, last_synced_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
negotiation_messages = db.execute(
|
||||
"""
|
||||
SELECT negotiation_hash, author_username, is_me, body, sent_at
|
||||
FROM negotiation_messages
|
||||
ORDER BY COALESCE(sent_at, '') DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
profile = []
|
||||
for row in profile_rows:
|
||||
@@ -402,6 +480,8 @@ class MemoryStore:
|
||||
"profile": profile,
|
||||
"scheduled_jobs": [dict(row) for row in jobs],
|
||||
"outbox": [dict(row) for row in outbox],
|
||||
"negotiation_threads": [dict(row) for row in negotiation_threads],
|
||||
"negotiation_messages": [dict(row) for row in negotiation_messages],
|
||||
}
|
||||
|
||||
def clear(
|
||||
@@ -425,6 +505,10 @@ class MemoryStore:
|
||||
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
||||
if include_outbox:
|
||||
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
|
||||
deleted["negotiation_threads"] = db.execute("DELETE FROM negotiation_threads").rowcount
|
||||
deleted["negotiation_messages"] = db.execute("DELETE FROM negotiation_messages").rowcount
|
||||
deleted["negotiation_ratings"] = db.execute("DELETE FROM negotiation_ratings").rowcount
|
||||
deleted["negotiation_sync_state"] = db.execute("DELETE FROM negotiation_sync_state").rowcount
|
||||
return deleted
|
||||
|
||||
def set_profile(self, key: str, value: Any) -> None:
|
||||
@@ -555,3 +639,448 @@ class MemoryStore:
|
||||
except json.JSONDecodeError:
|
||||
data["metadata"] = {}
|
||||
return data
|
||||
|
||||
def set_negotiation_sync_state(self, key: str, value: Any) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_sync_state(key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, json.dumps(value), iso_now()),
|
||||
)
|
||||
|
||||
def get_negotiation_sync_state(self, key: str, default: Any = None) -> Any:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT value FROM negotiation_sync_state WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return default
|
||||
try:
|
||||
return json.loads(row["value"])
|
||||
except json.JSONDecodeError:
|
||||
return default
|
||||
|
||||
def upsert_negotiation(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
*,
|
||||
uex_negotiation_id: int | None = None,
|
||||
listing_id: int | None = None,
|
||||
listing_slug: str | None = None,
|
||||
title: str | None = None,
|
||||
counterparty_username: str | None = None,
|
||||
status: str = "open",
|
||||
last_message_at: str | None = None,
|
||||
last_synced_at: str | None = None,
|
||||
last_notification_id: int | None = None,
|
||||
last_notification_at: str | None = None,
|
||||
unread_count: int | None = None,
|
||||
closed_at: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if not negotiation_hash.strip():
|
||||
return
|
||||
now = last_synced_at or iso_now()
|
||||
with self._connect() as db:
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT unread_count, metadata_json
|
||||
FROM negotiation_threads
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchone()
|
||||
current_unread = int(existing["unread_count"]) if existing else 0
|
||||
merged_metadata = {}
|
||||
if existing:
|
||||
try:
|
||||
merged_metadata = json.loads(existing["metadata_json"])
|
||||
except json.JSONDecodeError:
|
||||
merged_metadata = {}
|
||||
if metadata:
|
||||
merged_metadata.update(metadata)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_threads(
|
||||
negotiation_hash,
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status,
|
||||
last_message_at,
|
||||
last_synced_at,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
unread_count,
|
||||
closed_at,
|
||||
metadata_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(negotiation_hash) DO UPDATE SET
|
||||
uex_negotiation_id = COALESCE(excluded.uex_negotiation_id, negotiation_threads.uex_negotiation_id),
|
||||
listing_id = COALESCE(excluded.listing_id, negotiation_threads.listing_id),
|
||||
listing_slug = COALESCE(excluded.listing_slug, negotiation_threads.listing_slug),
|
||||
title = COALESCE(excluded.title, negotiation_threads.title),
|
||||
counterparty_username = COALESCE(excluded.counterparty_username, negotiation_threads.counterparty_username),
|
||||
status = COALESCE(excluded.status, negotiation_threads.status),
|
||||
last_message_at = COALESCE(excluded.last_message_at, negotiation_threads.last_message_at),
|
||||
last_synced_at = excluded.last_synced_at,
|
||||
last_notification_id = COALESCE(excluded.last_notification_id, negotiation_threads.last_notification_id),
|
||||
last_notification_at = COALESCE(excluded.last_notification_at, negotiation_threads.last_notification_at),
|
||||
unread_count = COALESCE(excluded.unread_count, negotiation_threads.unread_count),
|
||||
closed_at = COALESCE(excluded.closed_at, negotiation_threads.closed_at),
|
||||
metadata_json = excluded.metadata_json
|
||||
""",
|
||||
(
|
||||
negotiation_hash.strip(),
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status or "open",
|
||||
last_message_at,
|
||||
now,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
current_unread if unread_count is None else max(0, int(unread_count)),
|
||||
closed_at,
|
||||
json.dumps(merged_metadata),
|
||||
),
|
||||
)
|
||||
|
||||
def replace_negotiation_messages(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
mark_read: bool = False,
|
||||
) -> None:
|
||||
if not negotiation_hash.strip():
|
||||
return
|
||||
normalized = [self._normalize_negotiation_message(negotiation_hash, item) for item in messages]
|
||||
normalized = [item for item in normalized if item]
|
||||
with self._connect() as db:
|
||||
db.execute("DELETE FROM negotiation_messages WHERE negotiation_hash = ?", (negotiation_hash,))
|
||||
for item in normalized:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_messages(
|
||||
negotiation_hash,
|
||||
uex_message_id,
|
||||
author,
|
||||
author_username,
|
||||
is_me,
|
||||
body,
|
||||
sent_at,
|
||||
source,
|
||||
raw_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
negotiation_hash,
|
||||
item["uex_message_id"],
|
||||
item["author"],
|
||||
item["author_username"],
|
||||
1 if item["is_me"] else 0,
|
||||
item["body"],
|
||||
item["sent_at"],
|
||||
item["source"],
|
||||
json.dumps(item["raw_json"]),
|
||||
),
|
||||
)
|
||||
last_message_at = normalized[-1]["sent_at"] if normalized else None
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE negotiation_threads
|
||||
SET last_message_at = COALESCE(?, last_message_at),
|
||||
last_synced_at = ?,
|
||||
unread_count = CASE WHEN ? THEN 0 ELSE unread_count END
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(last_message_at, iso_now(), 1 if mark_read else 0, negotiation_hash),
|
||||
)
|
||||
|
||||
def mark_negotiation_notified(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
*,
|
||||
notification_id: int | None = None,
|
||||
notification_at: str | None = None,
|
||||
) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE negotiation_threads
|
||||
SET unread_count = unread_count + 1,
|
||||
last_notification_id = COALESCE(?, last_notification_id),
|
||||
last_notification_at = COALESCE(?, last_notification_at)
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(notification_id, notification_at, negotiation_hash),
|
||||
)
|
||||
|
||||
def mark_negotiation_read(self, negotiation_hash: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE negotiation_threads SET unread_count = 0 WHERE negotiation_hash = ?",
|
||||
(negotiation_hash,),
|
||||
)
|
||||
|
||||
def store_negotiation_rating(self, negotiation_hash: str, payload: dict[str, Any], raw_json: dict[str, Any] | None = None) -> None:
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO negotiation_ratings(
|
||||
negotiation_hash,
|
||||
deal_closed,
|
||||
deal_value,
|
||||
currency,
|
||||
clarity_rating,
|
||||
speed_rating,
|
||||
respect_rating,
|
||||
fairness_rating,
|
||||
comment,
|
||||
submitted_at,
|
||||
raw_json
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(negotiation_hash) DO UPDATE SET
|
||||
deal_closed = excluded.deal_closed,
|
||||
deal_value = excluded.deal_value,
|
||||
currency = excluded.currency,
|
||||
clarity_rating = excluded.clarity_rating,
|
||||
speed_rating = excluded.speed_rating,
|
||||
respect_rating = excluded.respect_rating,
|
||||
fairness_rating = excluded.fairness_rating,
|
||||
comment = excluded.comment,
|
||||
submitted_at = excluded.submitted_at,
|
||||
raw_json = excluded.raw_json
|
||||
""",
|
||||
(
|
||||
negotiation_hash,
|
||||
1 if payload.get("deal_closed") else 0,
|
||||
payload.get("deal_value"),
|
||||
payload.get("currency"),
|
||||
payload.get("clarity_rating"),
|
||||
payload.get("speed_rating"),
|
||||
payload.get("respect_rating"),
|
||||
payload.get("fairness_rating"),
|
||||
payload.get("comment"),
|
||||
now,
|
||||
json.dumps(raw_json or payload),
|
||||
),
|
||||
)
|
||||
|
||||
def list_negotiations(
|
||||
self,
|
||||
*,
|
||||
status: str = "all",
|
||||
unread_only: bool = False,
|
||||
search: str = "",
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
status_filter = str(status or "all").strip().casefold()
|
||||
search_filter = f"%{search.strip().casefold()}%" if search.strip() else None
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if status_filter not in {"", "all"}:
|
||||
clauses.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if unread_only:
|
||||
clauses.append("unread_count > 0")
|
||||
if search_filter:
|
||||
clauses.append(
|
||||
"""
|
||||
(
|
||||
lower(COALESCE(title, '')) LIKE ?
|
||||
OR lower(COALESCE(counterparty_username, '')) LIKE ?
|
||||
OR lower(COALESCE(listing_slug, '')) LIKE ?
|
||||
)
|
||||
"""
|
||||
)
|
||||
params.extend([search_filter, search_filter, search_filter])
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
f"""
|
||||
SELECT
|
||||
negotiation_hash,
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status,
|
||||
last_message_at,
|
||||
last_synced_at,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
unread_count,
|
||||
closed_at,
|
||||
metadata_json
|
||||
FROM negotiation_threads
|
||||
{where}
|
||||
ORDER BY
|
||||
unread_count DESC,
|
||||
COALESCE(last_message_at, last_notification_at, last_synced_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*params, max(1, min(limit, 500))),
|
||||
).fetchall()
|
||||
return [self._negotiation_thread_row(row) for row in rows]
|
||||
|
||||
def get_negotiation(self, negotiation_hash: str) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
thread = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
negotiation_hash,
|
||||
uex_negotiation_id,
|
||||
listing_id,
|
||||
listing_slug,
|
||||
title,
|
||||
counterparty_username,
|
||||
status,
|
||||
last_message_at,
|
||||
last_synced_at,
|
||||
last_notification_id,
|
||||
last_notification_at,
|
||||
unread_count,
|
||||
closed_at,
|
||||
metadata_json
|
||||
FROM negotiation_threads
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchone()
|
||||
if not thread:
|
||||
return None
|
||||
messages = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
uex_message_id,
|
||||
author,
|
||||
author_username,
|
||||
is_me,
|
||||
body,
|
||||
sent_at,
|
||||
source,
|
||||
raw_json
|
||||
FROM negotiation_messages
|
||||
WHERE negotiation_hash = ?
|
||||
ORDER BY COALESCE(sent_at, '') ASC, id ASC
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchall()
|
||||
rating = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
deal_closed,
|
||||
deal_value,
|
||||
currency,
|
||||
clarity_rating,
|
||||
speed_rating,
|
||||
respect_rating,
|
||||
fairness_rating,
|
||||
comment,
|
||||
submitted_at,
|
||||
raw_json
|
||||
FROM negotiation_ratings
|
||||
WHERE negotiation_hash = ?
|
||||
""",
|
||||
(negotiation_hash,),
|
||||
).fetchone()
|
||||
result = self._negotiation_thread_row(thread)
|
||||
result["messages"] = [self._negotiation_message_row(row) for row in messages]
|
||||
result["rating"] = self._negotiation_rating_row(rating) if rating else None
|
||||
return result
|
||||
|
||||
def search_negotiation_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||
q = query.strip()
|
||||
if not q:
|
||||
return []
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.negotiation_hash,
|
||||
t.title,
|
||||
t.counterparty_username,
|
||||
m.author_username,
|
||||
m.is_me,
|
||||
m.body,
|
||||
m.sent_at
|
||||
FROM negotiation_messages m
|
||||
JOIN negotiation_threads t ON t.negotiation_hash = m.negotiation_hash
|
||||
WHERE lower(m.body) LIKE ?
|
||||
ORDER BY COALESCE(m.sent_at, '') DESC, m.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{q.casefold()}%", max(1, min(limit, 50))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_negotiation_message(negotiation_hash: str, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
body = str(item.get("body") or item.get("message") or item.get("content") or item.get("text") or item.get("event") or "").strip()
|
||||
if not body:
|
||||
return None
|
||||
return {
|
||||
"negotiation_hash": negotiation_hash,
|
||||
"uex_message_id": MemoryStore._int_or_none(item.get("id") or item.get("id_message")),
|
||||
"author": str(item.get("user_name") or item.get("author") or item.get("sender") or item.get("user_username") or "UEX"),
|
||||
"author_username": str(item.get("user_username") or item.get("author_username") or item.get("username") or "").strip() or None,
|
||||
"is_me": bool(item.get("is_me")),
|
||||
"body": body,
|
||||
"sent_at": unix_to_iso(item.get("date_added")) or str(item.get("sent_at") or "").strip() or None,
|
||||
"source": str(item.get("api_name") or item.get("source") or "uex"),
|
||||
"raw_json": item,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_thread_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
try:
|
||||
data["metadata"] = json.loads(data.pop("metadata_json"))
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
data["metadata"] = {}
|
||||
data["hash"] = data.pop("negotiation_hash")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_message_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
try:
|
||||
data["raw_json"] = json.loads(data["raw_json"])
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
data["raw_json"] = {}
|
||||
data["is_me"] = bool(data.get("is_me"))
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_rating_row(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
try:
|
||||
data["raw_json"] = json.loads(data["raw_json"])
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
data["raw_json"] = {}
|
||||
data["deal_closed"] = bool(data.get("deal_closed"))
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _int_or_none(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from traderai.memory import MemoryStore, iso_now, unix_to_iso
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
UEX_NEGOTIATION_CLOSE_ENDPOINT = "marketplace_negotiations_close"
|
||||
|
||||
|
||||
def extract_negotiation_hash(redir: str | None) -> str | None:
|
||||
if not redir:
|
||||
return None
|
||||
parsed = urlparse(redir)
|
||||
path = parsed.path or str(redir)
|
||||
cleaned = path.strip("/")
|
||||
parts = cleaned.split("/")
|
||||
for index, part in enumerate(parts):
|
||||
if part == "hash" and index + 1 < len(parts):
|
||||
return parts[index + 1].strip() or None
|
||||
if len(parts) >= 3 and parts[-3:-1] == ["marketplace", "negotiations"]:
|
||||
return parts[-1].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NegotiationRefreshResult:
|
||||
hash: str
|
||||
refreshed: bool
|
||||
summary: dict[str, Any] | None = None
|
||||
messages_count: int = 0
|
||||
|
||||
|
||||
class NegotiationSyncService:
|
||||
def __init__(self, memory: MemoryStore, uex: UEXClient) -> None:
|
||||
self.memory = memory
|
||||
self.uex = uex
|
||||
self.recent_days = 30
|
||||
|
||||
async def startup_sync(self) -> dict[str, Any]:
|
||||
return await self.refresh_negotiations(seed_open_messages=True)
|
||||
|
||||
async def refresh_negotiations(self, *, seed_open_messages: bool = False) -> dict[str, Any]:
|
||||
response = await self.uex.list_negotiations()
|
||||
negotiations = response.get("negotiations") or response.get("data") or []
|
||||
kept_hashes: list[str] = []
|
||||
refreshed = 0
|
||||
for item in negotiations:
|
||||
normalized = self._normalize_negotiation_summary(item)
|
||||
if not normalized:
|
||||
continue
|
||||
cached = self.memory.get_negotiation(normalized["negotiation_hash"])
|
||||
if not self._should_keep_thread(normalized, cached):
|
||||
continue
|
||||
kept_hashes.append(normalized["negotiation_hash"])
|
||||
self.memory.upsert_negotiation(**normalized)
|
||||
if seed_open_messages and (normalized["status"] == "open" or cached is None):
|
||||
result = await self.refresh_negotiation(normalized["negotiation_hash"], mark_read=False, summary=normalized)
|
||||
if result.refreshed:
|
||||
refreshed += 1
|
||||
self.memory.set_negotiation_sync_state("last_full_negotiation_sync_at", iso_now())
|
||||
return {
|
||||
"count": len(kept_hashes),
|
||||
"refreshed_threads": refreshed,
|
||||
"negotiations": self.memory.list_negotiations(limit=200),
|
||||
}
|
||||
|
||||
async def refresh_negotiation(
|
||||
self,
|
||||
negotiation_hash: str,
|
||||
*,
|
||||
mark_read: bool = False,
|
||||
summary: dict[str, Any] | None = None,
|
||||
) -> NegotiationRefreshResult:
|
||||
summary_data = summary or await self._fetch_summary_by_hash(negotiation_hash)
|
||||
if summary_data:
|
||||
self.memory.upsert_negotiation(**summary_data)
|
||||
response = await self.uex.get_negotiation_messages(hash=negotiation_hash)
|
||||
messages = response.get("messages") or response.get("data") or []
|
||||
normalized_messages = [self._normalize_message(item) for item in messages if isinstance(item, dict)]
|
||||
normalized_messages = [item for item in normalized_messages if item]
|
||||
self.memory.replace_negotiation_messages(negotiation_hash, normalized_messages, mark_read=mark_read)
|
||||
if mark_read:
|
||||
self.memory.mark_negotiation_read(negotiation_hash)
|
||||
return NegotiationRefreshResult(
|
||||
hash=negotiation_hash,
|
||||
refreshed=True,
|
||||
summary=self.memory.get_negotiation(negotiation_hash),
|
||||
messages_count=len(normalized_messages),
|
||||
)
|
||||
|
||||
async def handle_notifications(self, notifications: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not notifications:
|
||||
return []
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
passthrough: list[dict[str, Any]] = []
|
||||
for item in notifications:
|
||||
negotiation_hash = extract_negotiation_hash(item.get("redir"))
|
||||
if not negotiation_hash:
|
||||
passthrough.append(item)
|
||||
continue
|
||||
grouped.setdefault(negotiation_hash, []).append(item)
|
||||
|
||||
for negotiation_hash, items in grouped.items():
|
||||
latest = max(items, key=lambda item: int(item.get("date_added") or 0))
|
||||
await self.refresh_negotiation(negotiation_hash, mark_read=False)
|
||||
self.memory.mark_negotiation_notified(
|
||||
negotiation_hash,
|
||||
notification_id=self._int_or_none(latest.get("id")),
|
||||
notification_at=unix_to_iso(latest.get("date_added")) or iso_now(),
|
||||
)
|
||||
|
||||
for item in passthrough:
|
||||
self.memory.add_outbox(self._notification_text(item))
|
||||
|
||||
self.memory.set_negotiation_sync_state("last_notification_sync_at", iso_now())
|
||||
self.memory.set_negotiation_sync_state(
|
||||
"last_seen_notification_ids",
|
||||
sorted(self._int_or_none(item.get("id")) for item in notifications if self._int_or_none(item.get("id")) is not None),
|
||||
)
|
||||
return notifications
|
||||
|
||||
async def manual_send_message(self, negotiation_hash: str, message: str) -> dict[str, Any]:
|
||||
result = await self.uex.send_negotiation_message(hash=negotiation_hash, message=message, is_production=1)
|
||||
await self.refresh_negotiation(negotiation_hash, mark_read=True)
|
||||
return result
|
||||
|
||||
async def manual_close_negotiation(self, negotiation_hash: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
result = await self.uex.close_negotiation(hash=negotiation_hash, **payload)
|
||||
await self.refresh_negotiation(negotiation_hash, mark_read=True)
|
||||
self.memory.store_negotiation_rating(negotiation_hash, payload, raw_json=result)
|
||||
return result
|
||||
|
||||
def list_negotiations(self, *, status: str = "all", unread_only: bool = False, search: str = "", limit: int = 50) -> list[dict[str, Any]]:
|
||||
return self.memory.list_negotiations(status=status, unread_only=unread_only, search=search, limit=limit)
|
||||
|
||||
def unread_count(self) -> int:
|
||||
return sum(int(item.get("unread_count") or 0) for item in self.memory.list_negotiations(unread_only=True, limit=500))
|
||||
|
||||
def get_negotiation(self, negotiation_hash: str, *, mark_read: bool = True) -> dict[str, Any] | None:
|
||||
negotiation = self.memory.get_negotiation(negotiation_hash)
|
||||
if negotiation and mark_read:
|
||||
self.memory.mark_negotiation_read(negotiation_hash)
|
||||
negotiation["unread_count"] = 0
|
||||
return negotiation
|
||||
|
||||
def search_messages(self, query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||
return self.memory.search_negotiation_messages(query, limit=limit)
|
||||
|
||||
async def _fetch_summary_by_hash(self, negotiation_hash: str) -> dict[str, Any] | None:
|
||||
response = await self.uex.list_negotiations(hash=negotiation_hash)
|
||||
negotiations = response.get("negotiations") or response.get("data") or []
|
||||
for item in negotiations:
|
||||
normalized = self._normalize_negotiation_summary(item)
|
||||
if normalized and normalized["negotiation_hash"] == negotiation_hash:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
def _normalize_negotiation_summary(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
negotiation_hash = str(item.get("hash") or item.get("negotiation_hash") or "").strip()
|
||||
if not negotiation_hash:
|
||||
return None
|
||||
user = self.memory.get_profile().get("uex_user") or {}
|
||||
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
|
||||
advertiser_username = str(item.get("advertiser_username") or "").strip()
|
||||
client_username = str(item.get("client_username") or "").strip()
|
||||
is_listing_advertiser = bool(item.get("is_listing_advertiser"))
|
||||
if current_username:
|
||||
if advertiser_username.casefold() == current_username:
|
||||
counterparty = client_username
|
||||
elif client_username.casefold() == current_username:
|
||||
counterparty = advertiser_username
|
||||
else:
|
||||
counterparty = client_username if is_listing_advertiser else advertiser_username
|
||||
else:
|
||||
counterparty = client_username if is_listing_advertiser else advertiser_username
|
||||
closed_at = unix_to_iso(item.get("date_closed") or item.get("date_closed_client"))
|
||||
metadata = {
|
||||
"advertiser_name": item.get("advertiser_name"),
|
||||
"advertiser_username": advertiser_username or None,
|
||||
"client_name": item.get("client_name"),
|
||||
"client_username": client_username or None,
|
||||
"deal_value": item.get("deal_value"),
|
||||
"deal_value_currency": item.get("deal_value_currency"),
|
||||
"price": item.get("price"),
|
||||
"unit": item.get("unit"),
|
||||
"currency": item.get("currency"),
|
||||
"raw": item,
|
||||
}
|
||||
return {
|
||||
"negotiation_hash": negotiation_hash,
|
||||
"uex_negotiation_id": self._int_or_none(item.get("id") or item.get("id_negotiation")),
|
||||
"listing_id": self._int_or_none(item.get("id_listing")),
|
||||
"listing_slug": str(item.get("listing_slug") or "").strip() or None,
|
||||
"title": str(item.get("listing_title") or item.get("title") or "").strip() or None,
|
||||
"counterparty_username": counterparty or None,
|
||||
"status": "closed" if closed_at else "open",
|
||||
"last_message_at": unix_to_iso(item.get("date_modified") or item.get("date_added")),
|
||||
"last_synced_at": iso_now(),
|
||||
"closed_at": closed_at,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
def _normalize_message(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
negotiation_hash = str(item.get("negotiation_hash") or "").strip()
|
||||
if not negotiation_hash:
|
||||
return None
|
||||
user = self.memory.get_profile().get("uex_user") or {}
|
||||
current_username = str(user.get("username") or user.get("user_username") or "").strip().casefold()
|
||||
username = str(item.get("user_username") or "").strip()
|
||||
normalized = dict(item)
|
||||
normalized["is_me"] = bool(current_username and username.casefold() == current_username)
|
||||
normalized["author"] = item.get("user_name") or username or "UEX"
|
||||
normalized["source"] = item.get("api_name") or "uex"
|
||||
normalized["body"] = item.get("message") or item.get("event") or ""
|
||||
return normalized
|
||||
|
||||
def _should_keep_thread(self, normalized: dict[str, Any], cached: dict[str, Any] | None) -> bool:
|
||||
if cached:
|
||||
return True
|
||||
if normalized["status"] == "open":
|
||||
return True
|
||||
last_message_at = normalized.get("last_message_at")
|
||||
if not last_message_at:
|
||||
return False
|
||||
try:
|
||||
age_seconds = max(0.0, (datetime.now(timezone.utc) - datetime.fromisoformat(last_message_at)).total_seconds())
|
||||
except ValueError:
|
||||
return False
|
||||
return age_seconds <= self.recent_days * 24 * 60 * 60
|
||||
|
||||
@staticmethod
|
||||
def _notification_text(item: dict[str, Any]) -> str:
|
||||
message = item.get("message") or "You have a pending UEX notification."
|
||||
redir = item.get("redir")
|
||||
return f"UEX notification: {message}" + (f" (path `{redir}`)" if redir else "")
|
||||
|
||||
@staticmethod
|
||||
def _int_or_none(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
+29
-5
@@ -232,6 +232,17 @@ class ContinualPlanStore:
|
||||
self.add_event(plan_id, status, f"Plan status changed to {status}.")
|
||||
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]:
|
||||
now = iso_now()
|
||||
with self.memory._connect() as db:
|
||||
@@ -517,12 +528,13 @@ class ContinualPlanRunner:
|
||||
|
||||
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
|
||||
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
|
||||
build_context = self._plan_build_context(plan["objective"])
|
||||
message = (
|
||||
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing "
|
||||
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. "
|
||||
f"Is it still available? I am trying to complete: {plan['objective']}. "
|
||||
f"Tone note: {tone}."
|
||||
)
|
||||
f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
|
||||
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
|
||||
f"{build_context}If you still have it, I can move quickly."
|
||||
).strip()
|
||||
return await self.tools.draft_negotiation_message(
|
||||
message=message,
|
||||
id_listing=self._int_or_none(candidate.get("listing_id")),
|
||||
@@ -532,6 +544,18 @@ class ContinualPlanRunner:
|
||||
listing_slug=candidate.get("listing_slug"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _plan_build_context(objective: str) -> str:
|
||||
text = str(objective or "").strip().rstrip(".")
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.casefold()
|
||||
if "polaris" in lowered:
|
||||
return "I'm putting together parts for a Polaris build. "
|
||||
if "mission" in lowered:
|
||||
return "I'm trying to wrap up a mission build. "
|
||||
return "I'm sourcing parts for a build. "
|
||||
|
||||
@staticmethod
|
||||
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
|
||||
price = float(listing.get("price") or 10**12)
|
||||
|
||||
@@ -23,6 +23,7 @@ class WakeScheduler:
|
||||
self.agent = None
|
||||
self.uex = None
|
||||
self.plan_runner = None
|
||||
self.negotiation_sync = None
|
||||
self.notification_poll_seconds = 60
|
||||
|
||||
def bind_agent(self, agent: Any) -> None:
|
||||
@@ -31,6 +32,9 @@ class WakeScheduler:
|
||||
def bind_plan_runner(self, plan_runner: Any) -> None:
|
||||
self.plan_runner = plan_runner
|
||||
|
||||
def bind_negotiation_sync(self, negotiation_sync: Any) -> None:
|
||||
self.negotiation_sync = negotiation_sync
|
||||
|
||||
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
||||
self.uex = uex
|
||||
self.notification_poll_seconds = max(15, poll_seconds)
|
||||
@@ -197,8 +201,11 @@ class WakeScheduler:
|
||||
new_pending = [item for item in pending if self._notification_key(item) not in seen]
|
||||
|
||||
if new_pending:
|
||||
for item in new_pending:
|
||||
self.memory.add_outbox(self._notification_text(item))
|
||||
if self.negotiation_sync is not None:
|
||||
await self.negotiation_sync.handle_notifications(new_pending)
|
||||
else:
|
||||
for item in new_pending:
|
||||
self.memory.add_outbox(self._notification_text(item))
|
||||
seen.update(self._notification_key(item) for item in new_pending)
|
||||
self.memory.set_profile("uex_seen_notification_keys", sorted(seen))
|
||||
self.memory.set_profile("uex_last_notification_check", iso_now())
|
||||
|
||||
+712
-58
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -23,12 +24,15 @@ from traderai.config import save_settings, settings_payload
|
||||
from traderai.config import get_settings
|
||||
from traderai.cornerstone_client import CornerstoneClient
|
||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||
from traderai.negotiations import NegotiationSyncService
|
||||
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||
from traderai.wikelo_projects_client import WikeloProjectsClient
|
||||
|
||||
|
||||
def resource_path(*parts: str) -> Path:
|
||||
@@ -60,6 +64,21 @@ class DirectNegotiationMessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class NegotiationDraftMessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class NegotiationCloseRequest(BaseModel):
|
||||
deal_closed: bool
|
||||
deal_value: float | None = None
|
||||
currency: str | None = None
|
||||
clarity_rating: int | None = None
|
||||
speed_rating: int | None = None
|
||||
respect_rating: int | None = None
|
||||
fairness_rating: int | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class ClearMemoryRequest(BaseModel):
|
||||
include_memories: bool = True
|
||||
include_conversations: bool = True
|
||||
@@ -83,6 +102,14 @@ class ContinualPlanCreateRequest(BaseModel):
|
||||
items: list[ContinualPlanItemRequest] = []
|
||||
|
||||
|
||||
class ContinualPlanDraftRequest(BaseModel):
|
||||
title: str = ""
|
||||
objective: str = ""
|
||||
kind: str = "buying"
|
||||
constraints: dict[str, Any] = {}
|
||||
items: list[ContinualPlanItemRequest] = []
|
||||
|
||||
|
||||
class ContinualPlanEventRequest(BaseModel):
|
||||
kind: str = "note"
|
||||
message: str
|
||||
@@ -106,34 +133,77 @@ def create_app() -> FastAPI:
|
||||
memory = MemoryStore(settings.traderai_memory_path)
|
||||
plan_store = ContinualPlanStore(memory)
|
||||
scheduler = WakeScheduler(memory)
|
||||
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
||||
scmdb = SCMDBClient(settings.scmdb_base_url)
|
||||
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
|
||||
tools = ToolRegistry(
|
||||
uex,
|
||||
settings.require_write_approval,
|
||||
memory=memory,
|
||||
scheduler=scheduler,
|
||||
scmdb=scmdb,
|
||||
cornerstone=cornerstone,
|
||||
plan_store=plan_store,
|
||||
)
|
||||
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||
tools.plan_runner = plan_runner
|
||||
agent = OllamaAgent(
|
||||
settings.openai_base_url if settings.model_provider == "openai" else settings.ollama_base_url,
|
||||
settings.openai_model if settings.model_provider == "openai" else settings.ollama_model,
|
||||
tools,
|
||||
memory=memory,
|
||||
user_name=settings.traderai_user_name,
|
||||
num_ctx=settings.ollama_num_ctx,
|
||||
provider=settings.model_provider,
|
||||
api_key=settings.openai_api_key,
|
||||
)
|
||||
plan_runner.bind_agent(agent)
|
||||
scheduler.bind_agent(agent)
|
||||
scheduler.bind_plan_runner(plan_runner)
|
||||
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
|
||||
runtime: dict[str, Any] = {}
|
||||
|
||||
def configure_runtime(current_settings: Any) -> None:
|
||||
uex = UEXClient(
|
||||
current_settings.uex_base_url,
|
||||
current_settings.uex_secret_key,
|
||||
current_settings.uex_bearer_token,
|
||||
negotiation_close_endpoint=current_settings.uex_negotiation_close_endpoint,
|
||||
)
|
||||
negotiation_sync = NegotiationSyncService(memory, uex)
|
||||
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)
|
||||
wikelo = WikeloProjectsClient()
|
||||
try:
|
||||
tools = ToolRegistry(
|
||||
uex,
|
||||
current_settings.require_write_approval,
|
||||
memory=memory,
|
||||
scheduler=scheduler,
|
||||
scmdb=scmdb,
|
||||
cornerstone=cornerstone,
|
||||
scwiki=scwiki,
|
||||
wikelo=wikelo,
|
||||
plan_store=plan_store,
|
||||
negotiation_sync=negotiation_sync,
|
||||
)
|
||||
except TypeError:
|
||||
tools = ToolRegistry(
|
||||
uex,
|
||||
current_settings.require_write_approval,
|
||||
memory=memory,
|
||||
scheduler=scheduler,
|
||||
scmdb=scmdb,
|
||||
cornerstone=cornerstone,
|
||||
scwiki=scwiki,
|
||||
wikelo=wikelo,
|
||||
plan_store=plan_store,
|
||||
)
|
||||
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||
tools.plan_runner = plan_runner
|
||||
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
|
||||
agent = OllamaAgent(
|
||||
provider_base_url,
|
||||
provider_model,
|
||||
tools,
|
||||
memory=memory,
|
||||
user_name=current_settings.traderai_user_name,
|
||||
num_ctx=current_settings.ollama_num_ctx,
|
||||
provider=current_settings.model_provider,
|
||||
api_key=provider_api_key,
|
||||
reasoning_effort=current_settings.model_reasoning_effort,
|
||||
)
|
||||
plan_runner.bind_agent(agent)
|
||||
scheduler.bind_agent(agent)
|
||||
scheduler.bind_plan_runner(plan_runner)
|
||||
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
|
||||
if hasattr(scheduler, "bind_negotiation_sync"):
|
||||
scheduler.bind_negotiation_sync(negotiation_sync)
|
||||
runtime.update(
|
||||
{
|
||||
"settings": current_settings,
|
||||
"uex": uex,
|
||||
"tools": tools,
|
||||
"plan_runner": plan_runner,
|
||||
"agent": agent,
|
||||
"negotiation_sync": negotiation_sync,
|
||||
}
|
||||
)
|
||||
|
||||
configure_runtime(settings)
|
||||
|
||||
app = FastAPI(title="TraderAI")
|
||||
static_dir = resource_path("web")
|
||||
@@ -142,6 +212,10 @@ def create_app() -> FastAPI:
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
await refresh_user_profile()
|
||||
try:
|
||||
await runtime["negotiation_sync"].startup_sync()
|
||||
except Exception:
|
||||
memory.set_profile("uex_last_negotiation_sync_error", "startup_sync_failed")
|
||||
scheduler.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
@@ -149,17 +223,20 @@ def create_app() -> FastAPI:
|
||||
scheduler.shutdown()
|
||||
|
||||
async def refresh_user_profile() -> None:
|
||||
if settings.traderai_user_name:
|
||||
memory.set_profile("configured_name", settings.traderai_user_name)
|
||||
agent.user_name = agent.user_name or settings.traderai_user_name
|
||||
current_settings = get_settings()
|
||||
agent = runtime["agent"]
|
||||
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:
|
||||
response = await uex.get_user(authenticated=True)
|
||||
except Exception as exc:
|
||||
memory.set_profile("uex_user_error", str(exc))
|
||||
if settings.traderai_user_name:
|
||||
if current_settings.traderai_user_name:
|
||||
try:
|
||||
response = await uex.get_user(username=settings.traderai_user_name)
|
||||
response = await uex.get_user(username=current_settings.traderai_user_name)
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
@@ -178,9 +255,13 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
agent = runtime["agent"]
|
||||
current_settings = get_settings()
|
||||
inference = await agent.health()
|
||||
return {
|
||||
"ollama": await agent.health(),
|
||||
"model_provider": settings.model_provider,
|
||||
"inference": inference,
|
||||
"ollama": inference,
|
||||
"model_provider": current_settings.model_provider,
|
||||
"user": memory.get_profile(),
|
||||
"jobs": scheduler.list_jobs(),
|
||||
"app_data_dir": settings_payload()["app_data_dir"],
|
||||
@@ -193,27 +274,62 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/config")
|
||||
async def update_config(request: ConfigUpdateRequest) -> dict:
|
||||
previous_settings = get_settings()
|
||||
updated = save_settings(request.values)
|
||||
updated["restart_required"] = True
|
||||
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect."
|
||||
current_settings = get_settings()
|
||||
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
|
||||
|
||||
@app.get("/api/ollama/status")
|
||||
async def ollama_status() -> dict:
|
||||
return await inspect_model_provider()
|
||||
|
||||
@app.get("/api/openai/models")
|
||||
async def openai_models() -> dict:
|
||||
status = await inspect_openai()
|
||||
@app.get("/api/provider/models")
|
||||
async def provider_models(provider: str | None = None) -> dict:
|
||||
status = await inspect_provider_models(provider)
|
||||
return {
|
||||
"provider": "openai",
|
||||
"provider": status.get("provider", "openai"),
|
||||
"configured_model": status.get("configured_model"),
|
||||
"models": status.get("models", []),
|
||||
"reasoning_efforts": status.get("reasoning_efforts", reasoning_effort_options()),
|
||||
"configured_reasoning_effort": status.get("configured_reasoning_effort", get_settings().model_reasoning_effort),
|
||||
"message": status.get("message", ""),
|
||||
"detail": status.get("detail", ""),
|
||||
"online": status.get("online", False),
|
||||
}
|
||||
|
||||
@app.post("/api/codex/login")
|
||||
async def launch_codex_login() -> dict:
|
||||
current_settings = get_settings()
|
||||
command = find_codex_cli(current_settings.codex_command)
|
||||
if not command:
|
||||
raise HTTPException(status_code=404, detail="Codex CLI was not found on PATH.")
|
||||
try:
|
||||
login = await start_codex_browser_login(command)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Codex App Server login failed: {exception_detail(exc)}") from exc
|
||||
return {
|
||||
"installed": True,
|
||||
"running": False,
|
||||
"online": False,
|
||||
"provider": "codex",
|
||||
"login_id": login.get("loginId"),
|
||||
"auth_url": login.get("authUrl"),
|
||||
"base_url": str(command),
|
||||
"message": "Opened Codex App Server sign-in in your browser. Finish the flow, then TraderAI will detect the new login.",
|
||||
}
|
||||
|
||||
@app.post("/api/ollama/launch")
|
||||
async def launch_ollama() -> dict:
|
||||
command = ollama_launch_command()
|
||||
@@ -319,6 +435,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: ChatRequest) -> dict:
|
||||
agent = runtime["agent"]
|
||||
try:
|
||||
return await agent.chat(
|
||||
request.message,
|
||||
@@ -330,6 +447,8 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||
agent = runtime["agent"]
|
||||
|
||||
async def events():
|
||||
async for event in agent.chat_events(
|
||||
request.message,
|
||||
@@ -367,6 +486,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/pending-actions")
|
||||
async def pending_actions() -> dict:
|
||||
agent = runtime["agent"]
|
||||
return {"pending_actions": agent._pending_payloads()}
|
||||
|
||||
@app.get("/api/notifications")
|
||||
@@ -391,17 +511,123 @@ def create_app() -> FastAPI:
|
||||
deleted = memory.delete_outbox(inbox_id)
|
||||
return {"deleted": deleted, "inbox": memory.list_outbox()}
|
||||
|
||||
@app.get("/api/negotiations")
|
||||
async def negotiations(status: str = "all", unread_only: bool = False, search: str = "", limit: int = 50) -> dict:
|
||||
negotiation_sync = runtime["negotiation_sync"]
|
||||
return {
|
||||
"negotiations": negotiation_sync.list_negotiations(
|
||||
status=status,
|
||||
unread_only=unread_only,
|
||||
search=search,
|
||||
limit=limit,
|
||||
)
|
||||
}
|
||||
|
||||
@app.get("/api/negotiations/unread-count")
|
||||
async def negotiations_unread_count() -> dict:
|
||||
return {"unread_count": runtime["negotiation_sync"].unread_count()}
|
||||
|
||||
@app.post("/api/negotiations/refresh-all")
|
||||
async def negotiations_refresh_all() -> dict:
|
||||
result = await runtime["negotiation_sync"].refresh_negotiations(seed_open_messages=True)
|
||||
return result
|
||||
|
||||
@app.get("/api/negotiations/{identifier}")
|
||||
async def negotiation_detail(identifier: str) -> dict:
|
||||
negotiation_sync = runtime["negotiation_sync"]
|
||||
negotiation = negotiation_sync.get_negotiation(identifier, mark_read=True)
|
||||
if not negotiation:
|
||||
raise HTTPException(status_code=404, detail="Negotiation not found.")
|
||||
return {"negotiation": negotiation}
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/refresh")
|
||||
async def refresh_negotiation(identifier: str) -> dict:
|
||||
negotiation_sync = runtime["negotiation_sync"]
|
||||
result = await negotiation_sync.refresh_negotiation(identifier, mark_read=False)
|
||||
return {"negotiation": negotiation_sync.get_negotiation(identifier, mark_read=False), "refreshed": result.refreshed}
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/open-chat")
|
||||
async def open_negotiation_chat(identifier: str) -> dict:
|
||||
negotiation_sync = runtime["negotiation_sync"]
|
||||
negotiation = negotiation_sync.get_negotiation(identifier, mark_read=False)
|
||||
if not negotiation:
|
||||
raise HTTPException(status_code=404, detail="Negotiation not found.")
|
||||
thread = memory.create_thread(negotiation.get("title") or f"Negotiation {identifier}")
|
||||
context = {
|
||||
"hash": negotiation.get("hash"),
|
||||
"title": negotiation.get("title"),
|
||||
"counterparty_username": negotiation.get("counterparty_username"),
|
||||
"status": negotiation.get("status"),
|
||||
"unread_count": negotiation.get("unread_count"),
|
||||
"last_message_at": negotiation.get("last_message_at"),
|
||||
"recent_messages": [
|
||||
{
|
||||
"author_username": item.get("author_username"),
|
||||
"is_me": item.get("is_me"),
|
||||
"body": item.get("body"),
|
||||
"sent_at": item.get("sent_at"),
|
||||
}
|
||||
for item in (negotiation.get("messages") or [])[-8:]
|
||||
],
|
||||
}
|
||||
memory.add_conversation(
|
||||
"assistant",
|
||||
"Negotiation context loaded:\n" + json.dumps(context, ensure_ascii=True, indent=2),
|
||||
thread["id"],
|
||||
)
|
||||
return {"chat": thread, "negotiation": negotiation}
|
||||
|
||||
@app.get("/api/negotiations/{identifier}/messages")
|
||||
async def negotiation_messages(identifier: str) -> dict:
|
||||
negotiation_sync = runtime["negotiation_sync"]
|
||||
negotiation = negotiation_sync.get_negotiation(identifier, mark_read=True)
|
||||
if negotiation:
|
||||
return {"messages": negotiation.get("messages", []), "negotiation": negotiation}
|
||||
uex = runtime["uex"]
|
||||
params = negotiation_identifier_params(identifier)
|
||||
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/messages")
|
||||
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
||||
uex = runtime["uex"]
|
||||
params = negotiation_identifier_params(identifier)
|
||||
payload = {**params, "message": request.message, "is_production": 1}
|
||||
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/messages/manual")
|
||||
async def send_negotiation_message_manual(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
||||
result = await runtime["negotiation_sync"].manual_send_message(identifier, request.message)
|
||||
return {
|
||||
**result,
|
||||
"message": "Sent",
|
||||
"negotiation": runtime["negotiation_sync"].get_negotiation(identifier, mark_read=True),
|
||||
}
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/messages/draft")
|
||||
async def draft_negotiation_message(identifier: str, request: NegotiationDraftMessageRequest) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.draft_negotiation_message(hash=identifier, message=request.message)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/close/draft")
|
||||
async def draft_negotiation_close(identifier: str, request: NegotiationCloseRequest) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.draft_negotiation_close(hash=identifier, **request.model_dump())
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/close/manual")
|
||||
async def close_negotiation_manual(identifier: str, request: NegotiationCloseRequest) -> dict:
|
||||
result = await runtime["negotiation_sync"].manual_close_negotiation(identifier, request.model_dump())
|
||||
return {
|
||||
**result,
|
||||
"message": "Deal submitted",
|
||||
"negotiation": runtime["negotiation_sync"].get_negotiation(identifier, mark_read=True),
|
||||
}
|
||||
|
||||
@app.get("/api/wake-jobs")
|
||||
async def wake_jobs() -> dict:
|
||||
return {"scheduled_jobs": scheduler.list_jobs()}
|
||||
@@ -412,6 +638,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans")
|
||||
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.create_continual_plan(
|
||||
title=request.title,
|
||||
objective=request.objective,
|
||||
@@ -424,6 +651,18 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
@app.post("/api/plans/draft")
|
||||
async def draft_continual_plan(request: ContinualPlanDraftRequest) -> dict:
|
||||
agent = runtime["agent"]
|
||||
draft = await agent.generate_plan_draft(
|
||||
title=request.title,
|
||||
objective=request.objective,
|
||||
kind=request.kind,
|
||||
constraints=request.constraints,
|
||||
items=[item.model_dump() for item in request.items],
|
||||
)
|
||||
return {"draft": draft}
|
||||
|
||||
@app.get("/api/plans/{plan_id}")
|
||||
async def continual_plan(plan_id: str) -> dict:
|
||||
plan = plan_store.get_plan(plan_id)
|
||||
@@ -433,6 +672,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans/{plan_id}/pause")
|
||||
async def pause_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.pause_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
@@ -440,6 +680,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans/{plan_id}/resume")
|
||||
async def resume_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.resume_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
@@ -447,13 +688,23 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans/{plan_id}/cancel")
|
||||
async def cancel_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.cancel_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
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")
|
||||
async def run_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.run_continual_plan_now(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
@@ -487,10 +738,12 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/approve/{action_id}")
|
||||
async def approve(action_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
return await tools.approve(action_id)
|
||||
|
||||
@app.post("/api/decline/{action_id}")
|
||||
async def decline(action_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
return await tools.decline(action_id)
|
||||
|
||||
return app
|
||||
@@ -509,33 +762,112 @@ async def inspect_model_provider() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if settings.model_provider == "openai":
|
||||
return await inspect_openai()
|
||||
if settings.model_provider == "deepseek":
|
||||
return await inspect_deepseek()
|
||||
if settings.model_provider == "codex":
|
||||
return await inspect_codex()
|
||||
return await inspect_ollama()
|
||||
|
||||
|
||||
async def inspect_openai() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
|
||||
|
||||
|
||||
async def inspect_deepseek() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return await inspect_cloud_provider_config(
|
||||
"deepseek",
|
||||
settings.deepseek_base_url,
|
||||
settings.deepseek_api_key,
|
||||
settings.deepseek_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()
|
||||
if settings.model_provider == "deepseek":
|
||||
return await inspect_deepseek()
|
||||
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()
|
||||
if normalized == "deepseek":
|
||||
return await inspect_deepseek()
|
||||
return await inspect_openai()
|
||||
|
||||
|
||||
async def inspect_cloud_provider_config(
|
||||
provider: str,
|
||||
base_url: str,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
models: list[str] = []
|
||||
online = False
|
||||
detail = ""
|
||||
if not settings.openai_api_key:
|
||||
provider_name = provider_display_name(provider)
|
||||
if not api_key:
|
||||
return {
|
||||
"installed": True,
|
||||
"running": False,
|
||||
"online": False,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": False,
|
||||
"configured_model": settings.openai_model,
|
||||
"base_url": settings.openai_base_url,
|
||||
"configured_model": model,
|
||||
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
|
||||
"reasoning_efforts": provider_reasoning_efforts(provider, model),
|
||||
"base_url": base_url,
|
||||
"models": [],
|
||||
"message": "OpenAI is selected, but no API key is configured.",
|
||||
"message": f"{provider_name} is selected, but no API key is configured.",
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(
|
||||
f"{settings.openai_base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {settings.openai_api_key}"},
|
||||
f"{base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
@@ -544,17 +876,19 @@ async def inspect_openai() -> dict[str, Any]:
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
detail = str(exc)
|
||||
|
||||
model_available = settings.openai_model in models
|
||||
model_available = model in models
|
||||
return {
|
||||
"installed": True,
|
||||
"running": online,
|
||||
"online": online,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": model_available,
|
||||
"configured_model": settings.openai_model,
|
||||
"base_url": settings.openai_base_url,
|
||||
"configured_model": model,
|
||||
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
|
||||
"reasoning_efforts": provider_reasoning_efforts(provider, model),
|
||||
"base_url": base_url,
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -587,6 +921,8 @@ async def inspect_ollama() -> dict[str, Any]:
|
||||
"provider": "ollama",
|
||||
"model_available": model_available,
|
||||
"configured_model": settings.ollama_model,
|
||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||
"reasoning_efforts": reasoning_effort_options(),
|
||||
"base_url": settings.ollama_base_url,
|
||||
"num_ctx": settings.ollama_num_ctx,
|
||||
"models": models,
|
||||
@@ -599,14 +935,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:
|
||||
return "OpenAI API key is not configured."
|
||||
return f"{provider_name} API key is not configured."
|
||||
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:
|
||||
return f'OpenAI is reachable, but model "{model}" was not returned by the API.'
|
||||
return "OpenAI is ready."
|
||||
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
|
||||
return f"{provider_name} is ready."
|
||||
|
||||
|
||||
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
|
||||
@@ -619,6 +956,316 @@ def ollama_status_message(installed: bool, running: bool, model_available: bool,
|
||||
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 == "deepseek":
|
||||
return settings.deepseek_base_url, settings.deepseek_model, settings.deepseek_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", "deepseek": "DeepSeek", "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 deepseek_reasoning_efforts(model: str) -> list[str]:
|
||||
supported_models = {"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"}
|
||||
return ["none", "high", "max"] if model in supported_models else ["none", "high"]
|
||||
|
||||
|
||||
def provider_reasoning_efforts(provider: str, model: str) -> list[str]:
|
||||
if provider == "deepseek":
|
||||
return deepseek_reasoning_efforts(model)
|
||||
return reasoning_effort_options()
|
||||
|
||||
|
||||
def canonical_provider_reasoning_effort(provider: str, effort: str) -> str:
|
||||
normalized = str(effort or "medium").strip().casefold()
|
||||
if provider != "deepseek":
|
||||
return normalized
|
||||
if normalized in {"none", "minimal"}:
|
||||
return "none"
|
||||
if normalized in {"xhigh", "max"}:
|
||||
return "max"
|
||||
return "high"
|
||||
|
||||
|
||||
def find_ollama_executable() -> Path | None:
|
||||
candidates = [
|
||||
shutil.which("ollama"),
|
||||
@@ -671,6 +1318,13 @@ def popen_hidden(command: list[str]) -> subprocess.Popen:
|
||||
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]:
|
||||
try:
|
||||
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
|
||||
+591
-3
@@ -8,9 +8,12 @@ from typing import Any, Awaitable, Callable
|
||||
|
||||
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.negotiations import UEX_NEGOTIATION_CLOSE_ENDPOINT
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
||||
from traderai.uex_client import UEXClient
|
||||
from traderai.wikelo_projects_client import WikeloProjectsClient
|
||||
|
||||
|
||||
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
|
||||
@@ -58,10 +61,14 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
|
||||
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
|
||||
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
|
||||
"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_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_history": {
|
||||
"params": [
|
||||
@@ -83,7 +90,11 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
|
||||
"group": "marketplace",
|
||||
"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"},
|
||||
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
|
||||
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
|
||||
@@ -140,6 +151,7 @@ UEX_RESOURCE_DESCRIPTIONS = {
|
||||
UEX_PRODUCTION_WRITE_RESOURCES = {
|
||||
"marketplace_advertise",
|
||||
"marketplace_negotiations_messages",
|
||||
UEX_NEGOTIATION_CLOSE_ENDPOINT,
|
||||
}
|
||||
|
||||
|
||||
@@ -162,25 +174,37 @@ class ToolRegistry:
|
||||
scheduler: WakeScheduler | None = None,
|
||||
scmdb: SCMDBClient | None = None,
|
||||
cornerstone: CornerstoneClient | None = None,
|
||||
scwiki: StarCitizenWikiClient | None = None,
|
||||
wikelo: WikeloProjectsClient | None = None,
|
||||
plan_store: Any | None = None,
|
||||
plan_runner: Any | None = None,
|
||||
negotiation_sync: Any | None = None,
|
||||
) -> None:
|
||||
self.uex = uex
|
||||
self.scmdb = scmdb or SCMDBClient()
|
||||
self.cornerstone = cornerstone or CornerstoneClient()
|
||||
self.scwiki = scwiki or StarCitizenWikiClient()
|
||||
self.wikelo = wikelo or WikeloProjectsClient()
|
||||
self.require_write_approval = require_write_approval
|
||||
self.memory = memory
|
||||
self.scheduler = scheduler
|
||||
self.plan_store = plan_store
|
||||
self.plan_runner = plan_runner
|
||||
self.negotiation_sync = negotiation_sync
|
||||
self.pending_actions: dict[str, PendingAction] = {}
|
||||
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
|
||||
self.handlers: dict[str, ToolHandler] = {
|
||||
"search_marketplace_listings": self.search_marketplace_listings,
|
||||
"get_marketplace_listing": self.get_marketplace_listing,
|
||||
"get_marketplace_trends": self.get_marketplace_trends,
|
||||
"list_marketplace_negotiations": self.list_marketplace_negotiations,
|
||||
"get_negotiation_messages": self.get_negotiation_messages,
|
||||
"draft_negotiation_message": self.draft_negotiation_message,
|
||||
"list_local_negotiations": self.list_local_negotiations,
|
||||
"get_local_negotiation": self.get_local_negotiation,
|
||||
"search_local_negotiation_messages": self.search_local_negotiation_messages,
|
||||
"draft_negotiation_close": self.draft_negotiation_close,
|
||||
"draft_negotiation_rating": self.draft_negotiation_rating,
|
||||
"draft_marketplace_listing": self.draft_marketplace_listing,
|
||||
"remember_user_fact": self.remember_user_fact,
|
||||
"recall_memory": self.recall_memory,
|
||||
@@ -192,11 +216,18 @@ class ToolRegistry:
|
||||
"pause_continual_plan": self.pause_continual_plan,
|
||||
"resume_continual_plan": self.resume_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,
|
||||
"check_uex_notifications": self.check_uex_notifications,
|
||||
"list_scmdb_versions": self.list_scmdb_versions,
|
||||
"search_scmdb_missions": self.search_scmdb_missions,
|
||||
"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_wikelo_ship_projects": self.search_wikelo_ship_projects,
|
||||
"get_wikelo_ship_project": self.get_wikelo_ship_project,
|
||||
"search_cornerstone_items": self.search_cornerstone_items,
|
||||
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
||||
"get_cornerstone_item_media": self.get_cornerstone_item_media,
|
||||
@@ -226,6 +257,8 @@ class ToolRegistry:
|
||||
*self._uex_post_schemas(),
|
||||
*self._uex_delete_schemas(),
|
||||
*self._scmdb_schemas(),
|
||||
*self._scwiki_schemas(),
|
||||
*self._wikelo_schemas(),
|
||||
*self._cornerstone_schemas(),
|
||||
{
|
||||
"type": "function",
|
||||
@@ -261,6 +294,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",
|
||||
"function": {
|
||||
@@ -312,6 +363,97 @@ class ToolRegistry:
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_local_negotiations",
|
||||
"description": "List locally synced UEX negotiations with unread and status details.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["all", "open", "closed"]},
|
||||
"unread_only": {"type": "boolean"},
|
||||
"search": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_local_negotiation",
|
||||
"description": "Get a locally synced UEX negotiation with compact metadata and recent messages.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hash": {"type": "string"},
|
||||
},
|
||||
"required": ["hash"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_local_negotiation_messages",
|
||||
"description": "Search locally cached negotiation message text so the assistant can reference prior UEX conversations without re-fetching them.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 20},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "draft_negotiation_close",
|
||||
"description": "Draft closing or rating a UEX negotiation. This creates a pending action that must be approved before sending.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hash": {"type": "string"},
|
||||
"id_negotiation": {"type": "integer"},
|
||||
"deal_closed": {"type": "boolean"},
|
||||
"deal_value": {"type": "number"},
|
||||
"currency": {"type": "string"},
|
||||
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"comment": {"type": "string"},
|
||||
},
|
||||
"required": ["deal_closed"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "draft_negotiation_rating",
|
||||
"description": "Alias for drafting a UEX negotiation close/rating action.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hash": {"type": "string"},
|
||||
"id_negotiation": {"type": "integer"},
|
||||
"deal_closed": {"type": "boolean"},
|
||||
"deal_value": {"type": "number"},
|
||||
"currency": {"type": "string"},
|
||||
"clarity_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"speed_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"respect_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"fairness_rating": {"type": "integer", "minimum": 1, "maximum": 5},
|
||||
"comment": {"type": "string"},
|
||||
},
|
||||
"required": ["deal_closed"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -480,6 +622,14 @@ class ToolRegistry:
|
||||
"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",
|
||||
"function": {
|
||||
@@ -965,6 +1115,101 @@ 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
|
||||
def _wikelo_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_wikelo_ship_projects",
|
||||
"description": "Search Wikelo ship projects and their required materials from wikelo-projects.com. Use this when the user asks for Wikelo ship requirements or build materials.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Ship or project name to search for, such as Polaris, Idris, Zeus, or Guardian."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_wikelo_ship_project",
|
||||
"description": "Fetch one Wikelo ship project with its required materials and contribution progress.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Wikelo ship project id."},
|
||||
"ship_name": {"type": "string", "description": "Ship or project name if the project id is not known."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
@@ -1213,6 +1458,49 @@ class ToolRegistry:
|
||||
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
|
||||
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(
|
||||
self,
|
||||
id: int | None = None,
|
||||
@@ -1230,6 +1518,37 @@ class ToolRegistry:
|
||||
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
|
||||
return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True)
|
||||
|
||||
async def list_local_negotiations(
|
||||
self,
|
||||
status: str = "all",
|
||||
unread_only: bool = False,
|
||||
search: str = "",
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
if self.negotiation_sync is None:
|
||||
return {"error": "Negotiation sync is not configured."}
|
||||
negotiations = self.negotiation_sync.list_negotiations(
|
||||
status=status,
|
||||
unread_only=unread_only,
|
||||
search=search,
|
||||
limit=limit,
|
||||
)
|
||||
return {"count": len(negotiations), "negotiations": negotiations}
|
||||
|
||||
async def get_local_negotiation(self, hash: str) -> dict[str, Any]:
|
||||
if self.negotiation_sync is None:
|
||||
return {"error": "Negotiation sync is not configured."}
|
||||
negotiation = self.negotiation_sync.get_negotiation(hash, mark_read=False)
|
||||
if not negotiation:
|
||||
return {"error": f"Negotiation not found: {hash}"}
|
||||
return {"negotiation": negotiation}
|
||||
|
||||
async def search_local_negotiation_messages(self, query: str, limit: int = 8) -> dict[str, Any]:
|
||||
if self.negotiation_sync is None:
|
||||
return {"error": "Negotiation sync is not configured."}
|
||||
matches = self.negotiation_sync.search_messages(query, limit=limit)
|
||||
return {"count": len(matches), "matches": matches}
|
||||
|
||||
async def draft_negotiation_message(
|
||||
self,
|
||||
message: str,
|
||||
@@ -1265,6 +1584,41 @@ class ToolRegistry:
|
||||
metadata=attached_image.get("metadata"),
|
||||
)
|
||||
|
||||
async def draft_negotiation_close(
|
||||
self,
|
||||
deal_closed: bool,
|
||||
hash: str | None = None,
|
||||
id_negotiation: int | None = None,
|
||||
deal_value: float | None = None,
|
||||
currency: str | None = None,
|
||||
clarity_rating: int | None = None,
|
||||
speed_rating: int | None = None,
|
||||
respect_rating: int | None = None,
|
||||
fairness_rating: int | None = None,
|
||||
comment: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"hash": hash,
|
||||
"id_negotiation": id_negotiation,
|
||||
"deal_closed": 1 if deal_closed else 0,
|
||||
"deal_value": deal_value,
|
||||
"currency": currency,
|
||||
"clarity_rating": clarity_rating,
|
||||
"speed_rating": speed_rating,
|
||||
"respect_rating": respect_rating,
|
||||
"fairness_rating": fairness_rating,
|
||||
"comment": comment,
|
||||
}
|
||||
metadata = {
|
||||
"hash": hash,
|
||||
"id_negotiation": id_negotiation,
|
||||
"kind": "negotiation_close",
|
||||
}
|
||||
return self._pending("Close negotiation", UEX_NEGOTIATION_CLOSE_ENDPOINT, payload, metadata=metadata)
|
||||
|
||||
async def draft_negotiation_rating(self, **payload: Any) -> dict[str, Any]:
|
||||
return await self.draft_negotiation_close(**payload)
|
||||
|
||||
async def draft_marketplace_listing_with_cornerstone_image(
|
||||
self,
|
||||
item_query: str,
|
||||
@@ -1405,6 +1759,19 @@ class ToolRegistry:
|
||||
self.scheduler.unschedule_plan(plan_id)
|
||||
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]:
|
||||
if self.plan_runner is None:
|
||||
return {"error": "Continual plan runner is not configured."}
|
||||
@@ -1535,6 +1902,94 @@ class ToolRegistry:
|
||||
"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_wikelo_ship_projects(self, query: str, limit: int = 5) -> dict[str, Any]:
|
||||
projects = await self.wikelo.list_ship_projects()
|
||||
q = (query or "").casefold().strip()
|
||||
matches = []
|
||||
for project in projects:
|
||||
score = self._wikelo_ship_match_score(q, project)
|
||||
if q and score <= 0:
|
||||
continue
|
||||
matches.append((score, project))
|
||||
matches.sort(
|
||||
key=lambda match: (
|
||||
-match[0],
|
||||
str(match[1].get("ship_name") or "").casefold(),
|
||||
str(match[1].get("id") or ""),
|
||||
)
|
||||
)
|
||||
limit = max(1, min(limit, 10))
|
||||
return {
|
||||
"source": f"{self.wikelo.base_url}/Ships",
|
||||
"query": query,
|
||||
"matched": len(matches),
|
||||
"projects": [self._summarize_wikelo_ship_project(item) for _, item in matches[:limit]],
|
||||
}
|
||||
|
||||
async def get_wikelo_ship_project(self, project_id: str | None = None, ship_name: str | None = None) -> dict[str, Any]:
|
||||
projects = await self.wikelo.list_ship_projects()
|
||||
if project_id:
|
||||
for project in projects:
|
||||
if str(project.get("id") or "").strip() == str(project_id).strip():
|
||||
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(project, detailed=True)}
|
||||
return {"error": "No Wikelo ship project matched that id."}
|
||||
|
||||
if not ship_name:
|
||||
return {"error": "Provide project_id or ship_name."}
|
||||
|
||||
ranked = [
|
||||
(self._wikelo_ship_match_score(ship_name.casefold().strip(), project), project)
|
||||
for project in projects
|
||||
]
|
||||
ranked = [match for match in ranked if match[0] > 0]
|
||||
ranked.sort(key=lambda match: (-match[0], str(match[1].get("ship_name") or "").casefold()))
|
||||
if not ranked:
|
||||
return {"error": "No Wikelo ship project matched."}
|
||||
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(ranked[0][1], detailed=True)}
|
||||
|
||||
async def search_cornerstone_items(
|
||||
self,
|
||||
query: str = "",
|
||||
@@ -2210,6 +2665,139 @@ class ToolRegistry:
|
||||
"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"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _wikelo_ship_match_score(query: str, project: dict[str, Any]) -> int:
|
||||
if not query:
|
||||
return 1
|
||||
ship_name = str(project.get("ship_name") or "").casefold()
|
||||
description = str(project.get("description") or "").casefold()
|
||||
materials = " ".join(
|
||||
str(item.get("material_name") or "").casefold()
|
||||
for item in (project.get("required_materials") or [])
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
haystack = " ".join(part for part in [ship_name, description, materials] if part)
|
||||
if ship_name == query:
|
||||
return 10000
|
||||
if query in ship_name:
|
||||
return 9000 - ship_name.index(query)
|
||||
if query in description:
|
||||
return 7000 - description.index(query)
|
||||
if query in materials:
|
||||
return 5000 - materials.index(query)
|
||||
tokens = [token for token in query.split() if token]
|
||||
if tokens and all(token in haystack for token in tokens):
|
||||
return 3000 - len(haystack)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _summarize_wikelo_ship_project(cls, project: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
|
||||
materials = []
|
||||
for item in (project.get("required_materials") or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
quantity_needed = item.get("quantity_needed")
|
||||
quantity_collected = item.get("quantity_collected")
|
||||
materials.append(
|
||||
{
|
||||
"material_name": item.get("material_name"),
|
||||
"quantity_needed": int(quantity_needed) if isinstance(quantity_needed, (int, float)) and float(quantity_needed).is_integer() else quantity_needed,
|
||||
"quantity_collected": int(quantity_collected) if isinstance(quantity_collected, (int, float)) and float(quantity_collected).is_integer() else quantity_collected,
|
||||
}
|
||||
)
|
||||
summary = {
|
||||
"id": project.get("id"),
|
||||
"ship_name": project.get("ship_name"),
|
||||
"description": project.get("description"),
|
||||
"status": project.get("status"),
|
||||
"privacy": project.get("privacy"),
|
||||
"owner_name": project.get("owner_name"),
|
||||
"org_name": project.get("org_name"),
|
||||
"home_port": project.get("home_port"),
|
||||
"ship_image": project.get("ship_image"),
|
||||
"materials_count": len(materials),
|
||||
"required_materials": materials if detailed else materials[:12],
|
||||
"source_url": f"https://wikelo-projects.com/Ships",
|
||||
}
|
||||
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
|
||||
|
||||
@classmethod
|
||||
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
|
||||
summary = cls._project_item(negotiation, mode="summary")
|
||||
|
||||
+96
-1
@@ -10,10 +10,17 @@ class UEXError(RuntimeError):
|
||||
|
||||
|
||||
class UEXClient:
|
||||
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
secret_key: str | None = None,
|
||||
bearer_token: str | None = None,
|
||||
negotiation_close_endpoint: str = "marketplace_negotiations_close",
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.secret_key = secret_key
|
||||
self.bearer_token = bearer_token
|
||||
self.negotiation_close_endpoint = negotiation_close_endpoint.strip().strip("/") or "marketplace_negotiations_close"
|
||||
|
||||
def _headers(self, authenticated: bool = False) -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
@@ -49,6 +56,94 @@ class UEXClient:
|
||||
data = [data]
|
||||
return {"status": body.get("status"), "notifications": data}
|
||||
|
||||
async def list_negotiations(
|
||||
self,
|
||||
id: int | None = None,
|
||||
id_listing: int | None = None,
|
||||
hash: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body = await self.get(
|
||||
"marketplace_negotiations",
|
||||
{"id": id, "id_listing": id_listing, "hash": hash},
|
||||
authenticated=True,
|
||||
)
|
||||
data = body.get("data") or []
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
return {"status": body.get("status"), "negotiations": data}
|
||||
|
||||
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
|
||||
body = await self.get(
|
||||
"marketplace_negotiations_messages",
|
||||
{"hash": hash, "id_negotiation": id_negotiation},
|
||||
authenticated=True,
|
||||
)
|
||||
data = body.get("data") or []
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
return {"status": body.get("status"), "messages": data}
|
||||
|
||||
async def send_negotiation_message(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
hash: str | None = None,
|
||||
id_negotiation: int | None = None,
|
||||
is_production: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
return await self.post(
|
||||
"marketplace_negotiations_messages",
|
||||
{
|
||||
"hash": hash,
|
||||
"id_negotiation": id_negotiation,
|
||||
"message": message,
|
||||
"is_production": is_production,
|
||||
},
|
||||
authenticated=True,
|
||||
)
|
||||
|
||||
async def close_negotiation(
|
||||
self,
|
||||
*,
|
||||
hash: str | None = None,
|
||||
id_negotiation: int | None = None,
|
||||
deal_closed: bool,
|
||||
deal_value: float | None = None,
|
||||
currency: str | None = None,
|
||||
clarity_rating: int | None = None,
|
||||
speed_rating: int | None = None,
|
||||
respect_rating: int | None = None,
|
||||
fairness_rating: int | None = None,
|
||||
comment: str | None = None,
|
||||
is_production: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"hash": hash,
|
||||
"id_negotiation": id_negotiation,
|
||||
"deal_closed": 1 if deal_closed else 0,
|
||||
"deal_value": deal_value,
|
||||
"currency": currency,
|
||||
"clarity_rating": clarity_rating,
|
||||
"speed_rating": speed_rating,
|
||||
"respect_rating": respect_rating,
|
||||
"fairness_rating": fairness_rating,
|
||||
"comment": comment,
|
||||
"is_production": is_production,
|
||||
}
|
||||
try:
|
||||
return await self.post(
|
||||
self.negotiation_close_endpoint,
|
||||
payload,
|
||||
authenticated=True,
|
||||
)
|
||||
except UEXError as exc:
|
||||
raise UEXError(
|
||||
"UEX negotiation close failed via endpoint "
|
||||
f"`{self.negotiation_close_endpoint}`. If UEX changed this route, set "
|
||||
"`UEX_NEGOTIATION_CLOSE_ENDPOINT` to the correct endpoint and retry. "
|
||||
f"Original error: {exc}"
|
||||
) from exc
|
||||
|
||||
async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.0.6"
|
||||
__version__ = "0.0.9"
|
||||
|
||||
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||
@@ -12,3 +12,7 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class WikeloProjectsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WikeloProjectsClient:
|
||||
APP_ID = "695be2905c0b4866dfb21265"
|
||||
|
||||
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
async def list_ship_projects(self) -> list[dict[str, Any]]:
|
||||
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
|
||||
if not isinstance(body, list):
|
||||
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
|
||||
return [item for item in body if isinstance(item, dict)]
|
||||
|
||||
async def _get_json(self, url: str) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
response = await client.get(url, headers={"Accept": "application/json"})
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
|
||||
return body
|
||||
File diff suppressed because one or more lines are too long
@@ -755,7 +755,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "traderai"
|
||||
version = "0.0.6"
|
||||
version = "0.0.9"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -1052,3 +1052,7 @@ wheels = [
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+659
-75
File diff suppressed because it is too large
Load Diff
+166
-40
@@ -25,6 +25,20 @@
|
||||
<div class="rail-heading">Chats</div>
|
||||
<div class="chat-list" id="chat-list"></div>
|
||||
</section>
|
||||
<section class="chat-nav-section">
|
||||
<div class="rail-heading-row">
|
||||
<div class="rail-heading">Negotiations</div>
|
||||
<div class="rail-heading-actions">
|
||||
<button class="rail-icon-button" id="negotiations-refresh-all" type="button" title="Refresh all negotiations">
|
||||
<i data-lucide="refresh-cw" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="rail-icon-button" id="negotiations-toggle" type="button" title="Negotiations" aria-expanded="false" aria-controls="negotiation-panel">
|
||||
<i data-lucide="messages-square" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plans-rail-list" id="negotiation-list"></div>
|
||||
</section>
|
||||
<section class="chat-nav-section">
|
||||
<div class="rail-heading-row">
|
||||
<div class="rail-heading">Plans</div>
|
||||
@@ -82,6 +96,7 @@
|
||||
<label>UEX API URL<input id="config-uex-base-url" name="uex_base_url" type="text"></label>
|
||||
<label>UEX Secret Key<input id="config-uex-secret-key" name="uex_secret_key" type="password" autocomplete="off"></label>
|
||||
<label>UEX Bearer Token<input id="config-uex-bearer-token" name="uex_bearer_token" type="password" autocomplete="off"></label>
|
||||
<label>UEX Close Endpoint<input id="config-uex-negotiation-close-endpoint" name="uex_negotiation_close_endpoint" type="text"></label>
|
||||
<label>UEX Username<input id="config-traderai-user-name" name="traderai_user_name" type="text"></label>
|
||||
<label>Memory DB Path<input id="config-traderai-memory-path" name="traderai_memory_path" type="text"></label>
|
||||
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
|
||||
@@ -119,22 +134,24 @@
|
||||
</div>
|
||||
<div class="sidebar-panel" id="ollama-panel" hidden>
|
||||
<div class="section-title-row">
|
||||
<h2>Model Provider</h2>
|
||||
<h2>Inference</h2>
|
||||
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
||||
</div>
|
||||
<form class="config-form" id="ollama-config-form">
|
||||
<label>Provider
|
||||
<select id="model-provider" name="model_provider">
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="deepseek">DeepSeek V4 (Recommended)</option>
|
||||
<option value="ollama">Local Ollama</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>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>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>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="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
|
||||
<label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
|
||||
<label data-provider-scope="deepseek" data-manual-model="true">DeepSeek Model<input id="deepseek-model" name="deepseek_model" type="text" list="provider-models"></label>
|
||||
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||
<label><span id="provider-model-label">Model</span><select id="provider-model-select"></select></label>
|
||||
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
|
||||
<datalist id="provider-models"></datalist>
|
||||
<button type="submit">Save Provider Config</button>
|
||||
</form>
|
||||
@@ -144,7 +161,6 @@
|
||||
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
||||
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
||||
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
||||
<button class="secondary small-button" id="openai-models-refresh" type="button">Load OpenAI Models</button>
|
||||
</div>
|
||||
<div class="config-status" id="ollama-message"></div>
|
||||
</div>
|
||||
@@ -157,10 +173,10 @@
|
||||
<i data-lucide="brain" aria-hidden="true"></i>
|
||||
<span>Memory</span>
|
||||
</button>
|
||||
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
|
||||
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Inference">
|
||||
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
|
||||
<i data-lucide="bot" aria-hidden="true"></i>
|
||||
<span>Ollama</span>
|
||||
<span>Inference</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -169,19 +185,107 @@
|
||||
<div class="floating-panel" id="negotiation-panel" hidden>
|
||||
<div class="floating-panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">UEX negotiation</p>
|
||||
<h2 id="negotiation-title">Negotiation</h2>
|
||||
<p class="eyebrow">UEX negotiations</p>
|
||||
<h2 id="negotiation-title">Negotiation workspace</h2>
|
||||
</div>
|
||||
<div class="floating-panel-actions">
|
||||
<div class="negotiation-sync-pill" id="negotiation-sync-pill">Local sync</div>
|
||||
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="negotiation-messages" id="negotiation-messages"></div>
|
||||
<form class="negotiation-composer" id="negotiation-form">
|
||||
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div class="config-status" id="negotiation-status"></div>
|
||||
<div class="negotiation-workspace">
|
||||
<aside class="negotiation-sidebar">
|
||||
<div class="negotiation-sidebar-controls">
|
||||
<input id="negotiation-search" type="text" placeholder="Search negotiations">
|
||||
<select id="negotiation-filter">
|
||||
<option value="open">Open</option>
|
||||
<option value="all">All</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="negotiation-list-panel" id="negotiation-panel-list"></div>
|
||||
</aside>
|
||||
<section class="negotiation-thread-shell">
|
||||
<div class="negotiation-thread-header" id="negotiation-thread-header">
|
||||
<div class="muted">Select a negotiation to load the local thread.</div>
|
||||
</div>
|
||||
<div class="negotiation-messages" id="negotiation-messages"></div>
|
||||
<form class="negotiation-composer" id="negotiation-form">
|
||||
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
||||
<div class="negotiation-composer-actions">
|
||||
<button class="secondary small-button" id="negotiation-draft-button" type="button">Ask AI to Draft</button>
|
||||
<button type="submit">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="config-status" id="negotiation-status"></div>
|
||||
</section>
|
||||
<aside class="negotiation-detail-rail">
|
||||
<div class="negotiation-detail-card" id="negotiation-meta-card">
|
||||
<h3>Deal</h3>
|
||||
<div class="muted">No negotiation selected.</div>
|
||||
</div>
|
||||
<div class="negotiation-detail-card" id="negotiation-user-card">
|
||||
<h3>User</h3>
|
||||
<div class="muted">No negotiation selected.</div>
|
||||
</div>
|
||||
<div class="negotiation-detail-card">
|
||||
<h3>Actions</h3>
|
||||
<div class="negotiation-action-stack">
|
||||
<button class="secondary small-button" id="negotiation-open-chat" type="button">Open in AI Chat</button>
|
||||
<button class="small-button" id="negotiation-refresh-button" type="button">Refresh Thread</button>
|
||||
<button class="danger-button small-button" id="negotiation-end-deal" type="button">End Deal</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="negotiation-close-modal" hidden>
|
||||
<section class="update-modal-card negotiation-close-card">
|
||||
<div class="section-title-row">
|
||||
<h2>End Deal</h2>
|
||||
<button class="icon-button light" id="negotiation-close-modal-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form class="config-form" id="negotiation-close-form">
|
||||
<label>Did you close a deal?
|
||||
<select id="close-deal-closed">
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="plan-form-split">
|
||||
<label>Deal value
|
||||
<input id="close-deal-value" type="number" min="0" step="1" placeholder="1000000">
|
||||
</label>
|
||||
<label>Currency
|
||||
<input id="close-currency" type="text" value="UEC">
|
||||
</label>
|
||||
</div>
|
||||
<label>Clear, timely, and honest?
|
||||
<input id="close-clarity" type="number" min="1" max="5" step="1" value="5">
|
||||
</label>
|
||||
<label>Delivery or response time?
|
||||
<input id="close-speed" type="number" min="1" max="5" step="1" value="5">
|
||||
</label>
|
||||
<label>Respectful and easy to deal with?
|
||||
<input id="close-respect" type="number" min="1" max="5" step="1" value="5">
|
||||
</label>
|
||||
<label>Price or offer fairness?
|
||||
<input id="close-fairness" type="number" min="1" max="5" step="1" value="5">
|
||||
</label>
|
||||
<label>Comments
|
||||
<textarea id="close-comment" rows="3" placeholder="Optional note"></textarea>
|
||||
</label>
|
||||
<div class="plan-form-actions">
|
||||
<button class="secondary" id="close-draft-button" type="button">Draft for Approval</button>
|
||||
<button type="submit">Rate Deal</button>
|
||||
</div>
|
||||
<div class="config-status" id="negotiation-close-status"></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
|
||||
<div class="floating-panel-header">
|
||||
@@ -199,23 +303,45 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="plans-panel-body">
|
||||
<form class="config-form" id="plan-form">
|
||||
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
|
||||
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
|
||||
<label>Kind
|
||||
<select id="plan-kind">
|
||||
<option value="buying">Buying</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
|
||||
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
|
||||
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
|
||||
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
|
||||
<button type="submit">Create Plan</button>
|
||||
<div class="config-status" id="plans-status"></div>
|
||||
</form>
|
||||
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||
<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>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
|
||||
<select id="plan-kind">
|
||||
<option value="buying">Buying</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</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>
|
||||
<div class="plan-form-actions">
|
||||
<button id="plan-autofill" type="button">AI Fill</button>
|
||||
<button type="submit">Create Plan</button>
|
||||
</div>
|
||||
<div class="config-status" id="plans-status"></div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="plans-dashboard-shell">
|
||||
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="update-modal" hidden>
|
||||
|
||||
+573
-33
@@ -105,7 +105,7 @@ body::before {
|
||||
|
||||
.chat-rail-content {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
|
||||
grid-template-rows: minmax(0, 1.7fr) minmax(72px, 0.45fr) minmax(92px, 0.65fr) minmax(120px, 0.95fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
@@ -139,6 +139,12 @@ body::before {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rail-heading-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rail-heading-row .rail-heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -962,6 +968,26 @@ button {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
#negotiation-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(1280px, calc(100vw - 28px));
|
||||
max-height: min(860px, calc(100vh - 56px));
|
||||
}
|
||||
|
||||
.negotiation-sync-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(240, 214, 129, 0.32);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 240, 0.12);
|
||||
color: var(--gold-2);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.floating-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -977,6 +1003,114 @@ button {
|
||||
color: var(--ivory);
|
||||
}
|
||||
|
||||
.negotiation-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 260px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.negotiation-sidebar,
|
||||
.negotiation-thread-shell,
|
||||
.negotiation-detail-rail {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.negotiation-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(255, 250, 240, 0.58);
|
||||
}
|
||||
|
||||
.negotiation-sidebar-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.negotiation-list-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.negotiation-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 253, 247, 0.86);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.negotiation-row.active {
|
||||
border-color: rgba(52, 83, 38, 0.42);
|
||||
background: #edf3df;
|
||||
}
|
||||
|
||||
.negotiation-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.negotiation-row-title {
|
||||
color: var(--forest);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.negotiation-row-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.negotiation-row-badge {
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #edf3df;
|
||||
color: var(--forest);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.negotiation-row-badge.closed {
|
||||
background: #efe6ce;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.negotiation-row-unread {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--forest);
|
||||
color: var(--ivory);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.negotiation-thread-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.negotiation-thread-header {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 253, 247, 0.82);
|
||||
}
|
||||
|
||||
.negotiation-messages {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
@@ -993,9 +1127,25 @@ button {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.negotiation-message.self {
|
||||
border-color: rgba(52, 83, 38, 0.28);
|
||||
background: #edf3df;
|
||||
}
|
||||
|
||||
.negotiation-message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.negotiation-composer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -1006,6 +1156,64 @@ button {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.negotiation-composer-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.negotiation-composer-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.negotiation-detail-rail {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: rgba(255, 250, 240, 0.52);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.negotiation-detail-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 253, 247, 0.86);
|
||||
}
|
||||
|
||||
.negotiation-detail-card h3 {
|
||||
margin: 0;
|
||||
color: var(--forest);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.negotiation-detail-kv {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.negotiation-detail-kv div {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.negotiation-detail-kv strong {
|
||||
color: var(--brown);
|
||||
}
|
||||
|
||||
.negotiation-action-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.negotiation-close-card {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1039,15 +1247,15 @@ button {
|
||||
|
||||
.plans-floating-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(680px, calc(100vw - 28px));
|
||||
width: min(980px, calc(100vw - 28px));
|
||||
}
|
||||
|
||||
.plans-panel-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1106,10 +1314,8 @@ button.secondary {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: auto;
|
||||
position: sticky;
|
||||
bottom: -28px;
|
||||
padding-bottom: 28px;
|
||||
background: linear-gradient(180deg, rgba(247, 241, 220, 0) 0%, var(--cream) 22%, var(--cream) 100%);
|
||||
padding-top: 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-tool-buttons {
|
||||
@@ -1119,6 +1325,12 @@ button.secondary {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
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 {
|
||||
@@ -1208,6 +1420,11 @@ button.secondary {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sidebar-panel .section-title-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1248,6 +1465,86 @@ button.secondary {
|
||||
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-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-form-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1482,36 +1779,175 @@ pre {
|
||||
|
||||
.plans-dashboard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
gap: 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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 250, 240, 0.82);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.92);
|
||||
border-radius: 20px;
|
||||
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 {
|
||||
border-color: rgba(52, 83, 38, 0.42);
|
||||
background: #edf3df;
|
||||
border-color: rgba(52, 83, 38, 0.32);
|
||||
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 {
|
||||
margin: 0;
|
||||
color: var(--forest);
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
font-size: 19px;
|
||||
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-line {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -1525,33 +1961,82 @@ pre {
|
||||
.plan-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(52, 83, 38, 0.24);
|
||||
min-height: 26px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(52, 83, 38, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 240, 0.8);
|
||||
background: rgba(255, 250, 240, 0.88);
|
||||
color: var(--forest);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
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 {
|
||||
flex: 1 1 80px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.plan-detail h4 {
|
||||
margin: 0;
|
||||
color: var(--forest);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
@@ -1563,15 +2048,29 @@ pre {
|
||||
}
|
||||
|
||||
.plan-list li {
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.72);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 253, 247, 0.72);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: var(--brown);
|
||||
font-size: 12px;
|
||||
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 {
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff9e9;
|
||||
@@ -1751,7 +2250,48 @@ pre {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.plans-panel-body {
|
||||
.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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.negotiation-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.negotiation-sidebar,
|
||||
.negotiation-detail-rail {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp" rel="icon" type="image/svg+xml"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<link href="/manifest.json" rel="manifest"/>
|
||||
<title>
|
||||
Ships | Wikelo Project Tracker
|
||||
</title>
|
||||
<script crossorigin="" src="/assets/index-DWqdqkK8.js" type="module">
|
||||
</script>
|
||||
<link crossorigin="" href="/assets/index-BzxCYXI2.css" rel="stylesheet"/>
|
||||
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="description"/>
|
||||
<meta content="Ships | Wikelo Project Tracker" property="og:title"/>
|
||||
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." property="og:description"/>
|
||||
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" property="og:image"/>
|
||||
<meta content="https://wikelo-projects.com/Ships" property="og:url"/>
|
||||
<meta content="website" property="og:type"/>
|
||||
<meta content="Wikelo Project Tracker" property="og:site_name"/>
|
||||
<meta content="Ships | Wikelo Project Tracker" name="twitter:title"/>
|
||||
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="twitter:description"/>
|
||||
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" name="twitter:image"/>
|
||||
<meta content="summary_large_image" name="twitter:card"/>
|
||||
<meta content="https://wikelo-projects.com/Ships" name="twitter:url"/>
|
||||
<meta content="yes" name="mobile-web-app-capable"/>
|
||||
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
|
||||
<meta content="Wikelo Project Tracker" name="apple-mobile-web-app-title"/>
|
||||
<link href="https://wikelo-projects.com/Ships" rel="canonical"/>
|
||||
<script data-seo-source="builder" type="application/ld+json">
|
||||
{"name": "Wikelo Project Tracker", "@context": "https://schema.org", "@type": "WebSite", "url": "https://wikelo-projects.com"}
|
||||
</script>
|
||||
<script data-seo-source="builder" type="application/ld+json">
|
||||
{"name": "Wikelo Project Tracker", "logo": "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp", "@context": "https://schema.org", "@type": "Organization", "url": "https://wikelo-projects.com"}
|
||||
</script>
|
||||
<script data-seo-source="builder" type="application/ld+json">
|
||||
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://wikelo-projects.com/"}, {"@type": "ListItem", "position": 2, "name": "Ships | Wikelo Project Tracker", "item": "https://wikelo-projects.com/Ships"}]}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div data-seo-source="builder" id="seo-snapshot" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
|
||||
<h1>
|
||||
Ships | Wikelo Project Tracker
|
||||
</h1>
|
||||
<p>
|
||||
Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships.
|
||||
</p>
|
||||
<nav aria-label="Pages">
|
||||
<h2>
|
||||
Pages
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/AdminAds">
|
||||
Admin Ads
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/AdvertiseWithUs">
|
||||
Advertise With Us
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/ArmorProjectDetails">
|
||||
Armor Project Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Armors">
|
||||
Armors
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/CleanupMaterials">
|
||||
Cleanup Materials
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Guide">
|
||||
Guide
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/">
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Inventory">
|
||||
Inventory
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Messages">
|
||||
Messages
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/NotificationSettings">
|
||||
Notification Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Notifications">
|
||||
Notifications
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/OrganizationDetails">
|
||||
Organization Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Organizations">
|
||||
Organizations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Profile">
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/ProjectDetails">
|
||||
Project Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/RecalculateContributionReputation">
|
||||
Recalculate Contribution Reputation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/RecalculateMaterials">
|
||||
Recalculate Materials
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/RecalculateReputation">
|
||||
Recalculate Reputation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Reputation">
|
||||
Reputation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/UpdateInfo">
|
||||
Update Info
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/WeaponProjectDetails">
|
||||
Weapon Project Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/Weapons">
|
||||
Weapons
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/WikeloProjectDetails">
|
||||
Wikelo Project Details
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user