Compare commits
4 Commits
0.0.2
..
d6c2d57fd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
d6c2d57fd9
|
|||
|
71638fcaed
|
|||
|
767e929bf9
|
|||
|
3b6e3c34d5
|
@@ -2,6 +2,8 @@ OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen3.5:9b
|
||||
OLLAMA_NUM_CTX=64512
|
||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||
SCMDB_BASE_URL=https://scmdb.net
|
||||
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
||||
UEX_SECRET_KEY=
|
||||
UEX_BEARER_TOKEN=
|
||||
TRADERAI_USER_NAME=
|
||||
|
||||
@@ -5,6 +5,8 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
||||
## What It Does
|
||||
|
||||
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
|
||||
- Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo.
|
||||
- Searches Cornerstone Universal Item Finder data so the assistant can find where in-game items are sold, including store/location, base price, and verified date.
|
||||
- Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set.
|
||||
- Drafts negotiation messages and marketplace listings as pending actions.
|
||||
- Requires browser approval before sending authenticated write requests to UEX.
|
||||
@@ -23,6 +25,8 @@ Local Ollama-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.
|
||||
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||
4. Install and run:
|
||||
|
||||
```powershell
|
||||
@@ -71,6 +75,8 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
|
||||
## Sources Used
|
||||
|
||||
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
|
||||
- SCMDB mission data: https://scmdb.net/
|
||||
- Cornerstone Universal Item Finder: https://finder.cstone.space/
|
||||
- UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1
|
||||
- UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1
|
||||
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
Add an option to use API: marketplace_trends as a tool for the AI from UEX
|
||||
Closed negotiations (not open): All other negotiations show date_closed > 0 or were marked as closed by client or Riggs0. find a more relaiable way to mark them as closed/open negotations for the AI
|
||||
some times when having a long tool call the assiant will not reply what it did/an awnser see ---
|
||||
|
||||
2026-05-06T19:41:17.786390+00:00 assistant
|
||||
Looking at the historical price data for **Wikelo Favor**, here's what happened recently:
|
||||
|
||||
## Recent Historical Price Activity (Last Few Hours)
|
||||
|
||||
**Sell Orders (asking prices):**
|
||||
- **1,500,000,000 UEC** (two listings at the same time)
|
||||
- **1,000,000,000 UEC** (one listing)
|
||||
|
||||
**Buy Orders (bidding prices):**
|
||||
- **300,000,000 UEC** (multiple buy orders)
|
||||
|
||||
**Current Active Listings:**
|
||||
- **500,000,000 UEC** per unit (9 in stock)
|
||||
- **1,000,000,000 UEC** per stack (5 in stock)
|
||||
|
||||
## Price Range Analysis
|
||||
|
||||
The market has been fluctuating significantly:
|
||||
- **Lowest recent price:** 300M UEC (buy orders)
|
||||
- **Highest recent price:** 1.5B UEC (sell orders)
|
||||
- **Current active listings:** 500M - 1B UEC
|
||||
|
||||
**What This Means for Pricing:**
|
||||
If you're looking to sell your Wikelo Favors competitively, I'd recommend pricing between **500M - 800M UEC** to match the current market, which is more realistic than the high-end 1.5B UEC listings. The 300M UEC buy orders suggest that's where many players are willing to bid, so anything between 300M-500M UEC might actually sell quickly.
|
||||
|
||||
Would you like me to search for more specific listings or help you create a listing at a particular price point?
|
||||
2026-05-06T19:40:51.966297+00:00 user
|
||||
What happened
|
||||
2026-05-06T19:38:27.178306+00:00 assistant
|
||||
2026-05-06T19:37:52.825713+00:00 user
|
||||
I want you to use the history tools and show me what they were selling for recently so I know what to price mine at
|
||||
|
||||
|
||||
----
|
||||
I want the user to beable to see and directly respond to negotations in the app seperate the ai so it should work like this, when a negotation is refrenced the user should be able to click on it and a floating block wiht the chat messages and an area for the user to type one to the other party,
|
||||
|
||||
I want the message box for the user to be forced to the bottom of the viewport, right now when their are no messages if moves up to the top then its sticky on the bottom when messages happen
|
||||
|
||||
I want the users messages to right aligned and not have abunch of empty space to the right or left of it.
|
||||
|
||||
when I approve messages they dont send I see
|
||||
|
||||
Approval result:
|
||||
|
||||
{
|
||||
|
||||
"status": "ok",
|
||||
|
||||
"http_code": 200,
|
||||
|
||||
"data": {
|
||||
|
||||
"id_message": "0"
|
||||
|
||||
},
|
||||
|
||||
"message": "Sandbox mode"
|
||||
|
||||
}
|
||||
|
||||
and an error that a python dll is missing from the exe.
|
||||
|
||||
|
||||
I want a default collapsed sidebar that has chats, they are to be like chat gpt chats where memories persist across them and the chat history is confied to them. I want the users to beable to delete them with a trash can icon, and I want them saved to disk.
|
||||
|
||||
|
||||
the wake jobs may not be working properly, I scehudled one and
|
||||
I see I'll check your UEX notifications and handle your open negotiations now!
|
||||
but I dont see an output for this job. to fix this I want to have an Inbox that the AI can send messages to. It should be the output of the wake job and have the ablity to continue the coversation in a new chat.
|
||||
|
||||
make a popup that prompts a user to update on open when a new update is avaible.
|
||||
+9
-1
@@ -1,14 +1,22 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||
|
||||
python_binaries = []
|
||||
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
|
||||
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
|
||||
if dll_path.exists():
|
||||
python_binaries.append((str(dll_path), "."))
|
||||
|
||||
|
||||
a = Analysis(
|
||||
["traderai\\desktop.py"],
|
||||
pathex=[],
|
||||
binaries=webview_binaries,
|
||||
binaries=[*webview_binaries, *python_binaries],
|
||||
datas=[("web", "web"), *webview_datas],
|
||||
hiddenimports=[
|
||||
*webview_hiddenimports,
|
||||
|
||||
+9
-1
@@ -1,14 +1,22 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
webview_datas, webview_binaries, webview_hiddenimports = collect_all("webview")
|
||||
|
||||
python_binaries = []
|
||||
for dll_name in ("python3.dll", f"python{sysconfig.get_python_version().replace('.', '')}.dll"):
|
||||
dll_path = Path(sysconfig.get_config_var("BINDIR") or "") / dll_name
|
||||
if dll_path.exists():
|
||||
python_binaries.append((str(dll_path), "."))
|
||||
|
||||
|
||||
a = Analysis(
|
||||
["traderai\\desktop.py"],
|
||||
pathex=[],
|
||||
binaries=webview_binaries,
|
||||
binaries=[*webview_binaries, *python_binaries],
|
||||
datas=[("web", "web"), *webview_datas],
|
||||
hiddenimports=[
|
||||
*webview_hiddenimports,
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "traderai"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -36,3 +36,4 @@ include = ["traderai*"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||
from traderai.memory import MemoryStore
|
||||
@@ -12,6 +13,117 @@ class EmptyTools:
|
||||
return {}
|
||||
|
||||
|
||||
class WakeTools(EmptyTools):
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def execute(self, name, arguments):
|
||||
self.calls.append((name, arguments))
|
||||
return {"count": 1, "notifications": [{"message": "Buyer replied"}]}
|
||||
|
||||
|
||||
class WakeAgent(OllamaAgent):
|
||||
def __init__(self, memory):
|
||||
super().__init__("http://127.0.0.1:1", "missing-model", WakeTools(), memory=memory)
|
||||
self.responses = [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "check_uex_notifications",
|
||||
"arguments": {},
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
{"message": {"role": "assistant", "content": "I checked notifications: Buyer replied."}},
|
||||
]
|
||||
|
||||
async def ensure_available(self):
|
||||
return None
|
||||
|
||||
async def _ollama_chat(self, *args, **kwargs):
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class TitleAgent(OllamaAgent):
|
||||
def __init__(self, memory):
|
||||
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
|
||||
|
||||
async def ensure_available(self):
|
||||
return None
|
||||
|
||||
async def _generate_chat_title(self, first_message):
|
||||
return "UEX Market Check"
|
||||
|
||||
async def _ollama_chat(self, *args, **kwargs):
|
||||
return {"message": {"role": "assistant", "content": "Done"}}
|
||||
|
||||
|
||||
class SlowToolTools(EmptyTools):
|
||||
schemas = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "slow_tool",
|
||||
"description": "Slow fake tool.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def execute(self, name, arguments):
|
||||
self.calls += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return {"status": "ok", "value": "slow result"}
|
||||
|
||||
|
||||
class SlowStreamingAgent(OllamaAgent):
|
||||
def __init__(self, memory):
|
||||
super().__init__("http://127.0.0.1:1", "missing-model", SlowToolTools(), memory=memory)
|
||||
self.stream_calls = 0
|
||||
|
||||
async def health(self):
|
||||
return {"online": True, "model": "test", "base_url": self.base_url}
|
||||
|
||||
async def _ollama_chat_stream(self, *args, **kwargs):
|
||||
self.stream_calls += 1
|
||||
if self.stream_calls == 1:
|
||||
yield {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
return
|
||||
yield {"message": {"role": "assistant", "content": ""}, "done": True}
|
||||
|
||||
|
||||
class FailingAfterToolAgent(SlowStreamingAgent):
|
||||
async def _ollama_chat_stream(self, *args, **kwargs):
|
||||
self.stream_calls += 1
|
||||
if self.stream_calls == 1:
|
||||
yield {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "slow_tool", "arguments": {}}}],
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
return
|
||||
raise RuntimeError("ollama timed out")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_events_warns_when_ollama_offline():
|
||||
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools())
|
||||
@@ -90,3 +202,56 @@ def test_ollama_options_include_num_ctx():
|
||||
agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000)
|
||||
|
||||
assert agent._ollama_options() == {"num_ctx": 64000}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wake_response_executes_tool_calls(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
agent = WakeAgent(memory)
|
||||
|
||||
response = await agent.generate_wake_response("Scheduled wake job fired. Check notifications.")
|
||||
|
||||
assert response == "I checked notifications: Buyer replied."
|
||||
assert agent.tools.calls == [("check_uex_notifications", {})]
|
||||
wake_rows = memory.recent_conversation(thread_id="wake")
|
||||
assert wake_rows[-1]["content"] == "I checked notifications: Buyer replied."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_chat_message_generates_thread_title(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
thread = memory.create_thread()
|
||||
agent = TitleAgent(memory)
|
||||
|
||||
result = await agent.chat("Check UEX market listings", thread_id=thread["id"])
|
||||
|
||||
assert result["message"] == "Done"
|
||||
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
agent = SlowStreamingAgent(memory)
|
||||
|
||||
events = [event async for event in agent.chat_events("run a slow tool")]
|
||||
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
|
||||
|
||||
assert agent.tools.calls == 1
|
||||
assert "I completed the tool call" in text
|
||||
assert "slow result" in text
|
||||
assert events[-1]["type"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_events_returns_tool_result_when_model_fails_after_slow_tool(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
agent = FailingAfterToolAgent(memory)
|
||||
|
||||
events = [event async for event in agent.chat_events("run a slow tool")]
|
||||
text = "".join(event.get("content", "") for event in events if event["type"] == "token")
|
||||
|
||||
assert agent.tools.calls == 1
|
||||
assert "local model stopped after the tool call" in text
|
||||
assert "slow result" in text
|
||||
assert events[-1]["type"] == "done"
|
||||
|
||||
@@ -25,3 +25,33 @@ def test_memory_store_clear_selected_sections(tmp_path):
|
||||
assert snapshot["memories"] == []
|
||||
assert snapshot["conversations"] == []
|
||||
assert snapshot["profile"][0]["key"] == "configured_name"
|
||||
|
||||
|
||||
def test_memory_store_separates_chat_threads_but_keeps_shared_memories(tmp_path):
|
||||
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
first = store.create_thread("First")
|
||||
second = store.create_thread("Second")
|
||||
store.add_conversation("user", "first thread message", first["id"])
|
||||
store.add_conversation("user", "second thread message", second["id"])
|
||||
store.remember("preference", "Shared trading preference", importance=5)
|
||||
|
||||
first_rows = store.recent_conversation(thread_id=first["id"])
|
||||
second_rows = store.recent_conversation(thread_id=second["id"])
|
||||
|
||||
assert [row["content"] for row in first_rows] == ["first thread message"]
|
||||
assert [row["content"] for row in second_rows] == ["second thread message"]
|
||||
assert store.recall("trading preference")[0]["content"] == "Shared trading preference"
|
||||
|
||||
|
||||
def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
|
||||
store = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
thread = store.create_thread("New chat")
|
||||
store.add_outbox("Wake job result")
|
||||
inbox_id = store.list_outbox()[0]["id"]
|
||||
|
||||
renamed = store.rename_thread(thread["id"], " Market Check ")
|
||||
deleted = store.delete_outbox(inbox_id)
|
||||
|
||||
assert renamed["title"] == "Market Check"
|
||||
assert deleted is True
|
||||
assert store.list_outbox() == []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from traderai.agent import OllamaAgent
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
|
||||
@@ -31,6 +32,102 @@ class FakeUEXNotifications:
|
||||
}
|
||||
|
||||
|
||||
class FailingUEXNotifications:
|
||||
async def get_user_notifications(self):
|
||||
raise RuntimeError("bad token")
|
||||
|
||||
|
||||
class FakeWakeAgent:
|
||||
async def generate_wake_response(self, wake_message):
|
||||
return f"Wake output: {wake_message}"
|
||||
|
||||
|
||||
class ListingWakeTools:
|
||||
schemas = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_marketplace_listings",
|
||||
"description": "Search active UEX marketplace listings.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.pending_actions = {}
|
||||
|
||||
async def execute(self, name, arguments):
|
||||
self.calls.append((name, arguments))
|
||||
return {
|
||||
"count": 2,
|
||||
"listings": [
|
||||
{
|
||||
"id": 100,
|
||||
"title": "Wikelo Favor",
|
||||
"operation": "sell",
|
||||
"price": 500_000_000,
|
||||
"currency": "UEC",
|
||||
"in_stock": 9,
|
||||
"advertiser": "pilot_a",
|
||||
},
|
||||
{
|
||||
"id": 101,
|
||||
"title": "Wikelo Favor stack",
|
||||
"operation": "sell",
|
||||
"price": 1_000_000_000,
|
||||
"currency": "UEC",
|
||||
"in_stock": 5,
|
||||
"advertiser": "pilot_b",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ListingWakeAgent(OllamaAgent):
|
||||
def __init__(self, memory):
|
||||
self.listing_tools = ListingWakeTools()
|
||||
super().__init__("http://127.0.0.1:1", "missing-model", self.listing_tools, memory=memory)
|
||||
self.responses = [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "search_marketplace_listings",
|
||||
"arguments": {
|
||||
"query": "Wikelo Favor",
|
||||
"operation": "sell",
|
||||
"limit": 5,
|
||||
},
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"Listing check complete: found 2 active Wikelo Favor sell listings. "
|
||||
"Cheapest listing is 500,000,000 UEC with 9 in stock; the next listing is "
|
||||
"1,000,000,000 UEC. Suggested next action: price near 500,000,000 UEC "
|
||||
"if you want to move yours quickly."
|
||||
),
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
async def ensure_available(self):
|
||||
return None
|
||||
|
||||
async def _ollama_chat(self, *args, **kwargs):
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_uex_notifications_adds_unread_once(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
@@ -45,3 +142,52 @@ async def test_poll_uex_notifications_adds_unread_once(tmp_path):
|
||||
assert second == []
|
||||
assert len(outbox) == 1
|
||||
assert "A buyer replied to your listing." in outbox[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_uex_notifications_reports_failures_to_outbox(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
scheduler = WakeScheduler(memory)
|
||||
scheduler.bind_uex_notifications(FailingUEXNotifications())
|
||||
|
||||
result = await scheduler.poll_uex_notifications()
|
||||
|
||||
assert result == []
|
||||
assert "bad token" in memory.inspect()["outbox"][0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wake_job_writes_agent_output_to_outbox_and_disables_one_shot(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
scheduler = WakeScheduler(memory)
|
||||
scheduler.bind_agent(FakeWakeAgent())
|
||||
memory.add_job("wake-test", "check notifications", "date", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
await scheduler._run_job("wake-test", "check notifications")
|
||||
snapshot = memory.inspect()
|
||||
|
||||
assert "Wake output:" in snapshot["outbox"][0]["content"]
|
||||
assert snapshot["scheduled_jobs"][0]["enabled"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wake_job_checks_listings_and_writes_analysis_to_outbox(tmp_path):
|
||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||
scheduler = WakeScheduler(memory)
|
||||
agent = ListingWakeAgent(memory)
|
||||
scheduler.bind_agent(agent)
|
||||
memory.add_job("wake-listings", "check Wikelo Favor listings and analyze the market", "date", "2099-01-01T00:00:00+00:00")
|
||||
|
||||
await scheduler._run_job("wake-listings", "check Wikelo Favor listings and analyze the market")
|
||||
snapshot = memory.inspect()
|
||||
content = snapshot["outbox"][0]["content"]
|
||||
|
||||
assert agent.listing_tools.calls == [
|
||||
(
|
||||
"search_marketplace_listings",
|
||||
{"query": "Wikelo Favor", "operation": "sell", "limit": 5},
|
||||
)
|
||||
]
|
||||
assert "Listing check complete" in content
|
||||
assert "500,000,000 UEC" in content
|
||||
assert "Suggested next action" in content
|
||||
|
||||
@@ -2,11 +2,15 @@ import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
class FakeUEX:
|
||||
def __init__(self):
|
||||
self.posts = []
|
||||
|
||||
async def get(self, path, params=None, authenticated=False):
|
||||
if path == "commodities_prices_history":
|
||||
return {
|
||||
@@ -113,6 +117,135 @@ class FakeUEX:
|
||||
async def delete(self, path, params=None, authenticated=True):
|
||||
return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}}
|
||||
|
||||
async def post(self, path, payload, authenticated=True):
|
||||
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
|
||||
return {"status": "ok", "posted": self.posts[-1]}
|
||||
|
||||
|
||||
class FakeSCMDB:
|
||||
base_url = "https://scmdb.test"
|
||||
|
||||
async def list_versions(self):
|
||||
return [
|
||||
{"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"},
|
||||
{"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"},
|
||||
]
|
||||
|
||||
async def get_data(self, version=None, channel="live"):
|
||||
return {
|
||||
"version": version or "4.7.2-live.1",
|
||||
"factions": {
|
||||
"fac-haul": {"name": "Covalex"},
|
||||
"fac-bounty": {"name": "Bounty Hunters Guild"},
|
||||
},
|
||||
"scopes": {
|
||||
"scope-rep": {"scopeName": "FactionReputation"},
|
||||
},
|
||||
"factionRewardsPools": [
|
||||
[{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}],
|
||||
[{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}],
|
||||
],
|
||||
"partialRewardPayoutPools": [
|
||||
[],
|
||||
[{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}],
|
||||
],
|
||||
"resourcePools": {
|
||||
"res-tungsten": {"name": "Tungsten"},
|
||||
},
|
||||
"blueprintPools": {
|
||||
"bp-pool": {
|
||||
"name": "Ship Salvage Rewards",
|
||||
"blueprints": [{"name": "Abrade Scraper Module"}],
|
||||
},
|
||||
},
|
||||
"locationPools": {
|
||||
"loc-a18": {"name": "Area18"},
|
||||
"loc-baijini": {"name": "Baijini Point"},
|
||||
},
|
||||
"contracts": [
|
||||
{
|
||||
"id": "mission-haul",
|
||||
"debugName": "Haul_Tungsten_Test",
|
||||
"title": "Move Tungsten",
|
||||
"description": "Move Tungsten to Baijini Point.",
|
||||
"missionType": "Hauling",
|
||||
"category": "career",
|
||||
"factionGuid": "fac-haul",
|
||||
"rewardUEC": 50250,
|
||||
"factionRewardsIndex": 0,
|
||||
"partialRewardPayoutIndex": 1,
|
||||
"haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}],
|
||||
"locations": ["loc-a18"],
|
||||
"destinations": ["loc-baijini"],
|
||||
"systems": ["Stanton"],
|
||||
"illegal": False,
|
||||
"canBeShared": False,
|
||||
},
|
||||
{
|
||||
"id": "mission-bounty",
|
||||
"debugName": "Bounty_Blueprint_Test",
|
||||
"title": "Ambush Op",
|
||||
"description": "Clean out targets.",
|
||||
"missionType": "Bounty Hunter",
|
||||
"factionGuid": "fac-bounty",
|
||||
"rewardUEC": 120000,
|
||||
"factionRewardsIndex": 1,
|
||||
"partialRewardPayoutIndex": 0,
|
||||
"itemRewards": [{"name": "Council Scrip", "amount": 5}],
|
||||
"blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}],
|
||||
"systems": ["Pyro"],
|
||||
"illegal": True,
|
||||
"canBeShared": True,
|
||||
},
|
||||
],
|
||||
"legacyContracts": [
|
||||
{
|
||||
"id": "legacy-delivery",
|
||||
"debugName": "Legacy_Delivery_Test",
|
||||
"title": "Old Box Run",
|
||||
"missionType": "Delivery",
|
||||
"factionGuid": "fac-haul",
|
||||
"rewardUEC": 1000,
|
||||
"factionRewardsIndex": 0,
|
||||
"partialRewardPayoutIndex": 0,
|
||||
"systems": ["Stanton"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class FakeCornerstone:
|
||||
base_url = "https://finder.cstone.test"
|
||||
|
||||
async def list_items(self):
|
||||
return [
|
||||
{"id": "item-abrade", "name": "Abrade Scraper Module", "sold": True},
|
||||
{"id": "item-cinch", "name": "Cinch Scraper Module", "sold": True},
|
||||
{"id": "item-poster", "name": "Zeus 2955 Ship Showdown Poster", "sold": False},
|
||||
]
|
||||
|
||||
async def get_item_page(self, item_id):
|
||||
assert item_id == "item-abrade"
|
||||
return {
|
||||
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
||||
"html": """
|
||||
<html>
|
||||
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
||||
<tr><td>MANUFACTURER</td><td>Greycat Industrial</td></tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||
<tr><td>Stanton - ArcCorp - Area18 - Dumper's Depot</td><td>21 250</td><td>2956-01-29</td></tr>
|
||||
<tr><td>Stanton - microTech - Port Tressler - Platinum Bay</td><td>21 250</td><td>2956-01-04</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_marketplace_listings_filters_locally():
|
||||
@@ -145,6 +278,19 @@ async def test_decline_pending_action_removes_without_sending():
|
||||
assert action_id not in registry.pending_actions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_negotiation_message_forces_production_send():
|
||||
fake = FakeUEX()
|
||||
registry = ToolRegistry(fake)
|
||||
result = await registry.draft_negotiation_message(hash="abc", message="Ready to close", is_production=0)
|
||||
action_id = result["pending_action"]["id"]
|
||||
|
||||
approved = await registry.approve(action_id)
|
||||
|
||||
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
|
||||
assert approved["posted"]["payload"]["is_production"] == 1
|
||||
|
||||
|
||||
def test_uex_client_uses_bearer_and_secret_headers():
|
||||
client = UEXClient("https://api.uexcorp.space/2.0", secret_key="secret", bearer_token="bearer")
|
||||
|
||||
@@ -216,6 +362,113 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
|
||||
assert "uex_draft_post" not in names
|
||||
|
||||
|
||||
def test_schemas_expose_scmdb_mission_tools():
|
||||
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||
|
||||
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||
|
||||
assert "list_scmdb_versions" in names
|
||||
assert "search_scmdb_missions" in names
|
||||
assert "get_scmdb_mission_rewards" in names
|
||||
|
||||
|
||||
def test_schemas_expose_cornerstone_item_tools():
|
||||
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||
|
||||
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||
|
||||
assert "search_cornerstone_items" in names
|
||||
assert "get_cornerstone_item_locations" in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_scmdb_missions_returns_reward_summary():
|
||||
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||
|
||||
result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling")
|
||||
|
||||
assert result["version"] == "4.7.2-live.1"
|
||||
assert result["matched"] == 1
|
||||
mission = result["missions"][0]
|
||||
assert mission["title"] == "Move Tungsten"
|
||||
assert mission["rewards"]["uec"] == 50250
|
||||
assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}]
|
||||
assert mission["rewards"]["hauling"] == [
|
||||
{"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations():
|
||||
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||
|
||||
result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test")
|
||||
|
||||
mission = result["mission"]
|
||||
assert mission["title"] == "Ambush Op"
|
||||
assert mission["faction"] == "Bounty Hunters Guild"
|
||||
assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}]
|
||||
assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_cornerstone_items_filters_sold_items():
|
||||
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||
|
||||
result = await registry.search_cornerstone_items(query="scraper", sold_only=True)
|
||||
|
||||
assert result["matched"] == 2
|
||||
assert {item["name"] for item in result["items"]} == {"Abrade Scraper Module", "Cinch Scraper Module"}
|
||||
assert result["items"][0]["url"].startswith("https://finder.cstone.test/Search/item-")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_cornerstone_item_locations_parses_store_prices():
|
||||
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||
|
||||
result = await registry.get_cornerstone_item_locations(query="abrade", location="Area18")
|
||||
|
||||
assert result["item"]["name"] == "Abrade Scraper Module"
|
||||
assert result["item"]["general"]["manufacturer"] == "Greycat Industrial"
|
||||
assert result["matched_locations"] == 1
|
||||
assert result["locations"] == [
|
||||
{
|
||||
"location": "Stanton - ArcCorp - Area18 - Dumper's Depot",
|
||||
"base_price": 21250,
|
||||
"base_price_display": "21 250",
|
||||
"verified": "2956-01-29",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_parse_cornerstone_item_page_extracts_locations():
|
||||
parsed = parse_cornerstone_item_page(
|
||||
"""
|
||||
<html><head><title>Star Citizen - Food - Whamburger</title></head>
|
||||
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
|
||||
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
|
||||
"""
|
||||
)
|
||||
|
||||
assert parsed["name"] == "Whamburger"
|
||||
assert parsed["locations"][0]["base_price"] == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_cornerstone_client_accepts_json_encoded_string_payload():
|
||||
respx.get("https://finder.cstone.space/GetSearch").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json='[{"id":"item-1","name":"Abrade Scraper Module","Sold":1}]',
|
||||
)
|
||||
)
|
||||
client = CornerstoneClient("https://finder.cstone.space")
|
||||
|
||||
assert await client.list_items() == [{"id": "item-1", "name": "Abrade Scraper Module", "sold": True}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_uex_api_index_finds_history_tools():
|
||||
registry = ToolRegistry(FakeUEX())
|
||||
|
||||
+279
-59
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from traderai.memory import MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||
from traderai.tools import ToolRegistry
|
||||
|
||||
|
||||
@@ -16,9 +17,12 @@ Use tools when the user asks about UEX data, open/current listings, active negot
|
||||
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.
|
||||
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
|
||||
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 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.
|
||||
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.
|
||||
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 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."""
|
||||
|
||||
|
||||
@@ -38,7 +42,7 @@ class OllamaAgent:
|
||||
self.memory = memory
|
||||
self.user_name = user_name
|
||||
self.num_ctx = num_ctx
|
||||
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -51,7 +55,7 @@ class OllamaAgent:
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"message": f"Ollama is offline or unreachable at {self.base_url}. Start Ollama and make sure the model is pulled.",
|
||||
"message": f"Ollama is offline or unreachable at {self.base_url}. Open the Ollama tab and use the recommended action.",
|
||||
"detail": str(exc),
|
||||
}
|
||||
|
||||
@@ -70,107 +74,214 @@ class OllamaAgent:
|
||||
if not health["online"]:
|
||||
raise OllamaUnavailable(health["message"])
|
||||
|
||||
async def chat(self, content: str) -> dict[str, Any]:
|
||||
async def chat(self, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> dict[str, Any]:
|
||||
await self.ensure_available()
|
||||
previous_interaction = self.memory.last_interaction() if self.memory else None
|
||||
resolved_thread_id = self._thread_id(thread_id)
|
||||
messages = self._messages_for_thread(resolved_thread_id)
|
||||
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
|
||||
if self.memory:
|
||||
self.memory.add_conversation("user", content)
|
||||
self.messages.append({"role": "user", "content": content})
|
||||
self.memory.add_conversation("user", content, resolved_thread_id)
|
||||
await self._title_first_message(resolved_thread_id, content, previous_interaction)
|
||||
messages.append({"role": "user", "content": content})
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
for _ in range(5):
|
||||
response = await self._ollama_chat(content, previous_interaction=previous_interaction)
|
||||
try:
|
||||
response = await self._ollama_chat(
|
||||
content,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=resolved_thread_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
if not last_tool_results:
|
||||
raise
|
||||
answer = self._tool_result_fallback(
|
||||
last_tool_results,
|
||||
f"The local model stopped after the tool call: {exc}",
|
||||
)
|
||||
messages.append({"role": "assistant", "content": answer})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", answer, resolved_thread_id)
|
||||
return {"message": answer, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
message = response.get("message") or {}
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
self.messages.append({"role": "assistant", "content": message.get("content", "")})
|
||||
answer = message.get("content", "")
|
||||
if not answer.strip():
|
||||
answer = self._empty_response_fallback(last_tool_results)
|
||||
messages.append({"role": "assistant", "content": answer})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", message.get("content", ""))
|
||||
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()}
|
||||
self.memory.add_conversation("assistant", answer, resolved_thread_id)
|
||||
return {"message": answer, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
|
||||
self.messages.append(message)
|
||||
messages.append(message)
|
||||
for call in tool_calls:
|
||||
name, arguments = self._extract_call(call)
|
||||
result = await self.tools.execute(name, arguments)
|
||||
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
||||
last_tool_results.append({"tool": name, "result": result})
|
||||
messages.append({"role": "tool", "tool_name": name, "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."
|
||||
self.messages.append({"role": "assistant", "content": fallback})
|
||||
messages.append({"role": "assistant", "content": fallback})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback)
|
||||
return {"message": fallback, "pending_actions": self._pending_payloads()}
|
||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||
return {"message": fallback, "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
|
||||
async def chat_events(self, content: str) -> AsyncIterator[dict[str, Any]]:
|
||||
async def chat_events(self, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> AsyncIterator[dict[str, Any]]:
|
||||
health = await self.health()
|
||||
if not health["online"]:
|
||||
yield {"type": "warning", "message": health["message"]}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||
return
|
||||
|
||||
previous_interaction = self.memory.last_interaction() if self.memory else None
|
||||
resolved_thread_id = self._thread_id(thread_id)
|
||||
messages = self._messages_for_thread(resolved_thread_id)
|
||||
previous_interaction = self.memory.last_interaction(resolved_thread_id) if self.memory else None
|
||||
if self.memory:
|
||||
self.memory.add_conversation("user", content)
|
||||
self.messages.append({"role": "user", "content": content})
|
||||
self.memory.add_conversation("user", content, resolved_thread_id)
|
||||
await self._title_first_message(resolved_thread_id, content, previous_interaction)
|
||||
messages.append({"role": "user", "content": content})
|
||||
yield {"type": "status", "message": "Thinking"}
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
|
||||
for _ in range(5):
|
||||
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction):
|
||||
message = event.get("message") or {}
|
||||
chunk = message.get("content") or ""
|
||||
if chunk:
|
||||
assistant_message["content"] += chunk
|
||||
yield {"type": "token", "content": chunk}
|
||||
if message.get("tool_calls"):
|
||||
tool_calls.extend(message["tool_calls"])
|
||||
if event.get("done"):
|
||||
metrics = self._stream_metrics(event)
|
||||
if metrics:
|
||||
yield {"type": "metrics", **metrics}
|
||||
try:
|
||||
async for event in self._ollama_chat_stream(
|
||||
content,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=resolved_thread_id,
|
||||
):
|
||||
message = event.get("message") or {}
|
||||
chunk = message.get("content") or ""
|
||||
if chunk:
|
||||
assistant_message["content"] += chunk
|
||||
yield {"type": "token", "content": chunk}
|
||||
if message.get("tool_calls"):
|
||||
tool_calls.extend(message["tool_calls"])
|
||||
if event.get("done"):
|
||||
metrics = self._stream_metrics(event)
|
||||
if metrics:
|
||||
yield {"type": "metrics", **metrics}
|
||||
except Exception as exc:
|
||||
if not last_tool_results:
|
||||
yield {"type": "warning", "message": f"Chat failed before any tool result was available: {exc}"}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
return
|
||||
fallback = self._tool_result_fallback(
|
||||
last_tool_results,
|
||||
f"The local model stopped after the tool call: {exc}",
|
||||
)
|
||||
assistant_message["content"] = fallback
|
||||
messages.append(assistant_message)
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||
yield {"type": "token", "content": fallback}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
return
|
||||
|
||||
if not tool_calls:
|
||||
self.messages.append(assistant_message)
|
||||
if not assistant_message.get("content", "").strip():
|
||||
fallback = self._empty_response_fallback(last_tool_results)
|
||||
assistant_message["content"] = fallback
|
||||
yield {"type": "token", "content": fallback}
|
||||
messages.append(assistant_message)
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", assistant_message.get("content", ""))
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||
self.memory.add_conversation("assistant", assistant_message.get("content", ""), resolved_thread_id)
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
return
|
||||
|
||||
assistant_message["tool_calls"] = tool_calls
|
||||
self.messages.append(assistant_message)
|
||||
messages.append(assistant_message)
|
||||
for call in tool_calls:
|
||||
name, arguments = self._extract_call(call)
|
||||
yield {"type": "status", "message": self._tool_status(name)}
|
||||
result = await self.tools.execute(name, arguments)
|
||||
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
||||
last_tool_results.append({"tool": name, "result": result})
|
||||
messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
||||
|
||||
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."
|
||||
self.messages.append({"role": "assistant", "content": fallback})
|
||||
messages.append({"role": "assistant", "content": fallback})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback)
|
||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||
yield {"type": "token", "content": fallback}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads(), "thread_id": resolved_thread_id}
|
||||
|
||||
async def generate_wake_response(self, wake_message: str) -> str:
|
||||
await self.ensure_available()
|
||||
self.messages.append({"role": "user", "content": wake_message})
|
||||
response = await self._ollama_chat(wake_message)
|
||||
message = response.get("message") or {}
|
||||
content = message.get("content", "")
|
||||
self.messages.append({"role": "assistant", "content": content})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("system", wake_message)
|
||||
self.memory.add_conversation("assistant", content)
|
||||
return content or wake_message
|
||||
messages = self._messages_for_thread("wake")
|
||||
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
||||
messages.append({"role": "user", "content": wake_message})
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
for _ in range(5):
|
||||
try:
|
||||
response = await self._ollama_chat(
|
||||
wake_message,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id="wake",
|
||||
)
|
||||
except Exception as exc:
|
||||
if not last_tool_results:
|
||||
raise
|
||||
content = self._tool_result_fallback(
|
||||
last_tool_results,
|
||||
f"The local model stopped after the wake-job tool call: {exc}",
|
||||
)
|
||||
messages.append({"role": "assistant", "content": content})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("system", wake_message, "wake")
|
||||
self.memory.add_conversation("assistant", content, "wake")
|
||||
return content
|
||||
message = response.get("message") or {}
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
content = message.get("content", "")
|
||||
if not content.strip():
|
||||
content = self._empty_response_fallback(last_tool_results)
|
||||
messages.append({"role": "assistant", "content": content})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("system", wake_message, "wake")
|
||||
self.memory.add_conversation("assistant", content, "wake")
|
||||
return content
|
||||
|
||||
async def _ollama_chat(self, query: str = "", previous_interaction: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
messages.append(message)
|
||||
for call in tool_calls:
|
||||
name, arguments = self._extract_call(call)
|
||||
result = await self.tools.execute(name, arguments)
|
||||
last_tool_results.append({"tool": name, "result": result})
|
||||
messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
||||
|
||||
content = "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
|
||||
messages.append({"role": "assistant", "content": content})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("system", wake_message, "wake")
|
||||
self.memory.add_conversation("assistant", content, "wake")
|
||||
return content
|
||||
|
||||
async def _ollama_chat(
|
||||
self,
|
||||
query: str = "",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
||||
"messages": self._messages_with_context(
|
||||
query,
|
||||
messages or self._messages_for_thread(thread_id),
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"options": self._ollama_options(),
|
||||
"stream": False,
|
||||
@@ -182,7 +293,9 @@ class OllamaAgent:
|
||||
async def _ollama_chat_stream(
|
||||
self,
|
||||
query: str = "",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with client.stream(
|
||||
@@ -190,7 +303,12 @@ class OllamaAgent:
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
||||
"messages": self._messages_with_context(
|
||||
query,
|
||||
messages or self._messages_for_thread(thread_id),
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"options": self._ollama_options(),
|
||||
"stream": True,
|
||||
@@ -204,14 +322,21 @@ class OllamaAgent:
|
||||
def _messages_with_context(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> list[dict[str, Any]]:
|
||||
context = self._runtime_context(query, previous_interaction=previous_interaction)
|
||||
context = self._runtime_context(query, previous_interaction=previous_interaction, thread_id=thread_id)
|
||||
if not context:
|
||||
return self.messages
|
||||
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]]
|
||||
return messages
|
||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||
|
||||
def _runtime_context(self, query: str, previous_interaction: dict[str, Any] | None = None) -> str:
|
||||
def _runtime_context(
|
||||
self,
|
||||
query: str,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> str:
|
||||
local_zone = get_localzone()
|
||||
parts = [
|
||||
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
||||
@@ -244,7 +369,7 @@ class OllamaAgent:
|
||||
parts.append(identity)
|
||||
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()
|
||||
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction(thread_id)
|
||||
if last:
|
||||
parts.append(
|
||||
f"Previous interaction before this message: {last['created_at']} "
|
||||
@@ -261,16 +386,85 @@ class OllamaAgent:
|
||||
)
|
||||
parts.append(f"Relevant long-term memories:\n{memory_text}")
|
||||
|
||||
recent = self.memory.recent_conversation(limit=6)
|
||||
recent = self.memory.recent_conversation(limit=6, thread_id=thread_id)
|
||||
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:\n{recent_text}")
|
||||
parts.append(f"Recent conversation excerpts from this chat:\n{recent_text}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
|
||||
resolved_thread_id = self._thread_id(thread_id)
|
||||
if resolved_thread_id not in self.thread_messages:
|
||||
messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
if self.memory:
|
||||
self.memory.ensure_thread(resolved_thread_id)
|
||||
for item in self.memory.recent_conversation(limit=30, thread_id=resolved_thread_id):
|
||||
role = item.get("role")
|
||||
if role in {"user", "assistant"} and item.get("content"):
|
||||
messages.append({"role": role, "content": item["content"]})
|
||||
self.thread_messages[resolved_thread_id] = messages
|
||||
return self.thread_messages[resolved_thread_id]
|
||||
|
||||
async def _title_first_message(
|
||||
self,
|
||||
thread_id: str,
|
||||
first_message: str,
|
||||
previous_interaction: dict[str, Any] | None,
|
||||
) -> None:
|
||||
if self.memory is None or previous_interaction is not None:
|
||||
return
|
||||
thread = self.memory.get_thread(thread_id)
|
||||
if not thread or thread.get("title") != "New chat":
|
||||
return
|
||||
title = await self._generate_chat_title(first_message)
|
||||
self.memory.rename_thread(thread_id, title or MemoryStore._thread_title(first_message))
|
||||
|
||||
async def _generate_chat_title(self, first_message: str) -> str:
|
||||
prompt = (
|
||||
"Create a concise chat title for this first user message. "
|
||||
"Use 2 to 6 words. No quotes, no punctuation at the end, no preamble.\n\n"
|
||||
f"Message: {first_message[:800]}"
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You write short chat titles."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"options": self._ollama_options(),
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
message = response.json().get("message") or {}
|
||||
return self._clean_generated_title(message.get("content", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _thread_id(thread_id: str | None) -> str:
|
||||
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||
|
||||
@staticmethod
|
||||
def _clean_generated_title(title: str) -> str:
|
||||
text = re.sub(r"[\r\n]+", " ", title).strip().strip('"').strip("'")
|
||||
text = re.sub(r"^(title|chat title)\s*:\s*", "", text, flags=re.IGNORECASE).strip()
|
||||
text = text.rstrip(".!?;:-").strip()
|
||||
if not text:
|
||||
return ""
|
||||
words = text.split()
|
||||
if len(words) > 8:
|
||||
text = " ".join(words[:8])
|
||||
return text[:64]
|
||||
|
||||
def _pending_payloads(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
@@ -288,6 +482,27 @@ class OllamaAgent:
|
||||
return {}
|
||||
return {"num_ctx": self.num_ctx}
|
||||
|
||||
@staticmethod
|
||||
def _empty_response_fallback(tool_results: list[dict[str, Any]]) -> str:
|
||||
if not tool_results:
|
||||
return "I did not get a usable response from the local model. Please try again, or narrow the request a bit."
|
||||
return OllamaAgent._tool_result_fallback(
|
||||
tool_results,
|
||||
"I completed the tool call, but the local model did not write a final answer.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_result_fallback(tool_results: list[dict[str, Any]], reason: str) -> str:
|
||||
last = tool_results[-1]
|
||||
text = json.dumps(last, indent=2, ensure_ascii=True)
|
||||
if len(text) > 1800:
|
||||
text = text[:1800] + "\n..."
|
||||
return (
|
||||
f"{reason} "
|
||||
"Here is the last tool result so you are not left staring at a blank response:\n\n"
|
||||
f"```json\n{text}\n```"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tool_status(name: str) -> str:
|
||||
if name.startswith("get_uex_"):
|
||||
@@ -301,6 +516,11 @@ class OllamaAgent:
|
||||
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
|
||||
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
|
||||
"summarize_uex_currency_index_history": "Summarizing currency index history",
|
||||
"list_scmdb_versions": "Checking SCMDB versions",
|
||||
"search_scmdb_missions": "Searching SCMDB missions",
|
||||
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
||||
"search_cornerstone_items": "Searching Cornerstone items",
|
||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||
"uex_api_catalog": "Checking UEX API catalog",
|
||||
"uex_get": "Fetching UEX data",
|
||||
"uex_draft_post": "Drafting UEX write for approval",
|
||||
|
||||
@@ -15,6 +15,8 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
||||
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
||||
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
|
||||
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
||||
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||
@@ -64,6 +66,8 @@ class Settings(BaseSettings):
|
||||
ollama_model: str = "qwen3.5:9b"
|
||||
ollama_num_ctx: int = 64512
|
||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||
scmdb_base_url: str = "https://scmdb.net"
|
||||
cornerstone_base_url: str = "https://finder.cstone.space"
|
||||
uex_secret_key: str | None = Field(default=None)
|
||||
uex_bearer_token: str | None = Field(default=None)
|
||||
traderai_user_name: str | None = Field(default=None)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class CornerstoneError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CornerstoneClient:
|
||||
def __init__(self, base_url: str = "https://finder.cstone.space") -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._items: list[dict[str, Any]] | None = None
|
||||
|
||||
async def list_items(self) -> list[dict[str, Any]]:
|
||||
if self._items is not None:
|
||||
return self._items
|
||||
body = await self._get_json("GetSearch")
|
||||
if isinstance(body, str):
|
||||
body = json.loads(body)
|
||||
if not isinstance(body, list):
|
||||
raise CornerstoneError("Cornerstone search response was not a list.")
|
||||
self._items = [
|
||||
{"id": item.get("id"), "name": item.get("name"), "sold": bool(item.get("Sold"))}
|
||||
for item in body
|
||||
if isinstance(item, dict) and item.get("id") and item.get("name")
|
||||
]
|
||||
return self._items
|
||||
|
||||
async def get_item_page(self, item_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/Search/{item_id.strip()}",
|
||||
headers={"Accept": "text/html,application/xhtml+xml"},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
||||
return {"url": str(response.url), "html": response.text}
|
||||
|
||||
async def _get_json(self, path: str) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise CornerstoneError(f"Cornerstone returned non-JSON response: HTTP {response.status_code}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {body}")
|
||||
return body
|
||||
|
||||
|
||||
class CornerstonePageParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.title = ""
|
||||
self.tables: list[list[list[str]]] = []
|
||||
self._skip_depth = 0
|
||||
self._in_title = False
|
||||
self._current_table: list[list[str]] | None = None
|
||||
self._current_row: list[str] | None = None
|
||||
self._current_cell: list[str] | None = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.casefold()
|
||||
if tag in {"script", "style"}:
|
||||
self._skip_depth += 1
|
||||
return
|
||||
if self._skip_depth:
|
||||
return
|
||||
if tag == "title":
|
||||
self._in_title = True
|
||||
elif tag == "table":
|
||||
self._current_table = []
|
||||
elif tag == "tr" and self._current_table is not None:
|
||||
self._current_row = []
|
||||
elif tag in {"td", "th"} and self._current_row is not None:
|
||||
self._current_cell = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.casefold()
|
||||
if tag in {"script", "style"} and self._skip_depth:
|
||||
self._skip_depth -= 1
|
||||
return
|
||||
if self._skip_depth:
|
||||
return
|
||||
if tag == "title":
|
||||
self._in_title = False
|
||||
elif tag in {"td", "th"} and self._current_cell is not None and self._current_row is not None:
|
||||
text = " ".join("".join(self._current_cell).split())
|
||||
self._current_row.append(text)
|
||||
self._current_cell = None
|
||||
elif tag == "tr" and self._current_row is not None and self._current_table is not None:
|
||||
if any(cell for cell in self._current_row):
|
||||
self._current_table.append(self._current_row)
|
||||
self._current_row = None
|
||||
elif tag == "table" and self._current_table is not None:
|
||||
if self._current_table:
|
||||
self.tables.append(self._current_table)
|
||||
self._current_table = None
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._skip_depth:
|
||||
return
|
||||
if self._in_title:
|
||||
self.title += data
|
||||
if self._current_cell is not None:
|
||||
self._current_cell.append(data)
|
||||
|
||||
|
||||
def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
|
||||
parser = CornerstonePageParser()
|
||||
parser.feed(html)
|
||||
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
||||
general: dict[str, str] = {}
|
||||
locations = []
|
||||
|
||||
for table in parser.tables:
|
||||
if not table:
|
||||
continue
|
||||
header = [cell.casefold() for cell in table[0]]
|
||||
if len(header) >= 3 and "location" in header[0] and "price" in header[1] and "verified" in header[2]:
|
||||
for row in table[1:]:
|
||||
if len(row) < 3:
|
||||
continue
|
||||
locations.append(
|
||||
{
|
||||
"location": row[0],
|
||||
"base_price": _parse_cornerstone_price(row[1]),
|
||||
"base_price_display": row[1],
|
||||
"verified": row[2],
|
||||
}
|
||||
)
|
||||
elif all(len(row) >= 2 for row in table):
|
||||
for row in table:
|
||||
key = row[0].strip().lower().replace(" ", "_")
|
||||
value = row[1].strip()
|
||||
if key and value and key not in general:
|
||||
general[key] = value
|
||||
|
||||
info["name"] = general.get("name") or _name_from_title(info["page_title"])
|
||||
if general:
|
||||
info["general"] = general
|
||||
info["locations"] = locations
|
||||
return info
|
||||
|
||||
|
||||
def _parse_cornerstone_price(value: str) -> int | None:
|
||||
digits = "".join(char for char in value if char.isdigit())
|
||||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def _name_from_title(title: str) -> str | None:
|
||||
if " - " not in title:
|
||||
return title or None
|
||||
return title.rsplit(" - ", 1)[-1].strip() or None
|
||||
+195
-16
@@ -8,6 +8,9 @@ from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
DEFAULT_THREAD_ID = "default"
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -65,8 +68,16 @@ class MemoryStore:
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
@@ -129,27 +140,152 @@ class MemoryStore:
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def add_conversation(self, role: str, content: str) -> None:
|
||||
with self._connect() as db:
|
||||
self._ensure_column(db, "conversations", "thread_id", "TEXT")
|
||||
now = iso_now()
|
||||
db.execute(
|
||||
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
|
||||
(role, content, iso_now()),
|
||||
"""
|
||||
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
""",
|
||||
(DEFAULT_THREAD_ID, "New chat", now, now),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE conversations SET thread_id = ? WHERE thread_id IS NULL OR thread_id = ''",
|
||||
(DEFAULT_THREAD_ID,),
|
||||
)
|
||||
|
||||
def last_interaction(self) -> dict[str, Any] | None:
|
||||
@staticmethod
|
||||
def _ensure_column(db: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
||||
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if column not in columns:
|
||||
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
def ensure_thread(self, thread_id: str | None = None, title: str | None = None) -> dict[str, Any]:
|
||||
now = iso_now()
|
||||
resolved_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||
resolved_title = (title or "New chat").strip() or "New chat"
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at
|
||||
""",
|
||||
(resolved_id, resolved_title, now, now),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
||||
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||
(resolved_id,),
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
def create_thread(self, title: str | None = None) -> dict[str, Any]:
|
||||
import uuid
|
||||
|
||||
thread_id = f"chat-{uuid.uuid4()}"
|
||||
return self.ensure_thread(thread_id, title or "New chat")
|
||||
|
||||
def list_threads(self) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.title,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
COUNT(c.id) AS message_count,
|
||||
MAX(c.created_at) AS last_message_at
|
||||
FROM chat_threads t
|
||||
LEFT JOIN conversations c ON c.thread_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY COALESCE(MAX(c.created_at), t.updated_at) DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_thread(self, thread_id: str) -> bool:
|
||||
with self._connect() as db:
|
||||
db.execute("DELETE FROM conversations WHERE thread_id = ?", (thread_id,))
|
||||
cursor = db.execute("DELETE FROM chat_threads WHERE id = ?", (thread_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def rename_thread(self, thread_id: str, title: str) -> dict[str, Any] | None:
|
||||
clean_title = self._clean_thread_title(title)
|
||||
if not clean_title:
|
||||
return None
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE chat_threads SET title = ?, updated_at = ? WHERE id = ?",
|
||||
(clean_title, now, thread_id),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
|
||||
def get_thread(self, thread_id: str) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
row = db.execute(
|
||||
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def add_conversation(self, role: str, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> None:
|
||||
resolved_thread_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||
self.ensure_thread(resolved_thread_id)
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"INSERT INTO conversations(thread_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(resolved_thread_id, role, content, now),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE chat_threads SET updated_at = ? WHERE id = ?",
|
||||
(now, resolved_thread_id),
|
||||
)
|
||||
|
||||
def last_interaction(self, thread_id: str | None = None) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
if thread_id:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT thread_id, role, content, created_at
|
||||
FROM conversations
|
||||
WHERE thread_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = db.execute(
|
||||
"SELECT thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def recent_conversation(self, limit: int = 8, thread_id: str | None = None) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
if thread_id:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, thread_id, role, content, created_at
|
||||
FROM conversations
|
||||
WHERE thread_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(thread_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT id, thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -222,13 +358,22 @@ class MemoryStore:
|
||||
).fetchall()
|
||||
conversations = db.execute(
|
||||
"""
|
||||
SELECT id, role, content, created_at
|
||||
SELECT id, thread_id, role, content, created_at
|
||||
FROM conversations
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
threads = db.execute(
|
||||
"""
|
||||
SELECT id, title, created_at, updated_at
|
||||
FROM chat_threads
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
profile_rows = db.execute(
|
||||
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
||||
).fetchall()
|
||||
@@ -252,6 +397,7 @@ class MemoryStore:
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"memories": [self._memory_row(row) for row in memories],
|
||||
"chat_threads": [dict(row) for row in threads],
|
||||
"conversations": [dict(row) for row in conversations],
|
||||
"profile": profile,
|
||||
"scheduled_jobs": [dict(row) for row in jobs],
|
||||
@@ -339,17 +485,38 @@ class MemoryStore:
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
|
||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None, enabled: bool = True) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
|
||||
(iso_now(), next_run_at, job_id),
|
||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
|
||||
(iso_now(), next_run_at, 1 if enabled else 0, job_id),
|
||||
)
|
||||
|
||||
def add_outbox(self, content: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
||||
|
||||
def list_outbox(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_outbox(self, inbox_id: int) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox WHERE id = ?",
|
||||
(inbox_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_outbox(self, inbox_id: int) -> bool:
|
||||
with self._connect() as db:
|
||||
cursor = db.execute("DELETE FROM outbox WHERE id = ?", (inbox_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
@@ -362,6 +529,18 @@ class MemoryStore:
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _thread_title(content: str) -> str:
|
||||
text = " ".join(content.strip().split())
|
||||
if not text:
|
||||
return "New chat"
|
||||
return text[:42] + ("..." if len(text) > 42 else "")
|
||||
|
||||
@staticmethod
|
||||
def _clean_thread_title(title: str) -> str:
|
||||
text = " ".join(title.strip().strip('"').strip("'").split())
|
||||
return text[:64]
|
||||
|
||||
@staticmethod
|
||||
def _fts_query(query: str) -> str:
|
||||
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
||||
|
||||
+17
-3
@@ -83,11 +83,20 @@ class WakeScheduler:
|
||||
)
|
||||
if self.agent is None:
|
||||
self.memory.add_outbox(wake_message)
|
||||
self._mark_job_finished(job_id)
|
||||
return
|
||||
|
||||
text = await self.agent.generate_wake_response(wake_message)
|
||||
try:
|
||||
text = await self.agent.generate_wake_response(wake_message)
|
||||
except Exception as exc:
|
||||
text = f"Wake job failed: {exc}. Job instruction: {prompt}"
|
||||
self.memory.add_outbox(text)
|
||||
self.memory.mark_job_run(job_id)
|
||||
self._mark_job_finished(job_id)
|
||||
|
||||
def _mark_job_finished(self, job_id: str) -> None:
|
||||
job = self.scheduler.get_job(job_id)
|
||||
next_run = job.next_run_time if job else None
|
||||
self.memory.mark_job_run(job_id, next_run.isoformat() if next_run else None, enabled=bool(next_run))
|
||||
|
||||
def _schedule_notification_poll(self) -> None:
|
||||
if self.uex is None:
|
||||
@@ -104,7 +113,12 @@ class WakeScheduler:
|
||||
if self.uex is None:
|
||||
return []
|
||||
|
||||
response = await self.uex.get_user_notifications()
|
||||
try:
|
||||
response = await self.uex.get_user_notifications()
|
||||
except Exception as exc:
|
||||
self.memory.add_outbox(f"UEX notification poll failed: {exc}")
|
||||
self.memory.set_profile("uex_last_notification_error", str(exc))
|
||||
return []
|
||||
notifications = response.get("notifications") or []
|
||||
pending = [item for item in notifications if not item.get("date_read")]
|
||||
profile = self.memory.get_profile()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class SCMDBError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SCMDBClient:
|
||||
def __init__(self, base_url: str = "https://scmdb.net") -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._versions: list[dict[str, Any]] | None = None
|
||||
self._data_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_versions(self) -> list[dict[str, Any]]:
|
||||
if self._versions is not None:
|
||||
return self._versions
|
||||
body = await self._get_json("data/versions.json")
|
||||
if not isinstance(body, list):
|
||||
raise SCMDBError("SCMDB versions response was not a list.")
|
||||
self._versions = [
|
||||
item
|
||||
for item in body
|
||||
if isinstance(item, dict) and item.get("version") and item.get("file")
|
||||
]
|
||||
return self._versions
|
||||
|
||||
async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||
selected = await self.resolve_version(version=version, channel=channel)
|
||||
cache_key = str(selected["version"])
|
||||
if cache_key not in self._data_cache:
|
||||
body = await self._get_json(f"data/{selected['file']}")
|
||||
if not isinstance(body, dict):
|
||||
raise SCMDBError(f"SCMDB data for {cache_key} was not an object.")
|
||||
self._data_cache[cache_key] = body
|
||||
return self._data_cache[cache_key]
|
||||
|
||||
async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||
versions = await self.list_versions()
|
||||
if not versions:
|
||||
raise SCMDBError("SCMDB did not return any data versions.")
|
||||
|
||||
if version:
|
||||
needle = version.casefold().strip()
|
||||
for item in versions:
|
||||
item_version = str(item["version"])
|
||||
if item_version.casefold() == needle or needle in item_version.casefold():
|
||||
return item
|
||||
raise SCMDBError(f"SCMDB version not found: {version}")
|
||||
|
||||
channel = (channel or "live").casefold().strip()
|
||||
if channel in {"latest", "any", "all"}:
|
||||
return versions[0]
|
||||
if channel not in {"live", "ptu"}:
|
||||
raise SCMDBError("SCMDB channel must be live, ptu, or latest.")
|
||||
for item in versions:
|
||||
if f"-{channel}." in str(item["version"]).casefold():
|
||||
return item
|
||||
return versions[0]
|
||||
|
||||
async def _get_json(self, path: str) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}")
|
||||
return body
|
||||
+91
-4
@@ -21,8 +21,10 @@ from pydantic import BaseModel
|
||||
from traderai.agent import OllamaAgent, OllamaUnavailable
|
||||
from traderai.config import save_settings, settings_payload
|
||||
from traderai.config import get_settings
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.cornerstone_client import CornerstoneClient
|
||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||
@@ -35,6 +37,19 @@ def resource_path(*parts: str) -> Path:
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
thread_id: str | None = DEFAULT_THREAD_ID
|
||||
|
||||
|
||||
class ChatThreadRequest(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class RenameChatThreadRequest(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
class DirectNegotiationMessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ClearMemoryRequest(BaseModel):
|
||||
@@ -62,7 +77,16 @@ def create_app() -> FastAPI:
|
||||
memory = MemoryStore(settings.traderai_memory_path)
|
||||
scheduler = WakeScheduler(memory)
|
||||
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
||||
tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler)
|
||||
scmdb = SCMDBClient(settings.scmdb_base_url)
|
||||
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
|
||||
tools = ToolRegistry(
|
||||
uex,
|
||||
settings.require_write_approval,
|
||||
memory=memory,
|
||||
scheduler=scheduler,
|
||||
scmdb=scmdb,
|
||||
cornerstone=cornerstone,
|
||||
)
|
||||
agent = OllamaAgent(
|
||||
settings.ollama_base_url,
|
||||
settings.ollama_model,
|
||||
@@ -246,18 +270,43 @@ def create_app() -> FastAPI:
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: ChatRequest) -> dict:
|
||||
try:
|
||||
return await agent.chat(request.message)
|
||||
return await agent.chat(request.message, thread_id=request.thread_id)
|
||||
except OllamaUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||
async def events():
|
||||
async for event in agent.chat_events(request.message):
|
||||
async for event in agent.chat_events(request.message, thread_id=request.thread_id):
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
return StreamingResponse(events(), media_type="text/event-stream")
|
||||
|
||||
@app.get("/api/chats")
|
||||
async def chats() -> dict:
|
||||
return {"chats": memory.list_threads()}
|
||||
|
||||
@app.post("/api/chats")
|
||||
async def create_chat(request: ChatThreadRequest) -> dict:
|
||||
return {"chat": memory.create_thread(request.title)}
|
||||
|
||||
@app.get("/api/chats/{thread_id}/messages")
|
||||
async def chat_messages(thread_id: str) -> dict:
|
||||
memory.ensure_thread(thread_id)
|
||||
return {"thread_id": thread_id, "messages": memory.recent_conversation(limit=200, thread_id=thread_id)}
|
||||
|
||||
@app.delete("/api/chats/{thread_id}")
|
||||
async def delete_chat(thread_id: str) -> dict:
|
||||
deleted = memory.delete_thread(thread_id)
|
||||
return {"deleted": deleted, "chats": memory.list_threads()}
|
||||
|
||||
@app.patch("/api/chats/{thread_id}")
|
||||
async def rename_chat(thread_id: str, request: RenameChatThreadRequest) -> dict:
|
||||
chat = memory.rename_thread(thread_id, request.title)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=400, detail="A non-empty chat title is required.")
|
||||
return {"chat": chat, "chats": memory.list_threads()}
|
||||
|
||||
@app.get("/api/pending-actions")
|
||||
async def pending_actions() -> dict:
|
||||
return {"pending_actions": agent._pending_payloads()}
|
||||
@@ -266,6 +315,35 @@ def create_app() -> FastAPI:
|
||||
async def notifications() -> dict:
|
||||
return {"notifications": memory.undelivered_outbox()}
|
||||
|
||||
@app.get("/api/inbox")
|
||||
async def inbox() -> dict:
|
||||
return {"inbox": memory.list_outbox()}
|
||||
|
||||
@app.post("/api/inbox/{inbox_id}/continue")
|
||||
async def continue_inbox(inbox_id: int) -> dict:
|
||||
item = memory.get_outbox(inbox_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inbox item not found.")
|
||||
thread = memory.create_thread("Inbox follow-up")
|
||||
memory.add_conversation("assistant", item["content"], thread["id"])
|
||||
return {"chat": thread, "message": item}
|
||||
|
||||
@app.delete("/api/inbox/{inbox_id}")
|
||||
async def delete_inbox(inbox_id: int) -> dict:
|
||||
deleted = memory.delete_outbox(inbox_id)
|
||||
return {"deleted": deleted, "inbox": memory.list_outbox()}
|
||||
|
||||
@app.get("/api/negotiations/{identifier}/messages")
|
||||
async def negotiation_messages(identifier: str) -> dict:
|
||||
params = negotiation_identifier_params(identifier)
|
||||
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/messages")
|
||||
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
||||
params = negotiation_identifier_params(identifier)
|
||||
payload = {**params, "message": request.message, "is_production": 1}
|
||||
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
||||
|
||||
@app.get("/api/wake-jobs")
|
||||
async def wake_jobs() -> dict:
|
||||
return {"scheduled_jobs": scheduler.list_jobs()}
|
||||
@@ -300,6 +378,15 @@ def create_app() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def negotiation_identifier_params(identifier: str) -> dict[str, Any]:
|
||||
value = identifier.strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=400, detail="Negotiation id or hash is required.")
|
||||
if value.isdigit():
|
||||
return {"id_negotiation": int(value)}
|
||||
return {"hash": value}
|
||||
|
||||
|
||||
async def inspect_ollama() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
executable = find_ollama_executable()
|
||||
|
||||
+664
-5
@@ -4,8 +4,10 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
@@ -129,9 +131,15 @@ UEX_DELETE_RESOURCES = {
|
||||
UEX_RESOURCE_DESCRIPTIONS = {
|
||||
"commodities_prices_history": "Historical commodity prices at a terminal. Requires id_terminal and id_commodity; accepts game_version. UEX limits this to 500 rows.",
|
||||
"marketplace_prices_history": "Historical marketplace price snapshots, one row per listing per price change. Requires at least one filter; supports date_start/date_end and up to 1000 records.",
|
||||
"marketplace_trends": "Current UEX marketplace trend metrics for an item. Use this when the user asks for trends, price movement, demand, or what the market is doing now.",
|
||||
"currencies_index_history": "Historical UEX currency index snapshots with basket component detail. Supports currency, date_from, and date_to timestamps.",
|
||||
}
|
||||
|
||||
UEX_PRODUCTION_WRITE_RESOURCES = {
|
||||
"marketplace_advertise",
|
||||
"marketplace_negotiations_messages",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingAction:
|
||||
@@ -149,8 +157,12 @@ class ToolRegistry:
|
||||
require_write_approval: bool = True,
|
||||
memory: MemoryStore | None = None,
|
||||
scheduler: WakeScheduler | None = None,
|
||||
scmdb: SCMDBClient | None = None,
|
||||
cornerstone: CornerstoneClient | None = None,
|
||||
) -> None:
|
||||
self.uex = uex
|
||||
self.scmdb = scmdb or SCMDBClient()
|
||||
self.cornerstone = cornerstone or CornerstoneClient()
|
||||
self.require_write_approval = require_write_approval
|
||||
self.memory = memory
|
||||
self.scheduler = scheduler
|
||||
@@ -167,6 +179,11 @@ class ToolRegistry:
|
||||
"schedule_wake_job": self.schedule_wake_job,
|
||||
"list_wake_jobs": self.list_wake_jobs,
|
||||
"check_uex_notifications": self.check_uex_notifications,
|
||||
"list_scmdb_versions": self.list_scmdb_versions,
|
||||
"search_scmdb_missions": self.search_scmdb_missions,
|
||||
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
|
||||
"search_cornerstone_items": self.search_cornerstone_items,
|
||||
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
||||
}
|
||||
self.handlers["uex_api_catalog"] = self.uex_api_catalog
|
||||
self.handlers["uex_get"] = self.uex_get
|
||||
@@ -191,6 +208,8 @@ class ToolRegistry:
|
||||
*self._history_summary_schemas(),
|
||||
*self._uex_post_schemas(),
|
||||
*self._uex_delete_schemas(),
|
||||
*self._scmdb_schemas(),
|
||||
*self._cornerstone_schemas(),
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -266,7 +285,7 @@ class ToolRegistry:
|
||||
"message": {"type": "string"},
|
||||
"hash": {"type": "string"},
|
||||
"id_negotiation": {"type": "integer"},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -298,7 +317,7 @@ class ToolRegistry:
|
||||
"in_stock": {"type": "integer"},
|
||||
"hours_expiration": {"type": "integer"},
|
||||
"is_hidden": {"type": "integer", "enum": [0, 1]},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -382,7 +401,7 @@ class ToolRegistry:
|
||||
return {"error": f"Pending action not found: {action_id}"}
|
||||
if action.method == "DELETE":
|
||||
return await self.uex.delete(action.endpoint, action.payload, authenticated=True)
|
||||
return await self.uex.post(action.endpoint, action.payload, authenticated=True)
|
||||
return await self.uex.post(action.endpoint, self._production_payload(action.endpoint, action.payload), authenticated=True)
|
||||
|
||||
async def decline(self, action_id: str) -> dict[str, Any]:
|
||||
action = self.pending_actions.pop(action_id, None)
|
||||
@@ -761,6 +780,102 @@ class ToolRegistry:
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _scmdb_schemas(cls) -> list[dict[str, Any]]:
|
||||
version_controls = {
|
||||
"version": {"type": "string", "description": "SCMDB game-data version, such as 4.7.2-live.11715810."},
|
||||
"channel": {"type": "string", "enum": ["live", "ptu", "latest"], "default": "live"},
|
||||
}
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_scmdb_versions",
|
||||
"description": "List SCMDB mission-data versions. Use this when the user asks which Star Citizen game versions are available.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {"type": "string", "enum": ["live", "ptu", "latest"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_scmdb_missions",
|
||||
"description": "Search SCMDB Star Citizen missions/contracts and return compact reward summaries: UEC, reputation, item, blueprint, and hauling rewards.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Text to search in title, debug name, description, faction, mission type, or reward names."},
|
||||
"mission_type": {"type": "string", "description": "Mission type such as Hauling, Delivery, Bounty Hunter, Mercenary, Racing, Salvage, or Mining."},
|
||||
"category": {"type": "string"},
|
||||
"faction": {"type": "string"},
|
||||
"system": {"type": "string", "description": "Star system such as Stanton or Pyro."},
|
||||
"illegal": {"type": "boolean"},
|
||||
"include_legacy": {"type": "boolean", "default": True},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
|
||||
**version_controls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_scmdb_mission_rewards",
|
||||
"description": "Fetch detailed SCMDB rewards and requirements for one Star Citizen mission/contract by id, debug name, or title.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"debug_name": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"include_legacy": {"type": "boolean", "default": True},
|
||||
**version_controls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_cornerstone_items",
|
||||
"description": "Search Cornerstone Universal Item Finder items. Use this to find exact item names and ids before asking where an item is sold.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Item name to search for."},
|
||||
"sold_only": {"type": "boolean", "default": False, "description": "Only return items marked as sold in-game by Cornerstone."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_cornerstone_item_locations",
|
||||
"description": "Fetch where a Star Citizen item is sold using Cornerstone Universal Item Finder, including store/location, base price, and verified date.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."},
|
||||
"query": {"type": "string", "description": "Item name if id is not known."},
|
||||
"location": {"type": "string", "description": "Optional local filter for system, planet, station, city, or shop name."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
@@ -915,7 +1030,13 @@ class ToolRegistry:
|
||||
id_listing: int | None = None,
|
||||
hash: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
|
||||
response = await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
|
||||
negotiations = [
|
||||
self._summarize_negotiation(item)
|
||||
for item in self._as_list(response.get("data"))
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return {**response, "data": negotiations, "negotiations": negotiations}
|
||||
|
||||
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)
|
||||
@@ -925,7 +1046,7 @@ class ToolRegistry:
|
||||
message: str,
|
||||
hash: str | None = None,
|
||||
id_negotiation: int | None = None,
|
||||
is_production: int = 0,
|
||||
is_production: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production}
|
||||
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload)
|
||||
@@ -968,9 +1089,201 @@ class ToolRegistry:
|
||||
pending = [item for item in notifications if not item.get("date_read")]
|
||||
return {"count": len(pending), "notifications": pending}
|
||||
|
||||
async def list_scmdb_versions(self, channel: str | None = None) -> dict[str, Any]:
|
||||
versions = await self.scmdb.list_versions()
|
||||
channel_filter = (channel or "").casefold().strip()
|
||||
if channel_filter in {"live", "ptu"}:
|
||||
versions = [
|
||||
item
|
||||
for item in versions
|
||||
if f"-{channel_filter}." in str(item.get("version", "")).casefold()
|
||||
]
|
||||
elif channel_filter not in {"", "latest"}:
|
||||
return {"error": "SCMDB channel must be live, ptu, or latest."}
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"count": len(versions),
|
||||
"versions": versions,
|
||||
"default_channel": "live",
|
||||
}
|
||||
|
||||
async def search_scmdb_missions(
|
||||
self,
|
||||
query: str = "",
|
||||
mission_type: str | None = None,
|
||||
category: str | None = None,
|
||||
faction: str | None = None,
|
||||
system: str | None = None,
|
||||
illegal: bool | None = None,
|
||||
include_legacy: bool = True,
|
||||
limit: int = 10,
|
||||
version: str | None = None,
|
||||
channel: str = "live",
|
||||
) -> dict[str, Any]:
|
||||
data = await self.scmdb.get_data(version=version, channel=channel)
|
||||
q = (query or "").casefold().strip()
|
||||
mission_type_filter = (mission_type or "").casefold().strip()
|
||||
category_filter = (category or "").casefold().strip()
|
||||
faction_filter = (faction or "").casefold().strip()
|
||||
system_filter = (system or "").casefold().strip()
|
||||
matched = []
|
||||
|
||||
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
|
||||
summary = self._summarize_scmdb_mission(data, mission, source=source)
|
||||
if mission_type_filter and mission_type_filter not in str(summary.get("mission_type") or "").casefold():
|
||||
continue
|
||||
if category_filter and category_filter not in str(summary.get("category") or "").casefold():
|
||||
continue
|
||||
if faction_filter and faction_filter not in str(summary.get("faction") or "").casefold():
|
||||
continue
|
||||
if system_filter and system_filter not in " ".join(summary.get("systems") or []).casefold():
|
||||
continue
|
||||
if illegal is not None and bool(summary.get("illegal")) != illegal:
|
||||
continue
|
||||
if q and q not in self._scmdb_search_text(data, mission, summary):
|
||||
continue
|
||||
matched.append(summary)
|
||||
|
||||
limit = max(1, min(limit, 25))
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"version": data.get("version"),
|
||||
"matched": len(matched),
|
||||
"returned": min(len(matched), limit),
|
||||
"truncated": len(matched) > limit,
|
||||
"missions": matched[:limit],
|
||||
}
|
||||
|
||||
async def get_scmdb_mission_rewards(
|
||||
self,
|
||||
id: str | None = None,
|
||||
debug_name: str | None = None,
|
||||
title: str | None = None,
|
||||
include_legacy: bool = True,
|
||||
version: str | None = None,
|
||||
channel: str = "live",
|
||||
) -> dict[str, Any]:
|
||||
if not any([id, debug_name, title]):
|
||||
return {"error": "Provide id, debug_name, or title."}
|
||||
|
||||
data = await self.scmdb.get_data(version=version, channel=channel)
|
||||
exact = []
|
||||
fuzzy = []
|
||||
id_filter = (id or "").casefold().strip()
|
||||
debug_filter = (debug_name or "").casefold().strip()
|
||||
title_filter = (title or "").casefold().strip()
|
||||
|
||||
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
|
||||
mission_id = str(mission.get("id") or "").casefold()
|
||||
mission_debug = str(mission.get("debugName") or "").casefold()
|
||||
mission_title = str(mission.get("title") or "").casefold()
|
||||
if id_filter and mission_id == id_filter:
|
||||
exact.append((source, mission))
|
||||
elif debug_filter and mission_debug == debug_filter:
|
||||
exact.append((source, mission))
|
||||
elif title_filter and mission_title == title_filter:
|
||||
exact.append((source, mission))
|
||||
elif title_filter and title_filter in mission_title:
|
||||
fuzzy.append((source, mission))
|
||||
elif debug_filter and debug_filter in mission_debug:
|
||||
fuzzy.append((source, mission))
|
||||
|
||||
candidates = exact or fuzzy
|
||||
if len(candidates) != 1:
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"version": data.get("version"),
|
||||
"matched": len(candidates),
|
||||
"error": "No SCMDB mission matched." if not candidates else "Multiple SCMDB missions matched; refine by id or debug_name.",
|
||||
"matches": [
|
||||
self._summarize_scmdb_mission(data, mission, source=source)
|
||||
for source, mission in candidates[:10]
|
||||
],
|
||||
}
|
||||
|
||||
source, mission = candidates[0]
|
||||
return {
|
||||
"source": self.scmdb.base_url,
|
||||
"version": data.get("version"),
|
||||
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
|
||||
}
|
||||
|
||||
async def search_cornerstone_items(
|
||||
self,
|
||||
query: str = "",
|
||||
sold_only: bool = False,
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
items = await self.cornerstone.list_items()
|
||||
q = (query or "").casefold().strip()
|
||||
matches = []
|
||||
for item in items:
|
||||
if sold_only and not item.get("sold"):
|
||||
continue
|
||||
score = self._cornerstone_match_score(q, str(item.get("name") or ""))
|
||||
if q and score <= 0:
|
||||
continue
|
||||
matches.append((score, item))
|
||||
matches.sort(key=lambda match: (-match[0], str(match[1].get("name") or "").casefold()))
|
||||
limit = max(1, min(limit, 25))
|
||||
compacted = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"name": item.get("name"),
|
||||
"sold": bool(item.get("sold")),
|
||||
"url": f"{self.cornerstone.base_url}/Search/{item.get('id')}",
|
||||
}
|
||||
for _, item in matches[:limit]
|
||||
]
|
||||
return {
|
||||
"source": self.cornerstone.base_url,
|
||||
"matched": len(matches),
|
||||
"returned": len(compacted),
|
||||
"truncated": len(matches) > limit,
|
||||
"items": compacted,
|
||||
}
|
||||
|
||||
async def get_cornerstone_item_locations(
|
||||
self,
|
||||
id: str | None = None,
|
||||
query: str | None = None,
|
||||
location: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
item = await self._resolve_cornerstone_item(id=id, query=query)
|
||||
if not item:
|
||||
return {"error": "No Cornerstone item matched. Provide an id or a more specific query."}
|
||||
|
||||
page = await self.cornerstone.get_item_page(str(item["id"]))
|
||||
parsed = parse_cornerstone_item_page(page["html"])
|
||||
locations = parsed.get("locations") or []
|
||||
location_filter = (location or "").casefold().strip()
|
||||
if location_filter:
|
||||
locations = [
|
||||
entry
|
||||
for entry in locations
|
||||
if location_filter in str(entry.get("location") or "").casefold()
|
||||
]
|
||||
limit = max(1, min(limit, 50))
|
||||
return {
|
||||
"source": self.cornerstone.base_url,
|
||||
"url": page["url"],
|
||||
"item": {
|
||||
"id": item.get("id"),
|
||||
"name": parsed.get("name") or item.get("name"),
|
||||
"sold": bool(item.get("sold")),
|
||||
"general": parsed.get("general") or {},
|
||||
},
|
||||
"matched_locations": len(locations),
|
||||
"returned": min(len(locations), limit),
|
||||
"truncated": len(locations) > limit,
|
||||
"locations": locations[:limit],
|
||||
}
|
||||
|
||||
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
|
||||
action_id = str(uuid.uuid4())
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
payload = self._production_payload(endpoint, payload)
|
||||
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method)
|
||||
return {
|
||||
"pending_action": {
|
||||
@@ -983,6 +1296,14 @@ class ToolRegistry:
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _production_payload(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if endpoint not in UEX_PRODUCTION_WRITE_RESOURCES:
|
||||
return payload
|
||||
next_payload = dict(payload)
|
||||
next_payload["is_production"] = 1
|
||||
return next_payload
|
||||
|
||||
@staticmethod
|
||||
def _validate_resource(resource: str, allowed: dict[str, Any] | set[str]) -> str:
|
||||
normalized = resource.strip().strip("/").casefold()
|
||||
@@ -1150,6 +1471,290 @@ class ToolRegistry:
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_contracts(data: dict[str, Any], include_legacy: bool = True) -> list[tuple[str, dict[str, Any]]]:
|
||||
contracts = [
|
||||
("contracts", mission)
|
||||
for mission in data.get("contracts") or []
|
||||
if isinstance(mission, dict)
|
||||
]
|
||||
if include_legacy:
|
||||
contracts.extend(
|
||||
("legacyContracts", mission)
|
||||
for mission in data.get("legacyContracts") or []
|
||||
if isinstance(mission, dict)
|
||||
)
|
||||
return contracts
|
||||
|
||||
@classmethod
|
||||
def _summarize_scmdb_mission(
|
||||
cls,
|
||||
data: dict[str, Any],
|
||||
mission: dict[str, Any],
|
||||
source: str,
|
||||
detailed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
summary: dict[str, Any] = {
|
||||
"id": mission.get("id"),
|
||||
"debug_name": mission.get("debugName"),
|
||||
"source": source,
|
||||
"title": mission.get("title"),
|
||||
"mission_type": mission.get("missionType"),
|
||||
"category": mission.get("category"),
|
||||
"faction": cls._scmdb_faction_name(data, mission.get("factionGuid")),
|
||||
"systems": mission.get("systems") or [],
|
||||
"illegal": bool(mission.get("illegal")),
|
||||
"can_be_shared": bool(mission.get("canBeShared")),
|
||||
"once_only": bool(mission.get("onceOnly")),
|
||||
"time_to_complete_minutes": mission.get("timeToComplete"),
|
||||
"max_players": mission.get("maxPlayersPerInstance"),
|
||||
"cooldown_minutes": mission.get("personalCooldownTime"),
|
||||
"min_standing": cls._scmdb_standing(mission.get("minStanding")),
|
||||
"max_standing": cls._scmdb_standing(mission.get("maxStanding")),
|
||||
"rewards": cls._scmdb_rewards(data, mission, detailed=detailed),
|
||||
}
|
||||
if detailed:
|
||||
summary.update(
|
||||
{
|
||||
"description": cls._compact_scalar(mission.get("description")),
|
||||
"locations": cls._scmdb_pool_names(data, "locationPools", mission.get("locations"), limit=20),
|
||||
"destinations": cls._scmdb_pool_names(data, "locationPools", mission.get("destinations"), limit=20),
|
||||
"prerequisites": cls._compact_scalar(mission.get("prerequisites")),
|
||||
"available_in_prison": bool(mission.get("availableInPrison")),
|
||||
"reaccept_after_abandoning": bool(mission.get("canReacceptAfterAbandoning")),
|
||||
"reaccept_after_failing": bool(mission.get("canReacceptAfterFailing")),
|
||||
"hide_in_mobiglas": bool(mission.get("hideInMobiGlas")),
|
||||
}
|
||||
)
|
||||
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
|
||||
|
||||
@classmethod
|
||||
def _scmdb_rewards(cls, data: dict[str, Any], mission: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
|
||||
rewards: dict[str, Any] = {
|
||||
"uec": mission.get("rewardUEC"),
|
||||
"buy_in": mission.get("buyIn"),
|
||||
"dynamic_uec": mission.get("rewardIsDynamic"),
|
||||
"reputation": cls._scmdb_indexed_reputation(data, mission.get("factionRewardsIndex")),
|
||||
"failure_reputation": cls._scmdb_reputation_rewards(data, mission.get("factionRewards_fail")),
|
||||
"items": cls._scmdb_item_rewards(mission.get("itemRewards")),
|
||||
"blueprints": cls._scmdb_blueprint_rewards(data, mission.get("blueprintRewards"), detailed=detailed),
|
||||
"hauling": cls._scmdb_hauling_orders(data, mission.get("haulingOrders")),
|
||||
"partial_payouts": cls._scmdb_partial_payouts(data, mission.get("partialRewardPayoutIndex")),
|
||||
}
|
||||
return {key: value for key, value in rewards.items() if value not in (None, "", [], {})}
|
||||
|
||||
@classmethod
|
||||
def _scmdb_indexed_reputation(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(index, int):
|
||||
return []
|
||||
pools = data.get("factionRewardsPools") or []
|
||||
if index < 0 or index >= len(pools):
|
||||
return []
|
||||
return cls._scmdb_reputation_rewards(data, pools[index])
|
||||
|
||||
@classmethod
|
||||
def _scmdb_reputation_rewards(cls, data: dict[str, Any], rewards: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(rewards, list):
|
||||
return []
|
||||
result = []
|
||||
for reward in rewards:
|
||||
if not isinstance(reward, dict):
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"faction": cls._scmdb_faction_name(data, reward.get("factionGuid")),
|
||||
"scope": cls._scmdb_scope_name(data, reward.get("scopeGuid")),
|
||||
"amount": reward.get("amount"),
|
||||
}
|
||||
)
|
||||
return [item for item in result if item.get("amount") not in (None, "")]
|
||||
|
||||
@classmethod
|
||||
def _scmdb_item_rewards(cls, rewards: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(rewards, list):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": reward.get("name"),
|
||||
"amount": reward.get("amount"),
|
||||
}
|
||||
for reward in rewards
|
||||
if isinstance(reward, dict)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _scmdb_blueprint_rewards(cls, data: dict[str, Any], rewards: Any, detailed: bool = False) -> list[dict[str, Any]]:
|
||||
if not isinstance(rewards, list):
|
||||
return []
|
||||
result = []
|
||||
for reward in rewards:
|
||||
if not isinstance(reward, dict):
|
||||
continue
|
||||
pool_id = reward.get("blueprintPool")
|
||||
pool = (data.get("blueprintPools") or {}).get(pool_id) or {}
|
||||
blueprints = pool.get("blueprints") or []
|
||||
result.append(
|
||||
{
|
||||
"pool": pool.get("name") or reward.get("poolName"),
|
||||
"chance": reward.get("chance"),
|
||||
"trigger": reward.get("trigger"),
|
||||
"blueprints": [
|
||||
cls._compact_scalar(item.get("name"))
|
||||
for item in blueprints[: 20 if detailed else 5]
|
||||
if isinstance(item, dict) and item.get("name")
|
||||
],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _scmdb_hauling_orders(cls, data: dict[str, Any], orders: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(orders, list):
|
||||
return []
|
||||
result = []
|
||||
resources = data.get("resourcePools") or {}
|
||||
for order in orders:
|
||||
if not isinstance(order, dict):
|
||||
continue
|
||||
resource = resources.get(order.get("resource")) or {}
|
||||
result.append(
|
||||
{
|
||||
"resource": resource.get("name") or order.get("resource"),
|
||||
"min_scu": order.get("minSCU"),
|
||||
"max_scu": order.get("maxSCU"),
|
||||
"max_container_size_scu": order.get("maxContainerSize"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _scmdb_partial_payouts(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(index, int):
|
||||
return []
|
||||
pools = data.get("partialRewardPayoutPools") or []
|
||||
if index < 0 or index >= len(pools):
|
||||
return []
|
||||
payouts = pools[index]
|
||||
if not isinstance(payouts, list):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"min_percent": payout.get("minPercentage"),
|
||||
"max_percent": payout.get("maxPercentage"),
|
||||
"currency_multiplier": payout.get("currencyRewardMultiplier"),
|
||||
"reputation_multipliers": payout.get("reputationMultipliers"),
|
||||
}
|
||||
for payout in payouts
|
||||
if isinstance(payout, dict)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_faction_name(data: dict[str, Any], guid: Any) -> str | None:
|
||||
if not guid:
|
||||
return None
|
||||
faction = (data.get("factions") or {}).get(guid)
|
||||
if isinstance(faction, dict):
|
||||
return faction.get("name") or guid
|
||||
return str(guid)
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_scope_name(data: dict[str, Any], guid: Any) -> str | None:
|
||||
if not guid:
|
||||
return None
|
||||
scope = (data.get("scopes") or {}).get(guid)
|
||||
if isinstance(scope, dict):
|
||||
return scope.get("scopeName") or guid
|
||||
return str(guid)
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_standing(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return {
|
||||
key: value.get(key)
|
||||
for key in ("name", "minReputation", "scopeName")
|
||||
if value.get(key) not in (None, "")
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _scmdb_pool_names(data: dict[str, Any], pool_key: str, keys: Any, limit: int = 10) -> list[str]:
|
||||
if not isinstance(keys, list):
|
||||
return []
|
||||
pool = data.get(pool_key) or {}
|
||||
names = []
|
||||
for key in keys[:limit]:
|
||||
item = pool.get(key)
|
||||
if isinstance(item, dict):
|
||||
names.append(str(item.get("name") or key))
|
||||
else:
|
||||
names.append(str(key))
|
||||
return names
|
||||
|
||||
@classmethod
|
||||
def _scmdb_search_text(cls, data: dict[str, Any], mission: dict[str, Any], summary: dict[str, Any]) -> str:
|
||||
pieces = [
|
||||
summary.get("title"),
|
||||
summary.get("debug_name"),
|
||||
summary.get("mission_type"),
|
||||
summary.get("category"),
|
||||
summary.get("faction"),
|
||||
mission.get("description"),
|
||||
" ".join(summary.get("systems") or []),
|
||||
]
|
||||
rewards = summary.get("rewards") or {}
|
||||
pieces.append(str(rewards.get("uec") or ""))
|
||||
for item in rewards.get("reputation") or []:
|
||||
pieces.extend([item.get("faction"), item.get("scope"), item.get("amount")])
|
||||
for item in rewards.get("items") or []:
|
||||
pieces.extend([item.get("name"), item.get("amount")])
|
||||
for item in rewards.get("blueprints") or []:
|
||||
pieces.extend([item.get("pool"), " ".join(item.get("blueprints") or [])])
|
||||
for item in rewards.get("hauling") or []:
|
||||
pieces.extend([item.get("resource"), item.get("min_scu"), item.get("max_scu")])
|
||||
return " ".join(str(piece) for piece in pieces if piece not in (None, "")).casefold()
|
||||
|
||||
async def _resolve_cornerstone_item(self, id: str | None = None, query: str | None = None) -> dict[str, Any] | None:
|
||||
items = await self.cornerstone.list_items()
|
||||
id_filter = (id or "").casefold().strip()
|
||||
if id_filter:
|
||||
for item in items:
|
||||
if str(item.get("id") or "").casefold() == id_filter:
|
||||
return item
|
||||
return {"id": id, "name": id, "sold": True}
|
||||
|
||||
q = (query or "").casefold().strip()
|
||||
if not q:
|
||||
return None
|
||||
exact = [item for item in items if str(item.get("name") or "").casefold() == q]
|
||||
if exact:
|
||||
exact.sort(key=lambda item: not bool(item.get("sold")))
|
||||
return exact[0]
|
||||
scored = [
|
||||
(self._cornerstone_match_score(q, str(item.get("name") or "")), item)
|
||||
for item in items
|
||||
]
|
||||
scored = [match for match in scored if match[0] > 0]
|
||||
if not scored:
|
||||
return None
|
||||
scored.sort(key=lambda match: (-match[0], not bool(match[1].get("sold")), str(match[1].get("name") or "").casefold()))
|
||||
return scored[0][1]
|
||||
|
||||
@staticmethod
|
||||
def _cornerstone_match_score(query: str, name: str) -> int:
|
||||
if not query:
|
||||
return 1
|
||||
normalized = name.casefold()
|
||||
if normalized == query:
|
||||
return 10000
|
||||
if normalized.startswith(query):
|
||||
return 9000 - len(normalized)
|
||||
if query in normalized:
|
||||
return 8000 - normalized.index(query)
|
||||
tokens = [token for token in query.split() if token]
|
||||
if tokens and all(token in normalized for token in tokens):
|
||||
return 7000 - len(normalized)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -1167,3 +1772,57 @@ class ToolRegistry:
|
||||
"advertiser": listing.get("user_username"),
|
||||
"expires_at": listing.get("date_expiration"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
|
||||
summary = cls._project_item(negotiation, mode="summary")
|
||||
state = cls._negotiation_state(negotiation)
|
||||
summary.update(
|
||||
{
|
||||
"state": state["state"],
|
||||
"is_open": state["is_open"],
|
||||
"state_reason": state["reason"],
|
||||
}
|
||||
)
|
||||
for key in ("hash", "id_listing", "id_user", "id_user_seller", "id_user_buyer", "date_closed"):
|
||||
if key in negotiation and key not in summary:
|
||||
summary[key] = negotiation.get(key)
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _negotiation_state(negotiation: dict[str, Any]) -> dict[str, Any]:
|
||||
closed_flags = [
|
||||
"is_closed",
|
||||
"closed",
|
||||
"is_cancelled",
|
||||
"is_canceled",
|
||||
"is_archived",
|
||||
"marked_closed",
|
||||
]
|
||||
for key in closed_flags:
|
||||
value = negotiation.get(key)
|
||||
if value in (True, 1, "1", "true", "True", "yes", "closed"):
|
||||
return {"state": "closed", "is_open": False, "reason": f"{key} is set"}
|
||||
|
||||
closed_dates = [
|
||||
"date_closed",
|
||||
"date_completed",
|
||||
"date_cancelled",
|
||||
"date_canceled",
|
||||
"closed_at",
|
||||
"completed_at",
|
||||
"cancelled_at",
|
||||
"canceled_at",
|
||||
]
|
||||
for key in closed_dates:
|
||||
value = negotiation.get(key)
|
||||
if value not in (None, "", 0, "0", False):
|
||||
return {"state": "closed", "is_open": False, "reason": f"{key} is populated"}
|
||||
|
||||
status = str(negotiation.get("status") or negotiation.get("state") or "").casefold()
|
||||
if status in {"closed", "cancelled", "canceled", "completed", "declined", "accepted", "rejected"}:
|
||||
return {"state": "closed", "is_open": False, "reason": f"status is {status}"}
|
||||
if status in {"open", "active", "pending", "new"}:
|
||||
return {"state": "open", "is_open": True, "reason": f"status is {status}"}
|
||||
|
||||
return {"state": "open", "is_open": True, "reason": "no closed flag, closed date, or closed status was present"}
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.0.2"
|
||||
__version__ = "0.0.3"
|
||||
|
||||
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||
@@ -8,3 +8,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -755,7 +755,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "traderai"
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -1048,3 +1048,4 @@ wheels = [
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+386
-18
@@ -29,9 +29,31 @@ const updateCheckButton = document.getElementById("update-check");
|
||||
const updateInstallButton = document.getElementById("update-install");
|
||||
const updateOpenReleasesButton = document.getElementById("update-open-releases");
|
||||
const updateStatusEl = document.getElementById("update-status");
|
||||
const shellEl = document.querySelector(".shell");
|
||||
const chatRailEl = document.getElementById("chat-rail");
|
||||
const chatSidebarToggle = document.getElementById("chat-sidebar-toggle");
|
||||
const newChatButton = document.getElementById("new-chat");
|
||||
const chatListEl = document.getElementById("chat-list");
|
||||
const inboxListEl = document.getElementById("inbox-list");
|
||||
const negotiationPanel = document.getElementById("negotiation-panel");
|
||||
const negotiationTitle = document.getElementById("negotiation-title");
|
||||
const negotiationMessagesEl = document.getElementById("negotiation-messages");
|
||||
const negotiationForm = document.getElementById("negotiation-form");
|
||||
const negotiationInput = document.getElementById("negotiation-input");
|
||||
const negotiationStatusEl = document.getElementById("negotiation-status");
|
||||
const negotiationCloseButton = document.getElementById("negotiation-close");
|
||||
const updateModal = document.getElementById("update-modal");
|
||||
const updateModalCopy = document.getElementById("update-modal-copy");
|
||||
const updateModalClose = document.getElementById("update-modal-close");
|
||||
const updateModalInstall = document.getElementById("update-modal-install");
|
||||
const updateModalReleases = document.getElementById("update-modal-releases");
|
||||
|
||||
let ollamaOnline = true;
|
||||
let latestUpdate = null;
|
||||
let currentThreadId = "default";
|
||||
let currentNegotiationId = null;
|
||||
let latestOllamaStatus = null;
|
||||
const clickedOllamaActions = new Set();
|
||||
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
@@ -49,6 +71,7 @@ function addMessage(role, text) {
|
||||
function setMessageMarkdown(node, text) {
|
||||
const body = node.querySelector(".message-body") || node;
|
||||
body.innerHTML = renderMarkdown(text);
|
||||
enhanceNegotiationLinks(body);
|
||||
}
|
||||
|
||||
function setMessageActivity(node, text, active = false) {
|
||||
@@ -342,6 +365,43 @@ function inlineMarkdown(text) {
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
|
||||
}
|
||||
|
||||
function enhanceNegotiationLinks(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
if (node.parentElement?.closest("a, button, code, pre")) return NodeFilter.FILTER_REJECT;
|
||||
return /(?:negotiation|id_negotiation|marketplace\/(?:negotiations|negotiate\/hash))/i.test(node.textContent || "")
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
});
|
||||
const textNodes = [];
|
||||
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
||||
const pattern = /((?:negotiation|id_negotiation)\s*(?:#|id|:)?\s*)([A-Za-z0-9_-]{3,})|(\/marketplace\/(?:negotiations|negotiate\/hash)\/)([A-Za-z0-9_-]+)/gi;
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent || "";
|
||||
let lastIndex = 0;
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const matchIndex = match.index || 0;
|
||||
const identifier = match[2] || match[4];
|
||||
if (!identifier) continue;
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)));
|
||||
if (match[1]) fragment.appendChild(document.createTextNode(match[1]));
|
||||
if (match[3]) fragment.appendChild(document.createTextNode(match[3]));
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "negotiation-link";
|
||||
button.dataset.negotiationId = identifier;
|
||||
button.textContent = identifier;
|
||||
button.addEventListener("click", () => openNegotiationPanel(identifier));
|
||||
fragment.appendChild(button);
|
||||
lastIndex = matchIndex + match[0].length;
|
||||
}
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||
textNode.replaceWith(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
function isTableRow(line) {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed);
|
||||
@@ -527,6 +587,7 @@ async function refreshOllamaStatus() {
|
||||
|
||||
function renderOllamaStatus(status) {
|
||||
if (!ollamaStatusEl) return;
|
||||
latestOllamaStatus = status;
|
||||
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
||||
const pillClass = status.installed && status.running && status.model_available ? "status-pill" : "status-pill warning";
|
||||
ollamaStatusEl.innerHTML = `
|
||||
@@ -537,14 +598,18 @@ function renderOllamaStatus(status) {
|
||||
${ollamaStatusItem("Model", status.configured_model || "")}
|
||||
${ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")}
|
||||
${ollamaStatusItem("URL", status.base_url || "")}
|
||||
${ollamaStatusItem("Auto Install", status.can_auto_install ? "Available" : "Unavailable")}
|
||||
${status.can_auto_install ? ollamaStatusItem("Auto Install", "Available") : ""}
|
||||
</div>
|
||||
${ollamaStatusItem("Installed Models", models)}
|
||||
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
||||
`;
|
||||
if (ollamaInstallButton) ollamaInstallButton.disabled = Boolean(status.installed);
|
||||
if (ollamaInstallButton) {
|
||||
ollamaInstallButton.hidden = !status.can_auto_install;
|
||||
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
|
||||
}
|
||||
if (ollamaLaunchButton) ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
||||
if (ollamaPullButton) ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
||||
updateOllamaAttention(status);
|
||||
}
|
||||
|
||||
function ollamaStatusItem(label, value) {
|
||||
@@ -572,11 +637,34 @@ async function postOllamaAction(endpoint, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function markOllamaActionClicked(action) {
|
||||
if (action) clickedOllamaActions.add(action);
|
||||
updateOllamaAttention();
|
||||
}
|
||||
|
||||
function setOllamaButtonAttention(button, action, active) {
|
||||
if (!button) return;
|
||||
const shouldPulse = active && !clickedOllamaActions.has(action) && !button.disabled && !button.hidden;
|
||||
button.classList.toggle("attention-pulse", shouldPulse);
|
||||
}
|
||||
|
||||
function updateOllamaAttention(status = null) {
|
||||
const currentStatus = status || latestOllamaStatus;
|
||||
if (!currentStatus) return;
|
||||
const ready = Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
|
||||
ollamaToggle?.classList.toggle("attention-pulse", !ready);
|
||||
setOllamaButtonAttention(ollamaDownloadButton, "download", !currentStatus.installed);
|
||||
setOllamaButtonAttention(ollamaInstallButton, "install", !currentStatus.installed && currentStatus.can_auto_install);
|
||||
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.installed && !currentStatus.running);
|
||||
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.running && !currentStatus.model_available);
|
||||
if (ready) clickedOllamaActions.clear();
|
||||
}
|
||||
|
||||
function configuredOllamaModel() {
|
||||
return document.getElementById("ollama-model")?.value || "";
|
||||
}
|
||||
|
||||
async function checkForUpdate() {
|
||||
async function checkForUpdate(promptUser = false) {
|
||||
if (!updateStatusEl) return;
|
||||
updateStatusEl.textContent = "Checking releases";
|
||||
try {
|
||||
@@ -584,6 +672,7 @@ async function checkForUpdate() {
|
||||
const result = await response.json();
|
||||
latestUpdate = result;
|
||||
renderUpdateStatus(result);
|
||||
if (promptUser) maybeShowUpdatePrompt(result);
|
||||
} catch (error) {
|
||||
updateStatusEl.textContent = `Update check failed: ${error.message}`;
|
||||
if (updateInstallButton) updateInstallButton.disabled = true;
|
||||
@@ -627,6 +716,22 @@ function openReleasesPage() {
|
||||
window.open(url, "_blank", "noreferrer");
|
||||
}
|
||||
|
||||
function maybeShowUpdatePrompt(update) {
|
||||
if (!update?.available || !updateModal) return;
|
||||
const dismissedVersion = localStorage.getItem("traderai.dismissedUpdateVersion");
|
||||
if (dismissedVersion === update.latest_version) return;
|
||||
updateModalCopy.textContent = update.message || `TraderAI ${update.latest_version} is available.`;
|
||||
updateModalInstall.disabled = !update.asset_download_url || !update.packaged;
|
||||
updateModal.hidden = false;
|
||||
}
|
||||
|
||||
function closeUpdatePrompt() {
|
||||
if (latestUpdate?.latest_version) {
|
||||
localStorage.setItem("traderai.dismissedUpdateVersion", latestUpdate.latest_version);
|
||||
}
|
||||
updateModal.hidden = true;
|
||||
}
|
||||
|
||||
function toggleSidebarPanel(panelName) {
|
||||
const panels = {
|
||||
settings: { panel: settingsPanel, button: settingsToggle },
|
||||
@@ -658,6 +763,245 @@ function toggleSidebarPanel(panelName) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChatRail() {
|
||||
const isCollapsed = chatRailEl?.classList.toggle("collapsed");
|
||||
shellEl?.classList.toggle("chat-open", !isCollapsed);
|
||||
chatSidebarToggle?.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
}
|
||||
|
||||
async function refreshChats() {
|
||||
if (!chatListEl) return;
|
||||
try {
|
||||
const response = await fetch("/api/chats");
|
||||
const result = await response.json();
|
||||
const chats = result.chats || [];
|
||||
if (!chats.length) {
|
||||
const created = await createChat(false);
|
||||
currentThreadId = created?.id || "default";
|
||||
return;
|
||||
}
|
||||
if (!chats.some((chat) => chat.id === currentThreadId)) {
|
||||
currentThreadId = chats[0].id;
|
||||
}
|
||||
renderChats(chats);
|
||||
} catch (error) {
|
||||
chatListEl.textContent = `Chats failed: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChats(chats) {
|
||||
chatListEl.innerHTML = "";
|
||||
for (const chat of chats) {
|
||||
const item = document.createElement("div");
|
||||
item.className = `chat-item${chat.id === currentThreadId ? " active" : ""}`;
|
||||
const title = document.createElement("button");
|
||||
title.type = "button";
|
||||
title.className = "chat-title";
|
||||
title.textContent = chat.title || "New chat";
|
||||
title.addEventListener("click", () => switchChat(chat.id));
|
||||
const rename = document.createElement("button");
|
||||
rename.type = "button";
|
||||
rename.className = "icon-button";
|
||||
rename.title = "Rename chat";
|
||||
rename.innerHTML = '<i data-lucide="pen" aria-hidden="true"></i>';
|
||||
rename.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
renameChat(chat.id, chat.title || "New chat");
|
||||
});
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "icon-button";
|
||||
remove.title = "Delete chat";
|
||||
remove.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
|
||||
remove.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
deleteChat(chat.id);
|
||||
});
|
||||
item.append(title, rename, remove);
|
||||
chatListEl.appendChild(item);
|
||||
}
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
async function createChat(shouldSwitch = true) {
|
||||
const response = await fetch("/api/chats", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "New chat" }),
|
||||
});
|
||||
const result = await response.json();
|
||||
const chat = result.chat;
|
||||
if (shouldSwitch && chat?.id) {
|
||||
currentThreadId = chat.id;
|
||||
await loadChatMessages(chat.id);
|
||||
}
|
||||
await refreshChats();
|
||||
return chat;
|
||||
}
|
||||
|
||||
async function switchChat(threadId) {
|
||||
if (!threadId || threadId === currentThreadId) return;
|
||||
currentThreadId = threadId;
|
||||
await loadChatMessages(threadId);
|
||||
await refreshChats();
|
||||
}
|
||||
|
||||
async function deleteChat(threadId) {
|
||||
await fetch(`/api/chats/${encodeURIComponent(threadId)}`, { method: "DELETE" });
|
||||
if (threadId === currentThreadId) currentThreadId = "";
|
||||
await refreshChats();
|
||||
if (currentThreadId) await loadChatMessages(currentThreadId);
|
||||
}
|
||||
|
||||
async function renameChat(threadId, currentTitle) {
|
||||
const title = window.prompt("Rename chat", currentTitle || "New chat");
|
||||
if (title === null) return;
|
||||
const cleanTitle = title.trim();
|
||||
if (!cleanTitle) return;
|
||||
await fetch(`/api/chats/${encodeURIComponent(threadId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: cleanTitle }),
|
||||
});
|
||||
await refreshChats();
|
||||
}
|
||||
|
||||
async function loadChatMessages(threadId) {
|
||||
messages.innerHTML = "";
|
||||
try {
|
||||
const response = await fetch(`/api/chats/${encodeURIComponent(threadId)}/messages`);
|
||||
const result = await response.json();
|
||||
const rows = result.messages || [];
|
||||
if (!rows.length) {
|
||||
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.role === "user" || row.role === "assistant") addMessage(row.role, row.content || "");
|
||||
}
|
||||
} catch (error) {
|
||||
addMessage("assistant warning-message", `Could not load chat: ${fetchErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshInbox() {
|
||||
if (!inboxListEl) return;
|
||||
try {
|
||||
const response = await fetch("/api/inbox");
|
||||
const result = await response.json();
|
||||
renderInbox(result.inbox || []);
|
||||
} catch (error) {
|
||||
inboxListEl.textContent = `Inbox failed: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInbox(items) {
|
||||
inboxListEl.innerHTML = "";
|
||||
if (!items.length) {
|
||||
inboxListEl.innerHTML = '<div class="pending-empty">No inbox messages</div>';
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "inbox-item";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-title";
|
||||
title.textContent = item.content || "";
|
||||
enhanceNegotiationLinks(title);
|
||||
const open = document.createElement("button");
|
||||
open.type = "button";
|
||||
open.className = "icon-button";
|
||||
open.title = "Continue in new chat";
|
||||
open.innerHTML = '<i data-lucide="message-square-plus" aria-hidden="true"></i>';
|
||||
open.addEventListener("click", () => continueInbox(item.id));
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "icon-button";
|
||||
remove.title = "Delete inbox alert";
|
||||
remove.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
|
||||
remove.addEventListener("click", () => deleteInboxItem(item.id));
|
||||
row.append(title, open, remove);
|
||||
inboxListEl.appendChild(row);
|
||||
}
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
async function continueInbox(id) {
|
||||
const response = await fetch(`/api/inbox/${id}/continue`, { method: "POST" });
|
||||
const result = await response.json();
|
||||
if (result.chat?.id) {
|
||||
currentThreadId = result.chat.id;
|
||||
await loadChatMessages(currentThreadId);
|
||||
await refreshChats();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInboxItem(id) {
|
||||
await fetch(`/api/inbox/${id}`, { method: "DELETE" });
|
||||
await refreshInbox();
|
||||
}
|
||||
|
||||
async function openNegotiationPanel(identifier) {
|
||||
currentNegotiationId = identifier;
|
||||
negotiationPanel.hidden = false;
|
||||
negotiationTitle.textContent = `Negotiation ${identifier}`;
|
||||
negotiationStatusEl.textContent = "";
|
||||
negotiationMessagesEl.textContent = "Loading";
|
||||
try {
|
||||
const response = await fetch(`/api/negotiations/${encodeURIComponent(identifier)}/messages`);
|
||||
const result = await response.json();
|
||||
renderNegotiationMessages(result.data || result.messages || result.notifications || []);
|
||||
} catch (error) {
|
||||
negotiationMessagesEl.textContent = `Could not load negotiation: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeNegotiationPanel() {
|
||||
negotiationPanel.hidden = true;
|
||||
currentNegotiationId = null;
|
||||
negotiationInput.value = "";
|
||||
negotiationStatusEl.textContent = "";
|
||||
}
|
||||
|
||||
function renderNegotiationMessages(data) {
|
||||
negotiationMessagesEl.innerHTML = "";
|
||||
const items = Array.isArray(data) ? data : [data].filter(Boolean);
|
||||
if (!items.length) {
|
||||
negotiationMessagesEl.textContent = "No messages returned for this negotiation.";
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "negotiation-message";
|
||||
const author = item.user_username || item.username || item.author || item.sender || "UEX";
|
||||
const body = item.message || item.content || item.text || JSON.stringify(item, null, 2);
|
||||
card.innerHTML = `<strong>${escapeHtml(String(author))}</strong><br>${inlineMarkdown(String(body))}`;
|
||||
negotiationMessagesEl.appendChild(card);
|
||||
}
|
||||
negotiationMessagesEl.scrollTop = negotiationMessagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
async function submitNegotiationMessage(event) {
|
||||
event.preventDefault();
|
||||
const text = negotiationInput.value.trim();
|
||||
if (!text || !currentNegotiationId) return;
|
||||
negotiationStatusEl.textContent = "Sending";
|
||||
try {
|
||||
const response = await fetch(`/api/negotiations/${encodeURIComponent(currentNegotiationId)}/messages`, {
|
||||
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}`);
|
||||
negotiationInput.value = "";
|
||||
negotiationStatusEl.textContent = result.message || "Sent";
|
||||
await openNegotiationPanel(currentNegotiationId);
|
||||
} catch (error) {
|
||||
negotiationStatusEl.textContent = `Send failed: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
@@ -666,20 +1010,24 @@ async function checkHealth() {
|
||||
ollamaOnline = Boolean(health.online);
|
||||
if (!ollamaOnline) {
|
||||
statusEl.textContent = "Offline";
|
||||
setWarning(health.message || "Ollama is offline. Start Ollama before chatting.");
|
||||
setWarning("Ollama needs attention. Open the Ollama tab and use the pulsing action button.");
|
||||
ollamaToggle?.classList.add("attention-pulse");
|
||||
return false;
|
||||
}
|
||||
if (health.model_available === false) {
|
||||
setWarning(`Ollama is online, but model "${health.model}" is not pulled. Run: ollama pull ${health.model}`);
|
||||
setWarning(`Ollama needs the configured model "${health.model}". Open the Ollama tab and use Install Model.`);
|
||||
ollamaToggle?.classList.add("attention-pulse");
|
||||
} else {
|
||||
setWarning("");
|
||||
ollamaToggle?.classList.remove("attention-pulse");
|
||||
}
|
||||
statusEl.textContent = "Ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
ollamaOnline = false;
|
||||
statusEl.textContent = "Offline";
|
||||
setWarning(`Could not check Ollama health: ${error.message}`);
|
||||
setWarning("Could not check Ollama health. Open the Ollama tab and use the pulsing action button.");
|
||||
ollamaToggle?.classList.add("attention-pulse");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -833,9 +1181,7 @@ async function pollNotifications() {
|
||||
try {
|
||||
const response = await fetch("/api/notifications");
|
||||
const result = await response.json();
|
||||
for (const notification of result.notifications || []) {
|
||||
addMessage("assistant", notification.content);
|
||||
}
|
||||
if ((result.notifications || []).length) await refreshInbox();
|
||||
} catch {
|
||||
// Notification polling should never interrupt chat.
|
||||
}
|
||||
@@ -862,20 +1208,39 @@ memoryToggle?.addEventListener("click", () => toggleSidebarPanel("memory"));
|
||||
ollamaToggle?.addEventListener("click", () => toggleSidebarPanel("ollama"));
|
||||
ollamaForm?.addEventListener("submit", saveOllamaConfig);
|
||||
ollamaRefreshButton?.addEventListener("click", refreshOllamaStatus);
|
||||
ollamaDownloadButton?.addEventListener("click", () => postOllamaAction("/api/ollama/download"));
|
||||
ollamaInstallButton?.addEventListener("click", () => postOllamaAction("/api/ollama/install"));
|
||||
ollamaLaunchButton?.addEventListener("click", () => postOllamaAction("/api/ollama/launch"));
|
||||
ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }));
|
||||
ollamaDownloadButton?.addEventListener("click", () => {
|
||||
markOllamaActionClicked("download");
|
||||
postOllamaAction("/api/ollama/download");
|
||||
});
|
||||
ollamaInstallButton?.addEventListener("click", () => {
|
||||
markOllamaActionClicked("install");
|
||||
postOllamaAction("/api/ollama/install");
|
||||
});
|
||||
ollamaLaunchButton?.addEventListener("click", () => {
|
||||
markOllamaActionClicked("launch");
|
||||
postOllamaAction("/api/ollama/launch");
|
||||
});
|
||||
ollamaPullButton?.addEventListener("click", () => {
|
||||
markOllamaActionClicked("pull");
|
||||
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
||||
});
|
||||
updateCheckButton?.addEventListener("click", checkForUpdate);
|
||||
updateInstallButton?.addEventListener("click", installUpdate);
|
||||
updateOpenReleasesButton?.addEventListener("click", openReleasesPage);
|
||||
chatSidebarToggle?.addEventListener("click", toggleChatRail);
|
||||
newChatButton?.addEventListener("click", () => createChat(true));
|
||||
negotiationCloseButton?.addEventListener("click", closeNegotiationPanel);
|
||||
negotiationForm?.addEventListener("submit", submitNegotiationMessage);
|
||||
updateModalClose?.addEventListener("click", closeUpdatePrompt);
|
||||
updateModalReleases?.addEventListener("click", openReleasesPage);
|
||||
updateModalInstall?.addEventListener("click", installUpdate);
|
||||
|
||||
async function sendMessage() {
|
||||
const message = input.value.trim();
|
||||
if (!message || input.disabled) return;
|
||||
const healthy = await checkHealth();
|
||||
if (!healthy) {
|
||||
addMessage("assistant warning-message", "Ollama is offline. Start Ollama, then try again.");
|
||||
addMessage("assistant warning-message", "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again.");
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
@@ -892,7 +1257,7 @@ async function sendMessage() {
|
||||
const response = await fetch("/api/chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
body: JSON.stringify({ message, thread_id: currentThreadId }),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -926,6 +1291,7 @@ async function sendMessage() {
|
||||
}
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
} else if (event.type === "done") {
|
||||
if (event.thread_id) currentThreadId = event.thread_id;
|
||||
const visibleContent = thinkParser.flush();
|
||||
if (visibleContent) {
|
||||
assistantText += visibleContent;
|
||||
@@ -937,7 +1303,7 @@ async function sendMessage() {
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.message.includes("503")
|
||||
? "Ollama is offline or unreachable. Start Ollama, then try again."
|
||||
? "Ollama needs attention before chat can continue. Open the Ollama tab and press the pulsing action button, then try again."
|
||||
: `Chat failed: ${error.message}`;
|
||||
setWarning(message);
|
||||
setMessageMarkdown(assistantNode, message);
|
||||
@@ -948,15 +1314,17 @@ async function sendMessage() {
|
||||
statusEl.textContent = "Ready";
|
||||
finishThinking(assistantNode);
|
||||
setMessageActivity(assistantNode, "");
|
||||
await refreshChats();
|
||||
}
|
||||
}
|
||||
|
||||
addMessage("assistant", "Tell me what to find or draft on UEX. I will ask for approval before sending anything.");
|
||||
refreshPending();
|
||||
refreshMemory();
|
||||
refreshConfig();
|
||||
refreshOllamaStatus();
|
||||
checkForUpdate();
|
||||
refreshChats().then(() => loadChatMessages(currentThreadId));
|
||||
refreshInbox();
|
||||
checkForUpdate(true);
|
||||
pollNotifications();
|
||||
checkHealth();
|
||||
setInterval(checkHealth, 30000);
|
||||
|
||||
@@ -9,6 +9,28 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox">
|
||||
<div class="chat-rail-top">
|
||||
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
||||
<i data-lucide="panel-left" aria-hidden="true"></i>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<button class="icon-button" id="new-chat" type="button" title="New chat">
|
||||
<i data-lucide="square-pen" aria-hidden="true"></i>
|
||||
<span>New chat</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-rail-content">
|
||||
<section class="chat-nav-section">
|
||||
<div class="rail-heading">Chats</div>
|
||||
<div class="chat-list" id="chat-list"></div>
|
||||
</section>
|
||||
<section class="chat-nav-section">
|
||||
<div class="rail-heading">Inbox</div>
|
||||
<div class="inbox-list" id="inbox-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div class="brand-block">
|
||||
@@ -120,6 +142,38 @@
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
<div class="floating-panel" id="negotiation-panel" hidden>
|
||||
<div class="floating-panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">UEX negotiation</p>
|
||||
<h2 id="negotiation-title">Negotiation</h2>
|
||||
</div>
|
||||
<button class="icon-button light" id="negotiation-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="negotiation-messages" id="negotiation-messages"></div>
|
||||
<form class="negotiation-composer" id="negotiation-form">
|
||||
<textarea id="negotiation-input" rows="2" placeholder="Reply to the other party..."></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<div class="config-status" id="negotiation-status"></div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="update-modal" hidden>
|
||||
<section class="update-modal-card">
|
||||
<div class="section-title-row">
|
||||
<h2>Update Available</h2>
|
||||
<button class="icon-button light" id="update-modal-close" type="button" title="Close">
|
||||
<i data-lucide="x" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="update-modal-copy"></p>
|
||||
<div class="update-actions">
|
||||
<button class="secondary small-button" id="update-modal-releases" type="button">Releases</button>
|
||||
<button class="small-button" id="update-modal-install" type="button">Update</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="https://unpkg.com/lucide@0.562.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
|
||||
+336
-8
@@ -22,6 +22,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
@@ -50,7 +54,7 @@ body::before {
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
grid-template-columns: 64px minmax(0, 1fr) 380px;
|
||||
gap: 24px;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
@@ -58,8 +62,13 @@ body::before {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.shell.chat-open {
|
||||
grid-template-columns: 280px minmax(0, 1fr) 380px;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.actions {
|
||||
.actions,
|
||||
.chat-rail {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(240, 214, 129, 0.34);
|
||||
@@ -69,12 +78,126 @@ body::before {
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-rail {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(180deg, #fffaf0 0%, #f7f1dc 100%);
|
||||
}
|
||||
|
||||
.chat-rail-top {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shell.chat-open .chat-rail-top {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.chat-rail-content {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
opacity: 1;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.chat-rail.collapsed .chat-rail-content {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.chat-nav-section {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-heading {
|
||||
margin-bottom: 8px;
|
||||
color: var(--forest);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chat-list,
|
||||
.inbox-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: calc(100% - 26px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-item,
|
||||
.inbox-item {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 250, 240, 0.78);
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.inbox-item {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.chat-item.active {
|
||||
border-color: rgba(52, 83, 38, 0.42);
|
||||
background: #edf3df;
|
||||
}
|
||||
|
||||
.chat-title,
|
||||
.inbox-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--brown);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-title:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.inbox-title {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
@@ -203,6 +326,7 @@ h2 {
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 28px 28px 18px;
|
||||
@@ -212,13 +336,15 @@ h2 {
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 920px;
|
||||
width: fit-content;
|
||||
max-width: min(920px, 88%);
|
||||
margin-bottom: 16px;
|
||||
padding: 17px 18px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.9);
|
||||
border-radius: 18px;
|
||||
color: var(--brown);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
box-shadow: 0 16px 38px rgba(38, 58, 27, 0.11);
|
||||
}
|
||||
|
||||
@@ -346,6 +472,7 @@ h2 {
|
||||
|
||||
.message.user {
|
||||
margin-left: auto;
|
||||
max-width: min(720px, 76%);
|
||||
border-color: rgba(52, 83, 38, 0.28);
|
||||
background: linear-gradient(180deg, #edf3df, #e5efd4);
|
||||
}
|
||||
@@ -360,7 +487,9 @@ h2 {
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
position: sticky;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
margin-top: auto;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
border-top: 1px solid var(--line);
|
||||
@@ -582,6 +711,162 @@ button {
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: #fff9e9;
|
||||
color: var(--forest);
|
||||
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
|
||||
}
|
||||
|
||||
.shell.chat-open .chat-rail-top .icon-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.icon-button span {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shell.chat-open .chat-rail-top .icon-button span {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
|
||||
.icon-button.light {
|
||||
background: rgba(255, 250, 240, 0.86);
|
||||
}
|
||||
|
||||
.chat-item .icon-button,
|
||||
.inbox-item .icon-button {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.negotiation-link {
|
||||
display: inline;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--forest-2);
|
||||
font-family: inherit;
|
||||
font-weight: 800;
|
||||
text-decoration: underline;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.negotiation-link:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.floating-panel {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
bottom: 28px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(180px, 360px) auto auto;
|
||||
width: min(520px, calc(100vw - 28px));
|
||||
max-height: min(760px, calc(100vh - 56px));
|
||||
border: 1px solid rgba(240, 214, 129, 0.42);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.floating-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, rgba(20, 33, 15, 0.96), rgba(38, 58, 27, 0.94));
|
||||
}
|
||||
|
||||
.floating-panel-header h2 {
|
||||
margin: 3px 0 0;
|
||||
color: var(--ivory);
|
||||
}
|
||||
|
||||
.negotiation-messages {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.negotiation-message {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 250, 240, 0.86);
|
||||
font-size: 13px;
|
||||
line-height: 1.42;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.negotiation-composer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.negotiation-composer textarea {
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(20, 33, 15, 0.42);
|
||||
}
|
||||
|
||||
.update-modal-card {
|
||||
width: min(440px, 100%);
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(240, 214, 129, 0.42);
|
||||
border-radius: 18px;
|
||||
background: var(--ivory);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.update-modal-card p {
|
||||
margin: 6px 0 18px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: linear-gradient(180deg, #3d612c, #263e1b);
|
||||
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
@@ -793,6 +1078,31 @@ button.secondary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attention-pulse {
|
||||
position: relative;
|
||||
border-color: rgba(47, 125, 50, 0.72) !important;
|
||||
box-shadow: 0 0 0 0 rgba(55, 148, 61, 0.54);
|
||||
animation: green-attention-pulse 1.35s ease-out infinite;
|
||||
}
|
||||
|
||||
.attention-pulse:disabled,
|
||||
.attention-pulse[hidden] {
|
||||
animation: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes green-attention-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(55, 148, 61, 0.58);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(55, 148, 61, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(55, 148, 61, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.update-box {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -950,7 +1260,19 @@ pre {
|
||||
@media (max-width: 960px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
|
||||
grid-template-rows: auto minmax(0, 1fr) minmax(220px, 34vh);
|
||||
}
|
||||
|
||||
.shell.chat-open {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-rail {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.chat-rail.collapsed {
|
||||
max-height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +1319,8 @@ pre {
|
||||
}
|
||||
|
||||
.messages,
|
||||
.actions {
|
||||
.actions,
|
||||
.chat-rail {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
@@ -1015,6 +1338,11 @@ pre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message,
|
||||
.message.user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-activity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user