Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cf0693f319
|
|||
|
8fac3d2bae
|
|||
|
454bb57484
|
+5
-1
@@ -5,6 +5,9 @@ OLLAMA_NUM_CTX=64512
|
|||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
OPENAI_MODEL=gpt-5.4-mini
|
OPENAI_MODEL=gpt-5.4-mini
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
MODEL_REASONING_EFFORT=medium
|
MODEL_REASONING_EFFORT=medium
|
||||||
CODEX_COMMAND=codex
|
CODEX_COMMAND=codex
|
||||||
CODEX_MODEL=gpt-5.4
|
CODEX_MODEL=gpt-5.4
|
||||||
@@ -15,7 +18,8 @@ SCWIKI_BASE_URL=https://starcitizen.tools
|
|||||||
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
|
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
|
||||||
UEX_SECRET_KEY=
|
UEX_SECRET_KEY=
|
||||||
UEX_BEARER_TOKEN=
|
UEX_BEARER_TOKEN=
|
||||||
|
UEX_NEGOTIATION_CLOSE_ENDPOINT=marketplace_negotiations_close
|
||||||
TRADERAI_USER_NAME=
|
TRADERAI_USER_NAME=
|
||||||
TRADERAI_MEMORY_PATH=
|
TRADERAI_MEMORY_PATH=
|
||||||
UEX_NOTIFICATION_POLL_SECONDS=60
|
UEX_NOTIFICATION_POLL_SECONDS=300
|
||||||
REQUIRE_WRITE_APPROVAL=true
|
REQUIRE_WRITE_APPROVAL=true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TraderAI
|
# TraderAI
|
||||||
|
|
||||||
Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
@@ -25,9 +25,10 @@ Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
||||||
|
If you want the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
|
||||||
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
|
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
|
||||||
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
|
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
|
||||||
`MODEL_REASONING_EFFORT` controls reasoning depth for OpenAI and Codex and defaults to `medium`.
|
`MODEL_REASONING_EFFORT` controls reasoning depth for DeepSeek, OpenAI, and Codex and defaults to `medium`.
|
||||||
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||||
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||||
4. Install and run:
|
4. Install and run:
|
||||||
@@ -41,7 +42,7 @@ Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
|
Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, DeepSeek's OpenAI-compatible Chat Completions API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
|
||||||
|
|
||||||
## Releases And Updates
|
## Releases And Updates
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.6"
|
version = "0.0.9"
|
||||||
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
|
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -41,3 +41,6 @@ include = ["traderai*"]
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
|
||||||
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
@@ -217,6 +218,38 @@ def test_ollama_options_include_num_ctx():
|
|||||||
assert agent._ollama_options() == {"num_ctx": 64000}
|
assert agent._ollama_options() == {"num_ctx": 64000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_tool_rounds_are_not_capped_at_ten():
|
||||||
|
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||||
|
|
||||||
|
rounds = list(itertools.islice(agent._tool_rounds(), 12))
|
||||||
|
|
||||||
|
assert len(rounds) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_normalization_extracts_json_and_defaults():
|
||||||
|
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
|
||||||
|
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
|
||||||
|
|
||||||
|
draft = OllamaAgent._normalize_plan_draft(raw, seed)
|
||||||
|
|
||||||
|
assert draft["title"] == "Wikelo Polaris Parts"
|
||||||
|
assert draft["cadence"] == "0 */3 * * *"
|
||||||
|
assert draft["constraints"]["message_tone"] == "casual"
|
||||||
|
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||||
|
assert draft["items"][0]["desired_quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_heuristic_fills_in_basic_instructions():
|
||||||
|
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
|
||||||
|
|
||||||
|
draft = OllamaAgent._heuristic_plan_draft(seed)
|
||||||
|
|
||||||
|
assert draft["kind"] == "custom"
|
||||||
|
assert draft["cadence"] == "0 */4 * * *"
|
||||||
|
assert "summarize" in draft["constraints"]["instructions"].casefold()
|
||||||
|
assert draft["constraints"]["message_tone"] == "friendly and direct"
|
||||||
|
|
||||||
|
|
||||||
def test_codex_prompt_mentions_tools_and_images(tmp_path):
|
def test_codex_prompt_mentions_tools_and_images(tmp_path):
|
||||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
|
||||||
@@ -257,6 +290,34 @@ def test_codex_prompt_mentions_tools_and_images(tmp_path):
|
|||||||
assert "tool search_marketplace_listings" in prompt
|
assert "tool search_marketplace_listings" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
|
||||||
|
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||||
|
|
||||||
|
messages = agent._openai_messages(
|
||||||
|
"check listing",
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": "Check this listing"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "I should check the current listing first.",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assistant_turn = next(message for message in messages if message["role"] == "assistant")
|
||||||
|
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
|
||||||
|
|
||||||
|
|
||||||
def test_codex_structured_response_extracts_text_and_tool_calls():
|
def test_codex_structured_response_extracts_text_and_tool_calls():
|
||||||
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -1,10 +1,22 @@
|
|||||||
from traderai.config import Settings
|
from traderai.config import Settings
|
||||||
|
|
||||||
|
|
||||||
def test_model_provider_accepts_codex():
|
def test_model_provider_codex_falls_back_to_ollama():
|
||||||
settings = Settings(model_provider="codex")
|
settings = Settings(model_provider="codex")
|
||||||
|
|
||||||
assert settings.model_provider == "codex"
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_openai_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="openai")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_accepts_deepseek():
|
||||||
|
settings = Settings(model_provider="deepseek")
|
||||||
|
|
||||||
|
assert settings.model_provider == "deepseek"
|
||||||
|
|
||||||
|
|
||||||
def test_model_provider_invalid_value_falls_back_to_ollama():
|
def test_model_provider_invalid_value_falls_back_to_ollama():
|
||||||
@@ -23,3 +35,9 @@ def test_reasoning_effort_accepts_supported_values():
|
|||||||
settings = Settings(model_reasoning_effort="high")
|
settings = Settings(model_reasoning_effort="high")
|
||||||
|
|
||||||
assert settings.model_reasoning_effort == "high"
|
assert settings.model_reasoning_effort == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_accepts_max():
|
||||||
|
settings = Settings(model_reasoning_effort="max")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "max"
|
||||||
|
|||||||
@@ -55,3 +55,22 @@ def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
|
|||||||
assert renamed["title"] == "Market Check"
|
assert renamed["title"] == "Market Check"
|
||||||
assert deleted is True
|
assert deleted is True
|
||||||
assert store.list_outbox() == []
|
assert store.list_outbox() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
|
||||||
|
original_cwd = tmp_path / "start"
|
||||||
|
original_cwd.mkdir()
|
||||||
|
monkeypatch.chdir(original_cwd)
|
||||||
|
|
||||||
|
store = MemoryStore("data/memory.sqlite3")
|
||||||
|
|
||||||
|
moved_cwd = tmp_path / "moved"
|
||||||
|
moved_cwd.mkdir()
|
||||||
|
monkeypatch.chdir(moved_cwd)
|
||||||
|
|
||||||
|
store.add_outbox("Notification survived cwd change")
|
||||||
|
|
||||||
|
snapshot = store.inspect()
|
||||||
|
|
||||||
|
assert store.path.is_absolute()
|
||||||
|
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore
|
||||||
|
from traderai.negotiations import NegotiationSyncService, extract_negotiation_hash
|
||||||
|
from traderai.tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNegotiationUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.list_calls = []
|
||||||
|
self.message_calls = []
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
|
async def list_negotiations(self, id=None, id_listing=None, hash=None):
|
||||||
|
self.list_calls.append({"id": id, "id_listing": id_listing, "hash": hash})
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"hash": "open-hash",
|
||||||
|
"id_listing": 101,
|
||||||
|
"listing_slug": "rgl-open",
|
||||||
|
"listing_title": "RGL Set",
|
||||||
|
"advertiser_username": "seller_a",
|
||||||
|
"client_username": "pilot_hudson",
|
||||||
|
"date_modified": 1_780_975_053,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"hash": "closed-recent",
|
||||||
|
"id_listing": 102,
|
||||||
|
"listing_slug": "rgl-closed",
|
||||||
|
"listing_title": "Closed Deal",
|
||||||
|
"advertiser_username": "seller_b",
|
||||||
|
"client_username": "pilot_hudson",
|
||||||
|
"date_modified": 1_780_975_053,
|
||||||
|
"date_closed": 1_780_975_054,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if hash:
|
||||||
|
data = [item for item in data if item["hash"] == hash]
|
||||||
|
return {"status": "ok", "negotiations": data}
|
||||||
|
|
||||||
|
async def get_negotiation_messages(self, hash=None, id_negotiation=None):
|
||||||
|
self.message_calls.append({"hash": hash, "id_negotiation": id_negotiation})
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": 201,
|
||||||
|
"negotiation_hash": hash,
|
||||||
|
"user_username": "seller_a" if hash == "open-hash" else "seller_b",
|
||||||
|
"user_name": "Seller",
|
||||||
|
"message": "Still available.",
|
||||||
|
"date_added": 1_780_975_053,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send_negotiation_message(self, **payload):
|
||||||
|
self.posts.append({"kind": "message", **payload})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
async def close_negotiation(self, **payload):
|
||||||
|
self.posts.append({"kind": "close", **payload})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_negotiation_hash_handles_uex_redirects():
|
||||||
|
assert extract_negotiation_hash("https://uexcorp.space/marketplace/negotiate/hash/abc-123") == "abc-123"
|
||||||
|
assert extract_negotiation_hash("/marketplace/negotiate/hash/def-456") == "def-456"
|
||||||
|
assert extract_negotiation_hash("/marketplace/item/info/foo") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_startup_sync_keeps_open_and_recent_threads(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||||
|
service = NegotiationSyncService(memory, FakeNegotiationUEX())
|
||||||
|
|
||||||
|
result = await service.startup_sync()
|
||||||
|
negotiations = memory.list_negotiations(limit=10)
|
||||||
|
|
||||||
|
assert result["count"] == 2
|
||||||
|
assert {item["hash"] for item in negotiations} == {"open-hash", "closed-recent"}
|
||||||
|
detail = memory.get_negotiation("open-hash")
|
||||||
|
assert detail["messages"][0]["body"] == "Still available."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notification_refresh_targets_only_changed_negotiation(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||||
|
fake = FakeNegotiationUEX()
|
||||||
|
service = NegotiationSyncService(memory, fake)
|
||||||
|
await service.startup_sync()
|
||||||
|
fake.message_calls.clear()
|
||||||
|
|
||||||
|
await service.handle_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 99,
|
||||||
|
"message": "seller_a: ping",
|
||||||
|
"redir": "https://uexcorp.space/marketplace/negotiate/hash/open-hash",
|
||||||
|
"date_added": 1_780_975_060,
|
||||||
|
"date_read": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake.message_calls == [{"hash": "open-hash", "id_negotiation": None}]
|
||||||
|
assert memory.get_negotiation("open-hash")["unread_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_manual_send_refreshes_local_thread(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
memory.set_profile("uex_user", {"username": "pilot_hudson"})
|
||||||
|
fake = FakeNegotiationUEX()
|
||||||
|
service = NegotiationSyncService(memory, fake)
|
||||||
|
await service.startup_sync()
|
||||||
|
|
||||||
|
result = await service.manual_send_message("open-hash", "I can buy tonight.")
|
||||||
|
|
||||||
|
assert result["posted"]["kind"] == "message"
|
||||||
|
assert fake.message_calls[-1]["hash"] == "open-hash"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_negotiation_close_creates_pending_action(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
registry = ToolRegistry(FakeNegotiationUEX(), memory=memory)
|
||||||
|
|
||||||
|
result = await registry.draft_negotiation_close(
|
||||||
|
hash="open-hash",
|
||||||
|
deal_closed=True,
|
||||||
|
deal_value=1_000_000,
|
||||||
|
currency="UEC",
|
||||||
|
clarity_rating=5,
|
||||||
|
speed_rating=5,
|
||||||
|
respect_rating=5,
|
||||||
|
fairness_rating=4,
|
||||||
|
comment="Smooth trade",
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
assert pending["endpoint"] == "marketplace_negotiations_close"
|
||||||
|
assert pending["payload"]["deal_closed"] == 1
|
||||||
|
assert pending["payload"]["is_production"] == 1
|
||||||
@@ -142,6 +142,9 @@ async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
|
|||||||
assert len(tools.pending_actions) == 1
|
assert len(tools.pending_actions) == 1
|
||||||
assert not tools.uex.posts
|
assert not tools.uex.posts
|
||||||
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
|
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
|
||||||
|
pending = next(iter(tools.pending_actions.values()))
|
||||||
|
assert "Tone note" not in pending.payload["message"]
|
||||||
|
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+111
-5
@@ -86,6 +86,7 @@ def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
|
|||||||
model_provider=values.get("model_provider", state["settings"].model_provider),
|
model_provider=values.get("model_provider", state["settings"].model_provider),
|
||||||
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
|
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
|
||||||
codex_model=values.get("codex_model", state["settings"].codex_model),
|
codex_model=values.get("codex_model", state["settings"].codex_model),
|
||||||
|
deepseek_model=values.get("deepseek_model", state["settings"].deepseek_model),
|
||||||
)
|
)
|
||||||
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
|
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
|
||||||
|
|
||||||
@@ -114,19 +115,120 @@ def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
updated = client.post(
|
updated = client.post(
|
||||||
"/api/config",
|
"/api/config",
|
||||||
json={"values": {"model_provider": "codex", "codex_model": "gpt-5.4"}},
|
json={"values": {"model_provider": "deepseek", "deepseek_model": "deepseek-v4-flash"}},
|
||||||
).json()
|
).json()
|
||||||
assert updated["restart_required"] is False
|
assert updated["restart_required"] is False
|
||||||
|
|
||||||
after = client.get("/api/health").json()
|
after = client.get("/api/health").json()
|
||||||
assert after["model_provider"] == "codex"
|
assert after["model_provider"] == "deepseek"
|
||||||
assert after["inference"]["provider"] == "codex"
|
assert after["inference"]["provider"] == "deepseek"
|
||||||
|
|
||||||
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
|
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
|
||||||
assert chat["message"] == "codex:gpt-5.4"
|
assert chat["message"] == "deepseek:deepseek-v4-flash"
|
||||||
|
|
||||||
|
|
||||||
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4"):
|
def test_plan_draft_endpoint_returns_agent_draft(monkeypatch, tmp_path):
|
||||||
|
state = {"settings": make_settings(tmp_path)}
|
||||||
|
|
||||||
|
class FakeScheduler:
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.memory = memory
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner):
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
|
def bind_uex_notifications(self, uex, poll_seconds=60):
|
||||||
|
self.uex = uex
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_jobs(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeUEXClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_user(self, username=None, authenticated=False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class FakeToolRegistry:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.pending_actions = {}
|
||||||
|
self.plan_runner = None
|
||||||
|
|
||||||
|
async def approve(self, action_id):
|
||||||
|
return {"approved": action_id}
|
||||||
|
|
||||||
|
async def decline(self, action_id):
|
||||||
|
return {"declined": action_id}
|
||||||
|
|
||||||
|
class FakePlanRunner:
|
||||||
|
def __init__(self, store, tools, memory, agent=None):
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fake_get_settings():
|
||||||
|
return state["settings"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
|
||||||
|
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
|
||||||
|
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
|
||||||
|
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
|
||||||
|
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "get_settings", fake_get_settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"settings_payload",
|
||||||
|
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_generate_plan_draft(self, title="", objective="", kind="buying", constraints=None, items=None):
|
||||||
|
return {
|
||||||
|
"title": title or "Draft title",
|
||||||
|
"objective": objective or "Draft objective",
|
||||||
|
"kind": kind,
|
||||||
|
"cadence": "0 */3 * * *",
|
||||||
|
"constraints": {"message_tone": "friendly and direct", "instructions": "Start with the best listings."},
|
||||||
|
"items": [{"item_name": "RCMBNT-RGL-1", "desired_quantity": 1, "max_unit_price": None}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "generate_plan_draft", fake_generate_plan_draft)
|
||||||
|
|
||||||
|
app = server.create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/plans/draft",
|
||||||
|
json={"title": "Polaris parts", "objective": "Find the required parts", "kind": "buying", "constraints": {}, "items": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
draft = response.json()["draft"]
|
||||||
|
assert draft["cadence"] == "0 */3 * * *"
|
||||||
|
assert draft["constraints"]["instructions"] == "Start with the best listings."
|
||||||
|
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||||
|
|
||||||
|
|
||||||
|
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4", deepseek_model="deepseek-v4-flash"):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
|
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
|
||||||
model_provider=model_provider,
|
model_provider=model_provider,
|
||||||
@@ -136,12 +238,16 @@ def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b",
|
|||||||
openai_base_url="https://api.openai.com/v1",
|
openai_base_url="https://api.openai.com/v1",
|
||||||
openai_api_key=None,
|
openai_api_key=None,
|
||||||
openai_model="gpt-5.4-mini",
|
openai_model="gpt-5.4-mini",
|
||||||
|
deepseek_base_url="https://api.deepseek.com",
|
||||||
|
deepseek_api_key=None,
|
||||||
|
deepseek_model=deepseek_model,
|
||||||
model_reasoning_effort="medium",
|
model_reasoning_effort="medium",
|
||||||
codex_command="codex",
|
codex_command="codex",
|
||||||
codex_model=codex_model,
|
codex_model=codex_model,
|
||||||
uex_base_url="https://api.uexcorp.space/2.0",
|
uex_base_url="https://api.uexcorp.space/2.0",
|
||||||
uex_secret_key=None,
|
uex_secret_key=None,
|
||||||
uex_bearer_token=None,
|
uex_bearer_token=None,
|
||||||
|
uex_negotiation_close_endpoint="marketplace_negotiations_close",
|
||||||
traderai_user_name=None,
|
traderai_user_name=None,
|
||||||
uex_notification_poll_seconds=60,
|
uex_notification_poll_seconds=60,
|
||||||
require_write_approval=True,
|
require_write_approval=True,
|
||||||
|
|||||||
@@ -368,6 +368,37 @@ class FakeSCWiki:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWikelo:
|
||||||
|
base_url = "https://wikelo-projects.test"
|
||||||
|
|
||||||
|
async def list_ship_projects(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "ship-1",
|
||||||
|
"ship_name": "Polaris Wikelo Special",
|
||||||
|
"description": "Now make Polaris. Short Time Deal",
|
||||||
|
"status": "planning",
|
||||||
|
"privacy": "public",
|
||||||
|
"owner_name": "Chimpanz33",
|
||||||
|
"required_materials": [
|
||||||
|
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
|
||||||
|
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship-2",
|
||||||
|
"ship_name": "Guardian",
|
||||||
|
"description": "Guardian Fight Mod",
|
||||||
|
"status": "planning",
|
||||||
|
"privacy": "public",
|
||||||
|
"owner_name": "Chimpanz33",
|
||||||
|
"required_materials": [
|
||||||
|
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_marketplace_listings_filters_locally():
|
async def test_search_marketplace_listings_filters_locally():
|
||||||
registry = ToolRegistry(FakeUEX())
|
registry = ToolRegistry(FakeUEX())
|
||||||
@@ -421,6 +452,17 @@ def test_uex_client_uses_bearer_and_secret_headers():
|
|||||||
assert headers["Authorization"] == "Bearer bearer"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_uex_get_projects_and_limits_results():
|
async def test_uex_get_projects_and_limits_results():
|
||||||
registry = ToolRegistry(FakeUEX())
|
registry = ToolRegistry(FakeUEX())
|
||||||
@@ -686,6 +728,35 @@ async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_wikelo_ship_projects_returns_material_matches():
|
||||||
|
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||||
|
|
||||||
|
result = await registry.search_wikelo_ship_projects(query="Polaris")
|
||||||
|
|
||||||
|
assert result["source"] == "https://wikelo-projects.test/Ships"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
|
||||||
|
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wikelo_ship_project_returns_full_requirements():
|
||||||
|
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||||
|
|
||||||
|
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
|
||||||
|
|
||||||
|
assert result["project"]["ship_name"] == "Guardian"
|
||||||
|
assert result["project"]["materials_count"] == 1
|
||||||
|
assert result["project"]["required_materials"] == [
|
||||||
|
{
|
||||||
|
"material_name": "Wikelo Favor",
|
||||||
|
"quantity_needed": 20,
|
||||||
|
"quantity_collected": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
|
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
|
||||||
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|||||||
+453
File diff suppressed because one or more lines are too long
+403
-38
@@ -20,8 +20,10 @@ from traderai.tools import ToolRegistry
|
|||||||
from traderai.version import __version__
|
from traderai.version import __version__
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
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.
|
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.
|
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.
|
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||||
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
||||||
@@ -29,11 +31,14 @@ When the user asks for history, trends, changes over time, or past prices, prefe
|
|||||||
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
|
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
|
||||||
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
||||||
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
|
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
|
||||||
|
Use Wikelo ship project tools when the user asks for Wikelo ship requirements, Wikelo build materials, or what items are needed for a Wikelo ship project.
|
||||||
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
||||||
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
||||||
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
||||||
Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise.
|
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.
|
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.
|
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.
|
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."""
|
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
|
||||||
@@ -64,7 +69,7 @@ class OllamaAgent:
|
|||||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
async def health(self) -> dict[str, Any]:
|
||||||
if self.provider == "openai":
|
if self._is_openai_compatible_provider():
|
||||||
return await self._openai_health()
|
return await self._openai_health()
|
||||||
if self.provider == "codex":
|
if self.provider == "codex":
|
||||||
return await self._codex_health()
|
return await self._codex_health()
|
||||||
@@ -119,7 +124,7 @@ class OllamaAgent:
|
|||||||
last_tool_results: list[dict[str, Any]] = []
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
||||||
with image_scope:
|
with image_scope:
|
||||||
for _ in range(10):
|
for _ in self._tool_rounds():
|
||||||
try:
|
try:
|
||||||
response = await self._chat_once(
|
response = await self._chat_once(
|
||||||
prompt_text,
|
prompt_text,
|
||||||
@@ -155,7 +160,7 @@ class OllamaAgent:
|
|||||||
result = await self.tools.execute(name, arguments)
|
result = await self.tools.execute(name, arguments)
|
||||||
last_tool_results.append({"tool": name, "result": result})
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
||||||
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
|
fallback = self._tool_round_limit_message()
|
||||||
messages.append({"role": "assistant", "content": fallback})
|
messages.append({"role": "assistant", "content": fallback})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||||
@@ -187,7 +192,7 @@ class OllamaAgent:
|
|||||||
last_tool_results: list[dict[str, Any]] = []
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
||||||
with image_scope:
|
with image_scope:
|
||||||
for _ in range(10):
|
for _ in self._tool_rounds():
|
||||||
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
||||||
tool_calls: list[dict[str, Any]] = []
|
tool_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
@@ -198,7 +203,15 @@ class OllamaAgent:
|
|||||||
previous_interaction=previous_interaction,
|
previous_interaction=previous_interaction,
|
||||||
thread_id=resolved_thread_id,
|
thread_id=resolved_thread_id,
|
||||||
):
|
):
|
||||||
|
if event.get("type") == "reasoning":
|
||||||
|
reasoning_chunk = event.get("content") or ""
|
||||||
|
if reasoning_chunk:
|
||||||
|
assistant_message["reasoning_content"] = assistant_message.get("reasoning_content", "") + reasoning_chunk
|
||||||
|
yield {"type": "reasoning", "content": reasoning_chunk}
|
||||||
|
continue
|
||||||
message = event.get("message") or {}
|
message = event.get("message") or {}
|
||||||
|
if message.get("reasoning_content"):
|
||||||
|
assistant_message["reasoning_content"] = message.get("reasoning_content")
|
||||||
chunk = message.get("content") or ""
|
chunk = message.get("content") or ""
|
||||||
if chunk:
|
if chunk:
|
||||||
assistant_message["content"] += chunk
|
assistant_message["content"] += chunk
|
||||||
@@ -247,7 +260,7 @@ class OllamaAgent:
|
|||||||
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
||||||
|
|
||||||
yield {"type": "status", "message": "Writing response"}
|
yield {"type": "status", "message": "Writing response"}
|
||||||
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
|
fallback = self._tool_round_limit_message()
|
||||||
messages.append({"role": "assistant", "content": fallback})
|
messages.append({"role": "assistant", "content": fallback})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||||
@@ -260,7 +273,7 @@ class OllamaAgent:
|
|||||||
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
||||||
messages.append({"role": "user", "content": wake_message})
|
messages.append({"role": "user", "content": wake_message})
|
||||||
last_tool_results: list[dict[str, Any]] = []
|
last_tool_results: list[dict[str, Any]] = []
|
||||||
for _ in range(10):
|
for _ in self._tool_rounds():
|
||||||
try:
|
try:
|
||||||
response = await self._chat_once(
|
response = await self._chat_once(
|
||||||
wake_message,
|
wake_message,
|
||||||
@@ -298,7 +311,7 @@ class OllamaAgent:
|
|||||||
result = await self.tools.execute(name, arguments)
|
result = await self.tools.execute(name, arguments)
|
||||||
last_tool_results.append({"tool": name, "result": result})
|
last_tool_results.append({"tool": name, "result": result})
|
||||||
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
||||||
content = "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
|
content = self._wake_tool_round_limit_message()
|
||||||
messages.append({"role": "assistant", "content": content})
|
messages.append({"role": "assistant", "content": content})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.add_conversation("system", wake_message, "wake")
|
self.memory.add_conversation("system", wake_message, "wake")
|
||||||
@@ -312,7 +325,7 @@ class OllamaAgent:
|
|||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if self.provider == "openai":
|
if self._is_openai_compatible_provider():
|
||||||
return await self._openai_chat(
|
return await self._openai_chat(
|
||||||
query,
|
query,
|
||||||
messages,
|
messages,
|
||||||
@@ -340,7 +353,7 @@ class OllamaAgent:
|
|||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
if self.provider == "openai":
|
if self._is_openai_compatible_provider():
|
||||||
async for event in self._openai_chat_stream(
|
async for event in self._openai_chat_stream(
|
||||||
query,
|
query,
|
||||||
messages,
|
messages,
|
||||||
@@ -441,8 +454,8 @@ class OllamaAgent:
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
),
|
),
|
||||||
"tools": self.tools.schemas,
|
"tools": self.tools.schemas,
|
||||||
"reasoning_effort": self.reasoning_effort,
|
|
||||||
"stream": False,
|
"stream": False,
|
||||||
|
**self._openai_request_options(stream=False),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -452,6 +465,7 @@ class OllamaAgent:
|
|||||||
return {
|
return {
|
||||||
"message": {
|
"message": {
|
||||||
"role": message.get("role", "assistant"),
|
"role": message.get("role", "assistant"),
|
||||||
|
"reasoning_content": message.get("reasoning_content") or "",
|
||||||
"content": message.get("content") or "",
|
"content": message.get("content") or "",
|
||||||
"tool_calls": message.get("tool_calls") or [],
|
"tool_calls": message.get("tool_calls") or [],
|
||||||
}
|
}
|
||||||
@@ -479,8 +493,8 @@ class OllamaAgent:
|
|||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
),
|
),
|
||||||
"tools": self.tools.schemas,
|
"tools": self.tools.schemas,
|
||||||
"reasoning_effort": self.reasoning_effort,
|
|
||||||
"stream": True,
|
"stream": True,
|
||||||
|
**self._openai_request_options(stream=True),
|
||||||
},
|
},
|
||||||
) as response:
|
) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -493,8 +507,15 @@ class OllamaAgent:
|
|||||||
if payload == "[DONE]":
|
if payload == "[DONE]":
|
||||||
break
|
break
|
||||||
event = json.loads(payload)
|
event = json.loads(payload)
|
||||||
|
if event.get("usage"):
|
||||||
|
metrics = self._cloud_usage_metrics(event["usage"])
|
||||||
|
if metrics:
|
||||||
|
yield {"type": "metrics", **metrics}
|
||||||
choice = (event.get("choices") or [{}])[0]
|
choice = (event.get("choices") or [{}])[0]
|
||||||
delta = choice.get("delta") or {}
|
delta = choice.get("delta") or {}
|
||||||
|
reasoning_content = delta.get("reasoning_content") or ""
|
||||||
|
if reasoning_content:
|
||||||
|
yield {"type": "reasoning", "content": reasoning_content}
|
||||||
content = delta.get("content") or ""
|
content = delta.get("content") or ""
|
||||||
if content:
|
if content:
|
||||||
yield {"message": {"role": "assistant", "content": content}}
|
yield {"message": {"role": "assistant", "content": content}}
|
||||||
@@ -502,9 +523,11 @@ class OllamaAgent:
|
|||||||
self._merge_openai_tool_call(tool_calls, tool_call)
|
self._merge_openai_tool_call(tool_calls, tool_call)
|
||||||
finish_reason = choice.get("finish_reason")
|
finish_reason = choice.get("finish_reason")
|
||||||
if finish_reason:
|
if finish_reason:
|
||||||
|
message = choice.get("message") or {}
|
||||||
yield {
|
yield {
|
||||||
"message": {
|
"message": {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
"reasoning_content": message.get("reasoning_content") or "",
|
||||||
"content": "",
|
"content": "",
|
||||||
"tool_calls": self._ordered_tool_calls(tool_calls),
|
"tool_calls": self._ordered_tool_calls(tool_calls),
|
||||||
},
|
},
|
||||||
@@ -574,18 +597,19 @@ class OllamaAgent:
|
|||||||
continue
|
continue
|
||||||
attached_image_count = len(message.get("images") or [])
|
attached_image_count = len(message.get("images") or [])
|
||||||
break
|
break
|
||||||
context = self._runtime_context(
|
stable_context, volatile_context = self._runtime_context_parts(
|
||||||
query,
|
query,
|
||||||
previous_interaction=previous_interaction,
|
previous_interaction=previous_interaction,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
attached_image_count=attached_image_count,
|
attached_image_count=attached_image_count,
|
||||||
)
|
)
|
||||||
if not context:
|
contexts = [part for part in ("\n".join(stable_context), "\n".join(volatile_context)) if part]
|
||||||
|
if not contexts:
|
||||||
return messages
|
return messages
|
||||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
return [messages[0], *({"role": "system", "content": context} for context in contexts), *messages[1:]]
|
||||||
|
|
||||||
async def _openai_health(self) -> dict[str, Any]:
|
async def _openai_health(self) -> dict[str, Any]:
|
||||||
return await self._cloud_health("openai")
|
return await self._cloud_health(self.provider if self._is_openai_compatible_provider() else "openai")
|
||||||
|
|
||||||
async def _codex_health(self) -> dict[str, Any]:
|
async def _codex_health(self) -> dict[str, Any]:
|
||||||
command = self._codex_command()
|
command = self._codex_command()
|
||||||
@@ -671,6 +695,19 @@ class OllamaAgent:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _openai_request_options(self, stream: bool) -> dict[str, Any]:
|
||||||
|
if self.provider == "deepseek":
|
||||||
|
options: dict[str, Any] = {}
|
||||||
|
if self.reasoning_effort in {"none", "minimal"}:
|
||||||
|
options["thinking"] = {"type": "disabled"}
|
||||||
|
else:
|
||||||
|
options["thinking"] = {"type": "enabled"}
|
||||||
|
options["reasoning_effort"] = "max" if self.reasoning_effort in {"xhigh", "max"} else "high"
|
||||||
|
if stream:
|
||||||
|
options["stream_options"] = {"include_usage": True}
|
||||||
|
return options
|
||||||
|
return {"reasoning_effort": self.reasoning_effort}
|
||||||
|
|
||||||
def _openai_messages(
|
def _openai_messages(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -706,6 +743,8 @@ class OllamaAgent:
|
|||||||
entry["content"] = content_parts
|
entry["content"] = content_parts
|
||||||
if role == "assistant" and message.get("tool_calls"):
|
if role == "assistant" and message.get("tool_calls"):
|
||||||
entry["tool_calls"] = message["tool_calls"]
|
entry["tool_calls"] = message["tool_calls"]
|
||||||
|
if role == "assistant" and message.get("reasoning_content"):
|
||||||
|
entry["reasoning_content"] = message["reasoning_content"]
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
entry["tool_call_id"] = message.get("tool_call_id") or message.get("tool_name") or "tool"
|
entry["tool_call_id"] = message.get("tool_call_id") or message.get("tool_name") or "tool"
|
||||||
normalized.append(entry)
|
normalized.append(entry)
|
||||||
@@ -729,10 +768,29 @@ class OllamaAgent:
|
|||||||
def _provider_label(self) -> str:
|
def _provider_label(self) -> str:
|
||||||
if self.provider == "openai":
|
if self.provider == "openai":
|
||||||
return "OpenAI model"
|
return "OpenAI model"
|
||||||
|
if self.provider == "deepseek":
|
||||||
|
return "DeepSeek model"
|
||||||
if self.provider == "codex":
|
if self.provider == "codex":
|
||||||
return "Codex model"
|
return "Codex model"
|
||||||
return "local model"
|
return "local model"
|
||||||
|
|
||||||
|
def _is_openai_compatible_provider(self) -> bool:
|
||||||
|
return self.provider in {"openai", "deepseek"}
|
||||||
|
|
||||||
|
def _tool_rounds(self):
|
||||||
|
if self.provider == "deepseek":
|
||||||
|
while True:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
for _ in range(10):
|
||||||
|
yield None
|
||||||
|
|
||||||
|
def _tool_round_limit_message(self) -> str:
|
||||||
|
return "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
|
||||||
|
|
||||||
|
def _wake_tool_round_limit_message(self) -> str:
|
||||||
|
return "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
||||||
index = int(delta.get("index") or 0)
|
index = int(delta.get("index") or 0)
|
||||||
@@ -1175,20 +1233,21 @@ class OllamaAgent:
|
|||||||
models.append(slug)
|
models.append(slug)
|
||||||
return sorted(set(models))
|
return sorted(set(models))
|
||||||
|
|
||||||
def _runtime_context(
|
def _runtime_context_parts(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
previous_interaction: dict[str, Any] | None = None,
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
attached_image_count: int = 0,
|
attached_image_count: int = 0,
|
||||||
) -> str:
|
) -> tuple[list[str], list[str]]:
|
||||||
local_zone = get_localzone()
|
local_zone = get_localzone()
|
||||||
parts = [
|
stable_parts: list[str] = []
|
||||||
|
volatile_parts = [
|
||||||
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
||||||
]
|
]
|
||||||
if attached_image_count:
|
if attached_image_count:
|
||||||
label = "image" if attached_image_count == 1 else "images"
|
label = "image" if attached_image_count == 1 else "images"
|
||||||
parts.append(
|
volatile_parts.append(
|
||||||
f"Current user message includes {attached_image_count} pasted {label}. "
|
f"Current user message includes {attached_image_count} pasted {label}. "
|
||||||
"You can inspect them visually. If the user wants one reused in a marketplace listing draft, "
|
"You can inspect them visually. If the user wants one reused in a marketplace listing draft, "
|
||||||
"call draft_marketplace_listing or draft_marketplace_listing_with_cornerstone_image with "
|
"call draft_marketplace_listing or draft_marketplace_listing_with_cornerstone_image with "
|
||||||
@@ -1202,34 +1261,34 @@ class OllamaAgent:
|
|||||||
if uex.bearer_token:
|
if uex.bearer_token:
|
||||||
auth_methods.append("bearer token")
|
auth_methods.append("bearer token")
|
||||||
if auth_methods:
|
if auth_methods:
|
||||||
parts.append(
|
stable_parts.append(
|
||||||
"UEX API authentication is configured server-side with "
|
"UEX API authentication is configured server-side with "
|
||||||
+ " and ".join(auth_methods)
|
+ " and ".join(auth_methods)
|
||||||
+ "; use authenticated UEX tools directly and do not ask for tokens."
|
+ "; use authenticated UEX tools directly and do not ask for tokens."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
parts.append("UEX API authentication is not configured server-side.")
|
stable_parts.append("UEX API authentication is not configured server-side.")
|
||||||
if self.user_name:
|
if self.user_name:
|
||||||
parts.append(f"Known user name/handle: {self.user_name}.")
|
stable_parts.append(f"Known user name/handle: {self.user_name}.")
|
||||||
|
|
||||||
if self.memory is None:
|
if self.memory is None:
|
||||||
return "\n".join(parts)
|
return stable_parts, volatile_parts
|
||||||
|
|
||||||
profile = self.memory.get_profile()
|
profile = self.memory.get_profile()
|
||||||
if profile:
|
if profile:
|
||||||
identity = self._profile_identity(profile)
|
identity = self._profile_identity(profile)
|
||||||
if identity:
|
if identity:
|
||||||
parts.append(identity)
|
stable_parts.append(identity)
|
||||||
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
|
stable_parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
|
||||||
|
|
||||||
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction(thread_id)
|
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction(thread_id)
|
||||||
if last:
|
if last:
|
||||||
parts.append(
|
volatile_parts.append(
|
||||||
f"Previous interaction before this message: {last['created_at']} "
|
f"Previous interaction before this message: {last['created_at']} "
|
||||||
f"({time_since(last['created_at'])}, role {last['role']})."
|
f"({time_since(last['created_at'])}, role {last['role']})."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
parts.append("Previous interaction before this message: none recorded.")
|
volatile_parts.append("Previous interaction before this message: none recorded.")
|
||||||
|
|
||||||
memories = self.memory.recall(query, limit=6)
|
memories = self.memory.recall(query, limit=6)
|
||||||
if memories:
|
if memories:
|
||||||
@@ -1237,17 +1296,24 @@ class OllamaAgent:
|
|||||||
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
|
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
|
||||||
for item in memories
|
for item in memories
|
||||||
)
|
)
|
||||||
parts.append(f"Relevant long-term memories:\n{memory_text}")
|
volatile_parts.append(f"Relevant long-term memories:\n{memory_text}")
|
||||||
|
|
||||||
recent = self.memory.recent_conversation(limit=6, thread_id=thread_id)
|
return stable_parts, volatile_parts
|
||||||
if recent:
|
|
||||||
recent_text = "\n".join(
|
|
||||||
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
|
|
||||||
for item in recent
|
|
||||||
)
|
|
||||||
parts.append(f"Recent conversation excerpts from this chat:\n{recent_text}")
|
|
||||||
|
|
||||||
return "\n".join(parts)
|
def _runtime_context(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
previous_interaction: dict[str, Any] | None = None,
|
||||||
|
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||||
|
attached_image_count: int = 0,
|
||||||
|
) -> str:
|
||||||
|
stable_parts, volatile_parts = self._runtime_context_parts(
|
||||||
|
query,
|
||||||
|
previous_interaction=previous_interaction,
|
||||||
|
thread_id=thread_id,
|
||||||
|
attached_image_count=attached_image_count,
|
||||||
|
)
|
||||||
|
return "\n".join(part for part in ("\n".join(stable_parts), "\n".join(volatile_parts)) if part)
|
||||||
|
|
||||||
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
|
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
|
||||||
resolved_thread_id = self._thread_id(thread_id)
|
resolved_thread_id = self._thread_id(thread_id)
|
||||||
@@ -1283,7 +1349,7 @@ class OllamaAgent:
|
|||||||
f"Message: {first_message[:800]}"
|
f"Message: {first_message[:800]}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if self.provider == "openai":
|
if self._is_openai_compatible_provider():
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/chat/completions",
|
f"{self.base_url}/chat/completions",
|
||||||
@@ -1295,6 +1361,7 @@ class OllamaAgent:
|
|||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
],
|
],
|
||||||
"stream": False,
|
"stream": False,
|
||||||
|
**self._openai_request_options(stream=False),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -1330,6 +1397,34 @@ class OllamaAgent:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def generate_plan_draft(
|
||||||
|
self,
|
||||||
|
title: str = "",
|
||||||
|
objective: str = "",
|
||||||
|
kind: str = "buying",
|
||||||
|
constraints: dict[str, Any] | None = None,
|
||||||
|
items: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clean_title = str(title or "").strip()
|
||||||
|
clean_objective = str(objective or "").strip()
|
||||||
|
clean_kind = str(kind or "buying").strip().casefold() or "buying"
|
||||||
|
clean_constraints = dict(constraints or {})
|
||||||
|
clean_items = self._normalize_plan_items(items or [])
|
||||||
|
seed = {
|
||||||
|
"title": clean_title,
|
||||||
|
"objective": clean_objective,
|
||||||
|
"kind": clean_kind,
|
||||||
|
"constraints": clean_constraints,
|
||||||
|
"items": clean_items,
|
||||||
|
}
|
||||||
|
prompt = self._plan_draft_prompt(seed)
|
||||||
|
fallback = self._heuristic_plan_draft(seed)
|
||||||
|
try:
|
||||||
|
payload = await self._generate_plain_text(prompt, system_prompt="You draft structured continual plan JSON for TraderAI.")
|
||||||
|
return self._normalize_plan_draft(payload, seed, fallback)
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _thread_id(thread_id: str | None) -> str:
|
def _thread_id(thread_id: str | None) -> str:
|
||||||
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||||
@@ -1346,6 +1441,254 @@ class OllamaAgent:
|
|||||||
text = " ".join(words[:8])
|
text = " ".join(words[:8])
|
||||||
return text[:64]
|
return text[:64]
|
||||||
|
|
||||||
|
async def _generate_plain_text(self, prompt: str, system_prompt: str) -> str:
|
||||||
|
if self._is_openai_compatible_provider():
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=self._openai_headers(),
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
**self._openai_request_options(stream=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
choice = (response.json().get("choices") or [{}])[0]
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
return str(message.get("content") or "")
|
||||||
|
if self.provider == "codex":
|
||||||
|
result = await self._codex_app_server_turn(
|
||||||
|
prompt,
|
||||||
|
[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
thread_id="plan-draft",
|
||||||
|
)
|
||||||
|
return str(result.get("message") or "")
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"options": self._ollama_options(),
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
message = response.json().get("message") or {}
|
||||||
|
return str(message.get("content") or "")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_plan_draft(
|
||||||
|
cls,
|
||||||
|
raw_text: str,
|
||||||
|
seed: dict[str, Any],
|
||||||
|
fallback: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
base = dict(fallback or cls._heuristic_plan_draft(seed))
|
||||||
|
payload = cls._parse_json_object(raw_text)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return base
|
||||||
|
|
||||||
|
title = str(payload.get("title") or seed.get("title") or base.get("title") or "").strip()
|
||||||
|
objective = str(payload.get("objective") or seed.get("objective") or base.get("objective") or "").strip()
|
||||||
|
kind = str(payload.get("kind") or seed.get("kind") or base.get("kind") or "buying").strip().casefold() or "buying"
|
||||||
|
cadence = cls._normalize_plan_cadence(payload.get("cadence")) or base.get("cadence")
|
||||||
|
constraints = cls._normalize_plan_constraints(payload.get("constraints"), seed.get("constraints") or {}, base.get("constraints") or {})
|
||||||
|
items = cls._normalize_plan_items(payload.get("items") or seed.get("items") or base.get("items") or [])
|
||||||
|
|
||||||
|
if kind == "buying" and not items:
|
||||||
|
items = list(base.get("items") or [])
|
||||||
|
if not constraints.get("instructions"):
|
||||||
|
constraints["instructions"] = (base.get("constraints") or {}).get("instructions") or cls._default_plan_instructions(kind)
|
||||||
|
if not constraints.get("message_tone"):
|
||||||
|
constraints["message_tone"] = (base.get("constraints") or {}).get("message_tone") or "friendly and direct"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title or base.get("title") or "Continual plan",
|
||||||
|
"objective": objective or base.get("objective") or title or "Continue this plan",
|
||||||
|
"kind": kind,
|
||||||
|
"cadence": cadence,
|
||||||
|
"constraints": constraints,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _heuristic_plan_draft(cls, seed: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
title = str(seed.get("title") or "").strip()
|
||||||
|
objective = str(seed.get("objective") or "").strip()
|
||||||
|
kind = str(seed.get("kind") or "buying").strip().casefold() or "buying"
|
||||||
|
constraints = cls._normalize_plan_constraints(seed.get("constraints"), {}, {})
|
||||||
|
items = cls._normalize_plan_items(seed.get("items") or [])
|
||||||
|
|
||||||
|
if not items and kind == "buying":
|
||||||
|
inferred_names = cls._infer_item_names(f"{title}\n{objective}")
|
||||||
|
items = [{"item_name": name, "desired_quantity": 1, "max_unit_price": None} for name in inferred_names[:8]]
|
||||||
|
|
||||||
|
if not constraints.get("message_tone"):
|
||||||
|
constraints["message_tone"] = "friendly and direct"
|
||||||
|
if not constraints.get("instructions"):
|
||||||
|
constraints["instructions"] = cls._default_plan_instructions(kind)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title or "Continual plan",
|
||||||
|
"objective": objective or title or "Continue this plan",
|
||||||
|
"kind": kind,
|
||||||
|
"cadence": cls._normalize_plan_cadence(seed.get("cadence")) or ("0 */6 * * *" if kind == "buying" else "0 */4 * * *"),
|
||||||
|
"constraints": constraints,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_plan_instructions(kind: str) -> str:
|
||||||
|
if kind == "custom":
|
||||||
|
return "Check for meaningful updates, summarize what changed, and suggest the next move."
|
||||||
|
return "Track the best active listings, avoid bad prices, and draft messages for approval when a strong candidate appears."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_draft_prompt(seed: dict[str, Any]) -> str:
|
||||||
|
return (
|
||||||
|
"Draft a continual TraderAI plan as strict JSON.\n"
|
||||||
|
"Return one JSON object only with keys: title, objective, kind, cadence, constraints, items.\n"
|
||||||
|
"constraints may include message_tone, instructions, preferred_locations, excluded_sellers, max_unit_price.\n"
|
||||||
|
"items must be an array of objects with item_name, desired_quantity, max_unit_price.\n"
|
||||||
|
"If the request is vague, still fill cadence, message_tone, and instructions.\n"
|
||||||
|
"Only include checklist items when they can be reasonably inferred from the request or existing draft.\n"
|
||||||
|
"Do not wrap the JSON in markdown.\n\n"
|
||||||
|
f"Current draft seed: {json.dumps(seed, ensure_ascii=True)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json_object(raw_text: str) -> dict[str, Any] | None:
|
||||||
|
text = str(raw_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start == -1 or end <= start:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text[start : end + 1])
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_plan_constraints(cls, value: Any, seed: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
merged: dict[str, Any] = {}
|
||||||
|
for source in (fallback, seed, value if isinstance(value, dict) else {}):
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
continue
|
||||||
|
for key, item in source.items():
|
||||||
|
if item in (None, "", [], {}):
|
||||||
|
continue
|
||||||
|
if key in {"preferred_locations", "excluded_sellers"}:
|
||||||
|
if isinstance(item, list):
|
||||||
|
merged[key] = [str(entry).strip() for entry in item if str(entry).strip()]
|
||||||
|
elif key == "max_unit_price":
|
||||||
|
try:
|
||||||
|
merged[key] = float(item)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
merged[key] = str(item).strip()
|
||||||
|
return merged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_plan_cadence(value: Any) -> str | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
parts = text.split()
|
||||||
|
return text if len(parts) == 5 else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_plan_items(cls, items: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = str(item.get("item_name") or item.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
normalized_item: dict[str, Any] = {"item_name": name}
|
||||||
|
try:
|
||||||
|
normalized_item["desired_quantity"] = max(1, int(item.get("desired_quantity") or item.get("quantity") or 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized_item["desired_quantity"] = 1
|
||||||
|
try:
|
||||||
|
if item.get("max_unit_price") not in (None, ""):
|
||||||
|
normalized_item["max_unit_price"] = float(item.get("max_unit_price"))
|
||||||
|
elif item.get("max_price") not in (None, ""):
|
||||||
|
normalized_item["max_unit_price"] = float(item.get("max_price"))
|
||||||
|
else:
|
||||||
|
normalized_item["max_unit_price"] = None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized_item["max_unit_price"] = None
|
||||||
|
normalized.append(normalized_item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_item_names(text: str) -> list[str]:
|
||||||
|
source = str(text or "")
|
||||||
|
quoted = [match.strip() for match in re.findall(r'"([^"\n]{2,80})"|\'([^\'\n]{2,80})\'', source)]
|
||||||
|
names = [next((part for part in group if part), "") for group in quoted]
|
||||||
|
if names:
|
||||||
|
return [name for name in names if name]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for raw_line in source.splitlines():
|
||||||
|
line = raw_line.strip(" -*\t")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if any(token in line for token in [",", ";", "/"]):
|
||||||
|
parts = re.split(r"[,;/]+", line)
|
||||||
|
lines.extend(part.strip() for part in parts if part.strip())
|
||||||
|
else:
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
stopwords = {
|
||||||
|
"need", "needs", "want", "wants", "find", "draft", "deal", "deals", "parts", "part", "items",
|
||||||
|
"watch", "track", "check", "buy", "buying", "for", "the", "and", "with", "from", "best", "cheapest",
|
||||||
|
}
|
||||||
|
inferred = []
|
||||||
|
for line in lines:
|
||||||
|
clean = re.sub(r"\s+", " ", line).strip().strip(".")
|
||||||
|
if len(clean) < 3:
|
||||||
|
continue
|
||||||
|
lowered = clean.casefold()
|
||||||
|
if lowered in stopwords:
|
||||||
|
continue
|
||||||
|
if any(phrase in lowered for phrase in ["find and draft", "check for", "continue this plan"]):
|
||||||
|
continue
|
||||||
|
inferred.append(clean[:120])
|
||||||
|
deduped = []
|
||||||
|
seen = set()
|
||||||
|
for item in inferred:
|
||||||
|
key = item.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(item)
|
||||||
|
return deduped
|
||||||
|
|
||||||
def _pending_payloads(self) -> list[dict[str, Any]]:
|
def _pending_payloads(self) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1405,6 +1748,8 @@ class OllamaAgent:
|
|||||||
"get_scwiki_page": "Reading Star Citizen Wiki page",
|
"get_scwiki_page": "Reading Star Citizen Wiki page",
|
||||||
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
|
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
|
||||||
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
|
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
|
||||||
|
"search_wikelo_ship_projects": "Searching Wikelo ship projects",
|
||||||
|
"get_wikelo_ship_project": "Fetching Wikelo ship requirements",
|
||||||
"search_cornerstone_items": "Searching Cornerstone items",
|
"search_cornerstone_items": "Searching Cornerstone items",
|
||||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||||
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
||||||
@@ -1415,8 +1760,13 @@ class OllamaAgent:
|
|||||||
"search_marketplace_listings": "Searching UEX listings",
|
"search_marketplace_listings": "Searching UEX listings",
|
||||||
"get_marketplace_listing": "Fetching listing details",
|
"get_marketplace_listing": "Fetching listing details",
|
||||||
"list_marketplace_negotiations": "Checking negotiations",
|
"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",
|
"get_negotiation_messages": "Reading negotiation messages",
|
||||||
"draft_negotiation_message": "Drafting message for approval",
|
"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": "Drafting listing for approval",
|
||||||
"draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image",
|
"draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image",
|
||||||
"check_uex_notifications": "Checking UEX notifications",
|
"check_uex_notifications": "Checking UEX notifications",
|
||||||
@@ -1442,6 +1792,21 @@ class OllamaAgent:
|
|||||||
"writing_tokens_per_second": rate(output_tokens, output_duration),
|
"writing_tokens_per_second": rate(output_tokens, output_duration),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cloud_usage_metrics(usage: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||||
|
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||||
|
cache_hit_tokens = int(usage.get("prompt_cache_hit_tokens") or 0)
|
||||||
|
cache_miss_tokens = int(usage.get("prompt_cache_miss_tokens") or 0)
|
||||||
|
metrics = {
|
||||||
|
"reading_tokens": prompt_tokens,
|
||||||
|
"writing_tokens": completion_tokens,
|
||||||
|
}
|
||||||
|
if cache_hit_tokens or cache_miss_tokens:
|
||||||
|
metrics["cache_hit_tokens"] = cache_hit_tokens
|
||||||
|
metrics["cache_miss_tokens"] = cache_miss_tokens
|
||||||
|
return metrics
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _profile_identity(profile: dict[str, Any]) -> str:
|
def _profile_identity(profile: dict[str, Any]) -> str:
|
||||||
user = profile.get("uex_user")
|
user = profile.get("uex_user")
|
||||||
|
|||||||
+13
-5
@@ -17,6 +17,8 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
|||||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||||
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||||
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
||||||
|
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
|
||||||
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
|
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
|
||||||
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
|
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
|
||||||
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
|
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
|
||||||
@@ -26,8 +28,10 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
|||||||
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
|
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
|
||||||
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
|
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
|
||||||
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
||||||
|
"deepseek_api_key": {"env": "DEEPSEEK_API_KEY", "type": "string", "secret": True},
|
||||||
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
||||||
|
"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_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||||
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "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},
|
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
|
||||||
@@ -77,6 +81,8 @@ class Settings(BaseSettings):
|
|||||||
ollama_num_ctx: int = 64512
|
ollama_num_ctx: int = 64512
|
||||||
openai_base_url: str = "https://api.openai.com/v1"
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
openai_model: str = "gpt-5.4-mini"
|
openai_model: str = "gpt-5.4-mini"
|
||||||
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
|
deepseek_model: str = "deepseek-v4-flash"
|
||||||
model_reasoning_effort: str = "medium"
|
model_reasoning_effort: str = "medium"
|
||||||
codex_command: str = "codex"
|
codex_command: str = "codex"
|
||||||
codex_model: str = "gpt-5.4"
|
codex_model: str = "gpt-5.4"
|
||||||
@@ -86,14 +92,16 @@ class Settings(BaseSettings):
|
|||||||
scwiki_base_url: str = "https://starcitizen.tools"
|
scwiki_base_url: str = "https://starcitizen.tools"
|
||||||
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
|
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
|
||||||
openai_api_key: str | None = Field(default=None)
|
openai_api_key: str | None = Field(default=None)
|
||||||
|
deepseek_api_key: str | None = Field(default=None)
|
||||||
uex_secret_key: str | None = Field(default=None)
|
uex_secret_key: str | None = Field(default=None)
|
||||||
uex_bearer_token: str | None = Field(default=None)
|
uex_bearer_token: str | None = Field(default=None)
|
||||||
|
uex_negotiation_close_endpoint: str = "marketplace_negotiations_close"
|
||||||
traderai_user_name: str | None = Field(default=None)
|
traderai_user_name: str | None = Field(default=None)
|
||||||
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
||||||
uex_notification_poll_seconds: int = 60
|
uex_notification_poll_seconds: int = 300
|
||||||
require_write_approval: bool = True
|
require_write_approval: bool = True
|
||||||
|
|
||||||
@field_validator("openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
@field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_optional(cls, value: Any) -> Any:
|
def _blank_optional(cls, value: Any) -> Any:
|
||||||
return None if value == "" else value
|
return None if value == "" else value
|
||||||
@@ -102,13 +110,13 @@ class Settings(BaseSettings):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_model_provider(cls, value: Any) -> str:
|
def _normalize_model_provider(cls, value: Any) -> str:
|
||||||
text = str(value or "ollama").strip().casefold()
|
text = str(value or "ollama").strip().casefold()
|
||||||
return text if text in {"ollama", "openai", "codex"} else "ollama"
|
return text if text in {"ollama", "deepseek"} else "ollama"
|
||||||
|
|
||||||
@field_validator("model_reasoning_effort", mode="before")
|
@field_validator("model_reasoning_effort", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_reasoning_effort(cls, value: Any) -> str:
|
def _normalize_reasoning_effort(cls, value: Any) -> str:
|
||||||
text = str(value or "medium").strip().casefold()
|
text = str(value or "medium").strip().casefold()
|
||||||
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh"} else "medium"
|
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
|
||||||
|
|
||||||
@field_validator("traderai_memory_path", mode="before")
|
@field_validator("traderai_memory_path", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -167,7 +175,7 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
def _coerce_value(key: str, value: Any) -> Any:
|
def _coerce_value(key: str, value: Any) -> Any:
|
||||||
field_type = CONFIG_FIELDS[key]["type"]
|
field_type = CONFIG_FIELDS[key]["type"]
|
||||||
if value == "":
|
if value == "":
|
||||||
return None if key in {"openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||||
if field_type == "integer":
|
if field_type == "integer":
|
||||||
return int(value)
|
return int(value)
|
||||||
if field_type == "boolean":
|
if field_type == "boolean":
|
||||||
|
|||||||
+530
-1
@@ -30,6 +30,16 @@ def parse_iso(value: str) -> datetime:
|
|||||||
return parsed
|
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:
|
def time_since(value: str, now: datetime | None = None) -> str:
|
||||||
then = parse_iso(value)
|
then = parse_iso(value)
|
||||||
current = now or utc_now()
|
current = now or utc_now()
|
||||||
@@ -55,7 +65,7 @@ def _plural(value: int, unit: str) -> str:
|
|||||||
|
|
||||||
class MemoryStore:
|
class MemoryStore:
|
||||||
def __init__(self, path: str) -> None:
|
def __init__(self, path: str) -> None:
|
||||||
self.path = Path(path)
|
self.path = Path(path).expanduser().resolve()
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
@@ -138,6 +148,56 @@ class MemoryStore:
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
delivered_at TEXT
|
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")
|
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 ?",
|
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).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 = []
|
profile = []
|
||||||
for row in profile_rows:
|
for row in profile_rows:
|
||||||
@@ -402,6 +480,8 @@ class MemoryStore:
|
|||||||
"profile": profile,
|
"profile": profile,
|
||||||
"scheduled_jobs": [dict(row) for row in jobs],
|
"scheduled_jobs": [dict(row) for row in jobs],
|
||||||
"outbox": [dict(row) for row in outbox],
|
"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(
|
def clear(
|
||||||
@@ -425,6 +505,10 @@ class MemoryStore:
|
|||||||
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
||||||
if include_outbox:
|
if include_outbox:
|
||||||
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
|
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
|
return deleted
|
||||||
|
|
||||||
def set_profile(self, key: str, value: Any) -> None:
|
def set_profile(self, key: str, value: Any) -> None:
|
||||||
@@ -555,3 +639,448 @@ class MemoryStore:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
data["metadata"] = {}
|
data["metadata"] = {}
|
||||||
return data
|
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
|
||||||
+18
-5
@@ -528,12 +528,13 @@ class ContinualPlanRunner:
|
|||||||
|
|
||||||
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||||
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
|
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
|
||||||
|
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
|
||||||
|
build_context = self._plan_build_context(plan["objective"])
|
||||||
message = (
|
message = (
|
||||||
f"Hi, I am interested in your {candidate.get('title') or item['item_name']} listing "
|
f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
|
||||||
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}. "
|
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
|
||||||
f"Is it still available? I am trying to complete: {plan['objective']}. "
|
f"{build_context}If you still have it, I can move quickly."
|
||||||
f"Tone note: {tone}."
|
).strip()
|
||||||
)
|
|
||||||
return await self.tools.draft_negotiation_message(
|
return await self.tools.draft_negotiation_message(
|
||||||
message=message,
|
message=message,
|
||||||
id_listing=self._int_or_none(candidate.get("listing_id")),
|
id_listing=self._int_or_none(candidate.get("listing_id")),
|
||||||
@@ -543,6 +544,18 @@ class ContinualPlanRunner:
|
|||||||
listing_slug=candidate.get("listing_slug"),
|
listing_slug=candidate.get("listing_slug"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_build_context(objective: str) -> str:
|
||||||
|
text = str(objective or "").strip().rstrip(".")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lowered = text.casefold()
|
||||||
|
if "polaris" in lowered:
|
||||||
|
return "I'm putting together parts for a Polaris build. "
|
||||||
|
if "mission" in lowered:
|
||||||
|
return "I'm trying to wrap up a mission build. "
|
||||||
|
return "I'm sourcing parts for a build. "
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
|
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
|
||||||
price = float(listing.get("price") or 10**12)
|
price = float(listing.get("price") or 10**12)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class WakeScheduler:
|
|||||||
self.agent = None
|
self.agent = None
|
||||||
self.uex = None
|
self.uex = None
|
||||||
self.plan_runner = None
|
self.plan_runner = None
|
||||||
|
self.negotiation_sync = None
|
||||||
self.notification_poll_seconds = 60
|
self.notification_poll_seconds = 60
|
||||||
|
|
||||||
def bind_agent(self, agent: Any) -> None:
|
def bind_agent(self, agent: Any) -> None:
|
||||||
@@ -31,6 +32,9 @@ class WakeScheduler:
|
|||||||
def bind_plan_runner(self, plan_runner: Any) -> None:
|
def bind_plan_runner(self, plan_runner: Any) -> None:
|
||||||
self.plan_runner = plan_runner
|
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:
|
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
||||||
self.uex = uex
|
self.uex = uex
|
||||||
self.notification_poll_seconds = max(15, poll_seconds)
|
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]
|
new_pending = [item for item in pending if self._notification_key(item) not in seen]
|
||||||
|
|
||||||
if new_pending:
|
if new_pending:
|
||||||
for item in new_pending:
|
if self.negotiation_sync is not None:
|
||||||
self.memory.add_outbox(self._notification_text(item))
|
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)
|
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_seen_notification_keys", sorted(seen))
|
||||||
self.memory.set_profile("uex_last_notification_check", iso_now())
|
self.memory.set_profile("uex_last_notification_check", iso_now())
|
||||||
|
|||||||
+226
-16
@@ -24,6 +24,7 @@ from traderai.config import save_settings, settings_payload
|
|||||||
from traderai.config import get_settings
|
from traderai.config import get_settings
|
||||||
from traderai.cornerstone_client import CornerstoneClient
|
from traderai.cornerstone_client import CornerstoneClient
|
||||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||||
|
from traderai.negotiations import NegotiationSyncService
|
||||||
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
from traderai.scmdb_client import SCMDBClient
|
from traderai.scmdb_client import SCMDBClient
|
||||||
@@ -31,6 +32,7 @@ from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
|||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||||
|
from traderai.wikelo_projects_client import WikeloProjectsClient
|
||||||
|
|
||||||
|
|
||||||
def resource_path(*parts: str) -> Path:
|
def resource_path(*parts: str) -> Path:
|
||||||
@@ -62,6 +64,21 @@ class DirectNegotiationMessageRequest(BaseModel):
|
|||||||
message: str
|
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):
|
class ClearMemoryRequest(BaseModel):
|
||||||
include_memories: bool = True
|
include_memories: bool = True
|
||||||
include_conversations: bool = True
|
include_conversations: bool = True
|
||||||
@@ -85,6 +102,14 @@ class ContinualPlanCreateRequest(BaseModel):
|
|||||||
items: list[ContinualPlanItemRequest] = []
|
items: list[ContinualPlanItemRequest] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanDraftRequest(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
objective: str = ""
|
||||||
|
kind: str = "buying"
|
||||||
|
constraints: dict[str, Any] = {}
|
||||||
|
items: list[ContinualPlanItemRequest] = []
|
||||||
|
|
||||||
|
|
||||||
class ContinualPlanEventRequest(BaseModel):
|
class ContinualPlanEventRequest(BaseModel):
|
||||||
kind: str = "note"
|
kind: str = "note"
|
||||||
message: str
|
message: str
|
||||||
@@ -111,20 +136,42 @@ def create_app() -> FastAPI:
|
|||||||
runtime: dict[str, Any] = {}
|
runtime: dict[str, Any] = {}
|
||||||
|
|
||||||
def configure_runtime(current_settings: Any) -> None:
|
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)
|
scmdb = SCMDBClient(current_settings.scmdb_base_url)
|
||||||
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
|
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
|
||||||
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
|
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
|
||||||
tools = ToolRegistry(
|
wikelo = WikeloProjectsClient()
|
||||||
uex,
|
try:
|
||||||
current_settings.require_write_approval,
|
tools = ToolRegistry(
|
||||||
memory=memory,
|
uex,
|
||||||
scheduler=scheduler,
|
current_settings.require_write_approval,
|
||||||
scmdb=scmdb,
|
memory=memory,
|
||||||
cornerstone=cornerstone,
|
scheduler=scheduler,
|
||||||
scwiki=scwiki,
|
scmdb=scmdb,
|
||||||
plan_store=plan_store,
|
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)
|
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||||
tools.plan_runner = plan_runner
|
tools.plan_runner = plan_runner
|
||||||
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
|
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
|
||||||
@@ -143,6 +190,8 @@ def create_app() -> FastAPI:
|
|||||||
scheduler.bind_agent(agent)
|
scheduler.bind_agent(agent)
|
||||||
scheduler.bind_plan_runner(plan_runner)
|
scheduler.bind_plan_runner(plan_runner)
|
||||||
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
|
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(
|
runtime.update(
|
||||||
{
|
{
|
||||||
"settings": current_settings,
|
"settings": current_settings,
|
||||||
@@ -150,6 +199,7 @@ def create_app() -> FastAPI:
|
|||||||
"tools": tools,
|
"tools": tools,
|
||||||
"plan_runner": plan_runner,
|
"plan_runner": plan_runner,
|
||||||
"agent": agent,
|
"agent": agent,
|
||||||
|
"negotiation_sync": negotiation_sync,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +212,10 @@ def create_app() -> FastAPI:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
await refresh_user_profile()
|
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()
|
scheduler.start()
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
@@ -457,8 +511,78 @@ def create_app() -> FastAPI:
|
|||||||
deleted = memory.delete_outbox(inbox_id)
|
deleted = memory.delete_outbox(inbox_id)
|
||||||
return {"deleted": deleted, "inbox": memory.list_outbox()}
|
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")
|
@app.get("/api/negotiations/{identifier}/messages")
|
||||||
async def negotiation_messages(identifier: str) -> dict:
|
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"]
|
uex = runtime["uex"]
|
||||||
params = negotiation_identifier_params(identifier)
|
params = negotiation_identifier_params(identifier)
|
||||||
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
||||||
@@ -470,6 +594,40 @@ def create_app() -> FastAPI:
|
|||||||
payload = {**params, "message": request.message, "is_production": 1}
|
payload = {**params, "message": request.message, "is_production": 1}
|
||||||
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
||||||
|
|
||||||
|
@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")
|
@app.get("/api/wake-jobs")
|
||||||
async def wake_jobs() -> dict:
|
async def wake_jobs() -> dict:
|
||||||
return {"scheduled_jobs": scheduler.list_jobs()}
|
return {"scheduled_jobs": scheduler.list_jobs()}
|
||||||
@@ -493,6 +651,18 @@ def create_app() -> FastAPI:
|
|||||||
raise HTTPException(status_code=400, detail=result["error"])
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/draft")
|
||||||
|
async def draft_continual_plan(request: ContinualPlanDraftRequest) -> dict:
|
||||||
|
agent = runtime["agent"]
|
||||||
|
draft = await agent.generate_plan_draft(
|
||||||
|
title=request.title,
|
||||||
|
objective=request.objective,
|
||||||
|
kind=request.kind,
|
||||||
|
constraints=request.constraints,
|
||||||
|
items=[item.model_dump() for item in request.items],
|
||||||
|
)
|
||||||
|
return {"draft": draft}
|
||||||
|
|
||||||
@app.get("/api/plans/{plan_id}")
|
@app.get("/api/plans/{plan_id}")
|
||||||
async def continual_plan(plan_id: str) -> dict:
|
async def continual_plan(plan_id: str) -> dict:
|
||||||
plan = plan_store.get_plan(plan_id)
|
plan = plan_store.get_plan(plan_id)
|
||||||
@@ -592,6 +762,8 @@ async def inspect_model_provider() -> dict[str, Any]:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.model_provider == "openai":
|
if settings.model_provider == "openai":
|
||||||
return await inspect_openai()
|
return await inspect_openai()
|
||||||
|
if settings.model_provider == "deepseek":
|
||||||
|
return await inspect_deepseek()
|
||||||
if settings.model_provider == "codex":
|
if settings.model_provider == "codex":
|
||||||
return await inspect_codex()
|
return await inspect_codex()
|
||||||
return await inspect_ollama()
|
return await inspect_ollama()
|
||||||
@@ -602,6 +774,16 @@ async def inspect_openai() -> dict[str, Any]:
|
|||||||
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
|
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_deepseek() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
return await inspect_cloud_provider_config(
|
||||||
|
"deepseek",
|
||||||
|
settings.deepseek_base_url,
|
||||||
|
settings.deepseek_api_key,
|
||||||
|
settings.deepseek_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def inspect_codex() -> dict[str, Any]:
|
async def inspect_codex() -> dict[str, Any]:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
command = find_codex_cli(settings.codex_command)
|
command = find_codex_cli(settings.codex_command)
|
||||||
@@ -638,6 +820,8 @@ async def inspect_cloud_provider() -> dict[str, Any]:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.model_provider == "codex":
|
if settings.model_provider == "codex":
|
||||||
return await inspect_codex()
|
return await inspect_codex()
|
||||||
|
if settings.model_provider == "deepseek":
|
||||||
|
return await inspect_deepseek()
|
||||||
return await inspect_openai()
|
return await inspect_openai()
|
||||||
|
|
||||||
|
|
||||||
@@ -647,6 +831,8 @@ async def inspect_provider_models(provider: str | None = None) -> dict[str, Any]
|
|||||||
return await inspect_codex()
|
return await inspect_codex()
|
||||||
if normalized == "ollama":
|
if normalized == "ollama":
|
||||||
return await inspect_ollama()
|
return await inspect_ollama()
|
||||||
|
if normalized == "deepseek":
|
||||||
|
return await inspect_deepseek()
|
||||||
return await inspect_openai()
|
return await inspect_openai()
|
||||||
|
|
||||||
|
|
||||||
@@ -669,8 +855,8 @@ async def inspect_cloud_provider_config(
|
|||||||
"provider": provider,
|
"provider": provider,
|
||||||
"model_available": False,
|
"model_available": False,
|
||||||
"configured_model": model,
|
"configured_model": model,
|
||||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
|
||||||
"reasoning_efforts": reasoning_effort_options(),
|
"reasoning_efforts": provider_reasoning_efforts(provider, model),
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"models": [],
|
"models": [],
|
||||||
"message": f"{provider_name} is selected, but no API key is configured.",
|
"message": f"{provider_name} is selected, but no API key is configured.",
|
||||||
@@ -698,8 +884,8 @@ async def inspect_cloud_provider_config(
|
|||||||
"provider": provider,
|
"provider": provider,
|
||||||
"model_available": model_available,
|
"model_available": model_available,
|
||||||
"configured_model": model,
|
"configured_model": model,
|
||||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
|
||||||
"reasoning_efforts": reasoning_effort_options(),
|
"reasoning_efforts": provider_reasoning_efforts(provider, model),
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"models": models,
|
"models": models,
|
||||||
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
|
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
|
||||||
@@ -783,13 +969,15 @@ def codex_status_message(installed: bool, logged_in: bool, model_available: bool
|
|||||||
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
|
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
|
||||||
if settings.model_provider == "openai":
|
if settings.model_provider == "openai":
|
||||||
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
|
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
|
||||||
|
if settings.model_provider == "deepseek":
|
||||||
|
return settings.deepseek_base_url, settings.deepseek_model, settings.deepseek_api_key
|
||||||
if settings.model_provider == "codex":
|
if settings.model_provider == "codex":
|
||||||
return settings.codex_command, settings.codex_model, None
|
return settings.codex_command, settings.codex_model, None
|
||||||
return settings.ollama_base_url, settings.ollama_model, None
|
return settings.ollama_base_url, settings.ollama_model, None
|
||||||
|
|
||||||
|
|
||||||
def provider_display_name(provider: str) -> str:
|
def provider_display_name(provider: str) -> str:
|
||||||
return {"openai": "OpenAI", "codex": "Codex"}.get(provider, "Ollama")
|
return {"openai": "OpenAI", "deepseek": "DeepSeek", "codex": "Codex"}.get(provider, "Ollama")
|
||||||
|
|
||||||
|
|
||||||
def find_codex_cli(configured_command: str | None = None) -> Path | None:
|
def find_codex_cli(configured_command: str | None = None) -> Path | None:
|
||||||
@@ -1056,6 +1244,28 @@ def reasoning_effort_options() -> list[str]:
|
|||||||
return ["none", "minimal", "low", "medium", "high", "xhigh"]
|
return ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||||
|
|
||||||
|
|
||||||
|
def deepseek_reasoning_efforts(model: str) -> list[str]:
|
||||||
|
supported_models = {"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"}
|
||||||
|
return ["none", "high", "max"] if model in supported_models else ["none", "high"]
|
||||||
|
|
||||||
|
|
||||||
|
def provider_reasoning_efforts(provider: str, model: str) -> list[str]:
|
||||||
|
if provider == "deepseek":
|
||||||
|
return deepseek_reasoning_efforts(model)
|
||||||
|
return reasoning_effort_options()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_provider_reasoning_effort(provider: str, effort: str) -> str:
|
||||||
|
normalized = str(effort or "medium").strip().casefold()
|
||||||
|
if provider != "deepseek":
|
||||||
|
return normalized
|
||||||
|
if normalized in {"none", "minimal"}:
|
||||||
|
return "none"
|
||||||
|
if normalized in {"xhigh", "max"}:
|
||||||
|
return "max"
|
||||||
|
return "high"
|
||||||
|
|
||||||
|
|
||||||
def find_ollama_executable() -> Path | None:
|
def find_ollama_executable() -> Path | None:
|
||||||
candidates = [
|
candidates = [
|
||||||
shutil.which("ollama"),
|
shutil.which("ollama"),
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ from typing import Any, Awaitable, Callable
|
|||||||
|
|
||||||
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
|
from traderai.negotiations import UEX_NEGOTIATION_CLOSE_ENDPOINT
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
from traderai.scmdb_client import SCMDBClient
|
from traderai.scmdb_client import SCMDBClient
|
||||||
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
|
from traderai.wikelo_projects_client import WikeloProjectsClient
|
||||||
|
|
||||||
|
|
||||||
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
|
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
|
||||||
@@ -149,6 +151,7 @@ UEX_RESOURCE_DESCRIPTIONS = {
|
|||||||
UEX_PRODUCTION_WRITE_RESOURCES = {
|
UEX_PRODUCTION_WRITE_RESOURCES = {
|
||||||
"marketplace_advertise",
|
"marketplace_advertise",
|
||||||
"marketplace_negotiations_messages",
|
"marketplace_negotiations_messages",
|
||||||
|
UEX_NEGOTIATION_CLOSE_ENDPOINT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -172,18 +175,22 @@ class ToolRegistry:
|
|||||||
scmdb: SCMDBClient | None = None,
|
scmdb: SCMDBClient | None = None,
|
||||||
cornerstone: CornerstoneClient | None = None,
|
cornerstone: CornerstoneClient | None = None,
|
||||||
scwiki: StarCitizenWikiClient | None = None,
|
scwiki: StarCitizenWikiClient | None = None,
|
||||||
|
wikelo: WikeloProjectsClient | None = None,
|
||||||
plan_store: Any | None = None,
|
plan_store: Any | None = None,
|
||||||
plan_runner: Any | None = None,
|
plan_runner: Any | None = None,
|
||||||
|
negotiation_sync: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.uex = uex
|
self.uex = uex
|
||||||
self.scmdb = scmdb or SCMDBClient()
|
self.scmdb = scmdb or SCMDBClient()
|
||||||
self.cornerstone = cornerstone or CornerstoneClient()
|
self.cornerstone = cornerstone or CornerstoneClient()
|
||||||
self.scwiki = scwiki or StarCitizenWikiClient()
|
self.scwiki = scwiki or StarCitizenWikiClient()
|
||||||
|
self.wikelo = wikelo or WikeloProjectsClient()
|
||||||
self.require_write_approval = require_write_approval
|
self.require_write_approval = require_write_approval
|
||||||
self.memory = memory
|
self.memory = memory
|
||||||
self.scheduler = scheduler
|
self.scheduler = scheduler
|
||||||
self.plan_store = plan_store
|
self.plan_store = plan_store
|
||||||
self.plan_runner = plan_runner
|
self.plan_runner = plan_runner
|
||||||
|
self.negotiation_sync = negotiation_sync
|
||||||
self.pending_actions: dict[str, PendingAction] = {}
|
self.pending_actions: dict[str, PendingAction] = {}
|
||||||
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
|
self._chat_images_var: ContextVar[list[dict[str, Any]]] = ContextVar("chat_images", default=[])
|
||||||
self.handlers: dict[str, ToolHandler] = {
|
self.handlers: dict[str, ToolHandler] = {
|
||||||
@@ -193,6 +200,11 @@ class ToolRegistry:
|
|||||||
"list_marketplace_negotiations": self.list_marketplace_negotiations,
|
"list_marketplace_negotiations": self.list_marketplace_negotiations,
|
||||||
"get_negotiation_messages": self.get_negotiation_messages,
|
"get_negotiation_messages": self.get_negotiation_messages,
|
||||||
"draft_negotiation_message": self.draft_negotiation_message,
|
"draft_negotiation_message": self.draft_negotiation_message,
|
||||||
|
"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,
|
"draft_marketplace_listing": self.draft_marketplace_listing,
|
||||||
"remember_user_fact": self.remember_user_fact,
|
"remember_user_fact": self.remember_user_fact,
|
||||||
"recall_memory": self.recall_memory,
|
"recall_memory": self.recall_memory,
|
||||||
@@ -214,6 +226,8 @@ class ToolRegistry:
|
|||||||
"get_scwiki_page": self.get_scwiki_page,
|
"get_scwiki_page": self.get_scwiki_page,
|
||||||
"search_scwiki_vehicles": self.search_scwiki_vehicles,
|
"search_scwiki_vehicles": self.search_scwiki_vehicles,
|
||||||
"get_scwiki_vehicle": self.get_scwiki_vehicle,
|
"get_scwiki_vehicle": self.get_scwiki_vehicle,
|
||||||
|
"search_wikelo_ship_projects": self.search_wikelo_ship_projects,
|
||||||
|
"get_wikelo_ship_project": self.get_wikelo_ship_project,
|
||||||
"search_cornerstone_items": self.search_cornerstone_items,
|
"search_cornerstone_items": self.search_cornerstone_items,
|
||||||
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
||||||
"get_cornerstone_item_media": self.get_cornerstone_item_media,
|
"get_cornerstone_item_media": self.get_cornerstone_item_media,
|
||||||
@@ -244,6 +258,7 @@ class ToolRegistry:
|
|||||||
*self._uex_delete_schemas(),
|
*self._uex_delete_schemas(),
|
||||||
*self._scmdb_schemas(),
|
*self._scmdb_schemas(),
|
||||||
*self._scwiki_schemas(),
|
*self._scwiki_schemas(),
|
||||||
|
*self._wikelo_schemas(),
|
||||||
*self._cornerstone_schemas(),
|
*self._cornerstone_schemas(),
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
@@ -348,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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -1071,6 +1177,39 @@ class ToolRegistry:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _wikelo_schemas(cls) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_wikelo_ship_projects",
|
||||||
|
"description": "Search Wikelo ship projects and their required materials from wikelo-projects.com. Use this when the user asks for Wikelo ship requirements or build materials.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Ship or project name to search for, such as Polaris, Idris, Zeus, or Guardian."},
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_wikelo_ship_project",
|
||||||
|
"description": "Fetch one Wikelo ship project with its required materials and contribution progress.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"project_id": {"type": "string", "description": "Wikelo ship project id."},
|
||||||
|
"ship_name": {"type": "string", "description": "Ship or project name if the project id is not known."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
|
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
@@ -1379,6 +1518,37 @@ class ToolRegistry:
|
|||||||
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
|
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)
|
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(
|
async def draft_negotiation_message(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
@@ -1414,6 +1584,41 @@ class ToolRegistry:
|
|||||||
metadata=attached_image.get("metadata"),
|
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(
|
async def draft_marketplace_listing_with_cornerstone_image(
|
||||||
self,
|
self,
|
||||||
item_query: str,
|
item_query: str,
|
||||||
@@ -1740,6 +1945,51 @@ class ToolRegistry:
|
|||||||
vehicle = await self.scwiki.get_vehicle(resolved_slug)
|
vehicle = await self.scwiki.get_vehicle(resolved_slug)
|
||||||
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
|
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
|
||||||
|
|
||||||
|
async def search_wikelo_ship_projects(self, query: str, limit: int = 5) -> dict[str, Any]:
|
||||||
|
projects = await self.wikelo.list_ship_projects()
|
||||||
|
q = (query or "").casefold().strip()
|
||||||
|
matches = []
|
||||||
|
for project in projects:
|
||||||
|
score = self._wikelo_ship_match_score(q, project)
|
||||||
|
if q and score <= 0:
|
||||||
|
continue
|
||||||
|
matches.append((score, project))
|
||||||
|
matches.sort(
|
||||||
|
key=lambda match: (
|
||||||
|
-match[0],
|
||||||
|
str(match[1].get("ship_name") or "").casefold(),
|
||||||
|
str(match[1].get("id") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
limit = max(1, min(limit, 10))
|
||||||
|
return {
|
||||||
|
"source": f"{self.wikelo.base_url}/Ships",
|
||||||
|
"query": query,
|
||||||
|
"matched": len(matches),
|
||||||
|
"projects": [self._summarize_wikelo_ship_project(item) for _, item in matches[:limit]],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_wikelo_ship_project(self, project_id: str | None = None, ship_name: str | None = None) -> dict[str, Any]:
|
||||||
|
projects = await self.wikelo.list_ship_projects()
|
||||||
|
if project_id:
|
||||||
|
for project in projects:
|
||||||
|
if str(project.get("id") or "").strip() == str(project_id).strip():
|
||||||
|
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(project, detailed=True)}
|
||||||
|
return {"error": "No Wikelo ship project matched that id."}
|
||||||
|
|
||||||
|
if not ship_name:
|
||||||
|
return {"error": "Provide project_id or ship_name."}
|
||||||
|
|
||||||
|
ranked = [
|
||||||
|
(self._wikelo_ship_match_score(ship_name.casefold().strip(), project), project)
|
||||||
|
for project in projects
|
||||||
|
]
|
||||||
|
ranked = [match for match in ranked if match[0] > 0]
|
||||||
|
ranked.sort(key=lambda match: (-match[0], str(match[1].get("ship_name") or "").casefold()))
|
||||||
|
if not ranked:
|
||||||
|
return {"error": "No Wikelo ship project matched."}
|
||||||
|
return {"source": f"{self.wikelo.base_url}/Ships", "project": self._summarize_wikelo_ship_project(ranked[0][1], detailed=True)}
|
||||||
|
|
||||||
async def search_cornerstone_items(
|
async def search_cornerstone_items(
|
||||||
self,
|
self,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
@@ -2492,6 +2742,62 @@ class ToolRegistry:
|
|||||||
"version": vehicle.get("version"),
|
"version": vehicle.get("version"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wikelo_ship_match_score(query: str, project: dict[str, Any]) -> int:
|
||||||
|
if not query:
|
||||||
|
return 1
|
||||||
|
ship_name = str(project.get("ship_name") or "").casefold()
|
||||||
|
description = str(project.get("description") or "").casefold()
|
||||||
|
materials = " ".join(
|
||||||
|
str(item.get("material_name") or "").casefold()
|
||||||
|
for item in (project.get("required_materials") or [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
haystack = " ".join(part for part in [ship_name, description, materials] if part)
|
||||||
|
if ship_name == query:
|
||||||
|
return 10000
|
||||||
|
if query in ship_name:
|
||||||
|
return 9000 - ship_name.index(query)
|
||||||
|
if query in description:
|
||||||
|
return 7000 - description.index(query)
|
||||||
|
if query in materials:
|
||||||
|
return 5000 - materials.index(query)
|
||||||
|
tokens = [token for token in query.split() if token]
|
||||||
|
if tokens and all(token in haystack for token in tokens):
|
||||||
|
return 3000 - len(haystack)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _summarize_wikelo_ship_project(cls, project: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
|
||||||
|
materials = []
|
||||||
|
for item in (project.get("required_materials") or []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
quantity_needed = item.get("quantity_needed")
|
||||||
|
quantity_collected = item.get("quantity_collected")
|
||||||
|
materials.append(
|
||||||
|
{
|
||||||
|
"material_name": item.get("material_name"),
|
||||||
|
"quantity_needed": int(quantity_needed) if isinstance(quantity_needed, (int, float)) and float(quantity_needed).is_integer() else quantity_needed,
|
||||||
|
"quantity_collected": int(quantity_collected) if isinstance(quantity_collected, (int, float)) and float(quantity_collected).is_integer() else quantity_collected,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
summary = {
|
||||||
|
"id": project.get("id"),
|
||||||
|
"ship_name": project.get("ship_name"),
|
||||||
|
"description": project.get("description"),
|
||||||
|
"status": project.get("status"),
|
||||||
|
"privacy": project.get("privacy"),
|
||||||
|
"owner_name": project.get("owner_name"),
|
||||||
|
"org_name": project.get("org_name"),
|
||||||
|
"home_port": project.get("home_port"),
|
||||||
|
"ship_image": project.get("ship_image"),
|
||||||
|
"materials_count": len(materials),
|
||||||
|
"required_materials": materials if detailed else materials[:12],
|
||||||
|
"source_url": f"https://wikelo-projects.com/Ships",
|
||||||
|
}
|
||||||
|
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
|
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
|
||||||
summary = cls._project_item(negotiation, mode="summary")
|
summary = cls._project_item(negotiation, mode="summary")
|
||||||
|
|||||||
+96
-1
@@ -10,10 +10,17 @@ class UEXError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class UEXClient:
|
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.base_url = base_url.rstrip("/")
|
||||||
self.secret_key = secret_key
|
self.secret_key = secret_key
|
||||||
self.bearer_token = bearer_token
|
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]:
|
def _headers(self, authenticated: bool = False) -> dict[str, str]:
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
@@ -49,6 +56,94 @@ class UEXClient:
|
|||||||
data = [data]
|
data = [data]
|
||||||
return {"status": body.get("status"), "notifications": 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 def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.0.6"
|
__version__ = "0.0.9"
|
||||||
|
|
||||||
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
@@ -13,3 +13,6 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class WikeloProjectsError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WikeloProjectsClient:
|
||||||
|
APP_ID = "695be2905c0b4866dfb21265"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def list_ship_projects(self) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
|
||||||
|
return [item for item in body if isinstance(item, dict)]
|
||||||
|
|
||||||
|
async def _get_json(self, url: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
File diff suppressed because one or more lines are too long
@@ -755,7 +755,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.6"
|
version = "0.0.9"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
@@ -1053,3 +1053,6 @@ wheels = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+420
-114
@@ -15,6 +15,7 @@ const configPathsEl = document.getElementById("config-paths");
|
|||||||
const settingsToggle = document.getElementById("settings-toggle");
|
const settingsToggle = document.getElementById("settings-toggle");
|
||||||
const memoryToggle = document.getElementById("memory-toggle");
|
const memoryToggle = document.getElementById("memory-toggle");
|
||||||
const plansToggle = document.getElementById("plans-toggle");
|
const plansToggle = document.getElementById("plans-toggle");
|
||||||
|
const negotiationsToggle = document.getElementById("negotiations-toggle");
|
||||||
const ollamaToggle = document.getElementById("ollama-toggle");
|
const ollamaToggle = document.getElementById("ollama-toggle");
|
||||||
const settingsPanel = document.getElementById("settings-panel");
|
const settingsPanel = document.getElementById("settings-panel");
|
||||||
const memoryPanel = document.getElementById("memory-panel");
|
const memoryPanel = document.getElementById("memory-panel");
|
||||||
@@ -26,9 +27,8 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
|
|||||||
const ollamaInstallButton = document.getElementById("ollama-install");
|
const ollamaInstallButton = document.getElementById("ollama-install");
|
||||||
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
||||||
const ollamaPullButton = document.getElementById("ollama-pull");
|
const ollamaPullButton = document.getElementById("ollama-pull");
|
||||||
const codexLoginButton = document.getElementById("codex-login");
|
|
||||||
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
|
|
||||||
const providerModelSelect = document.getElementById("provider-model-select");
|
const providerModelSelect = document.getElementById("provider-model-select");
|
||||||
|
const providerModelLabel = document.getElementById("provider-model-label");
|
||||||
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
|
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
|
||||||
const ollamaStatusEl = document.getElementById("ollama-status");
|
const ollamaStatusEl = document.getElementById("ollama-status");
|
||||||
const ollamaMessageEl = document.getElementById("ollama-message");
|
const ollamaMessageEl = document.getElementById("ollama-message");
|
||||||
@@ -49,6 +49,32 @@ const negotiationForm = document.getElementById("negotiation-form");
|
|||||||
const negotiationInput = document.getElementById("negotiation-input");
|
const negotiationInput = document.getElementById("negotiation-input");
|
||||||
const negotiationStatusEl = document.getElementById("negotiation-status");
|
const negotiationStatusEl = document.getElementById("negotiation-status");
|
||||||
const negotiationCloseButton = document.getElementById("negotiation-close");
|
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 updateModal = document.getElementById("update-modal");
|
||||||
const updateModalCopy = document.getElementById("update-modal-copy");
|
const updateModalCopy = document.getElementById("update-modal-copy");
|
||||||
const updateModalClose = document.getElementById("update-modal-close");
|
const updateModalClose = document.getElementById("update-modal-close");
|
||||||
@@ -57,6 +83,7 @@ const updateModalReleases = document.getElementById("update-modal-releases");
|
|||||||
const plansRefreshButton = document.getElementById("plans-refresh");
|
const plansRefreshButton = document.getElementById("plans-refresh");
|
||||||
const plansCloseButton = document.getElementById("plans-close");
|
const plansCloseButton = document.getElementById("plans-close");
|
||||||
const planForm = document.getElementById("plan-form");
|
const planForm = document.getElementById("plan-form");
|
||||||
|
const planAutofillButton = document.getElementById("plan-autofill");
|
||||||
const plansStatusEl = document.getElementById("plans-status");
|
const plansStatusEl = document.getElementById("plans-status");
|
||||||
const plansDashboardEl = document.getElementById("plans-dashboard");
|
const plansDashboardEl = document.getElementById("plans-dashboard");
|
||||||
const plansRailListEl = document.getElementById("plans-rail-list");
|
const plansRailListEl = document.getElementById("plans-rail-list");
|
||||||
@@ -66,6 +93,7 @@ let ollamaOnline = true;
|
|||||||
let latestUpdate = null;
|
let latestUpdate = null;
|
||||||
let currentThreadId = "default";
|
let currentThreadId = "default";
|
||||||
let currentNegotiationId = null;
|
let currentNegotiationId = null;
|
||||||
|
let negotiationRows = [];
|
||||||
let latestOllamaStatus = null;
|
let latestOllamaStatus = null;
|
||||||
let composerImages = [];
|
let composerImages = [];
|
||||||
const clickedOllamaActions = new Set();
|
const clickedOllamaActions = new Set();
|
||||||
@@ -164,6 +192,8 @@ function appendThinkingText(node, text) {
|
|||||||
steps.appendChild(item);
|
steps.appendChild(item);
|
||||||
}
|
}
|
||||||
item.textContent += text;
|
item.textContent += text;
|
||||||
|
const thinking = node.querySelector(".thinking-log");
|
||||||
|
if (thinking && !thinking.open) thinking.open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createThinkTagParser(node) {
|
function createThinkTagParser(node) {
|
||||||
@@ -564,7 +594,8 @@ function renderComposerImages() {
|
|||||||
function formatMetrics(event) {
|
function formatMetrics(event) {
|
||||||
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
|
const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
|
||||||
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
|
const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
|
||||||
return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | ");
|
const cache = formatCacheMetric(event.cache_hit_tokens, event.cache_miss_tokens);
|
||||||
|
return [read && `read ${read}`, wrote && `wrote ${wrote}`, cache].filter(Boolean).join(" | ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTokenMetric(tokens, speed) {
|
function formatTokenMetric(tokens, speed) {
|
||||||
@@ -573,6 +604,13 @@ function formatTokenMetric(tokens, speed) {
|
|||||||
return `${tokens} tok${speedText}`;
|
return `${tokens} tok${speedText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCacheMetric(hitTokens, missTokens) {
|
||||||
|
if (!hitTokens && !missTokens) return "";
|
||||||
|
const hit = Number(hitTokens || 0).toLocaleString();
|
||||||
|
const miss = Number(missTokens || 0).toLocaleString();
|
||||||
|
return `cache ${hit} hit / ${miss} miss`;
|
||||||
|
}
|
||||||
|
|
||||||
function setWarning(text) {
|
function setWarning(text) {
|
||||||
warningEl.hidden = !text;
|
warningEl.hidden = !text;
|
||||||
warningEl.textContent = text || "";
|
warningEl.textContent = text || "";
|
||||||
@@ -597,15 +635,13 @@ const configFieldIds = {
|
|||||||
|
|
||||||
const ollamaFieldIds = {
|
const ollamaFieldIds = {
|
||||||
model_provider: "model-provider",
|
model_provider: "model-provider",
|
||||||
|
deepseek_base_url: "deepseek-base-url",
|
||||||
|
deepseek_api_key: "deepseek-api-key",
|
||||||
|
deepseek_model: "deepseek-model",
|
||||||
ollama_base_url: "ollama-base-url",
|
ollama_base_url: "ollama-base-url",
|
||||||
ollama_model: "ollama-model",
|
ollama_model: "ollama-model",
|
||||||
ollama_num_ctx: "ollama-num-ctx",
|
ollama_num_ctx: "ollama-num-ctx",
|
||||||
openai_base_url: "openai-base-url",
|
|
||||||
openai_api_key: "openai-api-key",
|
|
||||||
openai_model: "openai-model",
|
|
||||||
model_reasoning_effort: "model-reasoning-effort",
|
model_reasoning_effort: "model-reasoning-effort",
|
||||||
codex_command: "codex-command",
|
|
||||||
codex_model: "codex-model",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function refreshConfig() {
|
async function refreshConfig() {
|
||||||
@@ -667,7 +703,7 @@ async function saveConfig(event) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
renderConfig(result);
|
renderConfig(result);
|
||||||
configStatusEl.textContent = result.message || "Saved";
|
configStatusEl.textContent = result.message || "Saved";
|
||||||
addMessage("assistant", "Config saved. Restart TraderAI for the new settings to fully apply.");
|
addMessage("assistant", result.message || "Config saved.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
|
configStatusEl.textContent = `Config save failed: ${fetchErrorMessage(error)}`;
|
||||||
}
|
}
|
||||||
@@ -715,18 +751,18 @@ function renderOllamaStatus(status) {
|
|||||||
updateProviderFieldVisibility(status.provider || "ollama");
|
updateProviderFieldVisibility(status.provider || "ollama");
|
||||||
const provider = providerDisplayName(status.provider);
|
const provider = providerDisplayName(status.provider);
|
||||||
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
||||||
const isOpenAIProvider = status.provider === "openai";
|
const isDeepSeekProvider = status.provider === "deepseek";
|
||||||
const isCodexProvider = status.provider === "codex";
|
const isCloudProvider = isDeepSeekProvider;
|
||||||
const ready = isOpenAIProvider
|
const ready = isCloudProvider
|
||||||
? Boolean(status.online && status.model_available)
|
? Boolean(status.online && status.model_available)
|
||||||
: Boolean(status.installed && status.running && status.model_available);
|
: Boolean(status.installed && status.running && status.model_available);
|
||||||
const pillClass = ready ? "status-pill" : "status-pill warning";
|
const pillClass = ready ? "status-pill" : "status-pill warning";
|
||||||
const detailItems = [
|
const detailItems = [
|
||||||
ollamaStatusItem("Provider", provider),
|
ollamaStatusItem("Provider", provider),
|
||||||
ollamaStatusItem("Model", status.configured_model || ""),
|
ollamaStatusItem("Model", status.configured_model || ""),
|
||||||
ollamaStatusItem(isCodexProvider ? "Command" : "URL", status.base_url || ""),
|
ollamaStatusItem("URL", status.base_url || ""),
|
||||||
];
|
];
|
||||||
if (!isOpenAIProvider && !isCodexProvider) {
|
if (!isCloudProvider) {
|
||||||
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
||||||
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
||||||
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
||||||
@@ -740,30 +776,22 @@ function renderOllamaStatus(status) {
|
|||||||
<div class="ollama-status-grid">
|
<div class="ollama-status-grid">
|
||||||
${detailItems.join("")}
|
${detailItems.join("")}
|
||||||
</div>
|
</div>
|
||||||
${ollamaStatusItem(isOpenAIProvider || isCodexProvider ? "Available Models" : "Installed Models", models)}
|
${ollamaStatusItem(isCloudProvider ? "Available Models" : "Installed Models", models)}
|
||||||
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
||||||
`;
|
`;
|
||||||
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider;
|
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isCloudProvider;
|
||||||
if (ollamaInstallButton) {
|
if (ollamaInstallButton) {
|
||||||
ollamaInstallButton.hidden = isOpenAIProvider || isCodexProvider || !status.can_auto_install;
|
ollamaInstallButton.hidden = isCloudProvider || !status.can_auto_install;
|
||||||
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
|
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
|
||||||
}
|
}
|
||||||
if (ollamaLaunchButton) {
|
if (ollamaLaunchButton) {
|
||||||
ollamaLaunchButton.hidden = isOpenAIProvider || isCodexProvider;
|
ollamaLaunchButton.hidden = isCloudProvider;
|
||||||
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
||||||
}
|
}
|
||||||
if (ollamaPullButton) {
|
if (ollamaPullButton) {
|
||||||
ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider;
|
ollamaPullButton.hidden = isCloudProvider;
|
||||||
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
||||||
}
|
}
|
||||||
if (codexLoginButton) {
|
|
||||||
codexLoginButton.hidden = !isCodexProvider;
|
|
||||||
codexLoginButton.disabled = Boolean(status.online);
|
|
||||||
}
|
|
||||||
if (openaiModelsRefreshButton) {
|
|
||||||
openaiModelsRefreshButton.hidden = false;
|
|
||||||
openaiModelsRefreshButton.disabled = false;
|
|
||||||
}
|
|
||||||
renderProviderModelOptions(status.models || [], status);
|
renderProviderModelOptions(status.models || [], status);
|
||||||
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
|
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
|
||||||
updateOllamaAttention(status);
|
updateOllamaAttention(status);
|
||||||
@@ -808,18 +836,16 @@ function setOllamaButtonAttention(button, action, active) {
|
|||||||
function updateOllamaAttention(status = null) {
|
function updateOllamaAttention(status = null) {
|
||||||
const currentStatus = status || latestOllamaStatus;
|
const currentStatus = status || latestOllamaStatus;
|
||||||
if (!currentStatus) return;
|
if (!currentStatus) return;
|
||||||
const isOpenAIProvider = currentStatus.provider === "openai";
|
const isDeepSeekProvider = currentStatus.provider === "deepseek";
|
||||||
const isCodexProvider = currentStatus.provider === "codex";
|
const isCloudProvider = isDeepSeekProvider;
|
||||||
const ready = isOpenAIProvider
|
const ready = isCloudProvider
|
||||||
? Boolean(currentStatus.online && currentStatus.model_available)
|
? Boolean(currentStatus.online && currentStatus.model_available)
|
||||||
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
|
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
|
||||||
ollamaToggle?.classList.toggle("attention-pulse", !ready);
|
ollamaToggle?.classList.toggle("attention-pulse", !ready);
|
||||||
setOllamaButtonAttention(ollamaDownloadButton, "download", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed);
|
setOllamaButtonAttention(ollamaDownloadButton, "download", !isCloudProvider && !currentStatus.installed);
|
||||||
setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install);
|
setOllamaButtonAttention(ollamaInstallButton, "install", !isCloudProvider && !currentStatus.installed && currentStatus.can_auto_install);
|
||||||
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running);
|
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isCloudProvider && currentStatus.installed && !currentStatus.running);
|
||||||
setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available);
|
setOllamaButtonAttention(ollamaPullButton, "pull", !isCloudProvider && currentStatus.running && !currentStatus.model_available);
|
||||||
setOllamaButtonAttention(codexLoginButton, "codex-login", isCodexProvider && !currentStatus.online);
|
|
||||||
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", isOpenAIProvider && !currentStatus.model_available);
|
|
||||||
if (ready) clickedOllamaActions.clear();
|
if (ready) clickedOllamaActions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,7 +856,11 @@ function configuredOllamaModel() {
|
|||||||
function updateProviderFieldVisibility(provider) {
|
function updateProviderFieldVisibility(provider) {
|
||||||
for (const field of providerScopedFields) {
|
for (const field of providerScopedFields) {
|
||||||
const scope = field.dataset.providerScope;
|
const scope = field.dataset.providerScope;
|
||||||
field.hidden = scope !== provider;
|
const hiddenManualModel = field.dataset.manualModel === "true" && provider !== "ollama";
|
||||||
|
field.hidden = scope !== provider || hiddenManualModel;
|
||||||
|
}
|
||||||
|
if (providerModelLabel) {
|
||||||
|
providerModelLabel.textContent = provider === "ollama" ? "Available Models" : "Model";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,54 +897,6 @@ function renderProviderModelOptions(models, status = latestOllamaStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOpenAIModels() {
|
|
||||||
setOllamaMessage("Loading provider models");
|
|
||||||
try {
|
|
||||||
const provider = document.getElementById("model-provider")?.value || latestOllamaStatus?.provider || "openai";
|
|
||||||
const response = await fetch(`/api/provider/models?provider=${encodeURIComponent(provider)}`);
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
|
||||||
renderProviderModelOptions(result.models || [], {
|
|
||||||
provider: result.provider || provider,
|
|
||||||
configured_model: configuredProviderModel(result.provider || provider),
|
|
||||||
});
|
|
||||||
setOllamaMessage(result.message || "Loaded provider models");
|
|
||||||
await refreshOllamaStatus();
|
|
||||||
} catch (error) {
|
|
||||||
setOllamaMessage(`Provider model load failed: ${fetchErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchCodexLogin() {
|
|
||||||
markOllamaActionClicked("codex-login");
|
|
||||||
setOllamaMessage("Starting Codex sign-in");
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/codex/login", { method: "POST" });
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
|
||||||
if (result.auth_url) {
|
|
||||||
window.open(result.auth_url, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
|
||||||
setOllamaMessage(result.message || "Opened Codex sign-in in your browser. Waiting for completion...");
|
|
||||||
await waitForCodexLogin();
|
|
||||||
} catch (error) {
|
|
||||||
setOllamaMessage(`Codex sign-in failed: ${fetchErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForCodexLogin() {
|
|
||||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 1500 : 3000));
|
|
||||||
await refreshOllamaStatus();
|
|
||||||
const provider = latestOllamaStatus?.provider || "";
|
|
||||||
if (provider === "codex" && latestOllamaStatus?.online) {
|
|
||||||
setOllamaMessage("Codex sign-in complete.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setOllamaMessage("Codex sign-in opened. If you completed it, click Load Provider Models or refresh provider status.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReasoningEffortOptions(efforts, configured) {
|
function renderReasoningEffortOptions(efforts, configured) {
|
||||||
if (!modelReasoningEffortSelect) return;
|
if (!modelReasoningEffortSelect) return;
|
||||||
const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))];
|
const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))];
|
||||||
@@ -929,8 +911,7 @@ function renderReasoningEffortOptions(efforts, configured) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function configuredProviderModel(provider) {
|
function configuredProviderModel(provider) {
|
||||||
if (provider === "openai") return document.getElementById("openai-model")?.value || "";
|
if (provider === "deepseek") return document.getElementById("deepseek-model")?.value || "";
|
||||||
if (provider === "codex") return document.getElementById("codex-model")?.value || "";
|
|
||||||
return document.getElementById("ollama-model")?.value || "";
|
return document.getElementById("ollama-model")?.value || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,13 +919,8 @@ function syncSelectedProviderModel() {
|
|||||||
const provider = document.getElementById("model-provider")?.value || "ollama";
|
const provider = document.getElementById("model-provider")?.value || "ollama";
|
||||||
const selectedModel = providerModelSelect?.value || "";
|
const selectedModel = providerModelSelect?.value || "";
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
if (provider === "openai") {
|
if (provider === "deepseek") {
|
||||||
const field = document.getElementById("openai-model");
|
const field = document.getElementById("deepseek-model");
|
||||||
if (field) field.value = selectedModel;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider === "codex") {
|
|
||||||
const field = document.getElementById("codex-model");
|
|
||||||
if (field) field.value = selectedModel;
|
if (field) field.value = selectedModel;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -953,8 +929,7 @@ function syncSelectedProviderModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function providerDisplayName(provider) {
|
function providerDisplayName(provider) {
|
||||||
if (provider === "openai") return "OpenAI";
|
if (provider === "deepseek") return "DeepSeek";
|
||||||
if (provider === "codex") return "Codex";
|
|
||||||
return "Local Ollama";
|
return "Local Ollama";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,16 +1210,114 @@ async function deleteInboxItem(id) {
|
|||||||
await refreshInbox();
|
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) {
|
async function openNegotiationPanel(identifier) {
|
||||||
|
if (!identifier) {
|
||||||
|
negotiationPanel.hidden = false;
|
||||||
|
negotiationsToggle?.setAttribute("aria-expanded", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
currentNegotiationId = identifier;
|
currentNegotiationId = identifier;
|
||||||
negotiationPanel.hidden = false;
|
negotiationPanel.hidden = false;
|
||||||
negotiationTitle.textContent = `Negotiation ${identifier}`;
|
negotiationsToggle?.setAttribute("aria-expanded", "true");
|
||||||
negotiationStatusEl.textContent = "";
|
negotiationStatusEl.textContent = "";
|
||||||
|
negotiationSyncPillEl.textContent = "Local sync";
|
||||||
|
await loadNegotiationDetail(identifier, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNegotiationDetail(identifier, refreshList = true) {
|
||||||
|
negotiationTitle.textContent = `Negotiation ${identifier}`;
|
||||||
negotiationMessagesEl.textContent = "Loading";
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
|
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}`);
|
||||||
const result = await response.json();
|
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) {
|
} catch (error) {
|
||||||
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
|
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
|
||||||
}
|
}
|
||||||
@@ -1255,6 +1328,7 @@ function closeNegotiationPanel() {
|
|||||||
currentNegotiationId = null;
|
currentNegotiationId = null;
|
||||||
negotiationInput.value = "";
|
negotiationInput.value = "";
|
||||||
negotiationStatusEl.textContent = "";
|
negotiationStatusEl.textContent = "";
|
||||||
|
negotiationsToggle?.setAttribute("aria-expanded", "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPlansPanel(openPlanId = null) {
|
function openPlansPanel(openPlanId = null) {
|
||||||
@@ -1270,6 +1344,37 @@ function closePlansPanel() {
|
|||||||
plansToggle?.setAttribute("aria-expanded", "false");
|
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) {
|
function renderNegotiationMessages(data) {
|
||||||
negotiationMessagesEl.innerHTML = "";
|
negotiationMessagesEl.innerHTML = "";
|
||||||
const items = Array.isArray(data) ? data : [data].filter(Boolean);
|
const items = Array.isArray(data) ? data : [data].filter(Boolean);
|
||||||
@@ -1279,10 +1384,15 @@ function renderNegotiationMessages(data) {
|
|||||||
}
|
}
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "negotiation-message";
|
card.className = `negotiation-message${item.is_me ? " self" : ""}`;
|
||||||
const author = item.user_username || item.username || item.author || item.sender || "UEX";
|
const author = item.author_username || item.user_username || item.username || item.author || item.sender || "UEX";
|
||||||
const body = item.message || item.content || item.text || JSON.stringify(item, null, 2);
|
const body = item.body || item.message || item.content || item.text || JSON.stringify(item, null, 2);
|
||||||
card.innerHTML = `<strong>${escapeHtml(String(author))}</strong><br>${inlineMarkdown(String(body))}`;
|
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.appendChild(card);
|
||||||
}
|
}
|
||||||
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
|
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
|
||||||
@@ -1294,7 +1404,7 @@ async function submitNegotiationMessage(event) {
|
|||||||
if (!text || !currentNegotiationId) return;
|
if (!text || !currentNegotiationId) return;
|
||||||
negotiationStatusEl.textContent = "Sending";
|
negotiationStatusEl.textContent = "Sending";
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
|
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages/manual`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: text }),
|
body: JSON.stringify({ message: text }),
|
||||||
@@ -1303,12 +1413,125 @@ async function submitNegotiationMessage(event) {
|
|||||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||||
negotiationInput.value = "";
|
negotiationInput.value = "";
|
||||||
negotiationStatusEl.textContent = result.message || "Sent";
|
negotiationStatusEl.textContent = result.message || "Sent";
|
||||||
await openNegotiationPanel(currentNegotiationId);
|
if (result.negotiation) renderNegotiationDetail(result.negotiation);
|
||||||
|
await refreshNegotiations(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(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) {
|
function parsePlanItems(text) {
|
||||||
return text
|
return text
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -1323,6 +1546,72 @@ function parsePlanItems(text) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPlanItems(items) {
|
||||||
|
return (items || [])
|
||||||
|
.map((item) => {
|
||||||
|
const name = String(item.item_name || item.name || "").trim();
|
||||||
|
if (!name) return "";
|
||||||
|
const quantity = Number(item.desired_quantity || item.quantity || 1);
|
||||||
|
const maxUnitPrice = item.max_unit_price ?? item.max_price;
|
||||||
|
const parts = [name];
|
||||||
|
if (Number.isFinite(quantity) && quantity > 1) parts.push(String(quantity));
|
||||||
|
else if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push("1");
|
||||||
|
if (maxUnitPrice !== null && maxUnitPrice !== undefined && maxUnitPrice !== "") parts.push(String(maxUnitPrice));
|
||||||
|
return parts.join(" | ");
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPlanDraft(draft) {
|
||||||
|
if (!draft) return;
|
||||||
|
document.getElementById("plan-title").value = draft.title || "";
|
||||||
|
document.getElementById("plan-objective").value = draft.objective || "";
|
||||||
|
document.getElementById("plan-kind").value = draft.kind || "buying";
|
||||||
|
document.getElementById("plan-tone").value = draft.constraints?.message_tone || "";
|
||||||
|
document.getElementById("plan-instructions").value = draft.constraints?.instructions || "";
|
||||||
|
document.getElementById("plan-cadence").value = draft.cadence || "";
|
||||||
|
document.getElementById("plan-items").value = formatPlanItems(draft.items || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autofillPlanDraft() {
|
||||||
|
const title = document.getElementById("plan-title").value.trim();
|
||||||
|
const objective = document.getElementById("plan-objective").value.trim();
|
||||||
|
if (!title && !objective) {
|
||||||
|
plansStatusEl.textContent = "Add at least a title or objective first";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tone = document.getElementById("plan-tone").value.trim();
|
||||||
|
const instructions = document.getElementById("plan-instructions").value.trim();
|
||||||
|
const constraints = {};
|
||||||
|
if (tone) constraints.message_tone = tone;
|
||||||
|
if (instructions) constraints.instructions = instructions;
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
objective,
|
||||||
|
kind: document.getElementById("plan-kind").value || "buying",
|
||||||
|
constraints,
|
||||||
|
items: parsePlanItems(document.getElementById("plan-items").value || ""),
|
||||||
|
};
|
||||||
|
plansStatusEl.textContent = "Drafting plan";
|
||||||
|
if (planAutofillButton) planAutofillButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/plans/draft", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||||
|
applyPlanDraft(result.draft || {});
|
||||||
|
plansStatusEl.textContent = "Draft filled in. Review and edit anything you want.";
|
||||||
|
} catch (error) {
|
||||||
|
plansStatusEl.textContent = `Plan draft failed: ${fetchErrorMessage(error)}`;
|
||||||
|
} finally {
|
||||||
|
if (planAutofillButton) planAutofillButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createPlan(event) {
|
async function createPlan(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const title = document.getElementById("plan-title").value.trim();
|
const title = document.getElementById("plan-title").value.trim();
|
||||||
@@ -1664,8 +1953,8 @@ async function checkHealth() {
|
|||||||
health = await fallbackResponse.json();
|
health = await fallbackResponse.json();
|
||||||
}
|
}
|
||||||
const provider = providerDisplayName(health.provider);
|
const provider = providerDisplayName(health.provider);
|
||||||
const isOpenAIProvider = health.provider === "openai";
|
const isDeepSeekProvider = health.provider === "deepseek";
|
||||||
const isCodexProvider = health.provider === "codex";
|
const isCloudProvider = isDeepSeekProvider;
|
||||||
ollamaOnline = Boolean(health.online);
|
ollamaOnline = Boolean(health.online);
|
||||||
if (!ollamaOnline) {
|
if (!ollamaOnline) {
|
||||||
statusEl.textContent = "Offline";
|
statusEl.textContent = "Offline";
|
||||||
@@ -1674,7 +1963,7 @@ async function checkHealth() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (health.model_available === false) {
|
if (health.model_available === false) {
|
||||||
const action = isOpenAIProvider ? "Load Provider Models." : isCodexProvider ? "Sign In to Codex." : "Install Model.";
|
const action = isCloudProvider ? "Save a working DeepSeek model." : "Install Model.";
|
||||||
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
|
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
|
||||||
ollamaToggle?.classList.add("attention-pulse");
|
ollamaToggle?.classList.add("attention-pulse");
|
||||||
} else {
|
} else {
|
||||||
@@ -1841,7 +2130,10 @@ async function pollNotifications() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch("/api/notifications");
|
const response = await fetch("/api/notifications");
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if ((result.notifications || []).length) await refreshInbox();
|
if ((result.notifications || []).length) {
|
||||||
|
await refreshInbox();
|
||||||
|
await refreshNegotiations(true);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Notification polling should never interrupt chat.
|
// Notification polling should never interrupt chat.
|
||||||
}
|
}
|
||||||
@@ -1886,10 +2178,16 @@ plansToggle?.addEventListener("click", () => {
|
|||||||
if (plansPanel?.hidden) openPlansPanel();
|
if (plansPanel?.hidden) openPlansPanel();
|
||||||
else closePlansPanel();
|
else closePlansPanel();
|
||||||
});
|
});
|
||||||
|
negotiationsToggle?.addEventListener("click", () => {
|
||||||
|
if (negotiationPanel?.hidden) openNegotiationPanel(currentNegotiationId || negotiationRows[0]?.hash || "");
|
||||||
|
else closeNegotiationPanel();
|
||||||
|
});
|
||||||
|
negotiationsRefreshAllButton?.addEventListener("click", refreshAllNegotiations);
|
||||||
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
||||||
plansRefreshButton?.addEventListener("click", () => refreshPlans());
|
plansRefreshButton?.addEventListener("click", () => refreshPlans());
|
||||||
plansCloseButton?.addEventListener("click", closePlansPanel);
|
plansCloseButton?.addEventListener("click", closePlansPanel);
|
||||||
planForm?.addEventListener("submit", createPlan);
|
planForm?.addEventListener("submit", createPlan);
|
||||||
|
planAutofillButton?.addEventListener("click", autofillPlanDraft);
|
||||||
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
||||||
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
||||||
ollamaDownloadButton?.addEventListener("click", () => {
|
ollamaDownloadButton?.addEventListener("click", () => {
|
||||||
@@ -1908,17 +2206,12 @@ ollamaPullButton?.addEventListener("click", () => {
|
|||||||
markOllamaActionClicked("pull");
|
markOllamaActionClicked("pull");
|
||||||
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
||||||
});
|
});
|
||||||
codexLoginButton?.addEventListener("click", launchCodexLogin);
|
|
||||||
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
|
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
|
||||||
document.getElementById("model-provider")?.addEventListener("change", () => {
|
document.getElementById("model-provider")?.addEventListener("change", () => {
|
||||||
const provider = document.getElementById("model-provider")?.value || "ollama";
|
const provider = document.getElementById("model-provider")?.value || "ollama";
|
||||||
updateProviderFieldVisibility(provider);
|
updateProviderFieldVisibility(provider);
|
||||||
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
|
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
|
||||||
});
|
});
|
||||||
openaiModelsRefreshButton?.addEventListener("click", () => {
|
|
||||||
markOllamaActionClicked("openai-models");
|
|
||||||
refreshOpenAIModels();
|
|
||||||
});
|
|
||||||
updateCheckButton?.addEventListener("click", checkForUpdate);
|
updateCheckButton?.addEventListener("click", checkForUpdate);
|
||||||
updateInstallButton?.addEventListener("click", installUpdate);
|
updateInstallButton?.addEventListener("click", installUpdate);
|
||||||
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
||||||
@@ -1926,6 +2219,15 @@ chatSidebarToggle?.addEventListener("click", toggleChatRail);
|
|||||||
newChatButton?.addEventListener("click", () => createChat(true));
|
newChatButton?.addEventListener("click", () => createChat(true));
|
||||||
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
|
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
|
||||||
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
|
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);
|
updateModalClose?.addEventListener("click", closeUpdatePrompt);
|
||||||
updateModalReleases?.addEventListener("click", openReleasesPage);
|
updateModalReleases?.addEventListener("click", openReleasesPage);
|
||||||
updateModalInstall?.addEventListener("click", installUpdate);
|
updateModalInstall?.addEventListener("click", installUpdate);
|
||||||
@@ -1979,6 +2281,8 @@ async function sendMessage() {
|
|||||||
const event = JSON.parse(line.slice(6));
|
const event = JSON.parse(line.slice(6));
|
||||||
if (event.type === "status") {
|
if (event.type === "status") {
|
||||||
setMessageActivity(assistantNode, event.message, true);
|
setMessageActivity(assistantNode, event.message, true);
|
||||||
|
} else if (event.type === "reasoning") {
|
||||||
|
appendThinkingText(assistantNode, event.content || "");
|
||||||
} else if (event.type === "metrics") {
|
} else if (event.type === "metrics") {
|
||||||
setMessageMetrics(assistantNode, formatMetrics(event));
|
setMessageMetrics(assistantNode, formatMetrics(event));
|
||||||
} else if (event.type === "warning") {
|
} else if (event.type === "warning") {
|
||||||
@@ -2027,8 +2331,10 @@ refreshConfig();
|
|||||||
refreshOllamaStatus();
|
refreshOllamaStatus();
|
||||||
refreshChats().then(() => loadChatMessages(currentThreadId));
|
refreshChats().then(() => loadChatMessages(currentThreadId));
|
||||||
refreshInbox();
|
refreshInbox();
|
||||||
|
refreshNegotiations(false);
|
||||||
checkForUpdate(true);
|
checkForUpdate(true);
|
||||||
pollNotifications();
|
pollNotifications();
|
||||||
checkHealth();
|
checkHealth();
|
||||||
setInterval(checkHealth, 30000);
|
setInterval(checkHealth, 30000);
|
||||||
setInterval(pollNotifications, 15000);
|
setInterval(pollNotifications, 15000);
|
||||||
|
setInterval(() => refreshNegotiations(true), 15000);
|
||||||
|
|||||||
+123
-22
@@ -25,6 +25,20 @@
|
|||||||
<div class="rail-heading">Chats</div>
|
<div class="rail-heading">Chats</div>
|
||||||
<div class="chat-list" id="chat-list"></div>
|
<div class="chat-list" id="chat-list"></div>
|
||||||
</section>
|
</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">
|
<section class="chat-nav-section">
|
||||||
<div class="rail-heading-row">
|
<div class="rail-heading-row">
|
||||||
<div class="rail-heading">Plans</div>
|
<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 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 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 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>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>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>
|
<label>Notification Poll Seconds<input id="config-uex-notification-poll-seconds" name="uex_notification_poll_seconds" type="number" min="15" step="15"></label>
|
||||||
@@ -125,20 +140,17 @@
|
|||||||
<form class="config-form" id="ollama-config-form">
|
<form class="config-form" id="ollama-config-form">
|
||||||
<label>Provider
|
<label>Provider
|
||||||
<select id="model-provider" name="model_provider">
|
<select id="model-provider" name="model_provider">
|
||||||
|
<option value="deepseek">DeepSeek V4 (Recommended)</option>
|
||||||
<option value="ollama">Local Ollama</option>
|
<option value="ollama">Local Ollama</option>
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="codex">Codex</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label data-provider-scope="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
|
||||||
|
<label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
|
||||||
|
<label data-provider-scope="deepseek" data-manual-model="true">DeepSeek Model<input id="deepseek-model" name="deepseek_model" type="text" list="provider-models"></label>
|
||||||
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||||
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||||
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||||
<label data-provider-scope="openai">OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
|
<label><span id="provider-model-label">Model</span><select id="provider-model-select"></select></label>
|
||||||
<label data-provider-scope="openai">OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
|
|
||||||
<label data-provider-scope="openai">OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
|
|
||||||
<label data-provider-scope="codex">Codex Command<input id="codex-command" name="codex_command" type="text"></label>
|
|
||||||
<label data-provider-scope="codex">Codex Model<input id="codex-model" name="codex_model" type="text" list="provider-models"></label>
|
|
||||||
<label>Available Models<select id="provider-model-select"></select></label>
|
|
||||||
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
|
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
|
||||||
<datalist id="provider-models"></datalist>
|
<datalist id="provider-models"></datalist>
|
||||||
<button type="submit">Save Provider Config</button>
|
<button type="submit">Save Provider Config</button>
|
||||||
@@ -149,8 +161,6 @@
|
|||||||
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
||||||
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
||||||
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
||||||
<button class="secondary small-button" id="codex-login" type="button">Sign In to Codex</button>
|
|
||||||
<button class="secondary small-button" id="openai-models-refresh" type="button">Load Provider Models</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="config-status" id="ollama-message"></div>
|
<div class="config-status" id="ollama-message"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,19 +185,107 @@
|
|||||||
<div class="floating-panel" id="negotiation-panel" hidden>
|
<div class="floating-panel" id="negotiation-panel" hidden>
|
||||||
<div class="floating-panel-header">
|
<div class="floating-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">UEX negotiation</p>
|
<p class="eyebrow">UEX negotiations</p>
|
||||||
<h2 id="negotiation-title">Negotiation</h2>
|
<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>
|
</div>
|
||||||
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
|
||||||
<i data-lucide="x" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="negotiation-messages" id="negotiation-messages"></div>
|
<div class="negotiation-workspace">
|
||||||
<form class="negotiation-composer" id="negotiation-form">
|
<aside class="negotiation-sidebar">
|
||||||
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
<div class="negotiation-sidebar-controls">
|
||||||
<button type="submit">Send</button>
|
<input id="negotiation-search" type="text" placeholder="Search negotiations">
|
||||||
</form>
|
<select id="negotiation-filter">
|
||||||
<div class="config-status" id="negotiation-status"></div>
|
<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>
|
||||||
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
|
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
|
||||||
<div class="floating-panel-header">
|
<div class="floating-panel-header">
|
||||||
@@ -233,7 +331,10 @@
|
|||||||
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
|
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Create Plan</button>
|
<div class="plan-form-actions">
|
||||||
|
<button id="plan-autofill" type="button">AI Fill</button>
|
||||||
|
<button type="submit">Create Plan</button>
|
||||||
|
</div>
|
||||||
<div class="config-status" id="plans-status"></div>
|
<div class="config-status" id="plans-status"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+228
-2
@@ -105,7 +105,7 @@ body::before {
|
|||||||
|
|
||||||
.chat-rail-content {
|
.chat-rail-content {
|
||||||
display: grid;
|
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;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
@@ -139,6 +139,12 @@ body::before {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-heading-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.rail-heading-row .rail-heading {
|
.rail-heading-row .rail-heading {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -962,6 +968,26 @@ button {
|
|||||||
box-shadow: var(--shadow);
|
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 {
|
.floating-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -977,6 +1003,114 @@ button {
|
|||||||
color: var(--ivory);
|
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 {
|
.negotiation-messages {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -993,9 +1127,25 @@ button {
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.negotiation-composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
@@ -1006,6 +1156,64 @@ button {
|
|||||||
border-radius: 12px;
|
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 {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1302,6 +1510,15 @@ button.secondary {
|
|||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-form-split {
|
.plan-form-split {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -2068,4 +2285,13 @@ pre {
|
|||||||
.plans-panel-body {
|
.plans-panel-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.negotiation-workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negotiation-sidebar,
|
||||||
|
.negotiation-detail-rail {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<link href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp" rel="icon" type="image/svg+xml"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="/manifest.json" rel="manifest"/>
|
||||||
|
<title>
|
||||||
|
Ships | Wikelo Project Tracker
|
||||||
|
</title>
|
||||||
|
<script crossorigin="" src="/assets/index-DWqdqkK8.js" type="module">
|
||||||
|
</script>
|
||||||
|
<link crossorigin="" href="/assets/index-BzxCYXI2.css" rel="stylesheet"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="description"/>
|
||||||
|
<meta content="Ships | Wikelo Project Tracker" property="og:title"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." property="og:description"/>
|
||||||
|
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" property="og:image"/>
|
||||||
|
<meta content="https://wikelo-projects.com/Ships" property="og:url"/>
|
||||||
|
<meta content="website" property="og:type"/>
|
||||||
|
<meta content="Wikelo Project Tracker" property="og:site_name"/>
|
||||||
|
<meta content="Ships | Wikelo Project Tracker" name="twitter:title"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="twitter:description"/>
|
||||||
|
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" name="twitter:image"/>
|
||||||
|
<meta content="summary_large_image" name="twitter:card"/>
|
||||||
|
<meta content="https://wikelo-projects.com/Ships" name="twitter:url"/>
|
||||||
|
<meta content="yes" name="mobile-web-app-capable"/>
|
||||||
|
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
|
||||||
|
<meta content="Wikelo Project Tracker" name="apple-mobile-web-app-title"/>
|
||||||
|
<link href="https://wikelo-projects.com/Ships" rel="canonical"/>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"name": "Wikelo Project Tracker", "@context": "https://schema.org", "@type": "WebSite", "url": "https://wikelo-projects.com"}
|
||||||
|
</script>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"name": "Wikelo Project Tracker", "logo": "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp", "@context": "https://schema.org", "@type": "Organization", "url": "https://wikelo-projects.com"}
|
||||||
|
</script>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://wikelo-projects.com/"}, {"@type": "ListItem", "position": 2, "name": "Ships | Wikelo Project Tracker", "item": "https://wikelo-projects.com/Ships"}]}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div data-seo-source="builder" id="seo-snapshot" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
|
||||||
|
<h1>
|
||||||
|
Ships | Wikelo Project Tracker
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships.
|
||||||
|
</p>
|
||||||
|
<nav aria-label="Pages">
|
||||||
|
<h2>
|
||||||
|
Pages
|
||||||
|
</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/AdminAds">
|
||||||
|
Admin Ads
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/AdvertiseWithUs">
|
||||||
|
Advertise With Us
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ArmorProjectDetails">
|
||||||
|
Armor Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Armors">
|
||||||
|
Armors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/CleanupMaterials">
|
||||||
|
Cleanup Materials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Guide">
|
||||||
|
Guide
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Inventory">
|
||||||
|
Inventory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Messages">
|
||||||
|
Messages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/NotificationSettings">
|
||||||
|
Notification Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Notifications">
|
||||||
|
Notifications
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/OrganizationDetails">
|
||||||
|
Organization Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Organizations">
|
||||||
|
Organizations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Profile">
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ProjectDetails">
|
||||||
|
Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateContributionReputation">
|
||||||
|
Recalculate Contribution Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateMaterials">
|
||||||
|
Recalculate Materials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateReputation">
|
||||||
|
Recalculate Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Reputation">
|
||||||
|
Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/UpdateInfo">
|
||||||
|
Update Info
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/WeaponProjectDetails">
|
||||||
|
Weapon Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Weapons">
|
||||||
|
Weapons
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/WikeloProjectDetails">
|
||||||
|
Wikelo Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user