From 8fac3d2bae3211c9c7c96cf1c01860d5e5978571 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Tue, 9 Jun 2026 11:24:15 -0400 Subject: [PATCH] feat: chat --- .env.example | 1 + tests/test_negotiations.py | 148 +++++++++++ tests/test_server.py | 1 + tests/test_tools.py | 11 + traderai/agent.py | 7 + traderai/config.py | 2 + traderai/memory.py | 529 +++++++++++++++++++++++++++++++++++++ traderai/negotiations.py | 248 +++++++++++++++++ traderai/scheduler.py | 11 +- traderai/server.py | 171 +++++++++++- traderai/tools.py | 166 ++++++++++++ traderai/uex_client.py | 97 ++++++- web/app.js | 315 +++++++++++++++++++++- web/index.html | 125 ++++++++- web/styles.css | 221 +++++++++++++++- 15 files changed, 2015 insertions(+), 38 deletions(-) create mode 100644 tests/test_negotiations.py create mode 100644 traderai/negotiations.py diff --git a/.env.example b/.env.example index 76bd66a..41d0dad 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,7 @@ 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=300 diff --git a/tests/test_negotiations.py b/tests/test_negotiations.py new file mode 100644 index 0000000..26299f7 --- /dev/null +++ b/tests/test_negotiations.py @@ -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 diff --git a/tests/test_server.py b/tests/test_server.py index 9ac644b..b7313b5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -247,6 +247,7 @@ def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", 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, diff --git a/tests/test_tools.py b/tests/test_tools.py index b3e764f..afb2178 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -452,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()) diff --git a/traderai/agent.py b/traderai/agent.py index 13efd4d..80b994e 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -23,6 +23,7 @@ from traderai.version import __version__ SYSTEM_PROMPT = """You are TraderAI, a sharp Star Citizen marketplace copilot for UEX work. Sound like a competent player who knows the game and the market. Be natural, direct, and helpful. Avoid corporate filler, robotic phrasing, and meta notes. Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads. +Prefer locally synced negotiation tools before live UEX negotiation reads when local context is available. Use continual plan tools when the user asks for multi-day or recurring marketplace work, such as finding several parts, watching for deals, tracking candidates, or coordinating negotiations over time. UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error. Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact. @@ -37,6 +38,7 @@ Prefer open and current UEX marketplace information. Do not use historical sale Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise. For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds. When drafting negotiation messages or marketplace replies, write like a real player would. Keep messages human, concise, and purposeful. Never include internal notes like "Tone note". +The user can manually send their own negotiation messages directly from the negotiations workspace, but you must still use approval-gated draft actions for AI-authored replies and deal-close submissions. For continual plans, never invent an unknown parts checklist. If the required items cannot be derived from provided details or tools, create the plan in a needs-input state and say what item list is missing. When a scheduled wake job fires, always write a concise Inbox-ready result that says what you checked, the key findings, and the suggested next action. Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next.""" @@ -1758,8 +1760,13 @@ class OllamaAgent: "search_marketplace_listings": "Searching UEX listings", "get_marketplace_listing": "Fetching listing details", "list_marketplace_negotiations": "Checking negotiations", + "list_local_negotiations": "Checking local negotiations", + "get_local_negotiation": "Reading local negotiation", + "search_local_negotiation_messages": "Searching local negotiation history", "get_negotiation_messages": "Reading negotiation messages", "draft_negotiation_message": "Drafting message for approval", + "draft_negotiation_close": "Drafting negotiation close for approval", + "draft_negotiation_rating": "Drafting negotiation rating for approval", "draft_marketplace_listing": "Drafting listing for approval", "draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image", "check_uex_notifications": "Checking UEX notifications", diff --git a/traderai/config.py b/traderai/config.py index 684e2c3..e254edb 100644 --- a/traderai/config.py +++ b/traderai/config.py @@ -31,6 +31,7 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = { "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}, @@ -94,6 +95,7 @@ class Settings(BaseSettings): 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 = 300 diff --git a/traderai/memory.py b/traderai/memory.py index df815fc..52d168b 100644 --- a/traderai/memory.py +++ b/traderai/memory.py @@ -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() @@ -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 diff --git a/traderai/negotiations.py b/traderai/negotiations.py new file mode 100644 index 0000000..0ce4f72 --- /dev/null +++ b/traderai/negotiations.py @@ -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 diff --git a/traderai/scheduler.py b/traderai/scheduler.py index af3cd00..d4af390 100644 --- a/traderai/scheduler.py +++ b/traderai/scheduler.py @@ -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()) diff --git a/traderai/server.py b/traderai/server.py index 169dc3a..2323795 100644 --- a/traderai/server.py +++ b/traderai/server.py @@ -24,6 +24,7 @@ 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 @@ -63,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 @@ -120,22 +136,42 @@ def create_app() -> FastAPI: 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) + 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() - tools = ToolRegistry( - uex, - current_settings.require_write_approval, - memory=memory, - scheduler=scheduler, - scmdb=scmdb, - cornerstone=cornerstone, - scwiki=scwiki, - wikelo=wikelo, - plan_store=plan_store, - ) + 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) @@ -154,6 +190,8 @@ def create_app() -> FastAPI: 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, @@ -161,6 +199,7 @@ def create_app() -> FastAPI: "tools": tools, "plan_runner": plan_runner, "agent": agent, + "negotiation_sync": negotiation_sync, } ) @@ -173,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") @@ -468,8 +511,78 @@ 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) @@ -481,6 +594,40 @@ def create_app() -> FastAPI: 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()} diff --git a/traderai/tools.py b/traderai/tools.py index 841437f..4c32c6f 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -8,6 +8,7 @@ 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 @@ -150,6 +151,7 @@ UEX_RESOURCE_DESCRIPTIONS = { UEX_PRODUCTION_WRITE_RESOURCES = { "marketplace_advertise", "marketplace_negotiations_messages", + UEX_NEGOTIATION_CLOSE_ENDPOINT, } @@ -176,6 +178,7 @@ class ToolRegistry: 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() @@ -187,6 +190,7 @@ class ToolRegistry: 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] = { @@ -196,6 +200,11 @@ class ToolRegistry: "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, @@ -354,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": { @@ -1418,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, @@ -1453,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, diff --git a/traderai/uex_client.py b/traderai/uex_client.py index 4912577..b655c70 100644 --- a/traderai/uex_client.py +++ b/traderai/uex_client.py @@ -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( diff --git a/web/app.js b/web/app.js index 3795805..347b103 100644 --- a/web/app.js +++ b/web/app.js @@ -15,6 +15,7 @@ const configPathsEl = document.getElementById("config-paths"); const settingsToggle = document.getElementById("settings-toggle"); const memoryToggle = document.getElementById("memory-toggle"); const plansToggle = document.getElementById("plans-toggle"); +const negotiationsToggle = document.getElementById("negotiations-toggle"); const ollamaToggle = document.getElementById("ollama-toggle"); const settingsPanel = document.getElementById("settings-panel"); const memoryPanel = document.getElementById("memory-panel"); @@ -48,6 +49,32 @@ const negotiationForm = document.getElementById("negotiation-form"); const negotiationInput = document.getElementById("negotiation-input"); const negotiationStatusEl = document.getElementById("negotiation-status"); const negotiationCloseButton = document.getElementById("negotiation-close"); +const negotiationListEl = document.getElementById("negotiation-list"); +const negotiationsRefreshAllButton = document.getElementById("negotiations-refresh-all"); +const negotiationPanelListEl = document.getElementById("negotiation-panel-list"); +const negotiationSearchEl = document.getElementById("negotiation-search"); +const negotiationFilterEl = document.getElementById("negotiation-filter"); +const negotiationThreadHeaderEl = document.getElementById("negotiation-thread-header"); +const negotiationMetaCardEl = document.getElementById("negotiation-meta-card"); +const negotiationUserCardEl = document.getElementById("negotiation-user-card"); +const negotiationRefreshButton = document.getElementById("negotiation-refresh-button"); +const negotiationDraftButton = document.getElementById("negotiation-draft-button"); +const negotiationOpenChatButton = document.getElementById("negotiation-open-chat"); +const negotiationEndDealButton = document.getElementById("negotiation-end-deal"); +const negotiationSyncPillEl = document.getElementById("negotiation-sync-pill"); +const negotiationCloseModal = document.getElementById("negotiation-close-modal"); +const negotiationCloseModalClose = document.getElementById("negotiation-close-modal-close"); +const negotiationCloseForm = document.getElementById("negotiation-close-form"); +const negotiationCloseStatusEl = document.getElementById("negotiation-close-status"); +const closeDealClosedEl = document.getElementById("close-deal-closed"); +const closeDealValueEl = document.getElementById("close-deal-value"); +const closeCurrencyEl = document.getElementById("close-currency"); +const closeClarityEl = document.getElementById("close-clarity"); +const closeSpeedEl = document.getElementById("close-speed"); +const closeRespectEl = document.getElementById("close-respect"); +const closeFairnessEl = document.getElementById("close-fairness"); +const closeCommentEl = document.getElementById("close-comment"); +const closeDraftButton = document.getElementById("close-draft-button"); const updateModal = document.getElementById("update-modal"); const updateModalCopy = document.getElementById("update-modal-copy"); const updateModalClose = document.getElementById("update-modal-close"); @@ -66,6 +93,7 @@ let ollamaOnline = true; let latestUpdate = null; let currentThreadId = "default"; let currentNegotiationId = null; +let negotiationRows = []; let latestOllamaStatus = null; let composerImages = []; const clickedOllamaActions = new Set(); @@ -1182,16 +1210,114 @@ async function deleteInboxItem(id) { await refreshInbox(); } +async function refreshNegotiations(preserveCurrent = true) { + const status = negotiationFilterEl?.value || "open"; + const search = negotiationSearchEl?.value?.trim() || ""; + try { + const response = await fetch(`/api/negotiations?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}&limit=100`); + const result = await response.json(); + negotiationRows = result.negotiations || []; + renderNegotiationLists(negotiationRows); + if (!preserveCurrent) return; + if (!currentNegotiationId && negotiationRows.length) currentNegotiationId = negotiationRows[0].hash; + if (currentNegotiationId && negotiationRows.some((item) => item.hash === currentNegotiationId) && !negotiationPanel.hidden) { + await loadNegotiationDetail(currentNegotiationId, false); + } + } catch (error) { + const message = `Negotiations failed: ${fetchErrorMessage(error)}`; + if (negotiationListEl) negotiationListEl.textContent = message; + if (negotiationPanelListEl) negotiationPanelListEl.textContent = message; + } +} + +async function refreshAllNegotiations() { + const previous = negotiationsRefreshAllButton?.disabled; + if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = true; + if (negotiationStatusEl) negotiationStatusEl.textContent = "Refreshing all negotiations"; + try { + const response = await fetch("/api/negotiations/refresh-all", { method: "POST" }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + await refreshNegotiations(true); + if (negotiationStatusEl) { + negotiationStatusEl.textContent = `Refreshed ${result.count || 0} negotiations`; + } + } catch (error) { + if (negotiationStatusEl) negotiationStatusEl.textContent = `Refresh all failed: ${fetchErrorMessage(error)}`; + } finally { + if (negotiationsRefreshAllButton) negotiationsRefreshAllButton.disabled = Boolean(previous); + } +} + +function renderNegotiationLists(items) { + renderNegotiationListInto(negotiationListEl, items.slice(0, 8)); + renderNegotiationListInto(negotiationPanelListEl, items); +} + +function renderNegotiationListInto(container, items) { + if (!container) return; + container.innerHTML = ""; + if (!items.length) { + container.innerHTML = '
No negotiations
'; + return; + } + for (const item of items) { + const row = document.createElement("button"); + row.type = "button"; + row.className = `negotiation-row${item.hash === currentNegotiationId ? " active" : ""}`; + row.addEventListener("click", () => openNegotiationPanel(item.hash)); + const top = document.createElement("div"); + top.className = "negotiation-row-top"; + const title = document.createElement("div"); + title.className = "negotiation-row-title"; + title.textContent = item.title || item.counterparty_username || item.hash; + const badge = document.createElement("span"); + badge.className = `negotiation-row-badge ${item.status === "closed" ? "closed" : ""}`; + badge.textContent = item.status || "open"; + top.append(title, badge); + const meta = document.createElement("div"); + meta.className = "negotiation-row-meta"; + meta.textContent = [ + item.counterparty_username || "Unknown user", + item.last_message_at ? formatShortDate(item.last_message_at) : "No messages", + ].join(" • "); + row.append(top, meta); + if (Number(item.unread_count || 0) > 0) { + const unread = document.createElement("span"); + unread.className = "negotiation-row-unread"; + unread.textContent = String(item.unread_count); + row.appendChild(unread); + } + container.appendChild(row); + } +} + async function openNegotiationPanel(identifier) { + if (!identifier) { + negotiationPanel.hidden = false; + negotiationsToggle?.setAttribute("aria-expanded", "true"); + return; + } currentNegotiationId = identifier; negotiationPanel.hidden = false; - negotiationTitle.textContent = `Negotiation ${identifier}`; + negotiationsToggle?.setAttribute("aria-expanded", "true"); negotiationStatusEl.textContent = ""; + negotiationSyncPillEl.textContent = "Local sync"; + await loadNegotiationDetail(identifier, true); +} + +async function loadNegotiationDetail(identifier, refreshList = true) { + negotiationTitle.textContent = `Negotiation ${identifier}`; negotiationMessagesEl.textContent = "Loading"; + negotiationThreadHeaderEl.innerHTML = '
Loading local thread...
'; + negotiationMetaCardEl.innerHTML = "

Deal

Loading
"; + negotiationUserCardEl.innerHTML = "

User

Loading
"; try { - const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`); + const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}`); const result = await response.json(); - renderNegotiationMessages(result.data || result.messages || result.notifications || []); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + renderNegotiationDetail(result.negotiation); + if (refreshList) await refreshNegotiations(false); } catch (error) { negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`; } @@ -1202,6 +1328,7 @@ function closeNegotiationPanel() { currentNegotiationId = null; negotiationInput.value = ""; negotiationStatusEl.textContent = ""; + negotiationsToggle?.setAttribute("aria-expanded", "false"); } function openPlansPanel(openPlanId = null) { @@ -1217,6 +1344,37 @@ function closePlansPanel() { plansToggle?.setAttribute("aria-expanded", "false"); } +function renderNegotiationDetail(negotiation) { + if (!negotiation) return; + negotiationTitle.textContent = negotiation.title || negotiation.counterparty_username || negotiation.hash; + negotiationSyncPillEl.textContent = negotiation.last_synced_at ? `Synced ${formatShortDate(negotiation.last_synced_at)}` : "Local sync"; + negotiationThreadHeaderEl.innerHTML = ` +
${escapeHtml(negotiation.title || "Negotiation")}
+
${escapeHtml(negotiation.counterparty_username || "Unknown user")} • ${escapeHtml(negotiation.status || "open")} • ${escapeHtml(negotiation.hash || "")}
+ `; + renderNegotiationMessages(negotiation.messages || []); + const raw = negotiation.metadata?.raw || {}; + negotiationMetaCardEl.innerHTML = ` +

Deal

+
+
Listing ${escapeHtml(negotiation.title || raw.listing_title || negotiation.hash)}
+
Status ${escapeHtml(negotiation.status || "open")}
+
Slug ${escapeHtml(negotiation.listing_slug || raw.listing_slug || "Unknown")}
+
Price ${escapeHtml(String(raw.price || raw.deal_value || "Unknown"))} ${escapeHtml(String(raw.currency || raw.deal_value_currency || ""))}
+
Last message ${escapeHtml(negotiation.last_message_at ? formatShortDate(negotiation.last_message_at) : "Unknown")}
+
+ `; + negotiationUserCardEl.innerHTML = ` +

User

+
+
Counterparty ${escapeHtml(negotiation.counterparty_username || raw.client_username || raw.advertiser_username || "Unknown")}
+
Advertiser ${escapeHtml(String(raw.advertiser_username || raw.advertiser_name || "Unknown"))}
+
Client ${escapeHtml(String(raw.client_username || raw.client_name || "Unknown"))}
+
Unread ${escapeHtml(String(negotiation.unread_count || 0))}
+
+ `; +} + function renderNegotiationMessages(data) { negotiationMessagesEl.innerHTML = ""; const items = Array.isArray(data) ? data : [data].filter(Boolean); @@ -1226,10 +1384,15 @@ function renderNegotiationMessages(data) { } for (const item of items) { const card = document.createElement("div"); - card.className = "negotiation-message"; - const author = item.user_username || item.username || item.author || item.sender || "UEX"; - const body = item.message || item.content || item.text || JSON.stringify(item, null, 2); - card.innerHTML = `${escapeHtml(String(author))}
${inlineMarkdown(String(body))}`; + card.className = `negotiation-message${item.is_me ? " self" : ""}`; + const author = item.author_username || item.user_username || item.username || item.author || item.sender || "UEX"; + const body = item.body || item.message || item.content || item.text || JSON.stringify(item, null, 2); + const meta = document.createElement("div"); + meta.className = "negotiation-message-meta"; + meta.innerHTML = `${escapeHtml(String(author))}${escapeHtml(item.sent_at ? formatShortDate(item.sent_at) : "")}`; + const text = document.createElement("div"); + text.innerHTML = inlineMarkdown(String(body)); + card.append(meta, text); negotiationMessagesEl.appendChild(card); } negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight; @@ -1241,7 +1404,7 @@ async function submitNegotiationMessage(event) { if (!text || !currentNegotiationId) return; negotiationStatusEl.textContent = "Sending"; try { - const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, { + const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/manual`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: text }), @@ -1250,12 +1413,125 @@ async function submitNegotiationMessage(event) { if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); negotiationInput.value = ""; negotiationStatusEl.textContent = result.message || "Sent"; - await openNegotiationPanel(currentNegotiationId); + if (result.negotiation) renderNegotiationDetail(result.negotiation); + await refreshNegotiations(false); } catch (error) { negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`; } } +async function draftNegotiationMessage() { + const text = negotiationInput.value.trim(); + if (!text || !currentNegotiationId) return; + negotiationStatusEl.textContent = "Drafting"; + try { + const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/draft`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + negotiationStatusEl.textContent = "Draft ready for approval"; + await refreshPending(); + } catch (error) { + negotiationStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`; + } +} + +async function refreshActiveNegotiation() { + if (!currentNegotiationId) return; + negotiationStatusEl.textContent = "Refreshing"; + try { + const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/refresh`, { method: "POST" }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + if (result.negotiation) renderNegotiationDetail(result.negotiation); + negotiationStatusEl.textContent = "Refreshed"; + await refreshNegotiations(false); + } catch (error) { + negotiationStatusEl.textContent = `Refresh failed: ${fetchErrorMessage(error)}`; + } +} + +async function openNegotiationInChat() { + if (!currentNegotiationId) return; + try { + const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/open-chat`, { method: "POST" }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + if (result.chat?.id) { + currentThreadId = result.chat.id; + await loadChatMessages(currentThreadId); + await refreshChats(); + } + } catch (error) { + negotiationStatusEl.textContent = `Open chat failed: ${fetchErrorMessage(error)}`; + } +} + +function openNegotiationCloseModal() { + if (!currentNegotiationId) return; + negotiationCloseStatusEl.textContent = ""; + negotiationCloseModal.hidden = false; +} + +function closeNegotiationCloseModal() { + negotiationCloseModal.hidden = true; +} + +function negotiationClosePayload() { + return { + deal_closed: closeDealClosedEl.value !== "false", + deal_value: closeDealValueEl.value ? Number(closeDealValueEl.value) : null, + currency: closeCurrencyEl.value.trim() || null, + clarity_rating: closeClarityEl.value ? Number(closeClarityEl.value) : null, + speed_rating: closeSpeedEl.value ? Number(closeSpeedEl.value) : null, + respect_rating: closeRespectEl.value ? Number(closeRespectEl.value) : null, + fairness_rating: closeFairnessEl.value ? Number(closeFairnessEl.value) : null, + comment: closeCommentEl.value.trim() || null, + }; +} + +async function submitNegotiationClose(event) { + event.preventDefault(); + if (!currentNegotiationId) return; + negotiationCloseStatusEl.textContent = "Submitting"; + try { + const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/manual`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(negotiationClosePayload()), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + negotiationCloseStatusEl.textContent = result.message || "Submitted"; + if (result.negotiation) renderNegotiationDetail(result.negotiation); + await refreshNegotiations(false); + closeNegotiationCloseModal(); + } catch (error) { + negotiationCloseStatusEl.textContent = `Close failed: ${fetchErrorMessage(error)}`; + } +} + +async function draftNegotiationClose() { + if (!currentNegotiationId) return; + negotiationCloseStatusEl.textContent = "Drafting"; + try { + const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/close/draft`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(negotiationClosePayload()), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + negotiationCloseStatusEl.textContent = "Draft ready for approval"; + await refreshPending(); + } catch (error) { + negotiationCloseStatusEl.textContent = `Draft failed: ${fetchErrorMessage(error)}`; + } +} + function parsePlanItems(text) { return text .split(/\r?\n/) @@ -1854,7 +2130,10 @@ async function pollNotifications() { try { const response = await fetch("/api/notifications"); const result = await response.json(); - if ((result.notifications || []).length) await refreshInbox(); + if ((result.notifications || []).length) { + await refreshInbox(); + await refreshNegotiations(true); + } } catch { // Notification polling should never interrupt chat. } @@ -1899,6 +2178,11 @@ plansToggle?.addEventListener("click", () => { if (plansPanel?.hidden) openPlansPanel(); else closePlansPanel(); }); +negotiationsToggle?.addEventListener("click", () => { + if (negotiationPanel?.hidden) openNegotiationPanel(currentNegotiationId || negotiationRows[0]?.hash || ""); + else closeNegotiationPanel(); +}); +negotiationsRefreshAllButton?.addEventListener("click", refreshAllNegotiations); ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama")); plansRefreshButton?.addEventListener("click", () => refreshPlans()); plansCloseButton?.addEventListener("click", closePlansPanel); @@ -1935,6 +2219,15 @@ chatSidebarToggle?.addEventListener("click", toggleChatRail); newChatButton?.addEventListener("click", () => createChat(true)); negotiationCloseButton?.addEventListener("click", closeNegotiationPanel); negotiationForm?.addEventListener("submit", submitNegotiationMessage); +negotiationDraftButton?.addEventListener("click", draftNegotiationMessage); +negotiationRefreshButton?.addEventListener("click", refreshActiveNegotiation); +negotiationOpenChatButton?.addEventListener("click", openNegotiationInChat); +negotiationEndDealButton?.addEventListener("click", openNegotiationCloseModal); +negotiationSearchEl?.addEventListener("input", () => refreshNegotiations(false)); +negotiationFilterEl?.addEventListener("change", () => refreshNegotiations(false)); +negotiationCloseModalClose?.addEventListener("click", closeNegotiationCloseModal); +negotiationCloseForm?.addEventListener("submit", submitNegotiationClose); +closeDraftButton?.addEventListener("click", draftNegotiationClose); updateModalClose?.addEventListener("click", closeUpdatePrompt); updateModalReleases?.addEventListener("click", openReleasesPage); updateModalInstall?.addEventListener("click", installUpdate); @@ -2038,8 +2331,10 @@ refreshConfig(); refreshOllamaStatus(); refreshChats().then(() => loadChatMessages(currentThreadId)); refreshInbox(); +refreshNegotiations(false); checkForUpdate(true); pollNotifications(); checkHealth(); setInterval(checkHealth, 30000); setInterval(pollNotifications, 15000); +setInterval(() => refreshNegotiations(true), 15000); diff --git a/web/index.html b/web/index.html index fe33b6b..7c745ea 100644 --- a/web/index.html +++ b/web/index.html @@ -25,6 +25,20 @@
Chats
+
+
+
Negotiations
+
+ + +
+
+
+
Plans
@@ -82,6 +96,7 @@ + @@ -170,19 +185,107 @@ +