diff --git a/pyproject.toml b/pyproject.toml index f9bd388..1c770a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,6 @@ dev = [ [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] + +[tool.setuptools.packages.find] +include = ["traderai*"] diff --git a/tests/test_tools.py b/tests/test_tools.py index 7d0d523..bd8eafd 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,6 +8,28 @@ from traderai.uex_client import UEXClient class FakeUEX: async def get(self, path, params=None, authenticated=False): + if path == "commodities_prices": + return { + "status": "ok", + "data": [ + { + "id": 10, + "commodity_name": "Gold", + "terminal_name": "Port Tressler", + "price_buy": 4120, + "price_sell": 5020, + "scu_buy": 1200, + "verbose_note": "x" * 300, + }, + { + "id": 11, + "commodity_name": "Beryl", + "terminal_name": "Area18", + "price_buy": 2500, + "price_sell": 3100, + }, + ], + } assert path == "marketplace_listings" return { "data": [ @@ -42,6 +64,9 @@ class FakeUEX: ] } + async def delete(self, path, params=None, authenticated=True): + return {"status": "ok", "deleted": {"path": path, "params": params, "authenticated": authenticated}} + @pytest.mark.asyncio async def test_search_marketplace_listings_filters_locally(): @@ -83,6 +108,68 @@ def test_uex_client_uses_bearer_and_secret_headers(): assert headers["Authorization"] == "Bearer bearer" +@pytest.mark.asyncio +async def test_uex_get_projects_and_limits_results(): + registry = ToolRegistry(FakeUEX()) + + result = await registry.execute( + "get_uex_commodities_prices", + { + "commodity_name": "Gold", + "ignored": "drop", + "fields": ["id", "commodity_name", "price_buy"], + "limit": 1, + }, + ) + + assert result["resource"] == "commodities_prices" + assert result["params"] == {"commodity_name": "Gold"} + assert result["returned"] == 1 + assert result["truncated"] is True + assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}] + + +@pytest.mark.asyncio +async def test_uex_api_catalog_exposes_resources_without_live_call(): + registry = ToolRegistry(FakeUEX()) + + result = await registry.uex_api_catalog(group="vehicles") + + resources = [item["resource"] for item in result["get"]["vehicles"]] + assert "vehicles" in resources + assert "vehicles_prices" in resources + assert "wallet_add" in result["post"] + + +@pytest.mark.asyncio +async def test_draft_delete_approves_with_delete_method(): + registry = ToolRegistry(FakeUEX()) + result = await registry.execute("delete_uex_marketplace_listings", {"id": 123, "label": "Remove listing"}) + action_id = result["pending_action"]["id"] + + approved = await registry.approve(action_id) + + assert result["pending_action"]["method"] == "DELETE" + assert approved["deleted"] == { + "path": "marketplace_listings", + "params": {"id": 123}, + "authenticated": True, + } + + +def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool(): + registry = ToolRegistry(FakeUEX()) + + names = {schema["function"]["name"] for schema in registry.schemas} + + assert "get_uex_commodities_prices" in names + assert "get_uex_vehicles" in names + assert "draft_uex_marketplace_advertise" in names + assert "delete_uex_marketplace_listings" in names + assert "uex_get" not in names + assert "uex_draft_post" not in names + + @pytest.mark.asyncio @respx.mock async def test_uex_client_get_user_normalizes_user_payload(): diff --git a/traderai/agent.py b/traderai/agent.py index 00abdbb..e5ce43b 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -12,7 +12,8 @@ from traderai.tools import ToolRegistry SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work. -Use tools when the user asks about open/current listings, active negotiations, unread notifications, messages, offers, or posting ads. +Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads. +Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact. Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales. Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise. For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds. @@ -258,6 +259,7 @@ class OllamaAgent: { "id": action.id, "label": action.label, + "method": action.method, "endpoint": action.endpoint, "payload": action.payload, } @@ -271,7 +273,17 @@ class OllamaAgent: @staticmethod def _tool_status(name: str) -> str: + if name.startswith("get_uex_"): + return f"Fetching UEX {name.removeprefix('get_uex_')}" + if name.startswith("draft_uex_"): + return f"Drafting UEX {name.removeprefix('draft_uex_')} for approval" + if name.startswith("delete_uex_"): + return f"Drafting UEX {name.removeprefix('delete_uex_')} delete for approval" labels = { + "uex_api_catalog": "Checking UEX API catalog", + "uex_get": "Fetching UEX data", + "uex_draft_post": "Drafting UEX write for approval", + "uex_draft_delete": "Drafting UEX delete for approval", "search_marketplace_listings": "Searching UEX listings", "get_marketplace_listing": "Fetching listing details", "list_marketplace_negotiations": "Checking negotiations", diff --git a/traderai/tools.py b/traderai/tools.py index 3a80133..3111a05 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -12,12 +12,109 @@ from traderai.uex_client import UEXClient ToolHandler = Callable[..., Awaitable[dict[str, Any]]] +UEX_GET_RESOURCES: dict[str, dict[str, Any]] = { + "categories": {"params": ["type", "section"], "auth": False, "group": "reference"}, + "categories_attributes": {"params": ["id_category", "category_name", "category_type"], "auth": False, "group": "reference"}, + "cities": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, + "commodities": {"params": ["id", "name", "code", "slug"], "auth": False, "group": "trade"}, + "commodities_alerts": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"}, + "commodities_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"}, + "commodities_prices": { + "params": ["id_terminal", "id_commodity", "terminal_name", "terminal_code", "terminal_slug", "commodity_name", "commodity_code", "commodity_slug"], + "auth": False, + "group": "trade", + }, + "commodities_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True}, + "commodities_prices_history": {"params": ["id_commodity", "id_terminal", "commodity_name", "terminal_name"], "auth": False, "group": "trade"}, + "commodities_ranking": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"}, + "commodities_raw_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "mining"}, + "commodities_raw_prices": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"}, + "commodities_raw_prices_all": {"params": [], "auth": False, "group": "mining", "heavy": True}, + "commodities_routes": {"params": ["id_terminal_origin", "id_terminal_destination", "id_commodity", "terminal_origin_name", "terminal_destination_name", "commodity_name"], "auth": False, "group": "trade"}, + "commodities_status": {"params": [], "auth": False, "group": "trade"}, + "companies": {"params": ["id", "name", "code"], "auth": False, "group": "reference"}, + "contacts": {"params": ["id", "name"], "auth": False, "group": "reference"}, + "contracts": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"}, + "crew": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"}, + "currencies_index": {"params": ["code"], "auth": False, "group": "reference"}, + "currencies_index_history": {"params": ["code"], "auth": False, "group": "reference"}, + "data_extract": {"params": ["table"], "auth": False, "group": "data"}, + "data_parameters": {"params": ["endpoint"], "auth": False, "group": "data"}, + "factions": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"}, + "fleet": {"params": ["username"], "auth": False, "group": "vehicles"}, + "fuel_prices": {"params": ["id_terminal", "terminal_name", "terminal_code", "terminal_slug"], "auth": False, "group": "trade"}, + "fuel_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True}, + "game_versions": {"params": [], "auth": False, "group": "reference"}, + "items": {"params": ["id", "id_category", "name", "uuid", "slug"], "auth": False, "group": "items"}, + "items_attributes": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "items"}, + "items_prices": {"params": ["id_item", "id_terminal", "item_name", "terminal_name"], "auth": False, "group": "items"}, + "items_prices_all": {"params": [], "auth": False, "group": "items", "heavy": True}, + "jump_points": {"params": ["id", "name", "slug"], "auth": False, "group": "locations"}, + "jurisdictions": {"params": ["id", "name"], "auth": False, "group": "locations"}, + "marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, + "marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, + "marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"}, + "marketplace_listings": {"params": ["id", "slug", "username"], "auth": False, "group": "marketplace"}, + "marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"}, + "marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"}, + "marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, + "marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, + "marketplace_prices_history": {"params": ["id_item", "id_listing", "item_name"], "auth": False, "group": "marketplace"}, + "marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, + "moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, + "orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, + "orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"}, + "organizations": {"params": ["sid", "name"], "auth": False, "group": "reference"}, + "outposts": {"params": ["id", "id_moon", "id_planet", "name", "slug"], "auth": False, "group": "locations"}, + "planets": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, + "poi": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, + "refineries_audits": {"params": ["id_terminal", "terminal_name"], "auth": False, "group": "mining"}, + "refineries_capacities": {"params": ["id_terminal", "terminal_name"], "auth": False, "group": "mining"}, + "refineries_methods": {"params": ["id", "name"], "auth": False, "group": "mining"}, + "refineries_yields": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"}, + "release_notes": {"params": [], "auth": False, "group": "reference"}, + "space_stations": {"params": ["id", "id_star_system", "id_planet", "id_moon", "name", "slug"], "auth": False, "group": "locations"}, + "star_systems": {"params": ["id", "name", "code", "slug"], "auth": False, "group": "locations"}, + "terminals": {"params": ["id", "id_star_system", "name", "code", "slug"], "auth": False, "group": "locations"}, + "terminals_distances": {"params": ["id_terminal_origin", "id_terminal_destination"], "auth": False, "group": "locations"}, + "user": {"params": ["username"], "auth": False, "group": "user"}, + "user_notifications": {"params": [], "auth": True, "group": "user"}, + "user_refineries_jobs": {"params": ["id"], "auth": True, "group": "user"}, + "user_trades": {"params": ["id"], "auth": True, "group": "user"}, + "vehicles": {"params": ["id", "name", "slug", "uuid"], "auth": False, "group": "vehicles"}, + "vehicles_loaners": {"params": ["id_vehicle", "vehicle_name", "vehicle_slug"], "auth": False, "group": "vehicles"}, + "vehicles_prices": {"params": ["id_vehicle", "vehicle_name", "vehicle_slug"], "auth": False, "group": "vehicles"}, + "vehicles_purchases_prices": {"params": ["id_vehicle", "id_terminal", "vehicle_name", "terminal_name"], "auth": False, "group": "vehicles"}, + "vehicles_purchases_prices_all": {"params": [], "auth": False, "group": "vehicles", "heavy": True}, + "vehicles_rentals_prices": {"params": ["id_vehicle", "id_terminal", "vehicle_name", "terminal_name"], "auth": False, "group": "vehicles"}, + "vehicles_rentals_prices_all": {"params": [], "auth": False, "group": "vehicles", "heavy": True}, + "wallet_balance": {"params": [], "auth": True, "group": "user"}, +} + +UEX_POST_RESOURCES = { + "data_submit", + "marketplace_advertise", + "marketplace_negotiations_messages", + "user_refineries_jobs_add", + "user_trades_add", + "user_trades_edit", + "wallet_add", +} + +UEX_DELETE_RESOURCES = { + "marketplace_listings", + "user_refineries_jobs_remove", + "user_trades_remove", +} + + @dataclass class PendingAction: id: str label: str endpoint: str payload: dict[str, Any] + method: str = "POST" class ToolRegistry: @@ -46,10 +143,23 @@ class ToolRegistry: "list_wake_jobs": self.list_wake_jobs, "check_uex_notifications": self.check_uex_notifications, } + self.handlers["uex_api_catalog"] = self.uex_api_catalog + self.handlers["uex_get"] = self.uex_get + self.handlers["uex_draft_post"] = self.uex_draft_post + self.handlers["uex_draft_delete"] = self.uex_draft_delete + for resource in UEX_GET_RESOURCES: + self.handlers[self._get_tool_name(resource)] = self._make_get_handler(resource) + for resource in UEX_POST_RESOURCES: + self.handlers[self._post_tool_name(resource)] = self._make_post_handler(resource) + for resource in UEX_DELETE_RESOURCES: + self.handlers[self._delete_tool_name(resource)] = self._make_delete_handler(resource) @property def schemas(self) -> list[dict[str, Any]]: return [ + *self._uex_get_schemas(), + *self._uex_post_schemas(), + *self._uex_delete_schemas(), { "type": "function", "function": { @@ -239,6 +349,8 @@ class ToolRegistry: action = self.pending_actions.pop(action_id, None) if not action: return {"error": f"Pending action not found: {action_id}"} + if action.method == "DELETE": + return await self.uex.delete(action.endpoint, action.payload, authenticated=True) return await self.uex.post(action.endpoint, action.payload, authenticated=True) async def decline(self, action_id: str) -> dict[str, Any]: @@ -250,11 +362,232 @@ class ToolRegistry: "pending_action": { "id": action.id, "label": action.label, + "method": action.method, "endpoint": action.endpoint, "payload": action.payload, }, } + async def uex_api_catalog(self, group: str | None = None, resource: str | None = None) -> dict[str, Any]: + if resource: + key = self._validate_resource(resource, UEX_GET_RESOURCES) + info = UEX_GET_RESOURCES[key] + return { + "resource": key, + "method": "GET", + "group": info["group"], + "authenticated": info["auth"], + "heavy": bool(info.get("heavy")), + "params": info["params"], + "write_resources": { + "post": sorted(UEX_POST_RESOURCES), + "delete": sorted(UEX_DELETE_RESOURCES), + }, + } + + grouped: dict[str, list[dict[str, Any]]] = {} + for name, info in sorted(UEX_GET_RESOURCES.items()): + if group and info["group"] != group: + continue + grouped.setdefault(info["group"], []).append( + { + "resource": name, + "params": info["params"], + "auth": info["auth"], + "heavy": bool(info.get("heavy")), + } + ) + return { + "get": grouped, + "post": sorted(UEX_POST_RESOURCES), + "delete": sorted(UEX_DELETE_RESOURCES), + "usage": "Call uex_get(resource, params, fields, limit, mode). Use fields and limit to keep responses small.", + } + + async def uex_get( + self, + resource: str, + params: dict[str, Any] | None = None, + fields: list[str] | None = None, + search: str | None = None, + limit: int = 10, + offset: int = 0, + mode: str = "summary", + ) -> dict[str, Any]: + resource = self._validate_resource(resource, UEX_GET_RESOURCES) + info = UEX_GET_RESOURCES[resource] + cleaned_params = self._filter_params(params or {}, info["params"]) + response = await self.uex.get(resource, cleaned_params, authenticated=bool(info["auth"])) + data = response.get("data") + items = self._as_list(data) + total = len(items) + if search: + needle = search.casefold() + items = [item for item in items if needle in self._search_text(item)] + filtered_total = len(items) + offset = max(0, offset) + limit = max(1, min(limit, 100)) + window = items[offset : offset + limit] + compacted = [ + self._project_item(item, fields=fields, mode=mode) + for item in window + ] + return { + "status": response.get("status"), + "resource": resource, + "params": cleaned_params, + "total": total, + "matched": filtered_total, + "returned": len(compacted), + "offset": offset, + "truncated": offset + len(compacted) < filtered_total, + "items": compacted, + } + + async def uex_draft_post(self, resource: str, payload: dict[str, Any], label: str | None = None) -> dict[str, Any]: + resource = self._validate_resource(resource, UEX_POST_RESOURCES) + return self._pending(label or f"POST {resource}", resource, payload, method="POST") + + async def uex_draft_delete( + self, + resource: str, + params: dict[str, Any] | None = None, + label: str | None = None, + ) -> dict[str, Any]: + resource = self._validate_resource(resource, UEX_DELETE_RESOURCES) + return self._pending(label or f"DELETE {resource}", resource, params or {}, method="DELETE") + + def _make_get_handler(self, resource: str) -> ToolHandler: + async def handler(**arguments: Any) -> dict[str, Any]: + fields = arguments.pop("fields", None) + search = arguments.pop("search", None) + limit = arguments.pop("limit", 10) + offset = arguments.pop("offset", 0) + mode = arguments.pop("mode", "summary") + return await self.uex_get( + resource, + params=arguments, + fields=fields, + search=search, + limit=limit, + offset=offset, + mode=mode, + ) + + return handler + + def _make_post_handler(self, resource: str) -> ToolHandler: + async def handler(payload: dict[str, Any], label: str | None = None) -> dict[str, Any]: + return await self.uex_draft_post(resource, payload, label=label) + + return handler + + def _make_delete_handler(self, resource: str) -> ToolHandler: + async def handler(label: str | None = None, **params: Any) -> dict[str, Any]: + return await self.uex_draft_delete(resource, params, label=label) + + return handler + + @classmethod + def _uex_get_schemas(cls) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": cls._get_tool_name(resource), + "description": cls._get_tool_description(resource, info), + "parameters": cls._get_tool_parameters(info["params"]), + }, + } + for resource, info in sorted(UEX_GET_RESOURCES.items()) + ] + + @classmethod + def _uex_post_schemas(cls) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": cls._post_tool_name(resource), + "description": f"Draft UEX POST /{resource}/ for user approval. Nothing is sent until approval.", + "parameters": { + "type": "object", + "required": ["payload"], + "properties": { + "payload": {"type": "object", "description": f"JSON body for UEX POST /{resource}/."}, + "label": {"type": "string", "description": "Short approval label."}, + }, + }, + }, + } + for resource in sorted(UEX_POST_RESOURCES) + ] + + @classmethod + def _uex_delete_schemas(cls) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": cls._delete_tool_name(resource), + "description": f"Draft UEX DELETE /{resource}/ for user approval. Nothing is deleted until approval.", + "parameters": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "label": {"type": "string", "description": "Short approval label."}, + }, + }, + }, + } + for resource in sorted(UEX_DELETE_RESOURCES) + ] + + @classmethod + def _get_tool_parameters(cls, endpoint_params: list[str]) -> dict[str, Any]: + properties = { + param: cls._query_param_schema(param) + for param in endpoint_params + } + properties.update( + { + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Fields to keep in each result row.", + }, + "search": {"type": "string", "description": "Local text filter after UEX returns data."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 10}, + "offset": {"type": "integer", "minimum": 0, "default": 0}, + "mode": {"type": "string", "enum": ["summary", "full"], "default": "summary"}, + } + ) + return {"type": "object", "properties": properties} + + @staticmethod + def _query_param_schema(param: str) -> dict[str, Any]: + if param == "id" or param.startswith("id_"): + return {"type": "integer"} + return {"type": "string"} + + @staticmethod + def _get_tool_description(resource: str, info: dict[str, Any]) -> str: + auth = " Authenticated." if info["auth"] else "" + heavy = " Heavy endpoint; use fields and limit." if info.get("heavy") else "" + return f"GET UEX /{resource}/ with compact, token-limited results.{auth}{heavy}" + + @staticmethod + def _get_tool_name(resource: str) -> str: + return f"get_uex_{resource}" + + @staticmethod + def _post_tool_name(resource: str) -> str: + return f"draft_uex_{resource}" + + @staticmethod + def _delete_tool_name(resource: str) -> str: + return f"delete_uex_{resource}" + async def search_marketplace_listings( self, query: str | None = None, @@ -353,20 +686,112 @@ class ToolRegistry: pending = [item for item in notifications if not item.get("date_read")] return {"count": len(pending), "notifications": pending} - def _pending(self, label: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]: action_id = str(uuid.uuid4()) payload = {key: value for key, value in payload.items() if value is not None} - self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload) + self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload, method) return { "pending_action": { "id": action_id, "label": label, + "method": method, "endpoint": endpoint, "payload": payload, "approval_required": self.require_write_approval, } } + @staticmethod + def _validate_resource(resource: str, allowed: dict[str, Any] | set[str]) -> str: + normalized = resource.strip().strip("/").casefold() + if normalized not in allowed: + choices = sorted(allowed.keys() if isinstance(allowed, dict) else allowed) + near = [name for name in choices if normalized in name or name in normalized][:8] + hint = f" Did you mean: {', '.join(near)}?" if near else "" + raise ValueError(f"Unsupported UEX resource: {resource}.{hint}") + return normalized + + @staticmethod + def _filter_params(params: dict[str, Any], allowed_params: list[str]) -> dict[str, Any]: + if not allowed_params: + return {key: value for key, value in params.items() if value is not None} + allowed = set(allowed_params) + return {key: value for key, value in params.items() if key in allowed and value is not None} + + @staticmethod + def _as_list(data: Any) -> list[Any]: + if data is None: + return [] + if isinstance(data, list): + return data + return [data] + + @classmethod + def _project_item(cls, item: Any, fields: list[str] | None = None, mode: str = "summary") -> Any: + if not isinstance(item, dict): + return item + if fields: + return {field: cls._compact_scalar(item.get(field)) for field in fields if field in item} + if mode == "full": + return {key: cls._compact_scalar(value) for key, value in item.items()} + + priority = [ + "id", + "uuid", + "code", + "slug", + "name", + "title", + "type", + "section", + "operation", + "price", + "currency", + "unit", + "location", + "terminal_name", + "commodity_name", + "item_name", + "vehicle_name", + "price_buy", + "price_sell", + "scu_buy", + "scu_sell", + "scu_sell_stock", + "status_buy", + "status_sell", + "date_modified", + "date_added", + ] + selected: dict[str, Any] = {} + for key in priority: + if key in item and item[key] not in (None, ""): + selected[key] = cls._compact_scalar(item[key]) + for key, value in item.items(): + if len(selected) >= 16: + break + if key in selected or value in (None, ""): + continue + if isinstance(value, (str, int, float, bool)): + selected[key] = cls._compact_scalar(value) + return selected + + @staticmethod + def _compact_scalar(value: Any) -> Any: + if isinstance(value, str) and len(value) > 240: + return value[:237] + "..." + if isinstance(value, list): + return value[:5] + if isinstance(value, dict): + return {key: nested_value for key, nested_value in list(value.items())[:12]} + return value + + @classmethod + def _search_text(cls, item: Any) -> str: + if isinstance(item, dict): + return " ".join(str(value) for value in item.values() if isinstance(value, (str, int, float))).casefold() + return str(item).casefold() + @staticmethod def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]: return { diff --git a/traderai/uex_client.py b/traderai/uex_client.py index dfc261f..4912577 100644 --- a/traderai/uex_client.py +++ b/traderai/uex_client.py @@ -58,6 +58,15 @@ class UEXClient: ) return self._handle_response(response) + async def delete(self, path: str, params: dict[str, Any] | None = None, authenticated: bool = True) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.delete( + f"{self.base_url}/{path.strip('/')}/", + params={k: v for k, v in (params or {}).items() if v is not None}, + headers=self._headers(authenticated), + ) + return self._handle_response(response) + @staticmethod def _handle_response(response: httpx.Response) -> dict[str, Any]: try: diff --git a/web/app.js b/web/app.js index ba9d0e0..32c69aa 100644 --- a/web/app.js +++ b/web/app.js @@ -27,6 +27,7 @@ function setMessageMarkdown(node, text) { function setMessageActivity(node, text, active = false) { const activity = node.querySelector(".message-activity"); if (!activity) return; + if (text) appendThinkingStep(node, reasoningSummaryForStatus(text), { fallback: true }); const phase = activity.querySelector(".message-phase"); phase.innerHTML = ""; if (text) { @@ -48,6 +49,122 @@ function setMessageMetrics(node, metrics) { metricsEl.textContent = metrics || ""; } +function appendThinkingStep(node, text, options = {}) { + const steps = node.querySelector(".thinking-steps"); + if (!steps || !text) return; + const previous = steps.lastElementChild?.textContent; + if (previous === text) return; + const item = document.createElement("li"); + if (options.fallback) item.dataset.fallback = "true"; + item.textContent = text; + steps.appendChild(item); +} + +function appendThinkingText(node, text) { + const steps = node.querySelector(".thinking-steps"); + if (!steps || !text) return; + node.querySelectorAll(".thinking-steps [data-fallback='true']").forEach((item) => item.remove()); + node.dataset.hasModelThinking = "true"; + let item = steps.querySelector(".thinking-raw-step"); + if (!item) { + item = document.createElement("li"); + item.className = "thinking-raw-step"; + steps.appendChild(item); + } + item.textContent += text; +} + +function createThinkTagParser(node) { + let buffer = ""; + let inThinking = false; + + const partialTagLength = (text) => { + const lower = text.toLowerCase(); + const tags = ["", ""]; + for (const tag of tags) { + for (let length = tag.length - 1; length > 0; length -= 1) { + if (lower.endsWith(tag.slice(0, length))) return length; + } + } + return 0; + }; + + const consume = (content, flush = false) => { + buffer += content; + let visible = ""; + + while (buffer) { + const lower = buffer.toLowerCase(); + if (inThinking) { + const closeIndex = lower.indexOf(""); + if (closeIndex === -1) { + if (flush) { + appendThinkingText(node, buffer); + buffer = ""; + } else { + const keep = partialTagLength(buffer); + appendThinkingText(node, buffer.slice(0, buffer.length - keep)); + buffer = buffer.slice(buffer.length - keep); + } + break; + } + appendThinkingText(node, buffer.slice(0, closeIndex)); + buffer = buffer.slice(closeIndex + "".length); + inThinking = false; + continue; + } + + const openIndex = lower.indexOf(""); + if (openIndex === -1) { + const keep = flush ? 0 : partialTagLength(buffer); + visible += buffer.slice(0, buffer.length - keep); + buffer = buffer.slice(buffer.length - keep); + break; + } + + visible += buffer.slice(0, openIndex); + buffer = buffer.slice(openIndex + "".length); + inThinking = true; + } + + return visible; + }; + + return { + consume, + flush: () => consume("", true), + }; +} + +function reasoningSummaryForStatus(text) { + const summaries = { + Thinking: "Reading your request and deciding whether I need current UEX data, memory, or a draft action before answering.", + "Searching UEX listings": "Checking current UEX marketplace listings so the answer is grounded in live item data instead of stale memory.", + "Fetching listing details": "Opening the specific listing details to avoid guessing about price, seller, quantity, or status.", + "Checking negotiations": "Looking through active negotiations because replies and offers can change what the next move should be.", + "Reading negotiation messages": "Reading the negotiation thread so any drafted reply matches the actual conversation.", + "Drafting message for approval": "Preparing the exact message as a pending action because marketplace writes need your approval first.", + "Drafting listing for approval": "Preparing the listing payload as a pending action so you can review it before anything is posted.", + "Checking UEX notifications": "Checking notifications for fresh replies or alerts that could change the recommendation.", + "Writing response": "Turning the gathered context into a concise response with the relevant details and next action.", + }; + if (summaries[text]) return summaries[text]; + if (text.startsWith("Running ")) { + return `Using ${text.replace(/^Running\s+/, "")} to gather the missing context before answering.`; + } + return text; +} + +function finishThinking(node) { + const thinking = node.querySelector(".thinking-log"); + const label = node.querySelector(".thinking-summary-label"); + if (!thinking || !label) return; + const startedAt = Number(thinking.dataset.startedAt || Date.now()); + const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + label.textContent = `Thought for ${elapsedSeconds}s`; + thinking.classList.remove("thinking-active"); +} + function ensureStreamingChrome(node) { if (node.querySelector(".message-activity")) return; node.innerHTML = ""; @@ -57,10 +174,22 @@ function ensureStreamingChrome(node) { phase.className = "message-phase"; const metrics = document.createElement("span"); metrics.className = "message-metrics"; + const thinking = document.createElement("details"); + thinking.className = "thinking-log"; + thinking.classList.add("thinking-active"); + thinking.dataset.startedAt = String(Date.now()); + const thinkingSummary = document.createElement("summary"); + const thinkingLabel = document.createElement("span"); + thinkingLabel.className = "thinking-summary-label"; + thinkingLabel.textContent = "Thinking..."; + const thinkingSteps = document.createElement("ol"); + thinkingSteps.className = "thinking-steps"; const body = document.createElement("div"); body.className = "message-body"; activity.append(phase, metrics); - node.append(activity, body); + thinkingSummary.appendChild(thinkingLabel); + thinking.append(thinkingSummary, thinkingSteps); + node.append(activity, thinking, body); } function renderMarkdown(text) { @@ -464,6 +593,7 @@ async function sendMessage() { const assistantNode = addMessage("assistant streaming", ""); ensureStreamingChrome(assistantNode); let assistantText = ""; + const thinkParser = createThinkTagParser(assistantNode); statusEl.textContent = "Working"; setMessageActivity(assistantNode, "Thinking", true); setMessageMetrics(assistantNode, ""); @@ -498,10 +628,18 @@ async function sendMessage() { assistantText += event.message; setMessageMarkdown(assistantNode, assistantText); } else if (event.type === "token") { - assistantText += event.content; - setMessageMarkdown(assistantNode, assistantText); + const visibleContent = thinkParser.consume(event.content); + if (visibleContent) { + assistantText += visibleContent; + setMessageMarkdown(assistantNode, assistantText); + } messages.scrollTop = messages.scrollHeight; } else if (event.type === "done") { + const visibleContent = thinkParser.flush(); + if (visibleContent) { + assistantText += visibleContent; + setMessageMarkdown(assistantNode, assistantText); + } renderPending(event.pending_actions || []); } } @@ -517,6 +655,7 @@ async function sendMessage() { input.disabled = false; input.focus(); statusEl.textContent = "Ready"; + finishThinking(assistantNode); setMessageActivity(assistantNode, ""); } } diff --git a/web/art/LBC_Logo.png b/web/art/LBC_Logo.png new file mode 100644 index 0000000..fa9b70f Binary files /dev/null and b/web/art/LBC_Logo.png differ diff --git a/web/index.html b/web/index.html index 91caa94..f4b30a3 100644 --- a/web/index.html +++ b/web/index.html @@ -10,9 +10,15 @@
-
-

TraderAI

-

Local Ollama chat for UEX marketplace work

+
+ +
+

Lambda Banking Conglomerate

+

TraderAI

+

Institutional marketplace intelligence for UEX operations

+
Ready
diff --git a/web/styles.css b/web/styles.css index 1725c74..2fd3f82 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,54 +1,132 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Playfair+Display:wght@600;700;800&display=swap"); + :root { - color-scheme: dark; - --bg: #111316; - --panel: #191d22; - --panel-2: #20262d; - --text: #edf1f5; - --muted: #97a1ad; - --accent: #44c2a5; - --accent-2: #e6b94d; - --border: #303842; - --danger: #e66b6b; + color-scheme: light; + --cream: #f7f1dc; + --ivory: #fffaf0; + --ivory-2: #fbf4df; + --forest: #263a1b; + --forest-2: #345326; + --forest-3: #14210f; + --brown: #2e1c18; + --muted: #6f5b50; + --gold: #d4af37; + --gold-2: #f0d681; + --line: #ddceb0; + --line-strong: #c4aa73; + --danger: #9f3c32; + --shadow: 0 24px 70px rgba(11, 19, 8, 0.34); } * { box-sizing: border-box; } +html, +body { + height: 100%; +} + body { margin: 0; - min-height: 100vh; - background: var(--bg); - color: var(--text); - font-family: Inter, Segoe UI, Arial, sans-serif; + height: 100vh; + overflow: hidden; + background: + radial-gradient(circle at 18% 10%, rgba(240, 214, 129, 0.24), transparent 30%), + radial-gradient(circle at 85% 16%, rgba(255, 250, 240, 0.12), transparent 28%), + linear-gradient(135deg, #14210f 0%, #263a1b 42%, #345326 100%); + color: var(--brown); + font-family: Inter, "Segoe UI", Arial, sans-serif; + text-rendering: geometricPrecision; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(115deg, rgba(255, 255, 255, 0.05), transparent 38%, rgba(0, 0, 0, 0.08)); } .shell { display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 1px; - min-height: 100vh; - background: var(--border); + grid-template-columns: minmax(0, 1fr) 380px; + gap: 24px; + height: 100vh; + min-height: 0; + overflow: hidden; + padding: 24px; } .workspace, .actions { - background: var(--panel); + position: relative; + overflow: hidden; + border: 1px solid rgba(240, 214, 129, 0.34); + border-radius: 28px; + background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%); + box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.76); } .workspace { display: grid; - grid-template-rows: auto 1fr auto; + grid-template-rows: auto auto 1fr auto; min-width: 0; + min-height: 0; +} + +.actions { + padding: 28px; + overflow: auto; + min-height: 0; } .topbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 20px; + padding: 26px 28px; + border-bottom: 1px solid rgba(240, 214, 129, 0.28); + background: + linear-gradient(90deg, rgba(20, 33, 15, 0.96), rgba(38, 58, 27, 0.94)), + radial-gradient(circle at 18% 0%, rgba(212, 175, 55, 0.24), transparent 34%); +} + +.brand-block { display: flex; align-items: center; - justify-content: space-between; - gap: 16px; - padding: 18px 22px; - border-bottom: 1px solid var(--border); + gap: 18px; + min-width: 0; +} + +.brand-copy { + display: grid; + align-items: center; + gap: 5px; + min-width: 0; +} + +.logo-wrap { + position: relative; + display: grid; + width: 72px; + height: 72px; + flex: 0 0 72px; + place-items: center; + border: 1px solid rgba(240, 214, 129, 0.46); + border-radius: 22px; + background: + radial-gradient(circle at 35% 25%, rgba(240, 214, 129, 0.34), transparent 44%), + linear-gradient(145deg, rgba(255, 250, 240, 0.12), rgba(0, 0, 0, 0.14)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24), 0 14px 32px rgba(0, 0, 0, 0.2); +} + +.logo-wrap img { + width: 56px; + height: 56px; + object-fit: contain; + filter: drop-shadow(0 2px 7px rgba(0, 0, 0, 0.28)); } h1, @@ -57,52 +135,91 @@ p { margin: 0; } +h1, +h2, +.eyebrow, +button, +.status { + font-family: "Playfair Display", Georgia, serif; +} + h1 { - font-size: 22px; - font-weight: 700; + color: var(--ivory); + font-size: 36px; + font-weight: 800; + line-height: 1.02; + text-shadow: 0 2px 22px rgba(0, 0, 0, 0.38); } h2 { - font-size: 16px; margin-bottom: 14px; + color: var(--brown); + font-size: 24px; + font-weight: 800; + line-height: 1.15; +} + +.eyebrow { + color: var(--gold-2); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + white-space: nowrap; +} + +.topbar p { + max-width: 580px; + color: rgba(255, 250, 240, 0.82); + font-size: 14px; + font-weight: 500; } -.topbar p, .muted { color: var(--muted); - font-size: 13px; + font-size: 14px; } .status { - min-width: 76px; - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: 6px; - color: var(--muted); - font-size: 13px; + min-width: 92px; + padding: 10px 14px; + border: 1px solid rgba(240, 214, 129, 0.42); + border-radius: 999px; + background: rgba(255, 250, 240, 0.1); + color: var(--gold-2); + font-size: 14px; + font-weight: 800; text-align: center; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); } .warning { - padding: 10px 22px; - border-bottom: 1px solid rgba(230, 185, 77, 0.32); - background: rgba(230, 185, 77, 0.11); - color: #f0d28a; - font-size: 13px; + padding: 12px 28px; + border-bottom: 1px solid rgba(196, 170, 115, 0.38); + background: #f2e7bd; + color: #553906; + font-size: 14px; + font-weight: 600; } .messages { overflow: auto; - padding: 22px; + min-height: 0; + padding: 28px; + background: + radial-gradient(circle at 50% 12%, rgba(212, 175, 55, 0.1), transparent 34%), + linear-gradient(180deg, rgba(247, 241, 220, 0.58), rgba(255, 250, 240, 0.64)); } .message { - max-width: 900px; - margin-bottom: 14px; - padding: 13px 14px; - border: 1px solid var(--border); - border-radius: 8px; - line-height: 1.45; + max-width: 920px; + margin-bottom: 16px; + padding: 17px 18px; + border: 1px solid rgba(221, 206, 176, 0.9); + border-radius: 18px; + color: var(--brown); + line-height: 1.55; + box-shadow: 0 16px 38px rgba(38, 58, 27, 0.11); } .message p { @@ -120,52 +237,57 @@ h2 { .message ul, .message ol { - margin: 0 0 10px 20px; + margin: 0 0 10px 22px; padding: 0; } .message li { - margin: 3px 0; + margin: 4px 0; } .message li.nested { margin-left: 20px; - color: #d7dde4; + color: var(--muted); } .message strong { - color: #ffffff; + color: var(--forest); + font-weight: 800; } .message h3 { - margin: 12px 0 8px; - font-size: 15px; - line-height: 1.3; + margin: 14px 0 8px; + color: var(--forest); + font-family: "Playfair Display", Georgia, serif; + font-size: 19px; + line-height: 1.25; } .message h4, .message h5, .message h6 { - margin: 10px 0 7px; - font-size: 14px; + margin: 11px 0 7px; + color: var(--forest); + font-family: "Playfair Display", Georgia, serif; + font-size: 16px; line-height: 1.3; } .message hr { height: 1px; - margin: 12px 0; + margin: 14px 0; border: 0; - background: var(--border); + background: linear-gradient(90deg, transparent, rgba(196, 170, 115, 0.72), transparent); } .message code { - padding: 1px 5px; - border: 1px solid var(--border); - border-radius: 4px; - background: #0e1114; - color: #dfe5eb; + padding: 2px 6px; + border: 1px solid rgba(38, 58, 27, 0.15); + border-radius: 7px; + background: rgba(38, 58, 27, 0.08); + color: var(--forest); font-family: Consolas, Monaco, monospace; - font-size: 0.92em; + font-size: 0.9em; } .message pre { @@ -179,18 +301,21 @@ h2 { padding: 0; border: 0; background: transparent; + color: inherit; } .message blockquote { margin: 0 0 10px; - padding: 8px 10px; - border-left: 3px solid var(--accent); - background: rgba(68, 194, 165, 0.08); - color: #d7dde4; + padding: 11px 13px; + border-left: 4px solid var(--gold); + border-radius: 0 12px 12px 0; + background: rgba(212, 175, 55, 0.13); + color: #4b352c; } .message a { - color: #69d7bd; + color: var(--forest-2); + font-weight: 700; } .table-wrap { @@ -203,63 +328,147 @@ h2 { width: 100%; min-width: 420px; border-collapse: collapse; - font-size: 13px; + font-size: 14px; } .message th, .message td { - padding: 8px 9px; - border: 1px solid var(--border); + padding: 9px 10px; + border: 1px solid var(--line); vertical-align: top; } .message th { - background: #20262d; - color: #ffffff; - font-weight: 700; + background: rgba(38, 58, 27, 0.11); + color: var(--forest); + font-weight: 800; } .message.user { margin-left: auto; - background: var(--panel-2); + border-color: rgba(52, 83, 38, 0.28); + background: linear-gradient(180deg, #edf3df, #e5efd4); } .message.assistant { - background: #15191e; + background: rgba(255, 250, 240, 0.96); } .message.warning-message { - border-color: rgba(230, 185, 77, 0.42); - background: rgba(230, 185, 77, 0.1); + border-color: rgba(212, 175, 55, 0.6); + background: #f5eac4; } .composer-wrap { - border-top: 1px solid var(--border); + border-top: 1px solid var(--line); + background: rgba(255, 250, 240, 0.88); } .message-activity { - display: flex; + display: grid; + grid-template-columns: minmax(90px, 1fr) auto minmax(90px, 1fr); align-items: center; - justify-content: space-between; gap: 12px; - min-height: 20px; - margin-bottom: 6px; - color: rgba(151, 161, 173, 0.82); + min-height: 32px; + margin: -4px 0 9px; + padding: 5px 8px; + border: 1px solid rgba(221, 206, 176, 0.68); + border-radius: 12px; + background: rgba(247, 241, 220, 0.52); + color: var(--muted); font-size: 12px; + font-weight: 600; } .message-phase { display: inline-flex; align-items: center; + justify-content: center; + grid-column: 2; min-width: 0; + line-height: 1; + text-align: center; } .message-metrics { - color: rgba(151, 161, 173, 0.56); + grid-column: 3; + justify-self: end; + color: #8b7769; text-align: right; white-space: nowrap; } +.thinking-log { + margin: 0 0 10px; + color: var(--muted); + font-size: 13px; +} + +.thinking-log summary { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 28px; + padding: 3px 2px; + color: #7b6b60; + cursor: pointer; + font-weight: 700; + list-style: none; +} + +.thinking-log summary::-webkit-details-marker { + display: none; +} + +.thinking-log summary::before { + content: ""; + width: 7px; + height: 7px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg); + transition: transform 160ms ease; +} + +.thinking-log[open] summary::before { + transform: rotate(45deg) translateY(-1px); +} + +.thinking-log.thinking-active summary { + color: var(--forest); +} + +.thinking-steps { + max-width: 720px; + margin: 3px 0 2px 16px; + padding: 2px 0 2px 16px; + border-left: 2px solid rgba(111, 91, 80, 0.2); + color: #7b6b60; + list-style: none; +} + +.thinking-steps li { + position: relative; + margin: 6px 0; + line-height: 1.45; +} + +.thinking-raw-step { + white-space: pre-wrap; +} + +.thinking-steps li::before { + content: ""; + position: absolute; + top: 0.72em; + left: -21px; + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(111, 91, 80, 0.42); + transform: translateY(-50%); +} + .working-dots { display: inline-flex; align-items: center; @@ -269,10 +478,10 @@ h2 { } .working-dots i { - width: 4px; - height: 4px; + width: 5px; + height: 5px; border-radius: 50%; - background: rgba(151, 161, 173, 0.8); + background: var(--gold); animation: pulse-dot 1.2s infinite ease-in-out; } @@ -288,7 +497,7 @@ h2 { 0%, 80%, 100% { - opacity: 0.3; + opacity: 0.35; transform: translateY(0); } 40% { @@ -300,69 +509,100 @@ h2 { .composer { display: grid; grid-template-columns: 1fr auto; - gap: 12px; - padding: 16px; + gap: 14px; + padding: 20px; } textarea { width: 100%; - resize: vertical; - min-height: 48px; + min-height: 58px; max-height: 180px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; + padding: 16px 17px; + resize: vertical; + border: 1px solid var(--line-strong); + border-radius: 18px; outline: none; - background: #101316; - color: var(--text); + background: #fffdf7; + color: var(--brown); font: inherit; + font-size: 15px; + line-height: 1.45; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 10px 28px rgba(38, 58, 27, 0.08); +} + +textarea::placeholder { + color: #88776c; +} + +textarea:focus { + border-color: var(--gold); + box-shadow: 0 0 0 4px rgba(212, 175, 55, 0.22), 0 10px 28px rgba(38, 58, 27, 0.09); } textarea:disabled { - opacity: 0.62; + opacity: 0.66; } button { - min-width: 92px; + min-width: 104px; border: 0; - border-radius: 8px; - background: var(--accent); - color: #07110f; - font-weight: 700; + border-radius: 18px; + background: linear-gradient(180deg, #345326, #1f3416); + color: var(--ivory); + font-weight: 800; cursor: pointer; + box-shadow: 0 16px 28px rgba(31, 52, 22, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.14); + transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease; +} + +button:hover { + background: linear-gradient(180deg, #3d612c, #263e1b); + box-shadow: 0 18px 34px rgba(31, 52, 22, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.16); + transform: translateY(-1px); +} + +button:active { + transform: translateY(0); } button.secondary { min-width: 0; - padding: 9px 12px; - border: 1px solid var(--border); - background: transparent; - color: var(--text); + padding: 10px 14px; + border: 1px solid var(--line-strong); + background: #fff9e9; + color: var(--forest); + box-shadow: 0 10px 22px rgba(38, 58, 27, 0.08); } .small-button { min-width: 0; - padding: 6px 9px; - font-size: 12px; + padding: 8px 12px; + font-size: 13px; } .danger-button { width: 100%; min-width: 0; - padding: 9px 10px; - margin: 10px 0; - border: 1px solid rgba(230, 107, 107, 0.45); - background: rgba(230, 107, 107, 0.14); - color: #f0b1b1; + padding: 12px 14px; + margin: 14px 0; + border: 1px solid rgba(159, 60, 50, 0.28); + background: linear-gradient(180deg, #fff5e9, #f5e6d4); + color: var(--danger); + box-shadow: 0 12px 25px rgba(88, 43, 33, 0.1); } -.actions { - padding: 18px; - overflow: auto; +.danger-button:hover { + background: linear-gradient(180deg, #fff2e5, #edd8c6); + color: #7f2d25; } .side-section { - margin-bottom: 22px; + margin-bottom: 28px; +} + +.side-section + .side-section { + padding-top: 24px; + border-top: 1px solid var(--line); } .section-title-row { @@ -375,116 +615,190 @@ button.secondary { .memory-controls { display: grid; grid-template-columns: 1fr 1fr; - gap: 6px 10px; + gap: 11px 14px; color: var(--muted); - font-size: 12px; + font-size: 14px; + font-weight: 600; } .memory-controls label { display: flex; align-items: center; - gap: 6px; + gap: 8px; +} + +input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: var(--forest); } .memory-inspector { - font-size: 12px; + font-size: 13px; + line-height: 1.35; } .memory-counts { - color: var(--text); - margin-bottom: 6px; + margin-bottom: 7px; + color: var(--brown); + font-weight: 800; } .memory-path { + margin-bottom: 12px; overflow-wrap: anywhere; color: var(--muted); - margin-bottom: 10px; } .memory-group { - margin-bottom: 8px; - border: 1px solid var(--border); - border-radius: 8px; - background: #15191e; + margin-bottom: 10px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 250, 240, 0.76); } .memory-group summary { + padding: 11px 13px; + color: var(--forest); cursor: pointer; - padding: 9px 10px; - color: var(--text); + font-weight: 800; } .memory-group pre { - margin: 0 10px 10px; + margin: 0 12px 12px; white-space: pre-wrap; } .pending-empty { color: var(--muted); - font-size: 13px; + font-size: 15px; + font-weight: 500; } .pending-card { - padding: 12px; - margin-bottom: 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: #15191e; + padding: 15px; + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 250, 240, 0.82); + box-shadow: 0 14px 32px rgba(38, 58, 27, 0.1); } .pending-card strong { display: block; margin-bottom: 8px; + color: var(--forest); + font-family: "Playfair Display", Georgia, serif; + font-size: 17px; } pre { - overflow: auto; max-height: 240px; - padding: 10px; - border-radius: 6px; - background: #0e1114; - color: #dfe5eb; + padding: 12px; + overflow: auto; + border: 1px solid rgba(38, 58, 27, 0.14); + border-radius: 14px; + background: #efe6ce; + color: #3c2923; font-size: 12px; } .pending-controls { display: grid; grid-template-columns: 1fr 1fr; - gap: 8px; + gap: 10px; } .pending-card button { width: 100%; - padding: 10px; + padding: 11px; } .decline-button { - border: 1px solid var(--border); - background: transparent; + border: 1px solid var(--line-strong); + background: #fff9e9; color: var(--muted); + box-shadow: none; } -@media (max-width: 860px) { +.decline-button:hover { + background: #f5ead4; + color: var(--brown); +} + +@media (max-width: 960px) { .shell { grid-template-columns: 1fr; - } - - .actions { - border-top: 1px solid var(--border); + grid-template-rows: minmax(0, 1fr) minmax(220px, 34vh); } } -@media (max-width: 560px) { +@media (max-width: 620px) { + .shell { + gap: 14px; + padding: 10px; + } + + .workspace, + .actions { + border-radius: 22px; + } + + .topbar { + align-items: flex-start; + grid-template-columns: 1fr; + padding: 22px; + } + + .brand-block { + align-items: flex-start; + } + + .logo-wrap { + width: 58px; + height: 58px; + flex-basis: 58px; + border-radius: 18px; + } + + .logo-wrap img { + width: 45px; + height: 45px; + } + + h1 { + font-size: 31px; + } + + .eyebrow { + font-size: 10px; + letter-spacing: 0.08em; + } + + .messages, + .actions { + padding: 22px; + } + .composer { grid-template-columns: 1fr; + padding: 18px; } .composer button { width: 100%; - min-height: 42px; + min-height: 48px; } .message-metrics { display: none; } + + .message-activity { + grid-template-columns: 1fr; + } + + .message-phase { + grid-column: 1; + } }