feat: chat sidebar and inbox, feat: saved chats, fix: wake jobs, fix: sandbox sends, ux: negotiation replies and draft box
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -7,6 +7,9 @@ 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 +116,10 @@ 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]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_marketplace_listings_filters_locally():
|
||||
@@ -145,6 +152,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")
|
||||
|
||||
|
||||
+271
-58
@@ -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
|
||||
|
||||
|
||||
@@ -19,6 +20,7 @@ When the user asks for history, trends, changes over time, or past prices, prefe
|
||||
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 +40,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:
|
||||
@@ -70,107 +72,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 +291,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 +301,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 +320,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 +367,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 +384,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 +480,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_"):
|
||||
|
||||
+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()
|
||||
|
||||
+79
-3
@@ -21,7 +21,7 @@ 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.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
@@ -35,6 +35,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):
|
||||
@@ -246,18 +259,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 +304,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 +367,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()
|
||||
|
||||
+80
-5
@@ -129,9 +129,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:
|
||||
@@ -266,7 +272,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 +304,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 +388,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)
|
||||
@@ -915,7 +921,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 +937,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)
|
||||
@@ -971,6 +983,7 @@ class ToolRegistry:
|
||||
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 +996,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()
|
||||
@@ -1167,3 +1188,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 = [
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+329
-7
@@ -29,9 +29,29 @@ 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;
|
||||
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
@@ -49,6 +69,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 +363,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);
|
||||
@@ -576,7 +634,7 @@ 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 +642,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 +686,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 +733,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");
|
||||
@@ -833,9 +1147,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.
|
||||
}
|
||||
@@ -869,6 +1181,13 @@ ollamaPullButton?.addEventListener("click", () => postOllamaAction("/api/ollama/
|
||||
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();
|
||||
@@ -892,7 +1211,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 +1245,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;
|
||||
@@ -948,15 +1268,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>
|
||||
|
||||
+311
-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);
|
||||
@@ -950,7 +1235,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 +1294,8 @@ pre {
|
||||
}
|
||||
|
||||
.messages,
|
||||
.actions {
|
||||
.actions,
|
||||
.chat-rail {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
@@ -1015,6 +1313,11 @@ pre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message,
|
||||
.message.user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-activity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user