Compare commits
7 Commits
d6c2d57fd9
...
0.0.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
454bb57484
|
|||
|
00cf6f8747
|
|||
|
6bd1e81a51
|
|||
|
a5a718b3e4
|
|||
|
7b65b62f58
|
|||
|
97c751c585
|
|||
|
e2f87481d6
|
+13
-1
@@ -1,12 +1,24 @@
|
|||||||
|
MODEL_PROVIDER=ollama
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
OLLAMA_MODEL=qwen3.5:9b
|
OLLAMA_MODEL=qwen3.5:9b
|
||||||
OLLAMA_NUM_CTX=64512
|
OLLAMA_NUM_CTX=64512
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-5.4-mini
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
MODEL_REASONING_EFFORT=medium
|
||||||
|
CODEX_COMMAND=codex
|
||||||
|
CODEX_MODEL=gpt-5.4
|
||||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
||||||
SCMDB_BASE_URL=https://scmdb.net
|
SCMDB_BASE_URL=https://scmdb.net
|
||||||
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
CORNERSTONE_BASE_URL=https://finder.cstone.space
|
||||||
|
SCWIKI_BASE_URL=https://starcitizen.tools
|
||||||
|
SCWIKI_API_BASE_URL=https://api.star-citizen.wiki
|
||||||
UEX_SECRET_KEY=
|
UEX_SECRET_KEY=
|
||||||
UEX_BEARER_TOKEN=
|
UEX_BEARER_TOKEN=
|
||||||
TRADERAI_USER_NAME=
|
TRADERAI_USER_NAME=
|
||||||
TRADERAI_MEMORY_PATH=
|
TRADERAI_MEMORY_PATH=
|
||||||
UEX_NOTIFICATION_POLL_SECONDS=60
|
UEX_NOTIFICATION_POLL_SECONDS=300
|
||||||
REQUIRE_WRITE_APPROVAL=true
|
REQUIRE_WRITE_APPROVAL=true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# TraderAI
|
# TraderAI
|
||||||
|
|
||||||
Local Ollama-powered chat for UEX marketplace workflows.
|
Local Ollama-, DeepSeek-, OpenAI-, or Codex-powered chat for UEX marketplace workflows.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions.
|
||||||
|
If you want the cheapest hosted default, set `MODEL_PROVIDER=deepseek`, set `DEEPSEEK_API_KEY`, and keep `DEEPSEEK_MODEL=deepseek-v4-flash` unless you specifically want `deepseek-v4-pro`.
|
||||||
|
If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`.
|
||||||
|
If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns.
|
||||||
|
`MODEL_REASONING_EFFORT` controls reasoning depth for DeepSeek, OpenAI, and Codex and defaults to `medium`.
|
||||||
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
`SCMDB_BASE_URL` defaults to `https://scmdb.net`.
|
||||||
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
`CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`.
|
||||||
4. Install and run:
|
4. Install and run:
|
||||||
@@ -38,7 +42,7 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it.
|
Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, DeepSeek's OpenAI-compatible Chat Completions API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. DeepSeek context caching is provider-side and automatic when repeated prompt prefixes line up.
|
||||||
|
|
||||||
## Releases And Updates
|
## Releases And Updates
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.3"
|
version = "0.0.8"
|
||||||
description = "Local Ollama-powered assistant for UEX marketplace workflows."
|
description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"apscheduler>=3.10.4",
|
"apscheduler>=3.10.4",
|
||||||
@@ -37,3 +37,9 @@ include = ["traderai*"]
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import itertools
|
||||||
|
|
||||||
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
from traderai.agent import OllamaAgent, SYSTEM_PROMPT
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
@@ -64,6 +65,19 @@ class TitleAgent(OllamaAgent):
|
|||||||
return {"message": {"role": "assistant", "content": "Done"}}
|
return {"message": {"role": "assistant", "content": "Done"}}
|
||||||
|
|
||||||
|
|
||||||
|
class ImageCaptureAgent(OllamaAgent):
|
||||||
|
def __init__(self, memory):
|
||||||
|
super().__init__("http://127.0.0.1:1", "missing-model", EmptyTools(), memory=memory)
|
||||||
|
self.last_messages = None
|
||||||
|
|
||||||
|
async def ensure_available(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _chat_once(self, query="", messages=None, **kwargs):
|
||||||
|
self.last_messages = messages
|
||||||
|
return {"message": {"role": "assistant", "content": "Seen"}}
|
||||||
|
|
||||||
|
|
||||||
class SlowToolTools(EmptyTools):
|
class SlowToolTools(EmptyTools):
|
||||||
schemas = [
|
schemas = [
|
||||||
{
|
{
|
||||||
@@ -204,6 +218,150 @@ def test_ollama_options_include_num_ctx():
|
|||||||
assert agent._ollama_options() == {"num_ctx": 64000}
|
assert agent._ollama_options() == {"num_ctx": 64000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_tool_rounds_are_not_capped_at_ten():
|
||||||
|
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||||
|
|
||||||
|
rounds = list(itertools.islice(agent._tool_rounds(), 12))
|
||||||
|
|
||||||
|
assert len(rounds) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_normalization_extracts_json_and_defaults():
|
||||||
|
seed = {"title": "Wikelo Polaris", "objective": "Find parts", "kind": "buying", "constraints": {}, "items": []}
|
||||||
|
raw = 'draft:\n{"title":"Wikelo Polaris Parts","objective":"Find and draft deals for the parts below","kind":"buying","cadence":"0 */3 * * *","constraints":{"message_tone":"casual","instructions":"Prioritize cheap listings first."},"items":[{"item_name":"RCMBNT-RGL-1","desired_quantity":2}]}'
|
||||||
|
|
||||||
|
draft = OllamaAgent._normalize_plan_draft(raw, seed)
|
||||||
|
|
||||||
|
assert draft["title"] == "Wikelo Polaris Parts"
|
||||||
|
assert draft["cadence"] == "0 */3 * * *"
|
||||||
|
assert draft["constraints"]["message_tone"] == "casual"
|
||||||
|
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||||
|
assert draft["items"][0]["desired_quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_heuristic_fills_in_basic_instructions():
|
||||||
|
seed = {"title": "Watch open negotiations", "objective": "", "kind": "custom", "constraints": {}, "items": []}
|
||||||
|
|
||||||
|
draft = OllamaAgent._heuristic_plan_draft(seed)
|
||||||
|
|
||||||
|
assert draft["kind"] == "custom"
|
||||||
|
assert draft["cadence"] == "0 */4 * * *"
|
||||||
|
assert "summarize" in draft["constraints"]["instructions"].casefold()
|
||||||
|
assert draft["constraints"]["message_tone"] == "friendly and direct"
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_prompt_mentions_tools_and_images(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex")
|
||||||
|
|
||||||
|
prompt = agent._codex_cli_prompt(
|
||||||
|
"check listing",
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Look at this",
|
||||||
|
"images": ["ZmFrZQ=="],
|
||||||
|
"image_content_types": ["image/png"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "search_marketplace_listings", "arguments": "{\"commodity\":\"gold\"}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_name": "search_marketplace_listings",
|
||||||
|
"tool_call_id": "call_123",
|
||||||
|
"content": "{\"ok\":true}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Available tools" in prompt
|
||||||
|
assert "attached images: 1" in prompt
|
||||||
|
assert "search_marketplace_listings" in prompt
|
||||||
|
assert "tool search_marketplace_listings" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_deepseek_openai_messages_include_reasoning_content_for_tool_turns():
|
||||||
|
agent = OllamaAgent("https://api.deepseek.com", "deepseek-v4-flash", EmptyTools(), provider="deepseek", api_key="test")
|
||||||
|
|
||||||
|
messages = agent._openai_messages(
|
||||||
|
"check listing",
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": "Check this listing"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"reasoning_content": "I should check the current listing first.",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "search_marketplace_listings", "arguments": "{\"query\":\"panel\"}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"role": "tool", "tool_name": "search_marketplace_listings", "tool_call_id": "call_123", "content": "{\"ok\":true}"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assistant_turn = next(message for message in messages if message["role"] == "assistant")
|
||||||
|
assert assistant_turn["reasoning_content"] == "I should check the current listing first."
|
||||||
|
|
||||||
|
|
||||||
|
def test_codex_structured_response_extracts_text_and_tool_calls():
|
||||||
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
|
||||||
|
|
||||||
|
result = agent._codex_structured_response(
|
||||||
|
{
|
||||||
|
"kind": "tool_call",
|
||||||
|
"message": "",
|
||||||
|
"tool_name": "search_marketplace_listings",
|
||||||
|
"arguments_json": "{\"commodity\":\"gold\"}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["message"]["content"] == ""
|
||||||
|
assert result["message"]["tool_calls"] == [
|
||||||
|
{
|
||||||
|
"id": result["message"]["tool_calls"][0]["id"],
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_marketplace_listings",
|
||||||
|
"arguments": "{\"commodity\":\"gold\"}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_codex_exec_output_reads_final_json():
|
||||||
|
agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex")
|
||||||
|
|
||||||
|
result = agent._parse_codex_exec_output(
|
||||||
|
{
|
||||||
|
"returncode": 0,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"events": [
|
||||||
|
{"type": "thread.started", "thread_id": "abc"},
|
||||||
|
{"type": "item.completed", "item": {"type": "agent_message", "text": "{\"kind\":\"final\",\"message\":\"hello\",\"tool_name\":\"\",\"arguments_json\":\"{}\"}"}},
|
||||||
|
{"type": "turn.completed"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"kind": "final", "message": "hello", "tool_name": "", "arguments_json": "{}"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wake_response_executes_tool_calls(tmp_path):
|
async def test_wake_response_executes_tool_calls(tmp_path):
|
||||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
@@ -229,6 +387,23 @@ async def test_first_chat_message_generates_thread_title(tmp_path):
|
|||||||
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
|
assert memory.get_thread(thread["id"])["title"] == "UEX Market Check"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_includes_pasted_images_and_memory_note(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
agent = ImageCaptureAgent(memory)
|
||||||
|
|
||||||
|
result = await agent.chat(
|
||||||
|
"",
|
||||||
|
images=[{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["message"] == "Seen"
|
||||||
|
user_message = next(message for message in reversed(agent.last_messages) if message.get("role") == "user")
|
||||||
|
assert user_message["images"] == ["ZmFrZS1pbWFnZQ=="]
|
||||||
|
assert user_message["content"] == "Please analyze the attached image."
|
||||||
|
assert "[Attached 1 pasted image]" in memory.recent_conversation()[-2]["content"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
async def test_chat_events_returns_fallback_after_slow_tool_and_empty_final_response(tmp_path):
|
||||||
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from traderai.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_codex_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="codex")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_openai_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="openai")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_accepts_deepseek():
|
||||||
|
settings = Settings(model_provider="deepseek")
|
||||||
|
|
||||||
|
assert settings.model_provider == "deepseek"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_provider_invalid_value_falls_back_to_ollama():
|
||||||
|
settings = Settings(model_provider="something-else")
|
||||||
|
|
||||||
|
assert settings.model_provider == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_normalizes_invalid_values():
|
||||||
|
settings = Settings(model_reasoning_effort="whatever")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_accepts_supported_values():
|
||||||
|
settings = Settings(model_reasoning_effort="high")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_effort_accepts_max():
|
||||||
|
settings = Settings(model_reasoning_effort="max")
|
||||||
|
|
||||||
|
assert settings.model_reasoning_effort == "max"
|
||||||
@@ -55,3 +55,22 @@ def test_memory_store_renames_threads_and_deletes_outbox_items(tmp_path):
|
|||||||
assert renamed["title"] == "Market Check"
|
assert renamed["title"] == "Market Check"
|
||||||
assert deleted is True
|
assert deleted is True
|
||||||
assert store.list_outbox() == []
|
assert store.list_outbox() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_store_uses_absolute_path_across_working_directory_changes(tmp_path, monkeypatch):
|
||||||
|
original_cwd = tmp_path / "start"
|
||||||
|
original_cwd.mkdir()
|
||||||
|
monkeypatch.chdir(original_cwd)
|
||||||
|
|
||||||
|
store = MemoryStore("data/memory.sqlite3")
|
||||||
|
|
||||||
|
moved_cwd = tmp_path / "moved"
|
||||||
|
moved_cwd.mkdir()
|
||||||
|
monkeypatch.chdir(moved_cwd)
|
||||||
|
|
||||||
|
store.add_outbox("Notification survived cwd change")
|
||||||
|
|
||||||
|
snapshot = store.inspect()
|
||||||
|
|
||||||
|
assert store.path.is_absolute()
|
||||||
|
assert snapshot["outbox"][0]["content"] == "Notification survived cwd change"
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, utc_now
|
||||||
|
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||||
|
from traderai.scheduler import WakeScheduler
|
||||||
|
from traderai.tools import ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class BuyingUEX:
|
||||||
|
def __init__(self):
|
||||||
|
self.posts = []
|
||||||
|
|
||||||
|
async def get(self, path, params=None, authenticated=False):
|
||||||
|
if path == "marketplace_listings":
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 501,
|
||||||
|
"slug": "wikelo-panel-good",
|
||||||
|
"title": "Wikelo Idris panel",
|
||||||
|
"operation": "sell",
|
||||||
|
"type": "item",
|
||||||
|
"price": 450_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 2,
|
||||||
|
"location": "Orison",
|
||||||
|
"user_username": "seller_a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 502,
|
||||||
|
"slug": "wikelo-panel-expensive",
|
||||||
|
"title": "Wikelo Idris panel premium",
|
||||||
|
"operation": "sell",
|
||||||
|
"type": "item",
|
||||||
|
"price": 900_000,
|
||||||
|
"currency": "UEC",
|
||||||
|
"in_stock": 1,
|
||||||
|
"location": "Area18",
|
||||||
|
"user_username": "seller_b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"data": []}
|
||||||
|
|
||||||
|
async def post(self, path, payload, authenticated=True):
|
||||||
|
self.posts.append({"path": path, "payload": payload, "authenticated": authenticated})
|
||||||
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
async def delete(self, path, params=None, authenticated=True):
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlanAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.prompts = []
|
||||||
|
|
||||||
|
async def generate_wake_response(self, wake_message):
|
||||||
|
self.prompts.append(wake_message)
|
||||||
|
return "Custom plan checked notifications and found no blockers."
|
||||||
|
|
||||||
|
|
||||||
|
def plan_stack(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
store = ContinualPlanStore(memory)
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
tools = ToolRegistry(BuyingUEX(), memory=memory, scheduler=scheduler, plan_store=store)
|
||||||
|
runner = ContinualPlanRunner(store, tools, memory)
|
||||||
|
tools.plan_runner = runner
|
||||||
|
scheduler.bind_plan_runner(runner)
|
||||||
|
return memory, store, tools, runner, scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_creates_needs_input_plan(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan("Wikelo Idris", objective="Get all parts", items=[])
|
||||||
|
|
||||||
|
assert plan["status"] == "needs_input"
|
||||||
|
assert plan["items"] == []
|
||||||
|
assert plan["events"][0]["kind"] == "needs_input"
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_plan_without_items_is_active(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan("Watch negotiations", kind="custom", objective="Check replies and summarize next steps", items=[])
|
||||||
|
|
||||||
|
assert plan["status"] == "active"
|
||||||
|
assert plan["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_creates_buying_checklist(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 2, "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan["status"] == "active"
|
||||||
|
assert plan["items"][0]["item_name"] == "Wikelo Idris panel"
|
||||||
|
assert plan["items"][0]["desired_quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_continual_plan_store_deletes_plan_and_related_records(tmp_path):
|
||||||
|
_, store, _, _, _ = plan_stack(tmp_path)
|
||||||
|
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Delete me",
|
||||||
|
objective="Remove everything",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}],
|
||||||
|
)
|
||||||
|
item_id = int(plan["items"][0]["id"])
|
||||||
|
candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9)
|
||||||
|
store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"})
|
||||||
|
|
||||||
|
assert store.delete_plan(plan["id"]) is True
|
||||||
|
assert store.get_plan(plan["id"]) is None
|
||||||
|
assert store.list_items(plan["id"]) == []
|
||||||
|
assert store.list_candidates(plan["id"]) == []
|
||||||
|
assert store.list_negotiations(plan["id"]) == []
|
||||||
|
assert store.list_events(plan["id"]) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path):
|
||||||
|
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1, "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.run_plan(plan["id"])
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["drafted"] == 1
|
||||||
|
assert any(candidate["listing_id"] == "501" and candidate["status"] == "drafted" for candidate in snapshot["candidates"])
|
||||||
|
assert snapshot["negotiations"][0]["status"] == "drafted"
|
||||||
|
assert len(tools.pending_actions) == 1
|
||||||
|
assert not tools.uex.posts
|
||||||
|
assert "Drafted 1 negotiation" in memory.list_outbox()[0]["content"]
|
||||||
|
pending = next(iter(tools.pending_actions.values()))
|
||||||
|
assert "Tone note" not in pending.payload["message"]
|
||||||
|
assert "Polaris build" not in pending.payload["message"] or "putting together parts for a Polaris build" in pending.payload["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plan_approval_logs_back_to_plan(tmp_path):
|
||||||
|
_, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Wikelo Idris",
|
||||||
|
objective="Get all listed parts",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel", "max_unit_price": 500_000}],
|
||||||
|
)
|
||||||
|
await runner.run_plan(plan["id"])
|
||||||
|
action_id = next(iter(tools.pending_actions))
|
||||||
|
|
||||||
|
approved = await tools.approve(action_id)
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert approved["posted"]["path"] == "marketplace_negotiations_messages"
|
||||||
|
assert any(event["kind"] == "approved" for event in snapshot["events"])
|
||||||
|
assert any(negotiation["status"] == "approved" for negotiation in snapshot["negotiations"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_custom_runner_continues_plan_through_agent(tmp_path):
|
||||||
|
memory, store, tools, runner, _ = plan_stack(tmp_path)
|
||||||
|
agent = FakePlanAgent()
|
||||||
|
runner.bind_agent(agent)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Watch open negotiations",
|
||||||
|
kind="custom",
|
||||||
|
objective="Check UEX replies and recommend next action",
|
||||||
|
constraints={"instructions": "Pay attention to stale buyer replies."},
|
||||||
|
items=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await runner.run_plan(plan["id"])
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "Custom plan checked notifications" in result["summary"]
|
||||||
|
assert plan["id"] in agent.prompts[0]
|
||||||
|
assert any(event["kind"] == "run" for event in snapshot["events"])
|
||||||
|
assert "Custom plan checked notifications" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduler_plan_run_survives_runner_error(tmp_path):
|
||||||
|
memory = MemoryStore(str(tmp_path / "memory.sqlite3"))
|
||||||
|
store = ContinualPlanStore(memory)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Broken plan",
|
||||||
|
objective="Test failure handling",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
class FailingRunner:
|
||||||
|
def __init__(self, store):
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
async def run_plan(self, plan_id):
|
||||||
|
self.store.add_event(plan_id, "error", "boom")
|
||||||
|
memory.add_outbox("Broken plan: boom")
|
||||||
|
return {"error": "boom", "plan": self.store.get_plan(plan_id)}
|
||||||
|
|
||||||
|
scheduler = WakeScheduler(memory)
|
||||||
|
scheduler.bind_plan_runner(FailingRunner(store))
|
||||||
|
|
||||||
|
await scheduler._run_plan(plan["id"])
|
||||||
|
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
assert snapshot["status"] == "active"
|
||||||
|
assert snapshot["events"][0]["kind"] == "error"
|
||||||
|
assert "boom" in memory.list_outbox()[0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path):
|
||||||
|
memory, store, _, runner, scheduler = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Overdue plan",
|
||||||
|
objective="Check after restart",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
store.update_schedule(plan["id"], (utc_now() - timedelta(minutes=5)).isoformat())
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
try:
|
||||||
|
catchup = scheduler.scheduler.get_job(scheduler._plan_catchup_job_id(plan["id"]))
|
||||||
|
snapshot = store.get_plan(plan["id"])
|
||||||
|
finally:
|
||||||
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
assert catchup is not None
|
||||||
|
assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tools_delete_continual_plan_removes_it(tmp_path):
|
||||||
|
_, store, tools, _, _ = plan_stack(tmp_path)
|
||||||
|
plan = store.create_plan(
|
||||||
|
"Delete through tools",
|
||||||
|
objective="Remove via registry",
|
||||||
|
items=[{"item_name": "Wikelo Idris panel"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await tools.delete_continual_plan(plan["id"])
|
||||||
|
|
||||||
|
assert result["deleted"] is True
|
||||||
|
assert result["plan_id"] == plan["id"]
|
||||||
|
assert store.get_plan(plan["id"]) is None
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import traderai.server as server
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path):
|
||||||
|
state = {"settings": make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b")}
|
||||||
|
|
||||||
|
class FakeScheduler:
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.memory = memory
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner):
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
|
def bind_uex_notifications(self, uex, poll_seconds=60):
|
||||||
|
self.uex = uex
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_jobs(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeUEXClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_user(self, username=None, authenticated=False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class FakeToolRegistry:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.pending_actions = {}
|
||||||
|
self.plan_runner = None
|
||||||
|
|
||||||
|
async def approve(self, action_id):
|
||||||
|
return {"approved": action_id}
|
||||||
|
|
||||||
|
async def decline(self, action_id):
|
||||||
|
return {"declined": action_id}
|
||||||
|
|
||||||
|
class FakePlanRunner:
|
||||||
|
def __init__(self, store, tools, memory, agent=None):
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def fake_health(self):
|
||||||
|
return {
|
||||||
|
"online": True,
|
||||||
|
"provider": self.provider,
|
||||||
|
"model": self.model,
|
||||||
|
"model_available": True,
|
||||||
|
"message": f"{self.provider} ready",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_chat(self, content, thread_id=None, images=None):
|
||||||
|
return {"message": f"{self.provider}:{self.model}", "pending_actions": [], "thread_id": thread_id}
|
||||||
|
|
||||||
|
def fake_get_settings():
|
||||||
|
return state["settings"]
|
||||||
|
|
||||||
|
def fake_save_settings(values):
|
||||||
|
state["settings"] = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
model_provider=values.get("model_provider", state["settings"].model_provider),
|
||||||
|
ollama_model=values.get("ollama_model", state["settings"].ollama_model),
|
||||||
|
codex_model=values.get("codex_model", state["settings"].codex_model),
|
||||||
|
deepseek_model=values.get("deepseek_model", state["settings"].deepseek_model),
|
||||||
|
)
|
||||||
|
return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
|
||||||
|
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
|
||||||
|
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
|
||||||
|
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
|
||||||
|
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "get_settings", fake_get_settings)
|
||||||
|
monkeypatch.setattr(server, "save_settings", fake_save_settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"settings_payload",
|
||||||
|
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "health", fake_health)
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "chat", fake_chat)
|
||||||
|
|
||||||
|
app = server.create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
before = client.get("/api/health").json()
|
||||||
|
assert before["model_provider"] == "ollama"
|
||||||
|
assert before["inference"]["provider"] == "ollama"
|
||||||
|
|
||||||
|
updated = client.post(
|
||||||
|
"/api/config",
|
||||||
|
json={"values": {"model_provider": "deepseek", "deepseek_model": "deepseek-v4-flash"}},
|
||||||
|
).json()
|
||||||
|
assert updated["restart_required"] is False
|
||||||
|
|
||||||
|
after = client.get("/api/health").json()
|
||||||
|
assert after["model_provider"] == "deepseek"
|
||||||
|
assert after["inference"]["provider"] == "deepseek"
|
||||||
|
|
||||||
|
chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json()
|
||||||
|
assert chat["message"] == "deepseek:deepseek-v4-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_draft_endpoint_returns_agent_draft(monkeypatch, tmp_path):
|
||||||
|
state = {"settings": make_settings(tmp_path)}
|
||||||
|
|
||||||
|
class FakeScheduler:
|
||||||
|
def __init__(self, memory):
|
||||||
|
self.memory = memory
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner):
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
|
def bind_uex_notifications(self, uex, poll_seconds=60):
|
||||||
|
self.uex = uex
|
||||||
|
self.poll_seconds = poll_seconds
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_jobs(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeUEXClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_user(self, username=None, authenticated=False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
class FakeToolRegistry:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.pending_actions = {}
|
||||||
|
self.plan_runner = None
|
||||||
|
|
||||||
|
async def approve(self, action_id):
|
||||||
|
return {"approved": action_id}
|
||||||
|
|
||||||
|
async def decline(self, action_id):
|
||||||
|
return {"declined": action_id}
|
||||||
|
|
||||||
|
class FakePlanRunner:
|
||||||
|
def __init__(self, store, tools, memory, agent=None):
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent):
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fake_get_settings():
|
||||||
|
return state["settings"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "WakeScheduler", FakeScheduler)
|
||||||
|
monkeypatch.setattr(server, "UEXClient", FakeUEXClient)
|
||||||
|
monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry)
|
||||||
|
monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner)
|
||||||
|
monkeypatch.setattr(server, "SCMDBClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "CornerstoneClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient)
|
||||||
|
monkeypatch.setattr(server, "get_settings", fake_get_settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
server,
|
||||||
|
"settings_payload",
|
||||||
|
lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_generate_plan_draft(self, title="", objective="", kind="buying", constraints=None, items=None):
|
||||||
|
return {
|
||||||
|
"title": title or "Draft title",
|
||||||
|
"objective": objective or "Draft objective",
|
||||||
|
"kind": kind,
|
||||||
|
"cadence": "0 */3 * * *",
|
||||||
|
"constraints": {"message_tone": "friendly and direct", "instructions": "Start with the best listings."},
|
||||||
|
"items": [{"item_name": "RCMBNT-RGL-1", "desired_quantity": 1, "max_unit_price": None}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(server.OllamaAgent, "generate_plan_draft", fake_generate_plan_draft)
|
||||||
|
|
||||||
|
app = server.create_app()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/plans/draft",
|
||||||
|
json={"title": "Polaris parts", "objective": "Find the required parts", "kind": "buying", "constraints": {}, "items": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
draft = response.json()["draft"]
|
||||||
|
assert draft["cadence"] == "0 */3 * * *"
|
||||||
|
assert draft["constraints"]["instructions"] == "Start with the best listings."
|
||||||
|
assert draft["items"][0]["item_name"] == "RCMBNT-RGL-1"
|
||||||
|
|
||||||
|
|
||||||
|
def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4", deepseek_model="deepseek-v4-flash"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
traderai_memory_path=str(tmp_path / "memory.sqlite3"),
|
||||||
|
model_provider=model_provider,
|
||||||
|
ollama_base_url="http://localhost:11434",
|
||||||
|
ollama_model=ollama_model,
|
||||||
|
ollama_num_ctx=64512,
|
||||||
|
openai_base_url="https://api.openai.com/v1",
|
||||||
|
openai_api_key=None,
|
||||||
|
openai_model="gpt-5.4-mini",
|
||||||
|
deepseek_base_url="https://api.deepseek.com",
|
||||||
|
deepseek_api_key=None,
|
||||||
|
deepseek_model=deepseek_model,
|
||||||
|
model_reasoning_effort="medium",
|
||||||
|
codex_command="codex",
|
||||||
|
codex_model=codex_model,
|
||||||
|
uex_base_url="https://api.uexcorp.space/2.0",
|
||||||
|
uex_secret_key=None,
|
||||||
|
uex_bearer_token=None,
|
||||||
|
traderai_user_name=None,
|
||||||
|
uex_notification_poll_seconds=60,
|
||||||
|
require_write_approval=True,
|
||||||
|
scmdb_base_url="https://scmdb.net",
|
||||||
|
cornerstone_base_url="https://finder.cstone.space",
|
||||||
|
scwiki_base_url="https://starcitizen.tools",
|
||||||
|
scwiki_api_base_url="https://api.star-citizen.wiki",
|
||||||
|
)
|
||||||
+366
-3
@@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient
|
|||||||
class FakeUEX:
|
class FakeUEX:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.posts = []
|
self.posts = []
|
||||||
|
self.get_calls = []
|
||||||
|
|
||||||
async def get(self, path, params=None, authenticated=False):
|
async def get(self, path, params=None, authenticated=False):
|
||||||
|
self.get_calls.append({"path": path, "params": params, "authenticated": authenticated})
|
||||||
if path == "commodities_prices_history":
|
if path == "commodities_prices_history":
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -80,6 +82,34 @@ class FakeUEX:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
if path == "marketplace_trends":
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id_item": 2791,
|
||||||
|
"item_name": "\"Quantanium\" Water Bottle",
|
||||||
|
"item_slug": "quantanium-water-bottle",
|
||||||
|
"currency": "UEC",
|
||||||
|
"price_avg_sell": "937500",
|
||||||
|
"price_avg_month_sell": "1072222",
|
||||||
|
"price_min_sell": "750000",
|
||||||
|
"price_max_sell": "1200000",
|
||||||
|
"listings_count_sell": 4,
|
||||||
|
"price_avg_buy": "500000",
|
||||||
|
"price_avg_month_buy": "525000",
|
||||||
|
"price_min_buy": "450000",
|
||||||
|
"price_max_buy": "550000",
|
||||||
|
"listings_count_buy": 2,
|
||||||
|
"total_listings_count": 6,
|
||||||
|
"negotiations_count": 18,
|
||||||
|
"negotiations_open": 7,
|
||||||
|
"negotiations_success": 9,
|
||||||
|
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
|
||||||
|
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
assert path == "marketplace_listings"
|
assert path == "marketplace_listings"
|
||||||
return {
|
return {
|
||||||
"data": [
|
"data": [
|
||||||
@@ -230,7 +260,10 @@ class FakeCornerstone:
|
|||||||
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
"url": f"{self.base_url}/ShipSalvageMods1/{item_id}",
|
||||||
"html": """
|
"html": """
|
||||||
<html>
|
<html>
|
||||||
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head>
|
<head>
|
||||||
|
<title>Star Citizen - Salvage modifier - Abrade Scraper Module</title>
|
||||||
|
<meta property="og:image" content="/images/abrade.png">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<table>
|
<table>
|
||||||
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
||||||
@@ -246,6 +279,125 @@ class FakeCornerstone:
|
|||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def get_image_data(self, url, max_bytes=10_000_000):
|
||||||
|
assert url == f"{self.base_url}/images/abrade.png"
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"content_type": "image/png",
|
||||||
|
"size_bytes": 12,
|
||||||
|
"image_data": "ZmFrZS1pbWFnZQ==",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSCWiki:
|
||||||
|
base_url = "https://starcitizen.tools"
|
||||||
|
api_base_url = "https://api.star-citizen.wiki"
|
||||||
|
|
||||||
|
async def search_pages(self, query, limit=5):
|
||||||
|
assert query == "Carrack"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"pageid": 415,
|
||||||
|
"title": "Carrack",
|
||||||
|
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
|
||||||
|
"extract": "The Anvil Carrack is a multi-crew explorer.",
|
||||||
|
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
|
||||||
|
"url": "https://starcitizen.tools/Carrack",
|
||||||
|
}
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
async def get_page_summary(self, title=None, pageid=None, chars=700):
|
||||||
|
assert title == "Carrack" or pageid == 415
|
||||||
|
return {
|
||||||
|
"pageid": 415,
|
||||||
|
"title": "Carrack",
|
||||||
|
"description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace",
|
||||||
|
"extract": "The Anvil Carrack is a multi-crew explorer.",
|
||||||
|
"thumbnail": "https://media.starcitizen.tools/carrack.webp",
|
||||||
|
"url": "https://starcitizen.tools/Carrack",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search_verse(self, query):
|
||||||
|
assert query == "Carrack"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "vehicles",
|
||||||
|
"label": "Vehicles",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "Anvil Carrack",
|
||||||
|
"class_name": "ANVL_Carrack",
|
||||||
|
"extra_label": "Exploration",
|
||||||
|
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
|
||||||
|
"api_url": "https://api.star-citizen.wiki/api/vehicles/anvl-carrack",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_vehicle(self, slug):
|
||||||
|
assert slug == "anvl-carrack"
|
||||||
|
return {
|
||||||
|
"name": "Carrack",
|
||||||
|
"game_name": "Anvil Carrack",
|
||||||
|
"slug": "anvl-carrack",
|
||||||
|
"manufacturer": {"name": "Anvil Aerospace"},
|
||||||
|
"career": "Exploration",
|
||||||
|
"role": "Expedition",
|
||||||
|
"size_class": 5,
|
||||||
|
"cargo_capacity": 456,
|
||||||
|
"crew": {"min": 6, "max": 6},
|
||||||
|
"msrp": 600,
|
||||||
|
"pledge_url": "https://robertsspaceindustries.com/pledge/ships/carrack/Carrack",
|
||||||
|
"uex_prices": {
|
||||||
|
"purchase": [
|
||||||
|
{
|
||||||
|
"price_buy": 34398000,
|
||||||
|
"terminal_name": "Astro Armada - Area 18",
|
||||||
|
"starmap_location": {"name": "Area18", "parent_name": "ArcCorp", "star_system_name": "Stanton"},
|
||||||
|
"game_version": "4.8.1-LIVE.11952564",
|
||||||
|
"date_updated": "2026-05-20T18:39:37-04:00",
|
||||||
|
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": {"en_EN": "The Anvil Carrack features reinforced fuel tanks for long-duration flight."},
|
||||||
|
"web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack",
|
||||||
|
"updated_at": "2026-06-08T00:34:00Z",
|
||||||
|
"version": "4.8.1-LIVE.11952564",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWikelo:
|
||||||
|
base_url = "https://wikelo-projects.test"
|
||||||
|
|
||||||
|
async def list_ship_projects(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "ship-1",
|
||||||
|
"ship_name": "Polaris Wikelo Special",
|
||||||
|
"description": "Now make Polaris. Short Time Deal",
|
||||||
|
"status": "planning",
|
||||||
|
"privacy": "public",
|
||||||
|
"owner_name": "Chimpanz33",
|
||||||
|
"required_materials": [
|
||||||
|
{"material_name": "Wikelo Favor", "quantity_needed": 50.0, "quantity_collected": 0.0},
|
||||||
|
{"material_name": "Polaris Bit", "quantity_needed": 15.0, "quantity_collected": 2.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ship-2",
|
||||||
|
"ship_name": "Guardian",
|
||||||
|
"description": "Guardian Fight Mod",
|
||||||
|
"status": "planning",
|
||||||
|
"privacy": "public",
|
||||||
|
"owner_name": "Chimpanz33",
|
||||||
|
"required_materials": [
|
||||||
|
{"material_name": "Wikelo Favor", "quantity_needed": 20.0, "quantity_collected": 0.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_marketplace_listings_filters_locally():
|
async def test_search_marketplace_listings_filters_locally():
|
||||||
@@ -321,6 +473,65 @@ async def test_uex_get_projects_and_limits_results():
|
|||||||
assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}]
|
assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uex_get_marketplace_listings_accepts_item_and_operation_filters():
|
||||||
|
fake = FakeUEX()
|
||||||
|
registry = ToolRegistry(fake)
|
||||||
|
|
||||||
|
result = await registry.execute(
|
||||||
|
"get_uex_marketplace_listings",
|
||||||
|
{
|
||||||
|
"id_item": 2791,
|
||||||
|
"operation": "sell",
|
||||||
|
"fields": ["id", "slug", "operation"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["params"] == {"id_item": 2791, "operation": "sell"}
|
||||||
|
assert fake.get_calls[-1]["path"] == "marketplace_listings"
|
||||||
|
assert fake.get_calls[-1]["params"] == {"id_item": 2791, "operation": "sell"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_marketplace_trends_returns_compact_wts_wtb_and_negotiation_metrics():
|
||||||
|
fake = FakeUEX()
|
||||||
|
registry = ToolRegistry(fake)
|
||||||
|
|
||||||
|
result = await registry.get_marketplace_trends(item_name="Quantanium", currency="UEC", quality_tier=0)
|
||||||
|
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert result["count"] == 1
|
||||||
|
assert result["filters"] == {"item_name": "Quantanium", "currency": "UEC", "quality_tier": 0}
|
||||||
|
assert fake.get_calls[-1]["path"] == "marketplace_trends"
|
||||||
|
assert fake.get_calls[-1]["params"] == {"id_item": None, "item_name": "Quantanium", "item_slug": None, "id_category": None, "currency": "UEC", "quality_tier": 0}
|
||||||
|
assert result["trends"][0] == {
|
||||||
|
"id_item": 2791,
|
||||||
|
"item_name": "\"Quantanium\" Water Bottle",
|
||||||
|
"item_slug": "quantanium-water-bottle",
|
||||||
|
"currency": "UEC",
|
||||||
|
"sell": {
|
||||||
|
"avg_price": "937500",
|
||||||
|
"avg_price_month": "1072222",
|
||||||
|
"min_price": "750000",
|
||||||
|
"max_price": "1200000",
|
||||||
|
"listings_count": 4,
|
||||||
|
},
|
||||||
|
"buy": {
|
||||||
|
"avg_price": "500000",
|
||||||
|
"avg_price_month": "525000",
|
||||||
|
"min_price": "450000",
|
||||||
|
"max_price": "550000",
|
||||||
|
"listings_count": 2,
|
||||||
|
},
|
||||||
|
"total_listings_count": 6,
|
||||||
|
"negotiations_count": 18,
|
||||||
|
"negotiations_open": 7,
|
||||||
|
"negotiations_success": 9,
|
||||||
|
"link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list",
|
||||||
|
"link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_uex_api_catalog_exposes_resources_without_live_call():
|
async def test_uex_api_catalog_exposes_resources_without_live_call():
|
||||||
registry = ToolRegistry(FakeUEX())
|
registry = ToolRegistry(FakeUEX())
|
||||||
@@ -356,6 +567,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
|
|||||||
|
|
||||||
assert "get_uex_commodities_prices" in names
|
assert "get_uex_commodities_prices" in names
|
||||||
assert "get_uex_vehicles" in names
|
assert "get_uex_vehicles" in names
|
||||||
|
assert "get_marketplace_trends" in names
|
||||||
assert "draft_uex_marketplace_advertise" in names
|
assert "draft_uex_marketplace_advertise" in names
|
||||||
assert "delete_uex_marketplace_listings" in names
|
assert "delete_uex_marketplace_listings" in names
|
||||||
assert "uex_get" not in names
|
assert "uex_get" not in names
|
||||||
@@ -379,6 +591,19 @@ def test_schemas_expose_cornerstone_item_tools():
|
|||||||
|
|
||||||
assert "search_cornerstone_items" in names
|
assert "search_cornerstone_items" in names
|
||||||
assert "get_cornerstone_item_locations" in names
|
assert "get_cornerstone_item_locations" in names
|
||||||
|
assert "get_cornerstone_item_media" in names
|
||||||
|
assert "draft_marketplace_listing_with_cornerstone_image" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_scwiki_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "search_scwiki_pages" in names
|
||||||
|
assert "get_scwiki_page" in names
|
||||||
|
assert "search_scwiki_vehicles" in names
|
||||||
|
assert "get_scwiki_vehicle" in names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -441,18 +666,156 @@ async def test_get_cornerstone_item_locations_parses_store_prices():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cornerstone_item_media_returns_absolute_image_urls():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.get_cornerstone_item_media(query="abrade")
|
||||||
|
|
||||||
|
assert result["media"] == [
|
||||||
|
{
|
||||||
|
"url": "https://finder.cstone.test/images/abrade.png",
|
||||||
|
"source": "og:image",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_scwiki_pages_returns_general_knowledge_matches():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
|
||||||
|
|
||||||
|
result = await registry.search_scwiki_pages(query="Carrack")
|
||||||
|
|
||||||
|
assert result["source"] == "https://starcitizen.tools"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
assert result["pages"][0]["title"] == "Carrack"
|
||||||
|
assert result["pages"][0]["url"] == "https://starcitizen.tools/Carrack"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki())
|
||||||
|
|
||||||
|
result = await registry.get_scwiki_vehicle(query="Carrack")
|
||||||
|
|
||||||
|
assert result["source"] == "https://api.star-citizen.wiki"
|
||||||
|
vehicle = result["vehicle"]
|
||||||
|
assert vehicle["name"] == "Carrack"
|
||||||
|
assert vehicle["manufacturer"] == "Anvil Aerospace"
|
||||||
|
assert vehicle["msrp"] == 600
|
||||||
|
assert vehicle["purchase_locations"] == [
|
||||||
|
{
|
||||||
|
"price_buy": 34398000,
|
||||||
|
"terminal_name": "Astro Armada - Area 18",
|
||||||
|
"location": "Area18",
|
||||||
|
"parent_location": "ArcCorp",
|
||||||
|
"star_system": "Stanton",
|
||||||
|
"game_version": "4.8.1-LIVE.11952564",
|
||||||
|
"date_updated": "2026-05-20T18:39:37-04:00",
|
||||||
|
"uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_wikelo_ship_projects_returns_material_matches():
|
||||||
|
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||||
|
|
||||||
|
result = await registry.search_wikelo_ship_projects(query="Polaris")
|
||||||
|
|
||||||
|
assert result["source"] == "https://wikelo-projects.test/Ships"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
assert result["projects"][0]["ship_name"] == "Polaris Wikelo Special"
|
||||||
|
assert result["projects"][0]["required_materials"][0]["material_name"] == "Wikelo Favor"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wikelo_ship_project_returns_full_requirements():
|
||||||
|
registry = ToolRegistry(FakeUEX(), wikelo=FakeWikelo())
|
||||||
|
|
||||||
|
result = await registry.get_wikelo_ship_project(ship_name="Guardian")
|
||||||
|
|
||||||
|
assert result["project"]["ship_name"] == "Guardian"
|
||||||
|
assert result["project"]["materials_count"] == 1
|
||||||
|
assert result["project"]["required_materials"] == [
|
||||||
|
{
|
||||||
|
"material_name": "Wikelo Favor",
|
||||||
|
"quantity_needed": 20,
|
||||||
|
"quantity_collected": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display():
|
||||||
|
registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone())
|
||||||
|
|
||||||
|
result = await registry.draft_marketplace_listing_with_cornerstone_image(
|
||||||
|
item_query="abrade",
|
||||||
|
id_category=3,
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
unit="unit",
|
||||||
|
title="Abrade Scraper Module",
|
||||||
|
description="Clean module, ready for pickup.",
|
||||||
|
price=21250,
|
||||||
|
currency="UEC",
|
||||||
|
language="en_US",
|
||||||
|
source="purchased_in_game",
|
||||||
|
in_stock=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
stored = registry.pending_actions[pending["id"]]
|
||||||
|
|
||||||
|
assert pending["endpoint"] == "marketplace_advertise"
|
||||||
|
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
|
||||||
|
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
|
||||||
|
assert pending["metadata"]["cornerstone_image_status"] == "included"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_marketplace_listing_can_reuse_pasted_chat_image():
|
||||||
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|
||||||
|
with registry.chat_image_scope([{"name": "listing.png", "content_type": "image/png", "image_data": "ZmFrZS1pbWFnZQ=="}]):
|
||||||
|
result = await registry.draft_marketplace_listing(
|
||||||
|
id_category=3,
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
unit="unit",
|
||||||
|
title="Abrade Scraper Module",
|
||||||
|
description="Clean module, ready for pickup.",
|
||||||
|
price=21250,
|
||||||
|
currency="UEC",
|
||||||
|
language="en_US",
|
||||||
|
use_attached_image=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending = result["pending_action"]
|
||||||
|
stored = registry.pending_actions[pending["id"]]
|
||||||
|
assert pending["payload"]["image_data"].startswith("<base64 image data redacted")
|
||||||
|
assert stored.payload["image_data"] == "ZmFrZS1pbWFnZQ=="
|
||||||
|
assert pending["metadata"]["attached_chat_image_name"] == "listing.png"
|
||||||
|
assert pending["metadata"]["attached_chat_image_status"] == "included"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_cornerstone_item_page_extracts_locations():
|
def test_parse_cornerstone_item_page_extracts_locations():
|
||||||
parsed = parse_cornerstone_item_page(
|
parsed = parse_cornerstone_item_page(
|
||||||
"""
|
"""
|
||||||
<html><head><title>Star Citizen - Food - Whamburger</title></head>
|
<html><head><title>Star Citizen - Food - Whamburger</title><meta property="og:image" content="/img/wham.png"></head>
|
||||||
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
|
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
|
||||||
|
<img src="https://example.test/extra.png" alt="Whamburger">
|
||||||
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
|
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
|
||||||
"""
|
""",
|
||||||
|
"https://finder.cstone.test/Search/item-wham",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert parsed["name"] == "Whamburger"
|
assert parsed["name"] == "Whamburger"
|
||||||
assert parsed["locations"][0]["base_price"] == 9
|
assert parsed["locations"][0]["base_price"] == 9
|
||||||
|
assert parsed["media"][0]["url"] == "https://finder.cstone.test/img/wham.png"
|
||||||
|
assert parsed["media"][1]["url"] == "https://example.test/extra.png"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+453
File diff suppressed because one or more lines are too long
+1343
-56
File diff suppressed because it is too large
Load Diff
+39
-3
@@ -11,12 +11,24 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
|
|
||||||
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||||
|
"model_provider": {"env": "MODEL_PROVIDER", "type": "string", "secret": False},
|
||||||
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
|
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
|
||||||
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
||||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||||
|
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
||||||
|
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
|
||||||
|
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
|
||||||
|
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
|
||||||
|
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
|
||||||
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
||||||
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
||||||
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
|
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
|
||||||
|
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
||||||
|
"deepseek_api_key": {"env": "DEEPSEEK_API_KEY", "type": "string", "secret": True},
|
||||||
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
||||||
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||||
@@ -62,24 +74,48 @@ class Settings(BaseSettings):
|
|||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
model_provider: str = "ollama"
|
||||||
ollama_base_url: str = "http://localhost:11434"
|
ollama_base_url: str = "http://localhost:11434"
|
||||||
ollama_model: str = "qwen3.5:9b"
|
ollama_model: str = "qwen3.5:9b"
|
||||||
ollama_num_ctx: int = 64512
|
ollama_num_ctx: int = 64512
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
|
openai_model: str = "gpt-5.4-mini"
|
||||||
|
deepseek_base_url: str = "https://api.deepseek.com"
|
||||||
|
deepseek_model: str = "deepseek-v4-flash"
|
||||||
|
model_reasoning_effort: str = "medium"
|
||||||
|
codex_command: str = "codex"
|
||||||
|
codex_model: str = "gpt-5.4"
|
||||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||||
scmdb_base_url: str = "https://scmdb.net"
|
scmdb_base_url: str = "https://scmdb.net"
|
||||||
cornerstone_base_url: str = "https://finder.cstone.space"
|
cornerstone_base_url: str = "https://finder.cstone.space"
|
||||||
|
scwiki_base_url: str = "https://starcitizen.tools"
|
||||||
|
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
|
||||||
|
openai_api_key: str | None = Field(default=None)
|
||||||
|
deepseek_api_key: str | None = Field(default=None)
|
||||||
uex_secret_key: str | None = Field(default=None)
|
uex_secret_key: str | None = Field(default=None)
|
||||||
uex_bearer_token: str | None = Field(default=None)
|
uex_bearer_token: str | None = Field(default=None)
|
||||||
traderai_user_name: str | None = Field(default=None)
|
traderai_user_name: str | None = Field(default=None)
|
||||||
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
|
||||||
uex_notification_poll_seconds: int = 60
|
uex_notification_poll_seconds: int = 300
|
||||||
require_write_approval: bool = True
|
require_write_approval: bool = True
|
||||||
|
|
||||||
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
@field_validator("openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_optional(cls, value: Any) -> Any:
|
def _blank_optional(cls, value: Any) -> Any:
|
||||||
return None if value == "" else value
|
return None if value == "" else value
|
||||||
|
|
||||||
|
@field_validator("model_provider", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_model_provider(cls, value: Any) -> str:
|
||||||
|
text = str(value or "ollama").strip().casefold()
|
||||||
|
return text if text in {"ollama", "deepseek"} else "ollama"
|
||||||
|
|
||||||
|
@field_validator("model_reasoning_effort", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_reasoning_effort(cls, value: Any) -> str:
|
||||||
|
text = str(value or "medium").strip().casefold()
|
||||||
|
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
|
||||||
|
|
||||||
@field_validator("traderai_memory_path", mode="before")
|
@field_validator("traderai_memory_path", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _blank_memory_path(cls, value: Any) -> Any:
|
def _blank_memory_path(cls, value: Any) -> Any:
|
||||||
@@ -137,7 +173,7 @@ def save_settings(values: dict[str, Any]) -> dict[str, Any]:
|
|||||||
def _coerce_value(key: str, value: Any) -> Any:
|
def _coerce_value(key: str, value: Any) -> Any:
|
||||||
field_type = CONFIG_FIELDS[key]["type"]
|
field_type = CONFIG_FIELDS[key]["type"]
|
||||||
if value == "":
|
if value == "":
|
||||||
return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
return None if key in {"openai_api_key", "deepseek_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
|
||||||
if field_type == "integer":
|
if field_type == "integer":
|
||||||
return int(value)
|
return int(value)
|
||||||
if field_type == "boolean":
|
if field_type == "boolean":
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -41,6 +43,23 @@ class CornerstoneClient:
|
|||||||
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}")
|
||||||
return {"url": str(response.url), "html": response.text}
|
return {"url": str(response.url), "html": response.text}
|
||||||
|
|
||||||
|
async def get_image_data(self, url: str, max_bytes: int = 10_000_000) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"Accept": "image/png,image/jpeg,image/*"})
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise CornerstoneError(f"Cornerstone image HTTP {response.status_code}: {response.text[:240]}")
|
||||||
|
content_type = response.headers.get("content-type", "").split(";")[0].strip().casefold()
|
||||||
|
if content_type not in {"image/jpeg", "image/jpg", "image/png"}:
|
||||||
|
raise CornerstoneError(f"Cornerstone image was not JPG or PNG: {content_type or 'unknown content type'}")
|
||||||
|
if len(response.content) > max_bytes:
|
||||||
|
raise CornerstoneError(f"Cornerstone image is larger than {max_bytes} bytes.")
|
||||||
|
return {
|
||||||
|
"url": str(response.url),
|
||||||
|
"content_type": content_type,
|
||||||
|
"size_bytes": len(response.content),
|
||||||
|
"image_data": base64.b64encode(response.content).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
async def _get_json(self, path: str) -> Any:
|
async def _get_json(self, path: str) -> Any:
|
||||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"})
|
||||||
@@ -58,6 +77,7 @@ class CornerstonePageParser(HTMLParser):
|
|||||||
super().__init__(convert_charrefs=True)
|
super().__init__(convert_charrefs=True)
|
||||||
self.title = ""
|
self.title = ""
|
||||||
self.tables: list[list[list[str]]] = []
|
self.tables: list[list[list[str]]] = []
|
||||||
|
self.images: list[dict[str, str]] = []
|
||||||
self._skip_depth = 0
|
self._skip_depth = 0
|
||||||
self._in_title = False
|
self._in_title = False
|
||||||
self._current_table: list[list[str]] | None = None
|
self._current_table: list[list[str]] | None = None
|
||||||
@@ -73,6 +93,29 @@ class CornerstonePageParser(HTMLParser):
|
|||||||
return
|
return
|
||||||
if tag == "title":
|
if tag == "title":
|
||||||
self._in_title = True
|
self._in_title = True
|
||||||
|
elif tag == "meta":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
name = (attr_map.get("property") or attr_map.get("name") or "").casefold()
|
||||||
|
content = attr_map.get("content") or ""
|
||||||
|
if content and name in {"og:image", "twitter:image", "twitter:image:src"}:
|
||||||
|
self.images.append({"url": content, "source": name})
|
||||||
|
elif tag == "link":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
rel = (attr_map.get("rel") or "").casefold()
|
||||||
|
href = attr_map.get("href") or ""
|
||||||
|
if href and "image_src" in rel:
|
||||||
|
self.images.append({"url": href, "source": "link:image_src"})
|
||||||
|
elif tag == "img":
|
||||||
|
attr_map = self._attrs(attrs)
|
||||||
|
url = attr_map.get("src") or attr_map.get("data-src") or attr_map.get("data-original") or ""
|
||||||
|
if url:
|
||||||
|
self.images.append(
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"alt": attr_map.get("alt") or "",
|
||||||
|
"source": "img",
|
||||||
|
}
|
||||||
|
)
|
||||||
elif tag == "table":
|
elif tag == "table":
|
||||||
self._current_table = []
|
self._current_table = []
|
||||||
elif tag == "tr" and self._current_table is not None:
|
elif tag == "tr" and self._current_table is not None:
|
||||||
@@ -110,8 +153,12 @@ class CornerstonePageParser(HTMLParser):
|
|||||||
if self._current_cell is not None:
|
if self._current_cell is not None:
|
||||||
self._current_cell.append(data)
|
self._current_cell.append(data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]:
|
||||||
|
return {key.casefold(): value or "" for key, value in attrs}
|
||||||
|
|
||||||
def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
|
|
||||||
|
def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]:
|
||||||
parser = CornerstonePageParser()
|
parser = CornerstonePageParser()
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
info: dict[str, Any] = {"page_title": " ".join(parser.title.split())}
|
||||||
@@ -142,6 +189,9 @@ def parse_cornerstone_item_page(html: str) -> dict[str, Any]:
|
|||||||
general[key] = value
|
general[key] = value
|
||||||
|
|
||||||
info["name"] = general.get("name") or _name_from_title(info["page_title"])
|
info["name"] = general.get("name") or _name_from_title(info["page_title"])
|
||||||
|
media = _dedupe_media(parser.images, page_url)
|
||||||
|
if media:
|
||||||
|
info["media"] = media
|
||||||
if general:
|
if general:
|
||||||
info["general"] = general
|
info["general"] = general
|
||||||
info["locations"] = locations
|
info["locations"] = locations
|
||||||
@@ -157,3 +207,20 @@ def _name_from_title(title: str) -> str | None:
|
|||||||
if " - " not in title:
|
if " - " not in title:
|
||||||
return title or None
|
return title or None
|
||||||
return title.rsplit(" - ", 1)[-1].strip() or None
|
return title.rsplit(" - ", 1)[-1].strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_media(images: list[dict[str, str]], page_url: str | None = None) -> list[dict[str, str]]:
|
||||||
|
media = []
|
||||||
|
seen = set()
|
||||||
|
for image in images:
|
||||||
|
raw_url = (image.get("url") or "").strip()
|
||||||
|
if not raw_url or raw_url.startswith("data:"):
|
||||||
|
continue
|
||||||
|
url = urljoin(page_url or "", raw_url)
|
||||||
|
if url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
item = dict(image)
|
||||||
|
item["url"] = url
|
||||||
|
media.append(item)
|
||||||
|
return media
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
try:
|
try:
|
||||||
_chdir_to_app_dir()
|
_chdir_to_app_dir()
|
||||||
|
backend_port = _backend_port_from_args()
|
||||||
|
if backend_port is not None:
|
||||||
|
_run_server(backend_port)
|
||||||
|
return
|
||||||
_log("TraderAI desktop starting")
|
_log("TraderAI desktop starting")
|
||||||
_log(f"cwd={Path.cwd()}")
|
_log(f"cwd={Path.cwd()}")
|
||||||
_log(f"executable={sys.executable}")
|
_log(f"executable={sys.executable}")
|
||||||
@@ -36,6 +41,10 @@ def main() -> None:
|
|||||||
_log("existing TraderAI backend found; opening window")
|
_log("existing TraderAI backend found; opening window")
|
||||||
_open_window(url)
|
_open_window(url)
|
||||||
return
|
return
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
backend_process = _start_backend_process(port)
|
||||||
|
_log(f"backend process started pid={backend_process.pid}")
|
||||||
|
else:
|
||||||
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
||||||
server_thread.start()
|
server_thread.start()
|
||||||
_log("backend thread started")
|
_log("backend thread started")
|
||||||
@@ -62,6 +71,22 @@ def _select_port() -> int:
|
|||||||
return _free_port()
|
return _free_port()
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_port_from_args() -> int | None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if len(args) >= 2 and args[0] == "--backend-port":
|
||||||
|
return int(args[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _start_backend_process(port: int) -> subprocess.Popen:
|
||||||
|
command = [sys.executable, "--backend-port", str(port)]
|
||||||
|
_log(f"starting backend subprocess: {' '.join(command)}")
|
||||||
|
kwargs: dict[str, object] = {}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
return subprocess.Popen(command, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _port_available(port: int) -> bool:
|
def _port_available(port: int) -> bool:
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
@@ -88,6 +113,9 @@ def _existing_server_ready(url: str) -> bool:
|
|||||||
def _run_server(port: int) -> NoReturn:
|
def _run_server(port: int) -> NoReturn:
|
||||||
try:
|
try:
|
||||||
_log(f"backend starting on port {port}")
|
_log(f"backend starting on port {port}")
|
||||||
|
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
|
||||||
from traderai.server import app
|
from traderai.server import app
|
||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ def _plural(value: int, unit: str) -> str:
|
|||||||
|
|
||||||
class MemoryStore:
|
class MemoryStore:
|
||||||
def __init__(self, path: str) -> None:
|
def __init__(self, path: str) -> None:
|
||||||
self.path = Path(path)
|
self.path = Path(path).expanduser().resolve()
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from traderai.memory import MemoryStore, iso_now
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PLAN_CADENCE = "0 */6 * * *"
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanStore:
|
||||||
|
def __init__(self, memory: MemoryStore) -> None:
|
||||||
|
self.memory = memory
|
||||||
|
self._init_db()
|
||||||
|
|
||||||
|
def _init_db(self) -> None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
objective TEXT NOT NULL,
|
||||||
|
constraints TEXT NOT NULL DEFAULT '{}',
|
||||||
|
cadence TEXT NOT NULL,
|
||||||
|
next_run_at TEXT,
|
||||||
|
last_run_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
item_name TEXT NOT NULL,
|
||||||
|
desired_quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
max_unit_price REAL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
acquired_quantity INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_candidates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
plan_item_id INTEGER NOT NULL,
|
||||||
|
listing_id TEXT,
|
||||||
|
listing_slug TEXT,
|
||||||
|
title TEXT,
|
||||||
|
seller TEXT,
|
||||||
|
price REAL,
|
||||||
|
currency TEXT,
|
||||||
|
stock INTEGER,
|
||||||
|
location TEXT,
|
||||||
|
score REAL,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'current',
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE(plan_item_id, listing_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
metadata TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS continual_plan_negotiations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id TEXT NOT NULL,
|
||||||
|
plan_item_id INTEGER,
|
||||||
|
candidate_id INTEGER,
|
||||||
|
listing_id TEXT,
|
||||||
|
listing_slug TEXT,
|
||||||
|
negotiation_id TEXT,
|
||||||
|
negotiation_hash TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'drafted',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_plan(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
kind: str = "buying",
|
||||||
|
objective: str = "",
|
||||||
|
items: list[dict[str, Any]] | None = None,
|
||||||
|
constraints: dict[str, Any] | None = None,
|
||||||
|
cadence: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clean_items = [item for item in (items or []) if str(item.get("item_name") or item.get("name") or "").strip()]
|
||||||
|
plan_id = f"plan-{uuid.uuid4()}"
|
||||||
|
now = iso_now()
|
||||||
|
clean_kind = (kind.strip() or "buying").casefold()
|
||||||
|
resolved_status = status or ("needs_input" if clean_kind == "buying" and not clean_items else "active")
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plans(id, title, kind, status, objective, constraints, cadence, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
title.strip() or "Continual plan",
|
||||||
|
clean_kind,
|
||||||
|
resolved_status,
|
||||||
|
objective.strip() or title.strip(),
|
||||||
|
json.dumps(constraints or {}),
|
||||||
|
(cadence or DEFAULT_PLAN_CADENCE).strip() or DEFAULT_PLAN_CADENCE,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in clean_items:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_items(
|
||||||
|
plan_id, item_name, desired_quantity, max_unit_price, status,
|
||||||
|
acquired_quantity, metadata, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
str(item.get("item_name") or item.get("name")).strip(),
|
||||||
|
max(1, int(item.get("desired_quantity") or item.get("quantity") or 1)),
|
||||||
|
item.get("max_unit_price"),
|
||||||
|
max(0, int(item.get("acquired_quantity") or 0)),
|
||||||
|
json.dumps(item.get("metadata") or {}),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if clean_kind == "buying" and not clean_items:
|
||||||
|
self.add_event(plan_id, "needs_input", "Created plan, but no item checklist was provided. Add the required parts before it can run.")
|
||||||
|
elif clean_items:
|
||||||
|
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan with {len(clean_items)} checklist item(s).")
|
||||||
|
else:
|
||||||
|
self.add_event(plan_id, "created", f"Created continual {clean_kind} plan.")
|
||||||
|
return self.get_plan(plan_id) or {}
|
||||||
|
|
||||||
|
def list_plans(self, include_inactive: bool = True) -> list[dict[str, Any]]:
|
||||||
|
where = "" if include_inactive else "WHERE status = 'active'"
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
f"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plans
|
||||||
|
{where}
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'active' THEN 0 WHEN 'needs_input' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END,
|
||||||
|
updated_at DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [self._plan_row(row) for row in rows]
|
||||||
|
|
||||||
|
def get_plan(self, plan_id: str) -> dict[str, Any] | None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
plan = db.execute("SELECT * FROM continual_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||||
|
if not plan:
|
||||||
|
return None
|
||||||
|
data = self._plan_row(plan)
|
||||||
|
data["items"] = self.list_items(plan_id)
|
||||||
|
data["candidates"] = self.list_candidates(plan_id)
|
||||||
|
data["negotiations"] = self.list_negotiations(plan_id)
|
||||||
|
data["events"] = self.list_events(plan_id)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def list_items(self, plan_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_items WHERE plan_id = ? ORDER BY id",
|
||||||
|
(plan_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_candidates(self, plan_id: str, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plan_candidates
|
||||||
|
WHERE plan_id = ?
|
||||||
|
ORDER BY status = 'current' DESC, score DESC, last_seen_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(plan_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_events(self, plan_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM continual_plan_events
|
||||||
|
WHERE plan_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(plan_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._json_row(row, "metadata") for row in rows]
|
||||||
|
|
||||||
|
def list_negotiations(self, plan_id: str) -> list[dict[str, Any]]:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_negotiations WHERE plan_id = ? ORDER BY updated_at DESC",
|
||||||
|
(plan_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def set_status(self, plan_id: str, status: str) -> dict[str, Any] | None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE continual_plans SET status = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(status, iso_now(), plan_id),
|
||||||
|
)
|
||||||
|
self.add_event(plan_id, status, f"Plan status changed to {status}.")
|
||||||
|
return self.get_plan(plan_id)
|
||||||
|
|
||||||
|
def delete_plan(self, plan_id: str) -> bool:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
|
||||||
|
if not deleted:
|
||||||
|
return False
|
||||||
|
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
|
||||||
|
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
|
||||||
|
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
|
||||||
|
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_events(plan_id, kind, message, metadata, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(plan_id, kind, message, json.dumps(metadata or {}), now),
|
||||||
|
)
|
||||||
|
return {"id": cursor.lastrowid, "plan_id": plan_id, "kind": kind, "message": message, "created_at": now}
|
||||||
|
|
||||||
|
def update_schedule(self, plan_id: str, next_run_at: str | None = None, last_run_at: str | None = None) -> None:
|
||||||
|
fields = ["next_run_at = ?", "updated_at = ?"]
|
||||||
|
values: list[Any] = [next_run_at, iso_now()]
|
||||||
|
if last_run_at is not None:
|
||||||
|
fields.insert(1, "last_run_at = ?")
|
||||||
|
values.insert(1, last_run_at)
|
||||||
|
values.append(plan_id)
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(f"UPDATE continual_plans SET {', '.join(fields)} WHERE id = ?", values)
|
||||||
|
|
||||||
|
def upsert_candidate(self, plan_id: str, plan_item_id: int, listing: dict[str, Any], score: float) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
listing_id = str(listing.get("id") or listing.get("listing_id") or listing.get("slug") or uuid.uuid4())
|
||||||
|
metadata = dict(listing)
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_candidates(
|
||||||
|
plan_id, plan_item_id, listing_id, listing_slug, title, seller, price, currency,
|
||||||
|
stock, location, score, first_seen_at, last_seen_at, status, metadata
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', ?)
|
||||||
|
ON CONFLICT(plan_item_id, listing_id) DO UPDATE SET
|
||||||
|
listing_slug=excluded.listing_slug,
|
||||||
|
title=excluded.title,
|
||||||
|
seller=excluded.seller,
|
||||||
|
price=excluded.price,
|
||||||
|
currency=excluded.currency,
|
||||||
|
stock=excluded.stock,
|
||||||
|
location=excluded.location,
|
||||||
|
score=excluded.score,
|
||||||
|
last_seen_at=excluded.last_seen_at,
|
||||||
|
status='current',
|
||||||
|
metadata=excluded.metadata
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
listing_id,
|
||||||
|
listing.get("slug"),
|
||||||
|
listing.get("title"),
|
||||||
|
listing.get("advertiser") or listing.get("user_username") or listing.get("seller"),
|
||||||
|
listing.get("price"),
|
||||||
|
listing.get("currency"),
|
||||||
|
listing.get("in_stock") or listing.get("stock"),
|
||||||
|
listing.get("location"),
|
||||||
|
score,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
json.dumps(metadata),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT * FROM continual_plan_candidates WHERE plan_item_id = ? AND listing_id = ?",
|
||||||
|
(plan_item_id, listing_id),
|
||||||
|
).fetchone()
|
||||||
|
return self._json_row(row, "metadata")
|
||||||
|
|
||||||
|
def mark_stale_candidates(self, plan_item_id: int, seen_listing_ids: set[str]) -> int:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, listing_id FROM continual_plan_candidates WHERE plan_item_id = ? AND status = 'current'",
|
||||||
|
(plan_item_id,),
|
||||||
|
).fetchall()
|
||||||
|
stale_ids = [row["id"] for row in rows if str(row["listing_id"]) not in seen_listing_ids]
|
||||||
|
if stale_ids:
|
||||||
|
placeholders = ",".join("?" for _ in stale_ids)
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE continual_plan_candidates SET status = 'stale', last_seen_at = ? WHERE id IN ({placeholders})",
|
||||||
|
(iso_now(), *stale_ids),
|
||||||
|
)
|
||||||
|
return len(stale_ids)
|
||||||
|
|
||||||
|
def mark_candidate_drafted(self, candidate_id: int) -> None:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
db.execute("UPDATE continual_plan_candidates SET status = 'drafted', last_seen_at = ? WHERE id = ?", (iso_now(), candidate_id))
|
||||||
|
|
||||||
|
def add_negotiation(self, plan_id: str, plan_item_id: int | None, candidate_id: int | None, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
now = iso_now()
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO continual_plan_negotiations(
|
||||||
|
plan_id, plan_item_id, candidate_id, listing_id, listing_slug,
|
||||||
|
negotiation_id, negotiation_hash, status, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
candidate_id,
|
||||||
|
metadata.get("listing_id"),
|
||||||
|
metadata.get("listing_slug"),
|
||||||
|
metadata.get("id_negotiation"),
|
||||||
|
metadata.get("hash"),
|
||||||
|
metadata.get("status") or "drafted",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = db.execute("SELECT * FROM continual_plan_negotiations WHERE id = ?", (cursor.lastrowid,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
def has_negotiation_for_candidate(self, plan_id: str, plan_item_id: int, candidate: dict[str, Any]) -> bool:
|
||||||
|
with self.memory._connect() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM continual_plan_negotiations
|
||||||
|
WHERE plan_id = ?
|
||||||
|
AND plan_item_id = ?
|
||||||
|
AND (
|
||||||
|
candidate_id = ?
|
||||||
|
OR (listing_id IS NOT NULL AND listing_id = ?)
|
||||||
|
OR (listing_slug IS NOT NULL AND listing_slug = ?)
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
plan_item_id,
|
||||||
|
candidate.get("id"),
|
||||||
|
candidate.get("listing_id"),
|
||||||
|
candidate.get("listing_slug"),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_row(row: Any, *json_fields: str) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
for field in json_fields:
|
||||||
|
try:
|
||||||
|
data[field] = json.loads(data.get(field) or "{}")
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
data[field] = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _plan_row(cls, row: Any) -> dict[str, Any]:
|
||||||
|
return cls._json_row(row, "constraints")
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanRunner:
|
||||||
|
def __init__(self, store: ContinualPlanStore, tools: Any, memory: MemoryStore, agent: Any | None = None) -> None:
|
||||||
|
self.store = store
|
||||||
|
self.tools = tools
|
||||||
|
self.memory = memory
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_agent(self, agent: Any) -> None:
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
async def run_plan(self, plan_id: str) -> dict[str, Any]:
|
||||||
|
plan = self.store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
return {"error": f"Plan not found: {plan_id}"}
|
||||||
|
if plan["status"] != "active":
|
||||||
|
message = f"Skipped {plan['title']} because status is {plan['status']}."
|
||||||
|
self.store.add_event(plan_id, "skipped", message)
|
||||||
|
return {"status": "skipped", "summary": message, "plan": self.store.get_plan(plan_id)}
|
||||||
|
try:
|
||||||
|
if plan["kind"] == "buying":
|
||||||
|
result = await self._run_buying_plan(plan)
|
||||||
|
else:
|
||||||
|
result = await self._run_agent_plan(plan)
|
||||||
|
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
|
||||||
|
self.memory.add_outbox(result["summary"])
|
||||||
|
return {**result, "plan": self.store.get_plan(plan_id)}
|
||||||
|
except Exception as exc:
|
||||||
|
message = f"Continual plan failed: {exc}"
|
||||||
|
self.store.add_event(plan_id, "error", message)
|
||||||
|
self.memory.add_outbox(f"{plan['title']}: {message}")
|
||||||
|
self.store.update_schedule(plan_id, plan.get("next_run_at"), last_run_at=iso_now())
|
||||||
|
return {"error": str(exc), "summary": message, "plan": self.store.get_plan(plan_id)}
|
||||||
|
|
||||||
|
async def _run_agent_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self.agent is None:
|
||||||
|
raise RuntimeError("No agent is bound to run generic continual plans.")
|
||||||
|
prompt = self._agent_plan_prompt(plan)
|
||||||
|
response = await self.agent.generate_wake_response(prompt)
|
||||||
|
summary = f"{plan['title']}: {response}"
|
||||||
|
self.store.add_event(plan["id"], "run", "Ran generic continual plan through the agent.", {"response": response})
|
||||||
|
return {"status": "ok", "summary": summary, "checked": 0, "drafted": 0}
|
||||||
|
|
||||||
|
async def _run_buying_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
items = [item for item in plan.get("items") or [] if item.get("status") != "acquired"]
|
||||||
|
if not items:
|
||||||
|
self.store.set_status(plan["id"], "completed")
|
||||||
|
summary = f"{plan['title']}: all checklist items are marked acquired."
|
||||||
|
return {"status": "completed", "summary": summary, "drafted": 0, "checked": 0}
|
||||||
|
|
||||||
|
checked = 0
|
||||||
|
drafted = 0
|
||||||
|
best_lines = []
|
||||||
|
constraints = plan.get("constraints") or {}
|
||||||
|
excluded_sellers = {str(value).casefold() for value in constraints.get("excluded_sellers") or []}
|
||||||
|
preferred_locations = [str(value).casefold() for value in constraints.get("preferred_locations") or []]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
response = await self.tools.search_marketplace_listings(
|
||||||
|
query=item["item_name"],
|
||||||
|
operation="sell",
|
||||||
|
type="item",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
listings = response.get("listings") or response.get("data") or []
|
||||||
|
seen: set[str] = set()
|
||||||
|
candidates = []
|
||||||
|
for listing in listings:
|
||||||
|
if not isinstance(listing, dict):
|
||||||
|
continue
|
||||||
|
listing_id = str(listing.get("id") or listing.get("slug") or "")
|
||||||
|
if listing_id:
|
||||||
|
seen.add(listing_id)
|
||||||
|
if str(listing.get("advertiser") or listing.get("seller") or "").casefold() in excluded_sellers:
|
||||||
|
continue
|
||||||
|
score = self._candidate_score(listing, item, preferred_locations)
|
||||||
|
candidate = self.store.upsert_candidate(plan["id"], int(item["id"]), listing, score)
|
||||||
|
candidates.append(candidate)
|
||||||
|
stale = self.store.mark_stale_candidates(int(item["id"]), seen)
|
||||||
|
checked += 1
|
||||||
|
current_candidates = [candidate for candidate in candidates if candidate.get("status") == "current"]
|
||||||
|
current_candidates.sort(key=lambda candidate: (-float(candidate.get("score") or 0), float(candidate.get("price") or 10**18)))
|
||||||
|
best = current_candidates[0] if current_candidates else None
|
||||||
|
if not best:
|
||||||
|
best_lines.append(f"{item['item_name']}: no active matching sell listings found.")
|
||||||
|
self.store.add_event(plan["id"], "search", f"{item['item_name']}: no active candidates found.", {"stale": stale})
|
||||||
|
continue
|
||||||
|
|
||||||
|
best_lines.append(
|
||||||
|
f"{item['item_name']}: best candidate is {best.get('title') or best.get('listing_slug')} "
|
||||||
|
f"at {self._format_price(best.get('price'), best.get('currency'))} from {best.get('seller') or 'unknown seller'}."
|
||||||
|
)
|
||||||
|
self.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"search",
|
||||||
|
f"{item['item_name']}: found {len(current_candidates)} current candidate(s); {stale} stale candidate(s) marked.",
|
||||||
|
{"best_candidate_id": best.get("id")},
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.store.has_negotiation_for_candidate(plan["id"], int(item["id"]), best) or not self._within_budget(best, item, constraints):
|
||||||
|
continue
|
||||||
|
draft = await self._draft_buying_message(plan, item, best)
|
||||||
|
if "pending_action" in draft:
|
||||||
|
drafted += 1
|
||||||
|
self.store.mark_candidate_drafted(int(best["id"]))
|
||||||
|
self.store.add_negotiation(
|
||||||
|
plan["id"],
|
||||||
|
int(item["id"]),
|
||||||
|
int(best["id"]),
|
||||||
|
{
|
||||||
|
"listing_id": best.get("listing_id"),
|
||||||
|
"listing_slug": best.get("listing_slug"),
|
||||||
|
"status": "drafted",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"draft",
|
||||||
|
f"Drafted negotiation opener for {item['item_name']} candidate {best.get('listing_id')}.",
|
||||||
|
{"pending_action_id": draft["pending_action"].get("id"), "candidate_id": best.get("id")},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = f"{plan['title']}: checked {checked} item(s). " + " ".join(best_lines[:4])
|
||||||
|
if drafted:
|
||||||
|
summary += f" Drafted {drafted} negotiation message(s) for approval."
|
||||||
|
self.store.add_event(plan["id"], "run", summary, {"checked": checked, "drafted": drafted})
|
||||||
|
return {"status": "ok", "summary": summary, "checked": checked, "drafted": drafted}
|
||||||
|
|
||||||
|
async def _draft_buying_message(self, plan: dict[str, Any], item: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
tone = (plan.get("constraints") or {}).get("message_tone") or "polite and concise"
|
||||||
|
greeting = "Hi" if "professional" in str(tone).casefold() or "polite" in str(tone).casefold() else "Hey"
|
||||||
|
build_context = self._plan_build_context(plan["objective"])
|
||||||
|
message = (
|
||||||
|
f"{greeting}, is your {candidate.get('title') or item['item_name']} listing still available "
|
||||||
|
f"for {self._format_price(candidate.get('price'), candidate.get('currency'))}? "
|
||||||
|
f"{build_context}If you still have it, I can move quickly."
|
||||||
|
).strip()
|
||||||
|
return await self.tools.draft_negotiation_message(
|
||||||
|
message=message,
|
||||||
|
id_listing=self._int_or_none(candidate.get("listing_id")),
|
||||||
|
plan_id=plan["id"],
|
||||||
|
plan_item_id=int(item["id"]),
|
||||||
|
candidate_id=int(candidate["id"]),
|
||||||
|
listing_slug=candidate.get("listing_slug"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_build_context(objective: str) -> str:
|
||||||
|
text = str(objective or "").strip().rstrip(".")
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lowered = text.casefold()
|
||||||
|
if "polaris" in lowered:
|
||||||
|
return "I'm putting together parts for a Polaris build. "
|
||||||
|
if "mission" in lowered:
|
||||||
|
return "I'm trying to wrap up a mission build. "
|
||||||
|
return "I'm sourcing parts for a build. "
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _candidate_score(listing: dict[str, Any], item: dict[str, Any], preferred_locations: list[str]) -> float:
|
||||||
|
price = float(listing.get("price") or 10**12)
|
||||||
|
max_price = item.get("max_unit_price")
|
||||||
|
budget_bonus = 40.0 if max_price and price <= float(max_price) else 0.0
|
||||||
|
stock = float(listing.get("in_stock") or listing.get("stock") or 1)
|
||||||
|
location = str(listing.get("location") or "").casefold()
|
||||||
|
location_bonus = 8.0 if preferred_locations and any(place in location for place in preferred_locations) else 0.0
|
||||||
|
return round(max(0.0, 50.0 - (price / 10_000_000.0)) + min(stock, 20.0) + budget_bonus + location_bonus, 4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _within_budget(candidate: dict[str, Any], item: dict[str, Any], constraints: dict[str, Any]) -> bool:
|
||||||
|
price = candidate.get("price")
|
||||||
|
if price is None:
|
||||||
|
return False
|
||||||
|
max_price = item.get("max_unit_price") or constraints.get("max_unit_price")
|
||||||
|
return max_price is None or float(price) <= float(max_price)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_price(price: Any, currency: Any) -> str:
|
||||||
|
if isinstance(price, (int, float)):
|
||||||
|
return f"{price:,.0f} {currency or 'UEC'}"
|
||||||
|
return f"unknown price {currency or 'UEC'}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _agent_plan_prompt(plan: dict[str, Any]) -> str:
|
||||||
|
recent_events = [
|
||||||
|
{
|
||||||
|
"kind": event.get("kind"),
|
||||||
|
"message": event.get("message"),
|
||||||
|
"created_at": event.get("created_at"),
|
||||||
|
}
|
||||||
|
for event in (plan.get("events") or [])[:8]
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"plan_id": plan.get("id"),
|
||||||
|
"title": plan.get("title"),
|
||||||
|
"kind": plan.get("kind"),
|
||||||
|
"objective": plan.get("objective"),
|
||||||
|
"constraints": plan.get("constraints") or {},
|
||||||
|
"items": plan.get("items") or [],
|
||||||
|
"recent_events": recent_events,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"Continual plan wake run. Continue this durable plan and write an Inbox-ready summary. "
|
||||||
|
"Use tools as needed. For any account-affecting marketplace write, only draft a pending action for approval. "
|
||||||
|
"Do not claim a message, offer, listing, or negotiation was sent unless an approved action result says it was sent. "
|
||||||
|
f"Plan JSON: {json.dumps(payload, ensure_ascii=True)}"
|
||||||
|
)
|
||||||
+73
-2
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from apscheduler.triggers.date import DateTrigger
|
|||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone
|
||||||
|
|
||||||
from traderai.memory import MemoryStore, iso_now, time_since
|
from traderai.memory import MemoryStore, iso_now, parse_iso, time_since, utc_now
|
||||||
|
|
||||||
|
|
||||||
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
|
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
|
||||||
@@ -22,11 +22,15 @@ class WakeScheduler:
|
|||||||
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
||||||
self.agent = None
|
self.agent = None
|
||||||
self.uex = None
|
self.uex = None
|
||||||
|
self.plan_runner = None
|
||||||
self.notification_poll_seconds = 60
|
self.notification_poll_seconds = 60
|
||||||
|
|
||||||
def bind_agent(self, agent: Any) -> None:
|
def bind_agent(self, agent: Any) -> None:
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
|
|
||||||
|
def bind_plan_runner(self, plan_runner: Any) -> None:
|
||||||
|
self.plan_runner = plan_runner
|
||||||
|
|
||||||
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None:
|
||||||
self.uex = uex
|
self.uex = uex
|
||||||
self.notification_poll_seconds = max(15, poll_seconds)
|
self.notification_poll_seconds = max(15, poll_seconds)
|
||||||
@@ -37,6 +41,9 @@ class WakeScheduler:
|
|||||||
self._schedule_notification_poll()
|
self._schedule_notification_poll()
|
||||||
for job in self.memory.list_jobs():
|
for job in self.memory.list_jobs():
|
||||||
self._schedule_existing(job)
|
self._schedule_existing(job)
|
||||||
|
if self.plan_runner is not None:
|
||||||
|
for plan in self.plan_runner.store.list_plans(include_inactive=False):
|
||||||
|
self.schedule_plan(plan)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
if self.scheduler.running:
|
if self.scheduler.running:
|
||||||
@@ -59,6 +66,70 @@ class WakeScheduler:
|
|||||||
def list_jobs(self) -> list[dict[str, Any]]:
|
def list_jobs(self) -> list[dict[str, Any]]:
|
||||||
return self.memory.list_jobs()
|
return self.memory.list_jobs()
|
||||||
|
|
||||||
|
def schedule_plan(self, plan: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self.plan_runner is None or plan.get("status") != "active":
|
||||||
|
return plan
|
||||||
|
job_id = self._plan_job_id(plan["id"])
|
||||||
|
previous_next_run = plan.get("next_run_at")
|
||||||
|
trigger = CronTrigger.from_crontab(plan.get("cadence") or "0 */6 * * *")
|
||||||
|
self.scheduler.add_job(self._run_plan, trigger=trigger, id=job_id, args=[plan["id"]], replace_existing=True)
|
||||||
|
job = self.scheduler.get_job(job_id)
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.plan_runner.store.update_schedule(plan["id"], next_run.isoformat() if next_run else None)
|
||||||
|
if self._plan_is_overdue(previous_next_run):
|
||||||
|
catchup_id = self._plan_catchup_job_id(plan["id"])
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_plan,
|
||||||
|
trigger=DateTrigger(run_date=datetime.now() + timedelta(seconds=5)),
|
||||||
|
id=catchup_id,
|
||||||
|
args=[plan["id"]],
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
self.plan_runner.store.add_event(
|
||||||
|
plan["id"],
|
||||||
|
"catchup_scheduled",
|
||||||
|
"Plan was overdue while the app was closed, so a one-time catch-up run was scheduled after startup.",
|
||||||
|
{"previous_next_run_at": previous_next_run},
|
||||||
|
)
|
||||||
|
return self.plan_runner.store.get_plan(plan["id"]) or plan
|
||||||
|
|
||||||
|
def unschedule_plan(self, plan_id: str) -> None:
|
||||||
|
job_id = self._plan_job_id(plan_id)
|
||||||
|
if self.scheduler.get_job(job_id):
|
||||||
|
self.scheduler.remove_job(job_id)
|
||||||
|
catchup_id = self._plan_catchup_job_id(plan_id)
|
||||||
|
if self.scheduler.get_job(catchup_id):
|
||||||
|
self.scheduler.remove_job(catchup_id)
|
||||||
|
if self.plan_runner is not None:
|
||||||
|
self.plan_runner.store.update_schedule(plan_id, None)
|
||||||
|
|
||||||
|
async def _run_plan(self, plan_id: str) -> None:
|
||||||
|
if self.plan_runner is None:
|
||||||
|
return
|
||||||
|
result = await self.plan_runner.run_plan(plan_id)
|
||||||
|
plan = result.get("plan") or self.plan_runner.store.get_plan(plan_id)
|
||||||
|
if plan and plan.get("status") == "active":
|
||||||
|
job = self.scheduler.get_job(self._plan_job_id(plan_id))
|
||||||
|
next_run = job.next_run_time if job else None
|
||||||
|
self.plan_runner.store.update_schedule(plan_id, next_run.isoformat() if next_run else None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_job_id(plan_id: str) -> str:
|
||||||
|
return f"continual-{plan_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_catchup_job_id(plan_id: str) -> str:
|
||||||
|
return f"continual-catchup-{plan_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_is_overdue(next_run_at: str | None) -> bool:
|
||||||
|
if not next_run_at:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return parse_iso(next_run_at) <= utc_now()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
||||||
if job["trigger_type"] == "cron":
|
if job["trigger_type"] == "cron":
|
||||||
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
||||||
|
|||||||
+712
-22
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -23,11 +24,14 @@ from traderai.config import save_settings, settings_payload
|
|||||||
from traderai.config import get_settings
|
from traderai.config import get_settings
|
||||||
from traderai.cornerstone_client import CornerstoneClient
|
from traderai.cornerstone_client import CornerstoneClient
|
||||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||||
|
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
from traderai.scmdb_client import SCMDBClient
|
from traderai.scmdb_client import SCMDBClient
|
||||||
|
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||||
|
from traderai.wikelo_projects_client import WikeloProjectsClient
|
||||||
|
|
||||||
|
|
||||||
def resource_path(*parts: str) -> Path:
|
def resource_path(*parts: str) -> Path:
|
||||||
@@ -38,6 +42,13 @@ def resource_path(*parts: str) -> Path:
|
|||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
thread_id: str | None = DEFAULT_THREAD_ID
|
thread_id: str | None = DEFAULT_THREAD_ID
|
||||||
|
images: list["ChatImageRequest"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ChatImageRequest(BaseModel):
|
||||||
|
name: str = "pasted-image.png"
|
||||||
|
content_type: str = "image/png"
|
||||||
|
image_data: str
|
||||||
|
|
||||||
|
|
||||||
class ChatThreadRequest(BaseModel):
|
class ChatThreadRequest(BaseModel):
|
||||||
@@ -60,6 +71,35 @@ class ClearMemoryRequest(BaseModel):
|
|||||||
include_outbox: bool = True
|
include_outbox: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanItemRequest(BaseModel):
|
||||||
|
item_name: str
|
||||||
|
desired_quantity: int = 1
|
||||||
|
max_unit_price: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanCreateRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
objective: str
|
||||||
|
kind: str = "buying"
|
||||||
|
cadence: str | None = None
|
||||||
|
constraints: dict[str, Any] = {}
|
||||||
|
items: list[ContinualPlanItemRequest] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanDraftRequest(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
objective: str = ""
|
||||||
|
kind: str = "buying"
|
||||||
|
constraints: dict[str, Any] = {}
|
||||||
|
items: list[ContinualPlanItemRequest] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ContinualPlanEventRequest(BaseModel):
|
||||||
|
kind: str = "note"
|
||||||
|
message: str
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
class ConfigUpdateRequest(BaseModel):
|
class ConfigUpdateRequest(BaseModel):
|
||||||
values: dict
|
values: dict
|
||||||
|
|
||||||
@@ -75,28 +115,56 @@ UPDATE_ASSET_NAME = "TraderAI.exe"
|
|||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
memory = MemoryStore(settings.traderai_memory_path)
|
memory = MemoryStore(settings.traderai_memory_path)
|
||||||
|
plan_store = ContinualPlanStore(memory)
|
||||||
scheduler = WakeScheduler(memory)
|
scheduler = WakeScheduler(memory)
|
||||||
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
runtime: dict[str, Any] = {}
|
||||||
scmdb = SCMDBClient(settings.scmdb_base_url)
|
|
||||||
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
|
def configure_runtime(current_settings: Any) -> None:
|
||||||
|
uex = UEXClient(current_settings.uex_base_url, current_settings.uex_secret_key, current_settings.uex_bearer_token)
|
||||||
|
scmdb = SCMDBClient(current_settings.scmdb_base_url)
|
||||||
|
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
|
||||||
|
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
|
||||||
|
wikelo = WikeloProjectsClient()
|
||||||
tools = ToolRegistry(
|
tools = ToolRegistry(
|
||||||
uex,
|
uex,
|
||||||
settings.require_write_approval,
|
current_settings.require_write_approval,
|
||||||
memory=memory,
|
memory=memory,
|
||||||
scheduler=scheduler,
|
scheduler=scheduler,
|
||||||
scmdb=scmdb,
|
scmdb=scmdb,
|
||||||
cornerstone=cornerstone,
|
cornerstone=cornerstone,
|
||||||
|
scwiki=scwiki,
|
||||||
|
wikelo=wikelo,
|
||||||
|
plan_store=plan_store,
|
||||||
)
|
)
|
||||||
|
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||||
|
tools.plan_runner = plan_runner
|
||||||
|
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
|
||||||
agent = OllamaAgent(
|
agent = OllamaAgent(
|
||||||
settings.ollama_base_url,
|
provider_base_url,
|
||||||
settings.ollama_model,
|
provider_model,
|
||||||
tools,
|
tools,
|
||||||
memory=memory,
|
memory=memory,
|
||||||
user_name=settings.traderai_user_name,
|
user_name=current_settings.traderai_user_name,
|
||||||
num_ctx=settings.ollama_num_ctx,
|
num_ctx=current_settings.ollama_num_ctx,
|
||||||
|
provider=current_settings.model_provider,
|
||||||
|
api_key=provider_api_key,
|
||||||
|
reasoning_effort=current_settings.model_reasoning_effort,
|
||||||
)
|
)
|
||||||
|
plan_runner.bind_agent(agent)
|
||||||
scheduler.bind_agent(agent)
|
scheduler.bind_agent(agent)
|
||||||
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
|
scheduler.bind_plan_runner(plan_runner)
|
||||||
|
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
|
||||||
|
runtime.update(
|
||||||
|
{
|
||||||
|
"settings": current_settings,
|
||||||
|
"uex": uex,
|
||||||
|
"tools": tools,
|
||||||
|
"plan_runner": plan_runner,
|
||||||
|
"agent": agent,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_runtime(settings)
|
||||||
|
|
||||||
app = FastAPI(title="TraderAI")
|
app = FastAPI(title="TraderAI")
|
||||||
static_dir = resource_path("web")
|
static_dir = resource_path("web")
|
||||||
@@ -112,17 +180,20 @@ def create_app() -> FastAPI:
|
|||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|
||||||
async def refresh_user_profile() -> None:
|
async def refresh_user_profile() -> None:
|
||||||
if settings.traderai_user_name:
|
current_settings = get_settings()
|
||||||
memory.set_profile("configured_name", settings.traderai_user_name)
|
agent = runtime["agent"]
|
||||||
agent.user_name = agent.user_name or settings.traderai_user_name
|
uex = runtime["uex"]
|
||||||
|
if current_settings.traderai_user_name:
|
||||||
|
memory.set_profile("configured_name", current_settings.traderai_user_name)
|
||||||
|
agent.user_name = agent.user_name or current_settings.traderai_user_name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await uex.get_user(authenticated=True)
|
response = await uex.get_user(authenticated=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
memory.set_profile("uex_user_error", str(exc))
|
memory.set_profile("uex_user_error", str(exc))
|
||||||
if settings.traderai_user_name:
|
if current_settings.traderai_user_name:
|
||||||
try:
|
try:
|
||||||
response = await uex.get_user(username=settings.traderai_user_name)
|
response = await uex.get_user(username=current_settings.traderai_user_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
@@ -141,8 +212,13 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
|
agent = runtime["agent"]
|
||||||
|
current_settings = get_settings()
|
||||||
|
inference = await agent.health()
|
||||||
return {
|
return {
|
||||||
"ollama": await agent.health(),
|
"inference": inference,
|
||||||
|
"ollama": inference,
|
||||||
|
"model_provider": current_settings.model_provider,
|
||||||
"user": memory.get_profile(),
|
"user": memory.get_profile(),
|
||||||
"jobs": scheduler.list_jobs(),
|
"jobs": scheduler.list_jobs(),
|
||||||
"app_data_dir": settings_payload()["app_data_dir"],
|
"app_data_dir": settings_payload()["app_data_dir"],
|
||||||
@@ -155,14 +231,61 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/api/config")
|
@app.post("/api/config")
|
||||||
async def update_config(request: ConfigUpdateRequest) -> dict:
|
async def update_config(request: ConfigUpdateRequest) -> dict:
|
||||||
|
previous_settings = get_settings()
|
||||||
updated = save_settings(request.values)
|
updated = save_settings(request.values)
|
||||||
updated["restart_required"] = True
|
current_settings = get_settings()
|
||||||
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect."
|
configure_runtime(current_settings)
|
||||||
|
await refresh_user_profile()
|
||||||
|
restart_required = (
|
||||||
|
"traderai_memory_path" in request.values
|
||||||
|
and str(request.values.get("traderai_memory_path") or "").strip() != str(previous_settings.traderai_memory_path)
|
||||||
|
)
|
||||||
|
updated["restart_required"] = restart_required
|
||||||
|
updated["message"] = (
|
||||||
|
"Configuration saved. Restart TraderAI to switch memory databases."
|
||||||
|
if restart_required
|
||||||
|
else "Configuration saved and applied."
|
||||||
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
@app.get("/api/ollama/status")
|
@app.get("/api/ollama/status")
|
||||||
async def ollama_status() -> dict:
|
async def ollama_status() -> dict:
|
||||||
return await inspect_ollama()
|
return await inspect_model_provider()
|
||||||
|
|
||||||
|
@app.get("/api/provider/models")
|
||||||
|
async def provider_models(provider: str | None = None) -> dict:
|
||||||
|
status = await inspect_provider_models(provider)
|
||||||
|
return {
|
||||||
|
"provider": status.get("provider", "openai"),
|
||||||
|
"configured_model": status.get("configured_model"),
|
||||||
|
"models": status.get("models", []),
|
||||||
|
"reasoning_efforts": status.get("reasoning_efforts", reasoning_effort_options()),
|
||||||
|
"configured_reasoning_effort": status.get("configured_reasoning_effort", get_settings().model_reasoning_effort),
|
||||||
|
"message": status.get("message", ""),
|
||||||
|
"detail": status.get("detail", ""),
|
||||||
|
"online": status.get("online", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/codex/login")
|
||||||
|
async def launch_codex_login() -> dict:
|
||||||
|
current_settings = get_settings()
|
||||||
|
command = find_codex_cli(current_settings.codex_command)
|
||||||
|
if not command:
|
||||||
|
raise HTTPException(status_code=404, detail="Codex CLI was not found on PATH.")
|
||||||
|
try:
|
||||||
|
login = await start_codex_browser_login(command)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Codex App Server login failed: {exception_detail(exc)}") from exc
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"running": False,
|
||||||
|
"online": False,
|
||||||
|
"provider": "codex",
|
||||||
|
"login_id": login.get("loginId"),
|
||||||
|
"auth_url": login.get("authUrl"),
|
||||||
|
"base_url": str(command),
|
||||||
|
"message": "Opened Codex App Server sign-in in your browser. Finish the flow, then TraderAI will detect the new login.",
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/ollama/launch")
|
@app.post("/api/ollama/launch")
|
||||||
async def launch_ollama() -> dict:
|
async def launch_ollama() -> dict:
|
||||||
@@ -173,7 +296,7 @@ def create_app() -> FastAPI:
|
|||||||
popen_hidden(command)
|
popen_hidden(command)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
|
||||||
status = await inspect_ollama()
|
status = await inspect_model_provider()
|
||||||
status["message"] = "Ollama launch requested."
|
status["message"] = "Ollama launch requested."
|
||||||
return status
|
return status
|
||||||
|
|
||||||
@@ -190,7 +313,7 @@ def create_app() -> FastAPI:
|
|||||||
popen_hidden([str(cli), "pull", model])
|
popen_hidden([str(cli), "pull", model])
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
|
||||||
status = await inspect_ollama()
|
status = await inspect_model_provider()
|
||||||
status["message"] = f"Started installing model {model}."
|
status["message"] = f"Started installing model {model}."
|
||||||
return status
|
return status
|
||||||
|
|
||||||
@@ -269,15 +392,26 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/api/chat")
|
@app.post("/api/chat")
|
||||||
async def chat(request: ChatRequest) -> dict:
|
async def chat(request: ChatRequest) -> dict:
|
||||||
|
agent = runtime["agent"]
|
||||||
try:
|
try:
|
||||||
return await agent.chat(request.message, thread_id=request.thread_id)
|
return await agent.chat(
|
||||||
|
request.message,
|
||||||
|
thread_id=request.thread_id,
|
||||||
|
images=[image.model_dump() for image in request.images],
|
||||||
|
)
|
||||||
except OllamaUnavailable as exc:
|
except OllamaUnavailable as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.post("/api/chat/stream")
|
@app.post("/api/chat/stream")
|
||||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||||
|
agent = runtime["agent"]
|
||||||
|
|
||||||
async def events():
|
async def events():
|
||||||
async for event in agent.chat_events(request.message, thread_id=request.thread_id):
|
async for event in agent.chat_events(
|
||||||
|
request.message,
|
||||||
|
thread_id=request.thread_id,
|
||||||
|
images=[image.model_dump() for image in request.images],
|
||||||
|
):
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(events(), media_type="text/event-stream")
|
return StreamingResponse(events(), media_type="text/event-stream")
|
||||||
@@ -309,6 +443,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/pending-actions")
|
@app.get("/api/pending-actions")
|
||||||
async def pending_actions() -> dict:
|
async def pending_actions() -> dict:
|
||||||
|
agent = runtime["agent"]
|
||||||
return {"pending_actions": agent._pending_payloads()}
|
return {"pending_actions": agent._pending_payloads()}
|
||||||
|
|
||||||
@app.get("/api/notifications")
|
@app.get("/api/notifications")
|
||||||
@@ -335,11 +470,13 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/negotiations/{identifier}/messages")
|
@app.get("/api/negotiations/{identifier}/messages")
|
||||||
async def negotiation_messages(identifier: str) -> dict:
|
async def negotiation_messages(identifier: str) -> dict:
|
||||||
|
uex = runtime["uex"]
|
||||||
params = negotiation_identifier_params(identifier)
|
params = negotiation_identifier_params(identifier)
|
||||||
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
||||||
|
|
||||||
@app.post("/api/negotiations/{identifier}/messages")
|
@app.post("/api/negotiations/{identifier}/messages")
|
||||||
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
||||||
|
uex = runtime["uex"]
|
||||||
params = negotiation_identifier_params(identifier)
|
params = negotiation_identifier_params(identifier)
|
||||||
payload = {**params, "message": request.message, "is_production": 1}
|
payload = {**params, "message": request.message, "is_production": 1}
|
||||||
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
||||||
@@ -348,6 +485,91 @@ def create_app() -> FastAPI:
|
|||||||
async def wake_jobs() -> dict:
|
async def wake_jobs() -> dict:
|
||||||
return {"scheduled_jobs": scheduler.list_jobs()}
|
return {"scheduled_jobs": scheduler.list_jobs()}
|
||||||
|
|
||||||
|
@app.get("/api/plans")
|
||||||
|
async def continual_plans(include_inactive: bool = True) -> dict:
|
||||||
|
return {"plans": plan_store.list_plans(include_inactive=include_inactive)}
|
||||||
|
|
||||||
|
@app.post("/api/plans")
|
||||||
|
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
|
result = await tools.create_continual_plan(
|
||||||
|
title=request.title,
|
||||||
|
objective=request.objective,
|
||||||
|
kind=request.kind,
|
||||||
|
items=[item.model_dump() for item in request.items],
|
||||||
|
constraints=request.constraints,
|
||||||
|
cadence=request.cadence,
|
||||||
|
)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/draft")
|
||||||
|
async def draft_continual_plan(request: ContinualPlanDraftRequest) -> dict:
|
||||||
|
agent = runtime["agent"]
|
||||||
|
draft = await agent.generate_plan_draft(
|
||||||
|
title=request.title,
|
||||||
|
objective=request.objective,
|
||||||
|
kind=request.kind,
|
||||||
|
constraints=request.constraints,
|
||||||
|
items=[item.model_dump() for item in request.items],
|
||||||
|
)
|
||||||
|
return {"draft": draft}
|
||||||
|
|
||||||
|
@app.get("/api/plans/{plan_id}")
|
||||||
|
async def continual_plan(plan_id: str) -> dict:
|
||||||
|
plan = plan_store.get_plan(plan_id)
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||||
|
return {"plan": plan}
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/pause")
|
||||||
|
async def pause_continual_plan(plan_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
|
result = await tools.pause_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/resume")
|
||||||
|
async def resume_continual_plan(plan_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
|
result = await tools.resume_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/cancel")
|
||||||
|
async def cancel_continual_plan(plan_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
|
result = await tools.cancel_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.delete("/api/plans/{plan_id}")
|
||||||
|
async def delete_continual_plan(plan_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
|
result = await tools.delete_continual_plan(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/run")
|
||||||
|
async def run_continual_plan(plan_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
|
result = await tools.run_continual_plan_now(plan_id)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/plans/{plan_id}/events")
|
||||||
|
async def add_continual_plan_event(plan_id: str, request: ContinualPlanEventRequest) -> dict:
|
||||||
|
if not plan_store.get_plan(plan_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||||
|
event = plan_store.add_event(plan_id, request.kind, request.message, request.metadata)
|
||||||
|
return {"event": event, "plan": plan_store.get_plan(plan_id)}
|
||||||
|
|
||||||
@app.get("/api/memory")
|
@app.get("/api/memory")
|
||||||
async def inspect_memory(limit: int = 50) -> dict:
|
async def inspect_memory(limit: int = 50) -> dict:
|
||||||
return memory.inspect(max(1, min(limit, 200)))
|
return memory.inspect(max(1, min(limit, 200)))
|
||||||
@@ -369,10 +591,12 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/api/approve/{action_id}")
|
@app.post("/api/approve/{action_id}")
|
||||||
async def approve(action_id: str) -> dict:
|
async def approve(action_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
return await tools.approve(action_id)
|
return await tools.approve(action_id)
|
||||||
|
|
||||||
@app.post("/api/decline/{action_id}")
|
@app.post("/api/decline/{action_id}")
|
||||||
async def decline(action_id: str) -> dict:
|
async def decline(action_id: str) -> dict:
|
||||||
|
tools = runtime["tools"]
|
||||||
return await tools.decline(action_id)
|
return await tools.decline(action_id)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -387,6 +611,141 @@ def negotiation_identifier_params(identifier: str) -> dict[str, Any]:
|
|||||||
return {"hash": value}
|
return {"hash": value}
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_model_provider() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.model_provider == "openai":
|
||||||
|
return await inspect_openai()
|
||||||
|
if settings.model_provider == "deepseek":
|
||||||
|
return await inspect_deepseek()
|
||||||
|
if settings.model_provider == "codex":
|
||||||
|
return await inspect_codex()
|
||||||
|
return await inspect_ollama()
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_openai() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_deepseek() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
return await inspect_cloud_provider_config(
|
||||||
|
"deepseek",
|
||||||
|
settings.deepseek_base_url,
|
||||||
|
settings.deepseek_api_key,
|
||||||
|
settings.deepseek_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_codex() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
command = find_codex_cli(settings.codex_command)
|
||||||
|
detail = ""
|
||||||
|
online = False
|
||||||
|
models: list[str] = []
|
||||||
|
effort_map: dict[str, list[str]] = {}
|
||||||
|
if command:
|
||||||
|
try:
|
||||||
|
account, models, effort_map = await inspect_codex_app_server(command)
|
||||||
|
online = bool(account)
|
||||||
|
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
|
||||||
|
except (OSError, RuntimeError, asyncio.TimeoutError) as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
configured_model = settings.codex_model
|
||||||
|
model_available = configured_model in models if models else bool(configured_model)
|
||||||
|
return {
|
||||||
|
"installed": bool(command),
|
||||||
|
"running": online,
|
||||||
|
"online": online,
|
||||||
|
"provider": "codex",
|
||||||
|
"model_available": model_available,
|
||||||
|
"configured_model": configured_model,
|
||||||
|
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||||
|
"reasoning_efforts": codex_reasoning_efforts(configured_model, effort_map),
|
||||||
|
"base_url": str(command) if command else settings.codex_command,
|
||||||
|
"models": models,
|
||||||
|
"message": codex_status_message(bool(command), online, model_available, configured_model),
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_cloud_provider() -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.model_provider == "codex":
|
||||||
|
return await inspect_codex()
|
||||||
|
if settings.model_provider == "deepseek":
|
||||||
|
return await inspect_deepseek()
|
||||||
|
return await inspect_openai()
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_provider_models(provider: str | None = None) -> dict[str, Any]:
|
||||||
|
normalized = str(provider or get_settings().model_provider).strip().casefold()
|
||||||
|
if normalized == "codex":
|
||||||
|
return await inspect_codex()
|
||||||
|
if normalized == "ollama":
|
||||||
|
return await inspect_ollama()
|
||||||
|
if normalized == "deepseek":
|
||||||
|
return await inspect_deepseek()
|
||||||
|
return await inspect_openai()
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_cloud_provider_config(
|
||||||
|
provider: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str | None,
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
models: list[str] = []
|
||||||
|
online = False
|
||||||
|
detail = ""
|
||||||
|
provider_name = provider_display_name(provider)
|
||||||
|
if not api_key:
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"running": False,
|
||||||
|
"online": False,
|
||||||
|
"provider": provider,
|
||||||
|
"model_available": False,
|
||||||
|
"configured_model": model,
|
||||||
|
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
|
||||||
|
"reasoning_efforts": provider_reasoning_efforts(provider, model),
|
||||||
|
"base_url": base_url,
|
||||||
|
"models": [],
|
||||||
|
"message": f"{provider_name} is selected, but no API key is configured.",
|
||||||
|
"detail": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{base_url.rstrip('/')}/models",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
online = True
|
||||||
|
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
detail = str(exc)
|
||||||
|
|
||||||
|
model_available = model in models
|
||||||
|
return {
|
||||||
|
"installed": True,
|
||||||
|
"running": online,
|
||||||
|
"online": online,
|
||||||
|
"provider": provider,
|
||||||
|
"model_available": model_available,
|
||||||
|
"configured_model": model,
|
||||||
|
"configured_reasoning_effort": canonical_provider_reasoning_effort(provider, settings.model_reasoning_effort),
|
||||||
|
"reasoning_efforts": provider_reasoning_efforts(provider, model),
|
||||||
|
"base_url": base_url,
|
||||||
|
"models": models,
|
||||||
|
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def inspect_ollama() -> dict[str, Any]:
|
async def inspect_ollama() -> dict[str, Any]:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
executable = find_ollama_executable()
|
executable = find_ollama_executable()
|
||||||
@@ -412,8 +771,11 @@ async def inspect_ollama() -> dict[str, Any]:
|
|||||||
"installed": installed,
|
"installed": installed,
|
||||||
"running": online,
|
"running": online,
|
||||||
"online": online,
|
"online": online,
|
||||||
|
"provider": "ollama",
|
||||||
"model_available": model_available,
|
"model_available": model_available,
|
||||||
"configured_model": settings.ollama_model,
|
"configured_model": settings.ollama_model,
|
||||||
|
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||||
|
"reasoning_efforts": reasoning_effort_options(),
|
||||||
"base_url": settings.ollama_base_url,
|
"base_url": settings.ollama_base_url,
|
||||||
"num_ctx": settings.ollama_num_ctx,
|
"num_ctx": settings.ollama_num_ctx,
|
||||||
"models": models,
|
"models": models,
|
||||||
@@ -426,6 +788,17 @@ async def inspect_ollama() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_status_message(provider: str, running: bool, configured: bool, model_available: bool, model: str) -> str:
|
||||||
|
provider_name = provider_display_name(provider)
|
||||||
|
if not configured:
|
||||||
|
return f"{provider_name} API key is not configured."
|
||||||
|
if not running:
|
||||||
|
return f"{provider_name} is not reachable with the configured key."
|
||||||
|
if not model_available:
|
||||||
|
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
|
||||||
|
return f"{provider_name} is ready."
|
||||||
|
|
||||||
|
|
||||||
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
|
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
|
||||||
if not installed:
|
if not installed:
|
||||||
return "Ollama is not installed."
|
return "Ollama is not installed."
|
||||||
@@ -436,6 +809,316 @@ def ollama_status_message(installed: bool, running: bool, model_available: bool,
|
|||||||
return "Ollama is ready."
|
return "Ollama is ready."
|
||||||
|
|
||||||
|
|
||||||
|
def codex_status_message(installed: bool, logged_in: bool, model_available: bool, model: str) -> str:
|
||||||
|
if not installed:
|
||||||
|
return "Codex CLI is not installed."
|
||||||
|
if not logged_in:
|
||||||
|
return "Codex CLI is installed, but the Codex App Server is not logged in with ChatGPT."
|
||||||
|
if not model_available:
|
||||||
|
return f'Codex App Server is logged in, but model "{model}" was not returned by the model list.'
|
||||||
|
return "Codex App Server is ready."
|
||||||
|
|
||||||
|
|
||||||
|
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
|
||||||
|
if settings.model_provider == "openai":
|
||||||
|
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
|
||||||
|
if settings.model_provider == "deepseek":
|
||||||
|
return settings.deepseek_base_url, settings.deepseek_model, settings.deepseek_api_key
|
||||||
|
if settings.model_provider == "codex":
|
||||||
|
return settings.codex_command, settings.codex_model, None
|
||||||
|
return settings.ollama_base_url, settings.ollama_model, None
|
||||||
|
|
||||||
|
|
||||||
|
def provider_display_name(provider: str) -> str:
|
||||||
|
return {"openai": "OpenAI", "deepseek": "DeepSeek", "codex": "Codex"}.get(provider, "Ollama")
|
||||||
|
|
||||||
|
|
||||||
|
def find_codex_cli(configured_command: str | None = None) -> Path | None:
|
||||||
|
candidates = [configured_command, shutil.which("codex"), os.path.join(os.environ.get("USERPROFILE", ""), ".codex", ".sandbox-bin", "codex.exe")]
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
resolved = shutil.which(candidate) if Path(candidate).name == candidate else candidate
|
||||||
|
if not resolved:
|
||||||
|
continue
|
||||||
|
path = Path(resolved)
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_codex_login_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def start_codex_browser_login(command: Path) -> dict[str, Any]:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
str(command),
|
||||||
|
"app-server",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
request_id = 1
|
||||||
|
|
||||||
|
async def write(payload: dict[str, Any]) -> None:
|
||||||
|
if process.stdin is None:
|
||||||
|
raise RuntimeError("Codex App Server stdin is unavailable.")
|
||||||
|
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
async def read(timeout: int = 30) -> dict[str, Any]:
|
||||||
|
if process.stdout is None:
|
||||||
|
raise RuntimeError("Codex App Server stdout is unavailable.")
|
||||||
|
try:
|
||||||
|
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise RuntimeError("Codex App Server timed out while starting browser login.") from exc
|
||||||
|
if not line:
|
||||||
|
stderr = ""
|
||||||
|
if process.stderr is not None:
|
||||||
|
try:
|
||||||
|
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stderr = ""
|
||||||
|
raise RuntimeError(stderr or "Codex App Server exited before login completed.")
|
||||||
|
return json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
nonlocal request_id
|
||||||
|
current_id = request_id
|
||||||
|
request_id += 1
|
||||||
|
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||||
|
if params is not None:
|
||||||
|
payload["params"] = params
|
||||||
|
await write(payload)
|
||||||
|
while True:
|
||||||
|
message = await read()
|
||||||
|
if message.get("id") == current_id:
|
||||||
|
if message.get("error"):
|
||||||
|
error = message["error"]
|
||||||
|
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||||
|
return message.get("result") or {}
|
||||||
|
await answer_codex_login_server_request(write, message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||||
|
"capabilities": {"experimentalApi": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||||
|
login = await send("account/login/start", {"type": "chatgpt"})
|
||||||
|
if login.get("type") != "chatgpt" or not login.get("authUrl"):
|
||||||
|
raise RuntimeError(f"Codex App Server did not return a browser login URL: {login!r}")
|
||||||
|
task = asyncio.create_task(watch_codex_browser_login(process, read, write, login.get("loginId")))
|
||||||
|
_codex_login_tasks.add(task)
|
||||||
|
task.add_done_callback(_codex_login_tasks.discard)
|
||||||
|
return login
|
||||||
|
except Exception:
|
||||||
|
await stop_process(process)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def answer_codex_login_server_request(write: Any, message: dict[str, Any]) -> None:
|
||||||
|
if "id" not in message or "method" not in message:
|
||||||
|
return
|
||||||
|
await write(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message["id"],
|
||||||
|
"error": {"code": -32601, "message": "TraderAI login does not handle server requests."},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def watch_codex_browser_login(process: asyncio.subprocess.Process, read: Any, write: Any, login_id: str | None) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await read(timeout=300)
|
||||||
|
if message.get("method") == "account/login/completed":
|
||||||
|
params = message.get("params") or {}
|
||||||
|
if login_id is None or params.get("loginId") == login_id:
|
||||||
|
return
|
||||||
|
await answer_codex_login_server_request(write, message)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
await stop_process(process)
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_process(process: asyncio.subprocess.Process) -> None:
|
||||||
|
if process.returncode is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=3)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def inspect_codex_app_server(command: Path) -> tuple[dict[str, Any] | None, list[str], dict[str, list[str]]]:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
str(command),
|
||||||
|
"app-server",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
request_id = 1
|
||||||
|
|
||||||
|
async def write(payload: dict[str, Any]) -> None:
|
||||||
|
if process.stdin is None:
|
||||||
|
raise RuntimeError("Codex App Server stdin is unavailable.")
|
||||||
|
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
|
||||||
|
await process.stdin.drain()
|
||||||
|
|
||||||
|
async def read(timeout: int = 30) -> dict[str, Any]:
|
||||||
|
if process.stdout is None:
|
||||||
|
raise RuntimeError("Codex App Server stdout is unavailable.")
|
||||||
|
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||||
|
if not line:
|
||||||
|
stderr = ""
|
||||||
|
if process.stderr is not None:
|
||||||
|
try:
|
||||||
|
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stderr = ""
|
||||||
|
raise RuntimeError(stderr or "Codex App Server exited without a response.")
|
||||||
|
return json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
|
||||||
|
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
nonlocal request_id
|
||||||
|
current_id = request_id
|
||||||
|
request_id += 1
|
||||||
|
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||||
|
if params is not None:
|
||||||
|
payload["params"] = params
|
||||||
|
await write(payload)
|
||||||
|
while True:
|
||||||
|
message = await read()
|
||||||
|
if message.get("id") == current_id:
|
||||||
|
if message.get("error"):
|
||||||
|
error = message["error"]
|
||||||
|
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||||
|
return message.get("result") or {}
|
||||||
|
if "id" in message and "method" in message:
|
||||||
|
await write(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": message["id"],
|
||||||
|
"error": {"code": -32601, "message": "TraderAI status checks do not handle server requests."},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await send(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||||
|
"capabilities": {"experimentalApi": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||||
|
account_result = await send("account/read", {"refreshToken": False})
|
||||||
|
models: list[str] = []
|
||||||
|
effort_map: dict[str, list[str]] = {}
|
||||||
|
cursor: str | None = None
|
||||||
|
for _ in range(20):
|
||||||
|
params: dict[str, Any] = {"limit": 50, "includeHidden": False}
|
||||||
|
if cursor:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
page = await send("model/list", params)
|
||||||
|
for item in page.get("data") or []:
|
||||||
|
model = item.get("id") or item.get("model")
|
||||||
|
if not model:
|
||||||
|
continue
|
||||||
|
models.append(model)
|
||||||
|
efforts = [
|
||||||
|
effort.get("reasoningEffort")
|
||||||
|
for effort in item.get("supportedReasoningEfforts", [])
|
||||||
|
if effort.get("reasoningEffort")
|
||||||
|
]
|
||||||
|
if efforts:
|
||||||
|
effort_map[model] = efforts
|
||||||
|
cursor = page.get("nextCursor")
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
return account_result.get("account"), sorted(set(models)), effort_map
|
||||||
|
finally:
|
||||||
|
if process.returncode is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=3)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def codex_models() -> list[str]:
|
||||||
|
cache_path = Path.home() / ".codex" / "models_cache.json"
|
||||||
|
if not cache_path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
body = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
models = []
|
||||||
|
for item in body.get("models", []):
|
||||||
|
slug = item.get("slug")
|
||||||
|
if slug:
|
||||||
|
models.append(slug)
|
||||||
|
return sorted(set(models))
|
||||||
|
|
||||||
|
|
||||||
|
def codex_reasoning_efforts(model: str, effort_map: dict[str, list[str]] | None = None) -> list[str]:
|
||||||
|
if effort_map and effort_map.get(model):
|
||||||
|
return effort_map[model]
|
||||||
|
cache_path = Path.home() / ".codex" / "models_cache.json"
|
||||||
|
if not cache_path.exists():
|
||||||
|
return reasoning_effort_options()
|
||||||
|
try:
|
||||||
|
body = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return reasoning_effort_options()
|
||||||
|
for item in body.get("models", []):
|
||||||
|
if item.get("slug") != model:
|
||||||
|
continue
|
||||||
|
efforts = [entry.get("effort") for entry in item.get("supported_reasoning_levels", []) if entry.get("effort")]
|
||||||
|
return efforts or reasoning_effort_options()
|
||||||
|
return reasoning_effort_options()
|
||||||
|
|
||||||
|
|
||||||
|
def reasoning_effort_options() -> list[str]:
|
||||||
|
return ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||||
|
|
||||||
|
|
||||||
|
def deepseek_reasoning_efforts(model: str) -> list[str]:
|
||||||
|
supported_models = {"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"}
|
||||||
|
return ["none", "high", "max"] if model in supported_models else ["none", "high"]
|
||||||
|
|
||||||
|
|
||||||
|
def provider_reasoning_efforts(provider: str, model: str) -> list[str]:
|
||||||
|
if provider == "deepseek":
|
||||||
|
return deepseek_reasoning_efforts(model)
|
||||||
|
return reasoning_effort_options()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_provider_reasoning_effort(provider: str, effort: str) -> str:
|
||||||
|
normalized = str(effort or "medium").strip().casefold()
|
||||||
|
if provider != "deepseek":
|
||||||
|
return normalized
|
||||||
|
if normalized in {"none", "minimal"}:
|
||||||
|
return "none"
|
||||||
|
if normalized in {"xhigh", "max"}:
|
||||||
|
return "max"
|
||||||
|
return "high"
|
||||||
|
|
||||||
|
|
||||||
def find_ollama_executable() -> Path | None:
|
def find_ollama_executable() -> Path | None:
|
||||||
candidates = [
|
candidates = [
|
||||||
shutil.which("ollama"),
|
shutil.which("ollama"),
|
||||||
@@ -488,6 +1171,13 @@ def popen_hidden(command: list[str]) -> subprocess.Popen:
|
|||||||
return subprocess.Popen(command, **kwargs)
|
return subprocess.Popen(command, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def exception_detail(exc: BaseException) -> str:
|
||||||
|
text = str(exc).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return f"{type(exc).__name__}: {exc!r}"
|
||||||
|
|
||||||
|
|
||||||
async def inspect_update() -> dict[str, Any]:
|
async def inspect_update() -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
latest = await latest_release()
|
latest = await latest_release()
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class StarCitizenWikiError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StarCitizenWikiClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str = "https://starcitizen.tools",
|
||||||
|
api_base_url: str = "https://api.star-citizen.wiki",
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_base_url = api_base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def search_pages(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(
|
||||||
|
f"{self.base_url}/api.php",
|
||||||
|
params={
|
||||||
|
"action": "query",
|
||||||
|
"generator": "prefixsearch",
|
||||||
|
"gpssearch": query,
|
||||||
|
"gpslimit": max(1, min(limit, 10)),
|
||||||
|
"prop": "description|pageimages|extracts",
|
||||||
|
"exintro": 1,
|
||||||
|
"explaintext": 1,
|
||||||
|
"exchars": 320,
|
||||||
|
"piprop": "thumbnail",
|
||||||
|
"pithumbsize": 240,
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
pages = body.get("query", {}).get("pages", {})
|
||||||
|
ordered = sorted(
|
||||||
|
(item for item in pages.values() if isinstance(item, dict)),
|
||||||
|
key=lambda item: int(item.get("index") or 0),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"pageid": item.get("pageid"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"description": item.get("description"),
|
||||||
|
"extract": item.get("extract"),
|
||||||
|
"thumbnail": (item.get("thumbnail") or {}).get("source"),
|
||||||
|
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
|
||||||
|
}
|
||||||
|
for item in ordered
|
||||||
|
if item.get("title")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_page_summary(self, title: str | None = None, pageid: int | None = None, chars: int = 700) -> dict[str, Any] | None:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"action": "query",
|
||||||
|
"prop": "extracts|description|pageimages",
|
||||||
|
"exintro": 1,
|
||||||
|
"explaintext": 1,
|
||||||
|
"exchars": max(120, min(chars, 1200)),
|
||||||
|
"piprop": "thumbnail",
|
||||||
|
"pithumbsize": 320,
|
||||||
|
"format": "json",
|
||||||
|
}
|
||||||
|
if pageid is not None:
|
||||||
|
params["pageids"] = pageid
|
||||||
|
elif title:
|
||||||
|
params["titles"] = title
|
||||||
|
else:
|
||||||
|
raise StarCitizenWikiError("title or pageid is required")
|
||||||
|
|
||||||
|
body = await self._get_json(f"{self.base_url}/api.php", params=params)
|
||||||
|
pages = body.get("query", {}).get("pages", {})
|
||||||
|
for item in pages.values():
|
||||||
|
if isinstance(item, dict) and item.get("pageid") and item.get("title"):
|
||||||
|
return {
|
||||||
|
"pageid": item.get("pageid"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"description": item.get("description"),
|
||||||
|
"extract": item.get("extract"),
|
||||||
|
"thumbnail": (item.get("thumbnail") or {}).get("source"),
|
||||||
|
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_verse(self, query: str) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(
|
||||||
|
f"{self.api_base_url}/api/search",
|
||||||
|
params={"filter[query]": query},
|
||||||
|
)
|
||||||
|
data = body.get("data")
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
async def get_vehicle(self, slug: str) -> dict[str, Any]:
|
||||||
|
body = await self._get_json(f"{self.api_base_url}/api/vehicles/{slug.strip('/')}")
|
||||||
|
data = body.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise StarCitizenWikiError(f"Vehicle response for {slug} was not an object.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, params=params, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise StarCitizenWikiError(f"Star Citizen Wiki returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise StarCitizenWikiError(f"Star Citizen Wiki HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
+873
-14
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.0.3"
|
__version__ = "0.0.8"
|
||||||
|
|
||||||
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"
|
||||||
@@ -9,3 +9,9 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class WikeloProjectsError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WikeloProjectsClient:
|
||||||
|
APP_ID = "695be2905c0b4866dfb21265"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = "https://wikelo-projects.com") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def list_ship_projects(self) -> list[dict[str, Any]]:
|
||||||
|
body = await self._get_json(f"{self.base_url}/api/apps/{self.APP_ID}/entities/ShipProject")
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise WikeloProjectsError("Wikelo ship projects response was not a list.")
|
||||||
|
return [item for item in body if isinstance(item, dict)]
|
||||||
|
|
||||||
|
async def _get_json(self, url: str) -> Any:
|
||||||
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
|
response = await client.get(url, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WikeloProjectsError(f"Wikelo Projects returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise WikeloProjectsError(f"Wikelo Projects HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
File diff suppressed because one or more lines are too long
@@ -755,7 +755,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traderai"
|
name = "traderai"
|
||||||
version = "0.0.3"
|
version = "0.0.8"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
@@ -1049,3 +1049,9 @@ wheels = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+750
-36
File diff suppressed because it is too large
Load Diff
+103
-21
@@ -9,7 +9,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats and inbox">
|
<nav class="chat-rail collapsed" id="chat-rail" aria-label="Chats, plans, and inbox">
|
||||||
<div class="chat-rail-top">
|
<div class="chat-rail-top">
|
||||||
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
<button class="icon-button" id="chat-sidebar-toggle" type="button" title="Chats" aria-expanded="false">
|
||||||
<i data-lucide="panel-left" aria-hidden="true"></i>
|
<i data-lucide="panel-left" aria-hidden="true"></i>
|
||||||
@@ -25,6 +25,15 @@
|
|||||||
<div class="rail-heading">Chats</div>
|
<div class="rail-heading">Chats</div>
|
||||||
<div class="chat-list" id="chat-list"></div>
|
<div class="chat-list" id="chat-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="chat-nav-section">
|
||||||
|
<div class="rail-heading-row">
|
||||||
|
<div class="rail-heading">Plans</div>
|
||||||
|
<button class="rail-icon-button" id="plans-toggle" type="button" title="Plans" aria-expanded="false" aria-controls="plans-panel">
|
||||||
|
<i data-lucide="list-checks" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="plans-rail-list" id="plans-rail-list"></div>
|
||||||
|
</section>
|
||||||
<section class="chat-nav-section">
|
<section class="chat-nav-section">
|
||||||
<div class="rail-heading">Inbox</div>
|
<div class="rail-heading">Inbox</div>
|
||||||
<div class="inbox-list" id="inbox-list"></div>
|
<div class="inbox-list" id="inbox-list"></div>
|
||||||
@@ -42,6 +51,7 @@
|
|||||||
<h1>TraderAI</h1>
|
<h1>TraderAI</h1>
|
||||||
<p>Institutional marketplace intelligence for UEX operations</p>
|
<p>Institutional marketplace intelligence for UEX operations</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="brand-short" aria-hidden="true">LBC</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="status">Ready</div>
|
<div class="status" id="status">Ready</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -49,7 +59,10 @@
|
|||||||
<div class="messages" id="messages"></div>
|
<div class="messages" id="messages"></div>
|
||||||
<div class="composer-wrap">
|
<div class="composer-wrap">
|
||||||
<form class="composer" id="chat-form">
|
<form class="composer" id="chat-form">
|
||||||
|
<div class="composer-main">
|
||||||
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
||||||
|
<div class="composer-images" id="composer-images" hidden></div>
|
||||||
|
</div>
|
||||||
<button type="submit">Send</button>
|
<button type="submit">Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,21 +73,6 @@
|
|||||||
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
<div id="pending-actions" class="pending-empty">No pending actions</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="side-section sidebar-tools">
|
<section class="side-section sidebar-tools">
|
||||||
<div class="sidebar-tool-buttons" role="tablist" aria-label="Sidebar panels">
|
|
||||||
<button class="sidebar-tool-button" id="settings-toggle" type="button" aria-expanded="false" aria-controls="settings-panel" title="Settings">
|
|
||||||
<i data-lucide="settings" aria-hidden="true"></i>
|
|
||||||
<span>Settings</span>
|
|
||||||
</button>
|
|
||||||
<button class="sidebar-tool-button" id="memory-toggle" type="button" aria-expanded="false" aria-controls="memory-panel" title="Memory">
|
|
||||||
<i data-lucide="brain" aria-hidden="true"></i>
|
|
||||||
<span>Memory</span>
|
|
||||||
</button>
|
|
||||||
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
|
|
||||||
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
|
|
||||||
<i data-lucide="bot" aria-hidden="true"></i>
|
|
||||||
<span>Ollama</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-panel" id="settings-panel" hidden>
|
<div class="sidebar-panel" id="settings-panel" hidden>
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<h2>Config</h2>
|
<h2>Config</h2>
|
||||||
@@ -121,14 +119,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sidebar-panel" id="ollama-panel" hidden>
|
<div class="sidebar-panel" id="ollama-panel" hidden>
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<h2>Ollama</h2>
|
<h2>Inference</h2>
|
||||||
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="config-form" id="ollama-config-form">
|
<form class="config-form" id="ollama-config-form">
|
||||||
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
<label>Provider
|
||||||
<label>Model<input id="ollama-model" name="ollama_model" type="text"></label>
|
<select id="model-provider" name="model_provider">
|
||||||
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
<option value="deepseek">DeepSeek V4 (Recommended)</option>
|
||||||
<button type="submit">Save Ollama Config</button>
|
<option value="ollama">Local Ollama</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label data-provider-scope="deepseek">DeepSeek URL<input id="deepseek-base-url" name="deepseek_base_url" type="text"></label>
|
||||||
|
<label data-provider-scope="deepseek">DeepSeek API Key<input id="deepseek-api-key" name="deepseek_api_key" type="password" autocomplete="off"></label>
|
||||||
|
<label data-provider-scope="deepseek" data-manual-model="true">DeepSeek Model<input id="deepseek-model" name="deepseek_model" type="text" list="provider-models"></label>
|
||||||
|
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||||
|
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||||
|
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||||
|
<label><span id="provider-model-label">Model</span><select id="provider-model-select"></select></label>
|
||||||
|
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
|
||||||
|
<datalist id="provider-models"></datalist>
|
||||||
|
<button type="submit">Save Provider Config</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="ollama-status" id="ollama-status"></div>
|
<div class="ollama-status" id="ollama-status"></div>
|
||||||
<div class="ollama-actions">
|
<div class="ollama-actions">
|
||||||
@@ -139,6 +149,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="config-status" id="ollama-message"></div>
|
<div class="config-status" id="ollama-message"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-tool-buttons" role="tablist" aria-label="Sidebar panels">
|
||||||
|
<button class="sidebar-tool-button" id="settings-toggle" type="button" aria-expanded="false" aria-controls="settings-panel" title="Settings">
|
||||||
|
<i data-lucide="settings" aria-hidden="true"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
<button class="sidebar-tool-button" id="memory-toggle" type="button" aria-expanded="false" aria-controls="memory-panel" title="Memory">
|
||||||
|
<i data-lucide="brain" aria-hidden="true"></i>
|
||||||
|
<span>Memory</span>
|
||||||
|
</button>
|
||||||
|
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Inference">
|
||||||
|
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
|
||||||
|
<i data-lucide="bot" aria-hidden="true"></i>
|
||||||
|
<span>Inference</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
@@ -159,6 +184,63 @@
|
|||||||
</form>
|
</form>
|
||||||
<div class="config-status" id="negotiation-status"></div>
|
<div class="config-status" id="negotiation-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="floating-panel plans-floating-panel" id="plans-panel" hidden>
|
||||||
|
<div class="floating-panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Continual work</p>
|
||||||
|
<h2>Plans</h2>
|
||||||
|
</div>
|
||||||
|
<div class="floating-panel-actions">
|
||||||
|
<button class="icon-button light" id="plans-refresh" type="button" title="Refresh plans">
|
||||||
|
<i data-lucide="refresh-cw" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-button light" id="plans-close" type="button" title="Close">
|
||||||
|
<i data-lucide="x" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plans-panel-body">
|
||||||
|
<aside class="plan-creator-shell">
|
||||||
|
<div class="plan-creator-card">
|
||||||
|
<div class="plan-creator-copy">
|
||||||
|
<p class="eyebrow">New continual plan</p>
|
||||||
|
<h3>Set the watch once</h3>
|
||||||
|
<p>Spin up buying runs or custom follow-up work with a title, a goal, and just enough guardrails to keep it on track.</p>
|
||||||
|
</div>
|
||||||
|
<form class="config-form plan-form-grid" id="plan-form">
|
||||||
|
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
|
||||||
|
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
|
||||||
|
<div class="plan-form-split">
|
||||||
|
<label>Kind
|
||||||
|
<select id="plan-kind">
|
||||||
|
<option value="buying">Buying</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
|
||||||
|
</div>
|
||||||
|
<label>Items<textarea id="plan-items" rows="5" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
|
||||||
|
<label>Instructions<textarea id="plan-instructions" rows="4" placeholder="Extra guidance for custom or buying plans"></textarea></label>
|
||||||
|
<div class="plan-form-split">
|
||||||
|
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
|
||||||
|
<div class="plan-form-hint">
|
||||||
|
<strong>Tip</strong>
|
||||||
|
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="plan-form-actions">
|
||||||
|
<button id="plan-autofill" type="button">AI Fill</button>
|
||||||
|
<button type="submit">Create Plan</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-status" id="plans-status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section class="plans-dashboard-shell">
|
||||||
|
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-backdrop" id="update-modal" hidden>
|
<div class="modal-backdrop" id="update-modal" hidden>
|
||||||
<section class="update-modal-card">
|
<section class="update-modal-card">
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
|
|||||||
+762
-35
@@ -105,7 +105,7 @@ body::before {
|
|||||||
|
|
||||||
.chat-rail-content {
|
.chat-rail-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(140px, 34%);
|
grid-template-rows: minmax(0, 1fr) minmax(92px, 20%) minmax(130px, 30%);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
@@ -131,8 +131,41 @@ body::before {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rail-heading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-heading-row .rail-heading {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff9e9;
|
||||||
|
color: var(--forest);
|
||||||
|
box-shadow: 0 8px 18px rgba(38, 58, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-icon-button svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-list,
|
.chat-list,
|
||||||
.inbox-list {
|
.inbox-list,
|
||||||
|
.plans-rail-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: calc(100% - 26px);
|
max-height: calc(100% - 26px);
|
||||||
@@ -140,7 +173,8 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-item,
|
.chat-item,
|
||||||
.inbox-item {
|
.inbox-item,
|
||||||
|
.plan-rail-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -159,13 +193,33 @@ body::before {
|
|||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-rail-item {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 250, 240, 0.78);
|
||||||
|
color: var(--brown);
|
||||||
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-rail-item:hover {
|
||||||
|
background: #edf3df;
|
||||||
|
color: var(--brown);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-item.active {
|
.chat-item.active {
|
||||||
border-color: rgba(52, 83, 38, 0.42);
|
border-color: rgba(52, 83, 38, 0.42);
|
||||||
background: #edf3df;
|
background: #edf3df;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-title,
|
.chat-title,
|
||||||
.inbox-title {
|
.inbox-title,
|
||||||
|
.plan-rail-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--brown);
|
color: var(--brown);
|
||||||
@@ -198,7 +252,25 @@ body::before {
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-rail-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-rail-status {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid rgba(52, 83, 38, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #edf3df;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -230,6 +302,10 @@ body::before {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-wrap {
|
.logo-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -481,6 +557,38 @@ h2 {
|
|||||||
background: rgba(255, 250, 240, 0.96);
|
background: rgba(255, 250, 240, 0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-images {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(88, 66, 47, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image-label {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #6d5b4e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.message.warning-message {
|
.message.warning-message {
|
||||||
border-color: rgba(212, 175, 55, 0.6);
|
border-color: rgba(212, 175, 55, 0.6);
|
||||||
background: #f5eac4;
|
background: #f5eac4;
|
||||||
@@ -646,6 +754,60 @@ h2 {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-images {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(88, 66, 47, 0.16);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
box-shadow: 0 12px 26px rgba(38, 58, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-name {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
color: #6d5b4e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(88, 66, 47, 0.18);
|
||||||
|
background: rgba(255, 250, 240, 0.92);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
@@ -678,7 +840,8 @@ textarea:disabled {
|
|||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="number"] {
|
input[type="number"],
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
@@ -694,7 +857,8 @@ input[type="number"] {
|
|||||||
|
|
||||||
input[type="text"]:focus,
|
input[type="text"]:focus,
|
||||||
input[type="password"]:focus,
|
input[type="password"]:focus,
|
||||||
input[type="number"]:focus {
|
input[type="number"]:focus,
|
||||||
|
select:focus {
|
||||||
border-color: var(--gold);
|
border-color: var(--gold);
|
||||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18);
|
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.18);
|
||||||
}
|
}
|
||||||
@@ -867,6 +1031,26 @@ button {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-panel-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-floating-panel {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(980px, calc(100vw - 28px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-panel-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background: linear-gradient(180deg, #3d612c, #263e1b);
|
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);
|
box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||||
@@ -909,7 +1093,7 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-section {
|
.side-section {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-section + .side-section {
|
.side-section + .side-section {
|
||||||
@@ -918,43 +1102,98 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tools {
|
.sidebar-tools {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 24px;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-buttons {
|
.sidebar-tool-buttons {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-wrap: nowrap;
|
||||||
gap: 10px;
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-button {
|
.sidebar-tool-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
flex: 0 1 42px;
|
||||||
min-width: 0;
|
gap: 0;
|
||||||
min-height: 46px;
|
width: 42px;
|
||||||
padding: 10px 12px;
|
min-width: 36px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px;
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background: #fff9e9;
|
background: #fff9e9;
|
||||||
color: var(--forest);
|
color: var(--forest);
|
||||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
|
box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08);
|
||||||
|
transition:
|
||||||
|
flex-basis 180ms ease,
|
||||||
|
width 180ms ease,
|
||||||
|
gap 180ms ease,
|
||||||
|
padding 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
background 180ms ease,
|
||||||
|
color 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-button:hover,
|
||||||
|
.sidebar-tool-button:focus-visible {
|
||||||
|
flex-basis: 108px;
|
||||||
|
width: 108px;
|
||||||
|
gap: 7px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
border-color: rgba(212, 175, 55, 0.72);
|
||||||
|
background: linear-gradient(180deg, #3d612c, #263e1b);
|
||||||
|
color: var(--ivory);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-button span {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
max-width 180ms ease,
|
||||||
|
opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tool-button:hover span,
|
||||||
|
.sidebar-tool-button:focus-visible span {
|
||||||
|
max-width: 70px;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-button svg {
|
.sidebar-tool-button svg {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
stroke-width: 2.3;
|
stroke-width: 2.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tool-image {
|
.sidebar-tool-image {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,8 +1208,13 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-panel {
|
.sidebar-panel {
|
||||||
padding-top: 12px;
|
padding-bottom: 12px;
|
||||||
border-top: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel .section-title-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-form {
|
.config-form {
|
||||||
@@ -1013,6 +1257,86 @@ button.secondary {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-creator-shell,
|
||||||
|
.plans-dashboard-shell {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-creator-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid rgba(212, 175, 55, 0.28);
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(240, 214, 129, 0.18), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(247, 241, 220, 0.94));
|
||||||
|
box-shadow: 0 20px 40px rgba(38, 58, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-creator-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-creator-copy h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--forest);
|
||||||
|
font-family: "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-creator-copy p:last-child {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-grid {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-grid textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-hint {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 13px;
|
||||||
|
border: 1px dashed rgba(52, 83, 38, 0.24);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(237, 243, 223, 0.68);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-form-hint strong {
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.ollama-status {
|
.ollama-status {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1245,6 +1569,300 @@ pre {
|
|||||||
padding: 11px;
|
padding: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans-dashboard {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-overview {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 6px 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-overview h3 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--forest);
|
||||||
|
font-family: "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 31px;
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-overview-copy {
|
||||||
|
max-width: 48ch;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-overview-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-overview-stat {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 110px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(212, 175, 55, 0.28);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 250, 240, 0.78);
|
||||||
|
box-shadow: 0 12px 24px rgba(38, 58, 27, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-overview-stat-value {
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-overview-stat-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-empty-state {
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px dashed rgba(52, 83, 38, 0.24);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 253, 247, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-empty-state h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(221, 206, 176, 0.92);
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(251, 244, 223, 0.88));
|
||||||
|
box-shadow: 0 16px 30px rgba(38, 58, 27, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card.active {
|
||||||
|
border-color: rgba(52, 83, 38, 0.32);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(190, 212, 144, 0.22), transparent 26%),
|
||||||
|
linear-gradient(180deg, rgba(247, 250, 238, 0.98), rgba(237, 243, 223, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-active {
|
||||||
|
border-color: rgba(52, 83, 38, 0.28);
|
||||||
|
background: rgba(237, 243, 223, 0.9);
|
||||||
|
color: var(--forest);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-badge.plan-status-active {
|
||||||
|
border: 1px solid rgba(52, 83, 38, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-paused {
|
||||||
|
border-color: rgba(196, 170, 115, 0.42);
|
||||||
|
background: rgba(255, 246, 220, 0.86);
|
||||||
|
color: #7a5a18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-badge.plan-status-paused {
|
||||||
|
border: 1px solid rgba(196, 170, 115, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-needs-input {
|
||||||
|
border-color: rgba(159, 60, 50, 0.24);
|
||||||
|
background: rgba(255, 241, 237, 0.88);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-badge.plan-status-needs-input {
|
||||||
|
border: 1px solid rgba(159, 60, 50, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-canceled,
|
||||||
|
.plan-status-completed {
|
||||||
|
opacity: 0.84;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-badge.plan-status-canceled,
|
||||||
|
.plan-status-badge.plan-status-completed {
|
||||||
|
border: 1px solid rgba(111, 91, 80, 0.18);
|
||||||
|
background: rgba(255, 250, 240, 0.82);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-meta,
|
||||||
|
.plan-line {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-pill-row,
|
||||||
|
.plan-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid rgba(52, 83, 38, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 250, 240, 0.88);
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-metric {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid rgba(221, 206, 176, 0.78);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 253, 247, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-metric-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-metric-value {
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-controls button {
|
||||||
|
flex: 1 1 80px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(221, 206, 176, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-detail-loading {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed rgba(52, 83, 38, 0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 253, 247, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-detail h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
border: 1px solid rgba(221, 206, 176, 0.72);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 253, 247, 0.8);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list-title {
|
||||||
|
color: var(--forest);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-list-body {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.decline-button {
|
.decline-button {
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: #fff9e9;
|
background: #fff9e9;
|
||||||
@@ -1277,8 +1895,17 @@ pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
body {
|
||||||
|
background: var(--cream);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,40 +1914,104 @@ pre {
|
|||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-rail {
|
||||||
|
position: fixed;
|
||||||
|
inset: 10px auto auto 10px;
|
||||||
|
z-index: 10;
|
||||||
|
width: min(320px, calc(100vw - 20px));
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
max-height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail.collapsed {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
max-height: 48px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail.collapsed .chat-rail-top {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-rail.collapsed #new-chat {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
align-items: flex-start;
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
align-items: center;
|
||||||
padding: 22px;
|
justify-content: center;
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 10px 58px 10px 66px;
|
||||||
|
border-bottom-color: var(--line);
|
||||||
|
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
.brand-block {
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrap {
|
.logo-wrap {
|
||||||
width: 58px;
|
width: 28px;
|
||||||
height: 58px;
|
height: 28px;
|
||||||
flex-basis: 58px;
|
flex: 0 0 28px;
|
||||||
border-radius: 18px;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrap::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: currentColor;
|
||||||
|
-webkit-mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
|
||||||
|
mask: url("/static/art/LBC_Logo.png") center / contain no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-wrap img {
|
.logo-wrap img {
|
||||||
width: 45px;
|
display: none;
|
||||||
height: 45px;
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy p,
|
||||||
|
.status {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 31px;
|
color: var(--brown);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.brand-short {
|
||||||
font-size: 10px;
|
display: inline-flex;
|
||||||
letter-spacing: 0.08em;
|
align-items: center;
|
||||||
|
color: var(--brown);
|
||||||
|
font-family: "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages,
|
.messages,
|
||||||
.actions,
|
.actions {
|
||||||
.chat-rail {
|
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,4 +2041,40 @@ pre {
|
|||||||
.message-phase {
|
.message-phase {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plans-overview {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.plan-form-split,
|
||||||
|
.plan-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-status-badge {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.plans-floating-panel {
|
||||||
|
width: min(100vw - 18px, 980px);
|
||||||
|
right: 9px;
|
||||||
|
bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans-panel-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<link href="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp" rel="icon" type="image/svg+xml"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="/manifest.json" rel="manifest"/>
|
||||||
|
<title>
|
||||||
|
Ships | Wikelo Project Tracker
|
||||||
|
</title>
|
||||||
|
<script crossorigin="" src="/assets/index-DWqdqkK8.js" type="module">
|
||||||
|
</script>
|
||||||
|
<link crossorigin="" href="/assets/index-BzxCYXI2.css" rel="stylesheet"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="description"/>
|
||||||
|
<meta content="Ships | Wikelo Project Tracker" property="og:title"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." property="og:description"/>
|
||||||
|
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" property="og:image"/>
|
||||||
|
<meta content="https://wikelo-projects.com/Ships" property="og:url"/>
|
||||||
|
<meta content="website" property="og:type"/>
|
||||||
|
<meta content="Wikelo Project Tracker" property="og:site_name"/>
|
||||||
|
<meta content="Ships | Wikelo Project Tracker" name="twitter:title"/>
|
||||||
|
<meta content="Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships." name="twitter:description"/>
|
||||||
|
<meta content="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/render/image/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp?width=1200&height=630&resize=contain" name="twitter:image"/>
|
||||||
|
<meta content="summary_large_image" name="twitter:card"/>
|
||||||
|
<meta content="https://wikelo-projects.com/Ships" name="twitter:url"/>
|
||||||
|
<meta content="yes" name="mobile-web-app-capable"/>
|
||||||
|
<meta content="black" name="apple-mobile-web-app-status-bar-style"/>
|
||||||
|
<meta content="Wikelo Project Tracker" name="apple-mobile-web-app-title"/>
|
||||||
|
<link href="https://wikelo-projects.com/Ships" rel="canonical"/>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"name": "Wikelo Project Tracker", "@context": "https://schema.org", "@type": "WebSite", "url": "https://wikelo-projects.com"}
|
||||||
|
</script>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"name": "Wikelo Project Tracker", "logo": "https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/695be2905c0b4866dfb21265/62b39a568_Wikapp3.webp", "@context": "https://schema.org", "@type": "Organization", "url": "https://wikelo-projects.com"}
|
||||||
|
</script>
|
||||||
|
<script data-seo-source="builder" type="application/ld+json">
|
||||||
|
{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://wikelo-projects.com/"}, {"@type": "ListItem", "position": 2, "name": "Ships | Wikelo Project Tracker", "item": "https://wikelo-projects.com/Ships"}]}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div data-seo-source="builder" id="seo-snapshot" style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;">
|
||||||
|
<h1>
|
||||||
|
Ships | Wikelo Project Tracker
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Ships on Wikelo Project Tracker. Track materials needed and contributed for building your Star Citizen ships.
|
||||||
|
</p>
|
||||||
|
<nav aria-label="Pages">
|
||||||
|
<h2>
|
||||||
|
Pages
|
||||||
|
</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/AdminAds">
|
||||||
|
Admin Ads
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/AdvertiseWithUs">
|
||||||
|
Advertise With Us
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ArmorProjectDetails">
|
||||||
|
Armor Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Armors">
|
||||||
|
Armors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/CleanupMaterials">
|
||||||
|
Cleanup Materials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Guide">
|
||||||
|
Guide
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Inventory">
|
||||||
|
Inventory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Messages">
|
||||||
|
Messages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/NotificationSettings">
|
||||||
|
Notification Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Notifications">
|
||||||
|
Notifications
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/OrganizationDetails">
|
||||||
|
Organization Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Organizations">
|
||||||
|
Organizations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Profile">
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ProjectDetails">
|
||||||
|
Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateContributionReputation">
|
||||||
|
Recalculate Contribution Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateMaterials">
|
||||||
|
Recalculate Materials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/RecalculateReputation">
|
||||||
|
Recalculate Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Reputation">
|
||||||
|
Reputation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/UpdateInfo">
|
||||||
|
Update Info
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/WeaponProjectDetails">
|
||||||
|
Weapon Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/Weapons">
|
||||||
|
Weapons
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/WikeloProjectDetails">
|
||||||
|
Wikelo Project Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Reference in New Issue
Block a user