feat: chat
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
+159
-12
@@ -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()}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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(
|
||||
|
||||
+305
-10
@@ -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 = '<div class="pending-empty">No negotiations</div>';
|
||||
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 = '<div class="muted">Loading local thread...</div>';
|
||||
negotiationMetaCardEl.innerHTML = "<h3>Deal</h3><div class='muted'>Loading</div>";
|
||||
negotiationUserCardEl.innerHTML = "<h3>User</h3><div class='muted'>Loading</div>";
|
||||
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 = `
|
||||
<div><strong>${escapeHtml(negotiation.title || "Negotiation")}</strong></div>
|
||||
<div class="muted">${escapeHtml(negotiation.counterparty_username || "Unknown user")} • ${escapeHtml(negotiation.status || "open")} • ${escapeHtml(negotiation.hash || "")}</div>
|
||||
`;
|
||||
renderNegotiationMessages(negotiation.messages || []);
|
||||
const raw = negotiation.metadata?.raw || {};
|
||||
negotiationMetaCardEl.innerHTML = `
|
||||
<h3>Deal</h3>
|
||||
<div class="negotiation-detail-kv">
|
||||
<div><strong>Listing</strong> ${escapeHtml(negotiation.title || raw.listing_title || negotiation.hash)}</div>
|
||||
<div><strong>Status</strong> ${escapeHtml(negotiation.status || "open")}</div>
|
||||
<div><strong>Slug</strong> ${escapeHtml(negotiation.listing_slug || raw.listing_slug || "Unknown")}</div>
|
||||
<div><strong>Price</strong> ${escapeHtml(String(raw.price || raw.deal_value || "Unknown"))} ${escapeHtml(String(raw.currency || raw.deal_value_currency || ""))}</div>
|
||||
<div><strong>Last message</strong> ${escapeHtml(negotiation.last_message_at ? formatShortDate(negotiation.last_message_at) : "Unknown")}</div>
|
||||
</div>
|
||||
`;
|
||||
negotiationUserCardEl.innerHTML = `
|
||||
<h3>User</h3>
|
||||
<div class="negotiation-detail-kv">
|
||||
<div><strong>Counterparty</strong> ${escapeHtml(negotiation.counterparty_username || raw.client_username || raw.advertiser_username || "Unknown")}</div>
|
||||
<div><strong>Advertiser</strong> ${escapeHtml(String(raw.advertiser_username || raw.advertiser_name || "Unknown"))}</div>
|
||||
<div><strong>Client</strong> ${escapeHtml(String(raw.client_username || raw.client_name || "Unknown"))}</div>
|
||||
<div><strong>Unread</strong> ${escapeHtml(String(negotiation.unread_count || 0))}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `<strong>${escapeHtml(String(author))}</strong><br>${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 = `<strong>${escapeHtml(String(author))}</strong><span>${escapeHtml(item.sent_at ? formatShortDate(item.sent_at) : "")}</span>`;
|
||||
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);
|
||||
|
||||
+114
-11
@@ -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>
|
||||
@@ -170,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">
|
||||
|
||||
+219
-2
@@ -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;
|
||||
@@ -2077,4 +2285,13 @@ pre {
|
||||
.plans-panel-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.negotiation-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.negotiation-sidebar,
|
||||
.negotiation-detail-rail {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user