Spaces:
Running
Running
Edge AI commited on
Commit ·
a71d403
1
Parent(s): da39e3a
Add sandbox manager, multi-layer throttle, monitoring, external testing, cyberpunk UI
Browse files- README.md +71 -18
- __pycache__/agent_system.cpython-312.pyc +0 -0
- __pycache__/app.cpython-312.pyc +0 -0
- __pycache__/router.cpython-312.pyc +0 -0
- __pycache__/sandbox_manager.cpython-312.pyc +0 -0
- __pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc +0 -0
- __pycache__/test_integration.cpython-312-pytest-8.4.2.pyc +0 -0
- __pycache__/test_router.cpython-312-pytest-8.4.2.pyc +0 -0
- __pycache__/worker.cpython-312.pyc +0 -0
- agent_system.py +5 -1
- app.py +393 -34
- router.py +264 -25
- sandbox_manager.py +291 -0
- templates/index.html +578 -503
- test_integration.py +299 -0
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: Altamira
|
| 3 |
emoji: 🧠
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
|
@@ -7,16 +7,31 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# Altamira
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
##
|
| 15 |
|
| 16 |
-
- **
|
| 17 |
-
- **
|
| 18 |
-
- **
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
## API Endpoints
|
| 22 |
|
|
@@ -24,18 +39,38 @@ Multi-agent orchestration platform running on Hugging Face Spaces.
|
|
| 24 |
|----------|--------|-------------|
|
| 25 |
| `/` | GET | Web UI |
|
| 26 |
| `/health` | GET | Health check |
|
|
|
|
| 27 |
| `/api/projects` | GET/POST | List/create projects |
|
| 28 |
-
| `/api/projects/{name}/
|
| 29 |
-
| `/api/
|
| 30 |
-
| `/api/
|
| 31 |
-
| `/api/
|
| 32 |
-
| `/api/
|
| 33 |
-
| `/api/resources` | GET |
|
| 34 |
-
| `/api/resources/{key}` |
|
| 35 |
-
| `/api/
|
| 36 |
-
| `/api/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
| `/api/router` | GET | Router/circuit-breaker status |
|
| 38 |
-
| `/api/
|
|
|
|
|
|
|
|
|
|
| 39 |
| `/worker/*` | - | Worker sub-app |
|
| 40 |
|
| 41 |
## LLM Providers (optional)
|
|
@@ -47,3 +82,21 @@ Set via the Resource Vault in the UI:
|
|
| 47 |
- `GEMINI_API_KEY` — Google Gemini (gemini-2.0-flash)
|
| 48 |
|
| 49 |
Without any keys, chat falls back to running shell commands in the project workspace.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Altamira AADE
|
| 3 |
emoji: 🧠
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Altamira AADE
|
| 11 |
|
| 12 |
+
Autonomous AI Development Environment — multi-agent orchestration system running on Hugging Face Spaces.
|
| 13 |
|
| 14 |
+
## Architecture
|
| 15 |
|
| 16 |
+
- **Main Orchestrator Space** — coordinates agents, manages projects, resource vault, sandbox lifecycle
|
| 17 |
+
- **Sandbox Spaces** — dynamically created HF Spaces per project for isolated code execution
|
| 18 |
+
- **Agent System** — Commander, Reviewer, Executor, Monitor roles with DAG-based task scheduling
|
| 19 |
+
|
| 20 |
+
## Features
|
| 21 |
+
|
| 22 |
+
| Feature | Description |
|
| 23 |
+
|---------|-------------|
|
| 24 |
+
| Multi-Agent Orchestration | Commander plans, Reviewer gates, Executor applies patches |
|
| 25 |
+
| DAG Scheduler | Topological sort, cycle detection, priority execution |
|
| 26 |
+
| Dynamic Sandboxes | Create HF Spaces per project for isolated dev environments |
|
| 27 |
+
| Resource Vault | Store API keys with per-project binding, ping verification, shareable labels |
|
| 28 |
+
| Multi-Layer Throttling | Layer 1: decentralized self-throttle, Layer 2: centralized queue, Layer 3: provider failover with session reinjection |
|
| 29 |
+
| LLM Provider Chain | NVIDIA NIM → OpenRouter → Gemini with circuit breaker |
|
| 30 |
+
| External Testing | Webhook tunnel (A), User download (B), GitHub Actions (C) |
|
| 31 |
+
| Monitoring | Manifest polling (`altamira-manifest.json`), heartbeat, real-time per-project |
|
| 32 |
+
| Aperture Navigation | Circular hover → horizontal grid → vertical icon list |
|
| 33 |
+
| OAuth Auth | Hugging Face OAuth + manual token login |
|
| 34 |
+
| Encrypted State | Fernet-encrypted file-based state with HF Datasets sync |
|
| 35 |
|
| 36 |
## API Endpoints
|
| 37 |
|
|
|
|
| 39 |
|----------|--------|-------------|
|
| 40 |
| `/` | GET | Web UI |
|
| 41 |
| `/health` | GET | Health check |
|
| 42 |
+
| `/api/auth/*` | GET/POST | Auth (OAuth login, callback, session) |
|
| 43 |
| `/api/projects` | GET/POST | List/create projects |
|
| 44 |
+
| `/api/projects/{name}/*` | POST/DELETE | Activate, git-sync, delete |
|
| 45 |
+
| `/api/agent/submit` | POST | Submit prompt to agent |
|
| 46 |
+
| `/api/agent/cycle` | POST | Trigger agent cycle |
|
| 47 |
+
| `/api/agent/tasks` | GET | List agent tasks |
|
| 48 |
+
| `/api/agent/status` | GET | Agent system status |
|
| 49 |
+
| `/api/resources` | GET/PUT/DELETE | Resource vault CRUD |
|
| 50 |
+
| `/api/resources/{key}/ping` | POST | Verify resource connectivity |
|
| 51 |
+
| `/api/resources/bind/{project}` | POST | Bind resource to project |
|
| 52 |
+
| `/api/resources/unbind/{project}` | POST | Unbind resource from project |
|
| 53 |
+
| `/api/resources/projects` | GET | List all project bindings |
|
| 54 |
+
| `/api/sandboxes` | GET | List sandbox spaces |
|
| 55 |
+
| `/api/sandboxes/create` | POST | Create sandbox HF Space |
|
| 56 |
+
| `/api/sandboxes/{project}/exec` | POST | Execute command in sandbox |
|
| 57 |
+
| `/api/sandboxes/{project}/write` | POST | Write file to sandbox |
|
| 58 |
+
| `/api/sandboxes/{project}/read` | POST | Read file from sandbox |
|
| 59 |
+
| `/api/sandboxes/{project}/files` | GET | List sandbox files |
|
| 60 |
+
| `/api/sandboxes/{project}` | DELETE | Delete sandbox space |
|
| 61 |
+
| `/api/monitor/manifest` | GET/POST | Manifest polling |
|
| 62 |
+
| `/api/monitor/heartbeat/{project}` | POST | Heartbeat ping |
|
| 63 |
+
| `/api/monitor/summary` | GET | Monitor summary |
|
| 64 |
+
| `/api/monitor/throttle` | GET | Throttle layer status |
|
| 65 |
+
| `/api/test/webhook/{project}` | POST | Trigger webhook test |
|
| 66 |
+
| `/api/test/download-url/{project}` | GET | Generate download link |
|
| 67 |
+
| `/api/test/github-actions/{project}` | POST | Trigger GHA workflow |
|
| 68 |
+
| `/api/test/artifact/{project}` | POST/GET | Upload/download artifacts |
|
| 69 |
| `/api/router` | GET | Router/circuit-breaker status |
|
| 70 |
+
| `/api/console/exec` | POST | Execute shell command |
|
| 71 |
+
| `/api/console/stream` | WS | Stream command output |
|
| 72 |
+
| `/api/state` | GET/POST | Encrypted state management |
|
| 73 |
+
| `/api/system` | GET | System info |
|
| 74 |
| `/worker/*` | - | Worker sub-app |
|
| 75 |
|
| 76 |
## LLM Providers (optional)
|
|
|
|
| 82 |
- `GEMINI_API_KEY` — Google Gemini (gemini-2.0-flash)
|
| 83 |
|
| 84 |
Without any keys, chat falls back to running shell commands in the project workspace.
|
| 85 |
+
|
| 86 |
+
## Resource Ping Targets
|
| 87 |
+
|
| 88 |
+
| Key | Endpoint |
|
| 89 |
+
|-----|----------|
|
| 90 |
+
| HF_TOKEN | `huggingface.co/api/whoami` |
|
| 91 |
+
| GITHUB_TOKEN | `api.github.com/user` |
|
| 92 |
+
| NVIDIA_NIM_API_KEY | `integrate.api.nvidia.com/v1/models` |
|
| 93 |
+
| OPENROUTER_API_KEY | `openrouter.ai/api/v1/auth/key` |
|
| 94 |
+
| OPENAI_API_KEY | `api.openai.com/v1/models` |
|
| 95 |
+
|
| 96 |
+
## Free Tier
|
| 97 |
+
|
| 98 |
+
Altamira runs entirely on Hugging Face Spaces free tier:
|
| 99 |
+
- CPU-basic hardware (no GPU cost)
|
| 100 |
+
- No credit card required
|
| 101 |
+
- Sandbox Spaces also on free tier
|
| 102 |
+
- All data in `/tmp` (ephemeral — restart resets state)
|
__pycache__/agent_system.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/agent_system.cpython-312.pyc and b/__pycache__/agent_system.cpython-312.pyc differ
|
|
|
__pycache__/app.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/app.cpython-312.pyc and b/__pycache__/app.cpython-312.pyc differ
|
|
|
__pycache__/router.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/router.cpython-312.pyc and b/__pycache__/router.cpython-312.pyc differ
|
|
|
__pycache__/sandbox_manager.cpython-312.pyc
ADDED
|
Binary file (14.8 kB). View file
|
|
|
__pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc
CHANGED
|
Binary files a/__pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc and b/__pycache__/test_agent_system.cpython-312-pytest-8.4.2.pyc differ
|
|
|
__pycache__/test_integration.cpython-312-pytest-8.4.2.pyc
ADDED
|
Binary file (64 kB). View file
|
|
|
__pycache__/test_router.cpython-312-pytest-8.4.2.pyc
CHANGED
|
Binary files a/__pycache__/test_router.cpython-312-pytest-8.4.2.pyc and b/__pycache__/test_router.cpython-312-pytest-8.4.2.pyc differ
|
|
|
__pycache__/worker.cpython-312.pyc
CHANGED
|
Binary files a/__pycache__/worker.cpython-312.pyc and b/__pycache__/worker.cpython-312.pyc differ
|
|
|
agent_system.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import Any, Callable, Optional
|
|
| 13 |
|
| 14 |
import httpx
|
| 15 |
|
| 16 |
-
from router import InferenceRouterCircuitBreaker, PredictiveContextFilter
|
| 17 |
|
| 18 |
# ──────────────────────────────────────────────
|
| 19 |
# Constants
|
|
@@ -565,6 +565,8 @@ Your job is to:
|
|
| 565 |
2. For each task, produce a JSON patch command using the available actions
|
| 566 |
3. Delegate review to the Reviewer before applying changes
|
| 567 |
4. Ensure no task proceeds without approval
|
|
|
|
|
|
|
| 568 |
|
| 569 |
{COMMANDER_TOOLS}
|
| 570 |
Output a JSON array of tasks, each with:
|
|
@@ -671,6 +673,7 @@ class AgentSubsystem:
|
|
| 671 |
self._dag = DAGScheduler()
|
| 672 |
self._key_pool = KeyPool()
|
| 673 |
self._throttle = ThrottleController()
|
|
|
|
| 674 |
self._monitor = Monitor()
|
| 675 |
self._resources = ResourceManager()
|
| 676 |
self._state = StateManager(dataset_repo, hf_token)
|
|
@@ -802,6 +805,7 @@ class AgentSubsystem:
|
|
| 802 |
"completed_ids": list(self._completed),
|
| 803 |
"active_ids": list(self._active_tasks.keys()),
|
| 804 |
"throttle": self._throttle.to_dict(),
|
|
|
|
| 805 |
"key_pool": self._key_pool.to_dict(),
|
| 806 |
"dag": self._dag.to_dict(),
|
| 807 |
"monitor": self._monitor.summary(),
|
|
|
|
| 13 |
|
| 14 |
import httpx
|
| 15 |
|
| 16 |
+
from router import InferenceRouterCircuitBreaker, PredictiveContextFilter, MultiLayerThrottle, DecentralizedThrottle
|
| 17 |
|
| 18 |
# ──────────────────────────────────────────────
|
| 19 |
# Constants
|
|
|
|
| 565 |
2. For each task, produce a JSON patch command using the available actions
|
| 566 |
3. Delegate review to the Reviewer before applying changes
|
| 567 |
4. Ensure no task proceeds without approval
|
| 568 |
+
5. Self-throttle: you have a 40 RPM limit. Group related changes to minimize API calls.
|
| 569 |
+
If you get rate-limited, back off and retry locally before escalating.
|
| 570 |
|
| 571 |
{COMMANDER_TOOLS}
|
| 572 |
Output a JSON array of tasks, each with:
|
|
|
|
| 673 |
self._dag = DAGScheduler()
|
| 674 |
self._key_pool = KeyPool()
|
| 675 |
self._throttle = ThrottleController()
|
| 676 |
+
self._multi_layer = MultiLayerThrottle()
|
| 677 |
self._monitor = Monitor()
|
| 678 |
self._resources = ResourceManager()
|
| 679 |
self._state = StateManager(dataset_repo, hf_token)
|
|
|
|
| 805 |
"completed_ids": list(self._completed),
|
| 806 |
"active_ids": list(self._active_tasks.keys()),
|
| 807 |
"throttle": self._throttle.to_dict(),
|
| 808 |
+
"multi_layer": self._multi_layer.to_dict(),
|
| 809 |
"key_pool": self._key_pool.to_dict(),
|
| 810 |
"dag": self._dag.to_dict(),
|
| 811 |
"monitor": self._monitor.summary(),
|
app.py
CHANGED
|
@@ -15,8 +15,9 @@ from fastapi.templating import Jinja2Templates
|
|
| 15 |
from cryptography.fernet import Fernet
|
| 16 |
from huggingface_hub import HfApi
|
| 17 |
|
| 18 |
-
from router import PredictiveContextFilter, InferenceRouterCircuitBreaker, ParallelEngine
|
| 19 |
from agent_system import AgentSubsystem, TaskStatus
|
|
|
|
| 20 |
|
| 21 |
logging.basicConfig(
|
| 22 |
level=logging.INFO,
|
|
@@ -119,6 +120,12 @@ filter_ctx = PredictiveContextFilter()
|
|
| 119 |
circuit_breaker = InferenceRouterCircuitBreaker()
|
| 120 |
engine = ParallelEngine()
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# agent subsystem
|
| 123 |
STATE_REPO = os.environ.get("ALTAMIRA_STATE_REPO", "")
|
| 124 |
STATE_HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
|
@@ -281,13 +288,16 @@ async def index():
|
|
| 281 |
@app.get("/health")
|
| 282 |
async def health():
|
| 283 |
from router import PAUSED_RETRY
|
|
|
|
| 284 |
return {
|
| 285 |
"status": "healthy",
|
| 286 |
-
"app": "altamira-
|
| 287 |
-
"version": "
|
| 288 |
"circuit_breaker": getattr(circuit_breaker, "state", "unknown"),
|
| 289 |
"failures": getattr(circuit_breaker, "failure_count", 0),
|
| 290 |
"paused_retry": circuit_breaker.failure_count >= circuit_breaker.MAX_RETRIES if hasattr(circuit_breaker, "MAX_RETRIES") else False,
|
|
|
|
|
|
|
| 291 |
}
|
| 292 |
|
| 293 |
# ==================== GATEKEEPER ====================
|
|
@@ -567,21 +577,77 @@ async def chat_task(body: dict):
|
|
| 567 |
output = result.stdout or result.stderr or "(no output)"
|
| 568 |
return {"status": "ok", "response": f"exit code: {result.returncode}", "output": output[:5000]}
|
| 569 |
|
| 570 |
-
# ==================== RESOURCES ====================
|
| 571 |
RESOURCES_FILE = STATE_DIR / "resources.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
|
| 573 |
def _load_resources() -> dict:
|
| 574 |
if RESOURCES_FILE.exists():
|
| 575 |
raw = json.loads(RESOURCES_FILE.read_text())
|
| 576 |
-
return {k: {"value": v.get("value", ""), "description": v.get("description", "")
|
|
|
|
|
|
|
|
|
|
| 577 |
return {}
|
| 578 |
|
|
|
|
| 579 |
def _save_resources(resources: dict):
|
| 580 |
RESOURCES_FILE.write_text(json.dumps(resources, indent=2))
|
| 581 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
@app.get("/api/resources")
|
| 583 |
-
async def list_resources():
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
@app.put("/api/resources/{key}")
|
| 587 |
async def save_resource(key: str, body: dict):
|
|
@@ -589,12 +655,87 @@ async def save_resource(key: str, body: dict):
|
|
| 589 |
if not key or not value:
|
| 590 |
raise HTTPException(400, "Key and value required")
|
| 591 |
resources = _load_resources()
|
| 592 |
-
resources[key] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
_save_resources(resources)
|
| 594 |
-
# Write to env + feed into agent KeyPool so Commander/Reviewer can use it
|
| 595 |
os.environ[key] = value
|
| 596 |
-
|
| 597 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
|
| 599 |
# ==================== STATE ====================
|
| 600 |
@app.get("/api/state")
|
|
@@ -646,6 +787,7 @@ async def router_status():
|
|
| 646 |
"filter_capacity": filter_ctx.capacity,
|
| 647 |
"filter_threshold": filter_ctx.threshold,
|
| 648 |
"parallel_max": engine.semaphore._value,
|
|
|
|
| 649 |
}
|
| 650 |
|
| 651 |
@app.post("/api/router/filter")
|
|
@@ -700,30 +842,242 @@ async def agent_add_key(body: dict):
|
|
| 700 |
agent_subsystem.add_api_key(key)
|
| 701 |
return {"status": "key added"}
|
| 702 |
|
| 703 |
-
# ==================== SANDBOX
|
| 704 |
-
@app.get("/api/sandbox/{name}")
|
| 705 |
-
async def get_sandbox(name: str):
|
| 706 |
-
path = SANDBOX_DIR / name
|
| 707 |
-
if not path.exists():
|
| 708 |
-
raise HTTPException(404, "Sandbox not found")
|
| 709 |
-
files = []
|
| 710 |
-
for f in path.rglob("*"):
|
| 711 |
-
if f.is_file():
|
| 712 |
-
files.append({"name": str(f.relative_to(path)),
|
| 713 |
-
"size": f.stat().st_size,
|
| 714 |
-
"modified": f.stat().st_mtime})
|
| 715 |
-
return {"name": name, "files": sorted(files, key=lambda x: x["name"])}
|
| 716 |
-
|
| 717 |
-
@app.get("/api/sandbox/{name}/read")
|
| 718 |
-
async def read_sandbox_file(name: str, file: str):
|
| 719 |
-
path = SANDBOX_DIR / name / file
|
| 720 |
-
if not path.exists() or not path.is_file():
|
| 721 |
-
raise HTTPException(404)
|
| 722 |
-
return {"content": path.read_text()}
|
| 723 |
-
|
| 724 |
@app.get("/api/sandboxes")
|
| 725 |
async def list_sandboxes():
|
| 726 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
|
| 728 |
# ==================== WORKER INTEGRATION ====================
|
| 729 |
# mount worker app as sub-app
|
|
@@ -743,16 +1097,21 @@ async def list_results():
|
|
| 743 |
@app.get("/api/system")
|
| 744 |
async def system_info():
|
| 745 |
import platform as _platform
|
|
|
|
| 746 |
return {
|
| 747 |
"platform": _platform.platform(),
|
| 748 |
"python": _platform.python_version(),
|
| 749 |
"hostname": os.uname().nodename,
|
| 750 |
"cpus": os.cpu_count(),
|
| 751 |
-
"
|
|
|
|
|
|
|
| 752 |
"workspace_count": len(list(WORKSPACE_DIR.iterdir())),
|
| 753 |
"disk_tmp": _disk_usage("/tmp"),
|
|
|
|
| 754 |
}
|
| 755 |
|
|
|
|
| 756 |
def _disk_usage(path: str) -> dict:
|
| 757 |
s = os.statvfs(path)
|
| 758 |
return {
|
|
|
|
| 15 |
from cryptography.fernet import Fernet
|
| 16 |
from huggingface_hub import HfApi
|
| 17 |
|
| 18 |
+
from router import PredictiveContextFilter, InferenceRouterCircuitBreaker, ParallelEngine, MultiLayerThrottle, DecentralizedThrottle
|
| 19 |
from agent_system import AgentSubsystem, TaskStatus
|
| 20 |
+
from sandbox_manager import SandboxManager
|
| 21 |
|
| 22 |
logging.basicConfig(
|
| 23 |
level=logging.INFO,
|
|
|
|
| 120 |
circuit_breaker = InferenceRouterCircuitBreaker()
|
| 121 |
engine = ParallelEngine()
|
| 122 |
|
| 123 |
+
# sandbox manager
|
| 124 |
+
sandbox_manager = SandboxManager(hf_token=os.environ.get("HF_TOKEN", ""))
|
| 125 |
+
|
| 126 |
+
# multi-layer throttle
|
| 127 |
+
multi_layer_throttle = MultiLayerThrottle()
|
| 128 |
+
|
| 129 |
# agent subsystem
|
| 130 |
STATE_REPO = os.environ.get("ALTAMIRA_STATE_REPO", "")
|
| 131 |
STATE_HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
|
|
|
| 288 |
@app.get("/health")
|
| 289 |
async def health():
|
| 290 |
from router import PAUSED_RETRY
|
| 291 |
+
sandbox_count = len(await sandbox_manager.list_sandboxes())
|
| 292 |
return {
|
| 293 |
"status": "healthy",
|
| 294 |
+
"app": "altamira-aade",
|
| 295 |
+
"version": "2.0.0",
|
| 296 |
"circuit_breaker": getattr(circuit_breaker, "state", "unknown"),
|
| 297 |
"failures": getattr(circuit_breaker, "failure_count", 0),
|
| 298 |
"paused_retry": circuit_breaker.failure_count >= circuit_breaker.MAX_RETRIES if hasattr(circuit_breaker, "MAX_RETRIES") else False,
|
| 299 |
+
"sandboxes": sandbox_count,
|
| 300 |
+
"throttle_layer": multi_layer_throttle.to_dict().get("active_layer", 1),
|
| 301 |
}
|
| 302 |
|
| 303 |
# ==================== GATEKEEPER ====================
|
|
|
|
| 577 |
output = result.stdout or result.stderr or "(no output)"
|
| 578 |
return {"status": "ok", "response": f"exit code: {result.returncode}", "output": output[:5000]}
|
| 579 |
|
| 580 |
+
# ==================== RESOURCES (with project scoping + ping) ====================
|
| 581 |
RESOURCES_FILE = STATE_DIR / "resources.json"
|
| 582 |
+
PROJECT_RESOURCES_FILE = STATE_DIR / "project_resources.json"
|
| 583 |
+
|
| 584 |
+
RESOURCE_PING_TARGETS = {
|
| 585 |
+
"HF_TOKEN": ("https://huggingface.co/api/whoami", {"Authorization": "Bearer {value}"}),
|
| 586 |
+
"GITHUB_TOKEN": ("https://api.github.com/user", {"Authorization": "Bearer {value}"}),
|
| 587 |
+
"OPENAI_API_KEY": ("https://api.openai.com/v1/models", {"Authorization": "Bearer {value}"}),
|
| 588 |
+
"NVIDIA_NIM_API_KEY": ("https://integrate.api.nvidia.com/v1/models", {"Authorization": "Bearer {value}"}),
|
| 589 |
+
"OPENROUTER_API_KEY": ("https://openrouter.ai/api/v1/auth/key", {"Authorization": "Bearer {value}"}),
|
| 590 |
+
"GEMINI_API_KEY": (None, None),
|
| 591 |
+
"SUPABASE_URL": (None, None),
|
| 592 |
+
"SUPABASE_KEY": (None, None),
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
|
| 596 |
def _load_resources() -> dict:
|
| 597 |
if RESOURCES_FILE.exists():
|
| 598 |
raw = json.loads(RESOURCES_FILE.read_text())
|
| 599 |
+
return {k: {"value": v.get("value", ""), "description": v.get("description", ""),
|
| 600 |
+
"scope": v.get("scope", "core"), "projects": v.get("projects", []),
|
| 601 |
+
"shareable": v.get("shareable", False), "created": v.get("created", time.time())}
|
| 602 |
+
for k, v in raw.items()}
|
| 603 |
return {}
|
| 604 |
|
| 605 |
+
|
| 606 |
def _save_resources(resources: dict):
|
| 607 |
RESOURCES_FILE.write_text(json.dumps(resources, indent=2))
|
| 608 |
|
| 609 |
+
|
| 610 |
+
def _load_project_resources() -> dict:
|
| 611 |
+
if PROJECT_RESOURCES_FILE.exists():
|
| 612 |
+
return json.loads(PROJECT_RESOURCES_FILE.read_text())
|
| 613 |
+
return {}
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def _save_project_resources(pr: dict):
|
| 617 |
+
PROJECT_RESOURCES_FILE.write_text(json.dumps(pr, indent=2))
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
async def _ping_resource(key: str, value: str) -> dict:
|
| 621 |
+
target_info = RESOURCE_PING_TARGETS.get(key)
|
| 622 |
+
if not target_info or not target_info[0]:
|
| 623 |
+
return {"status": "skipped", "reason": "No ping target configured for this resource type"}
|
| 624 |
+
url, headers_tmpl = target_info
|
| 625 |
+
headers = {k: v.format(value=value) for k, v in headers_tmpl.items()} if headers_tmpl else {}
|
| 626 |
+
try:
|
| 627 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 628 |
+
resp = await client.get(url, headers=headers)
|
| 629 |
+
if resp.status_code < 500:
|
| 630 |
+
return {"status": "ok", "code": resp.status_code}
|
| 631 |
+
return {"status": "error", "code": resp.status_code, "body": resp.text[:200]}
|
| 632 |
+
except Exception as e:
|
| 633 |
+
return {"status": "error", "error": str(e)}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
@app.get("/api/resources")
|
| 637 |
+
async def list_resources(project: str = ""):
|
| 638 |
+
all_resources = _load_resources()
|
| 639 |
+
if project:
|
| 640 |
+
project_bindings = _load_project_resources().get(project, {})
|
| 641 |
+
bound_keys = set(project_bindings.keys())
|
| 642 |
+
result = {}
|
| 643 |
+
for k, v in all_resources.items():
|
| 644 |
+
if k in bound_keys:
|
| 645 |
+
result[k] = {**v, "bound": "project", "project_value": project_bindings[k].get("value", "")}
|
| 646 |
+
elif v.get("scope") == "core" or project in v.get("projects", []):
|
| 647 |
+
result[k] = {**v, "bound": "global"}
|
| 648 |
+
return {"credentials": result, "project": project}
|
| 649 |
+
return {"credentials": all_resources}
|
| 650 |
+
|
| 651 |
|
| 652 |
@app.put("/api/resources/{key}")
|
| 653 |
async def save_resource(key: str, body: dict):
|
|
|
|
| 655 |
if not key or not value:
|
| 656 |
raise HTTPException(400, "Key and value required")
|
| 657 |
resources = _load_resources()
|
| 658 |
+
resources[key] = {
|
| 659 |
+
"value": value,
|
| 660 |
+
"description": body.get("description", ""),
|
| 661 |
+
"scope": body.get("scope", "core"),
|
| 662 |
+
"projects": body.get("projects", []),
|
| 663 |
+
"shareable": body.get("shareable", False),
|
| 664 |
+
"created": time.time(),
|
| 665 |
+
}
|
| 666 |
_save_resources(resources)
|
|
|
|
| 667 |
os.environ[key] = value
|
| 668 |
+
if key.endswith("_API_KEY"):
|
| 669 |
+
agent_subsystem.add_api_key(value)
|
| 670 |
+
ping_result = await _ping_resource(key, value)
|
| 671 |
+
return {"saved": key, "ping": ping_result}
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
@app.post("/api/resources/{key}/ping")
|
| 675 |
+
async def ping_resource(key: str):
|
| 676 |
+
resources = _load_resources()
|
| 677 |
+
entry = resources.get(key)
|
| 678 |
+
if not entry:
|
| 679 |
+
raise HTTPException(404, "Resource not found")
|
| 680 |
+
result = await _ping_resource(key, entry.get("value", ""))
|
| 681 |
+
return {"key": key, "ping": result}
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
@app.delete("/api/resources/{key}")
|
| 685 |
+
async def delete_resource(key: str):
|
| 686 |
+
resources = _load_resources()
|
| 687 |
+
if key not in resources:
|
| 688 |
+
raise HTTPException(404, "Resource not found")
|
| 689 |
+
del resources[key]
|
| 690 |
+
_save_resources(resources)
|
| 691 |
+
pr = _load_project_resources()
|
| 692 |
+
for proj in pr:
|
| 693 |
+
pr[proj].pop(key, None)
|
| 694 |
+
_save_project_resources(pr)
|
| 695 |
+
return {"deleted": key}
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
@app.get("/api/resources/projects")
|
| 699 |
+
async def list_project_resources_bindings():
|
| 700 |
+
return _load_project_resources()
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
@app.post("/api/resources/bind/{project}")
|
| 704 |
+
async def bind_resource_to_project(project: str, body: dict):
|
| 705 |
+
key = body.get("key", "")
|
| 706 |
+
value = body.get("value", "")
|
| 707 |
+
if not key or not value:
|
| 708 |
+
raise HTTPException(400, "Key and value required")
|
| 709 |
+
pr = _load_project_resources()
|
| 710 |
+
if project not in pr:
|
| 711 |
+
pr[project] = {}
|
| 712 |
+
pr[project][key] = {"value": value, "bound_at": time.time()}
|
| 713 |
+
_save_project_resources(pr)
|
| 714 |
+
resources = _load_resources()
|
| 715 |
+
if key in resources:
|
| 716 |
+
resources[key]["projects"] = list(set(resources[key].get("projects", []) + [project]))
|
| 717 |
+
_save_resources(resources)
|
| 718 |
+
return {"bound": key, "project": project}
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
@app.post("/api/resources/unbind/{project}")
|
| 722 |
+
async def unbind_resource_from_project(project: str, body: dict):
|
| 723 |
+
key = body.get("key", "")
|
| 724 |
+
if not key:
|
| 725 |
+
raise HTTPException(400, "Key required")
|
| 726 |
+
pr = _load_project_resources()
|
| 727 |
+
if project in pr and key in pr[project]:
|
| 728 |
+
del pr[project][key]
|
| 729 |
+
_save_project_resources(pr)
|
| 730 |
+
resources = _load_resources()
|
| 731 |
+
if key in resources:
|
| 732 |
+
projs = resources[key].get("projects", [])
|
| 733 |
+
if project in projs:
|
| 734 |
+
projs.remove(project)
|
| 735 |
+
resources[key]["projects"] = projs
|
| 736 |
+
_save_resources(resources)
|
| 737 |
+
return {"unbound": key, "project": project}
|
| 738 |
+
raise HTTPException(404, "Binding not found")
|
| 739 |
|
| 740 |
# ==================== STATE ====================
|
| 741 |
@app.get("/api/state")
|
|
|
|
| 787 |
"filter_capacity": filter_ctx.capacity,
|
| 788 |
"filter_threshold": filter_ctx.threshold,
|
| 789 |
"parallel_max": engine.semaphore._value,
|
| 790 |
+
"multi_layer_throttle": multi_layer_throttle.to_dict(),
|
| 791 |
}
|
| 792 |
|
| 793 |
@app.post("/api/router/filter")
|
|
|
|
| 842 |
agent_subsystem.add_api_key(key)
|
| 843 |
return {"status": "key added"}
|
| 844 |
|
| 845 |
+
# ==================== SANDBOX (HF Spaces) ====================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
@app.get("/api/sandboxes")
|
| 847 |
async def list_sandboxes():
|
| 848 |
+
local = [d.name for d in SANDBOX_DIR.iterdir() if d.is_dir()]
|
| 849 |
+
hf_sandboxes = await sandbox_manager.list_sandboxes()
|
| 850 |
+
return {"local": local, "hf_spaces": hf_sandboxes}
|
| 851 |
+
|
| 852 |
+
|
| 853 |
+
@app.post("/api/sandboxes/create")
|
| 854 |
+
async def create_sandbox(body: dict):
|
| 855 |
+
project = body.get("project", "").strip()
|
| 856 |
+
sandbox_name = body.get("sandbox_name", "").strip()
|
| 857 |
+
if not project:
|
| 858 |
+
raise HTTPException(400, "Project name required")
|
| 859 |
+
result = await sandbox_manager.create_sandbox_space(project, sandbox_name)
|
| 860 |
+
if result.get("status") == "error":
|
| 861 |
+
raise HTTPException(500, result.get("error", "Sandbox creation failed"))
|
| 862 |
+
return result
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
@app.post("/api/sandboxes/{project}/exec")
|
| 866 |
+
async def exec_sandbox(project: str, body: dict):
|
| 867 |
+
command = body.get("command", "")
|
| 868 |
+
env = body.get("env", {})
|
| 869 |
+
timeout = int(body.get("timeout", 60))
|
| 870 |
+
result = await sandbox_manager.exec_in_sandbox(project, command, env, timeout)
|
| 871 |
+
if result.get("status") == "error":
|
| 872 |
+
raise HTTPException(502, result.get("error", "Sandbox exec failed"))
|
| 873 |
+
return result
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
@app.post("/api/sandboxes/{project}/write")
|
| 877 |
+
async def write_sandbox(project: str, body: dict):
|
| 878 |
+
path = body.get("path", "")
|
| 879 |
+
content = body.get("content", "")
|
| 880 |
+
result = await sandbox_manager.write_to_sandbox(project, path, content)
|
| 881 |
+
if result.get("status") == "error":
|
| 882 |
+
raise HTTPException(502, result.get("error", "Sandbox write failed"))
|
| 883 |
+
return result
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
@app.post("/api/sandboxes/{project}/read")
|
| 887 |
+
async def read_sandbox(project: str, body: dict):
|
| 888 |
+
path = body.get("path", "")
|
| 889 |
+
result = await sandbox_manager.read_from_sandbox(project, path)
|
| 890 |
+
if result.get("status") == "error":
|
| 891 |
+
raise HTTPException(502, result.get("error", "Sandbox read failed"))
|
| 892 |
+
return result
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
@app.get("/api/sandboxes/{project}/files")
|
| 896 |
+
async def list_sandbox_files(project: str):
|
| 897 |
+
result = await sandbox_manager.list_sandbox_files(project)
|
| 898 |
+
if result.get("status") == "error":
|
| 899 |
+
raise HTTPException(502, result.get("error", "List files failed"))
|
| 900 |
+
return result
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
@app.delete("/api/sandboxes/{project}")
|
| 904 |
+
async def delete_sandbox(project: str):
|
| 905 |
+
result = await sandbox_manager.delete_sandbox_space(project)
|
| 906 |
+
if result.get("status") == "error":
|
| 907 |
+
raise HTTPException(500, result.get("error", "Sandbox deletion failed"))
|
| 908 |
+
return result
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
# ==================== MONITORING ====================
|
| 912 |
+
MANIFEST_FILE = Path("/tmp/altamira-manifest.json")
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def _load_manifest() -> dict:
|
| 916 |
+
if MANIFEST_FILE.exists():
|
| 917 |
+
return json.loads(MANIFEST_FILE.read_text())
|
| 918 |
+
return {}
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def _save_manifest(manifest: dict):
|
| 922 |
+
MANIFEST_FILE.write_text(json.dumps(manifest, indent=2))
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
@app.get("/api/monitor/manifest")
|
| 926 |
+
async def get_manifest(project: str = ""):
|
| 927 |
+
manifest = _load_manifest()
|
| 928 |
+
if project:
|
| 929 |
+
return {"manifest": manifest.get(project, {})}
|
| 930 |
+
return {"manifest": manifest}
|
| 931 |
+
|
| 932 |
+
|
| 933 |
+
@app.post("/api/monitor/manifest/{project}")
|
| 934 |
+
async def update_manifest(project: str, body: dict):
|
| 935 |
+
manifest = _load_manifest()
|
| 936 |
+
manifest[project] = {
|
| 937 |
+
"status": body.get("status", "unknown"),
|
| 938 |
+
"updated": time.time(),
|
| 939 |
+
"version": body.get("version", ""),
|
| 940 |
+
"message": body.get("message", ""),
|
| 941 |
+
"commit": body.get("commit", ""),
|
| 942 |
+
}
|
| 943 |
+
_save_manifest(manifest)
|
| 944 |
+
return {"saved": project, "manifest": manifest[project]}
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
@app.post("/api/monitor/heartbeat/{project}")
|
| 948 |
+
async def heartbeat(project: str, body: dict):
|
| 949 |
+
manifest = _load_manifest()
|
| 950 |
+
if project not in manifest:
|
| 951 |
+
manifest[project] = {}
|
| 952 |
+
manifest[project]["heartbeat"] = time.time()
|
| 953 |
+
manifest[project]["status"] = body.get("status", "running")
|
| 954 |
+
manifest[project]["message"] = body.get("message", "")
|
| 955 |
+
_save_manifest(manifest)
|
| 956 |
+
return {"pong": time.time(), "project": project}
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
@app.get("/api/monitor/summary")
|
| 960 |
+
async def monitor_summary():
|
| 961 |
+
manifest = _load_manifest()
|
| 962 |
+
result = {}
|
| 963 |
+
for proj, data in manifest.items():
|
| 964 |
+
result[proj] = {
|
| 965 |
+
"status": data.get("status", "unknown"),
|
| 966 |
+
"updated": data.get("updated", 0),
|
| 967 |
+
"heartbeat": data.get("heartbeat", 0),
|
| 968 |
+
"is_alive": (time.time() - data.get("heartbeat", 0)) < 60 if data.get("heartbeat") else False,
|
| 969 |
+
}
|
| 970 |
+
return {"projects": result}
|
| 971 |
+
|
| 972 |
+
|
| 973 |
+
@app.get("/api/monitor/throttle")
|
| 974 |
+
async def throttle_status():
|
| 975 |
+
return {"multi_layer": multi_layer_throttle.to_dict()}
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
# ==================== EXTERNAL TESTING ====================
|
| 979 |
+
ARTIFACTS_DIR = Path("/tmp/altamira-artifacts")
|
| 980 |
+
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 981 |
+
|
| 982 |
+
|
| 983 |
+
@app.post("/api/test/artifact/{project}")
|
| 984 |
+
async def upload_artifact(project: str, body: dict):
|
| 985 |
+
filename = body.get("filename", "artifact.zip")
|
| 986 |
+
data_b64 = body.get("data", "")
|
| 987 |
+
import base64
|
| 988 |
+
try:
|
| 989 |
+
data = base64.b64decode(data_b64)
|
| 990 |
+
except Exception:
|
| 991 |
+
raise HTTPException(400, "Invalid base64 data")
|
| 992 |
+
project_dir = ARTIFACTS_DIR / project
|
| 993 |
+
project_dir.mkdir(parents=True, exist_ok=True)
|
| 994 |
+
filepath = project_dir / filename
|
| 995 |
+
filepath.write_bytes(data)
|
| 996 |
+
return {"path": str(filepath), "size": len(data)}
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
@app.get("/api/test/artifact/{project}/{filename}")
|
| 1000 |
+
async def download_artifact(project: str, filename: str):
|
| 1001 |
+
filepath = ARTIFACTS_DIR / project / filename
|
| 1002 |
+
if not filepath.exists():
|
| 1003 |
+
raise HTTPException(404, "Artifact not found")
|
| 1004 |
+
import base64
|
| 1005 |
+
return {
|
| 1006 |
+
"filename": filename,
|
| 1007 |
+
"size": filepath.stat().st_size,
|
| 1008 |
+
"data": base64.b64encode(filepath.read_bytes()).decode(),
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
@app.get("/api/test/download-url/{project}")
|
| 1013 |
+
async def generate_download_url(project: str):
|
| 1014 |
+
project_dir = ARTIFACTS_DIR / project
|
| 1015 |
+
if not project_dir.exists():
|
| 1016 |
+
raise HTTPException(404, "No artifacts for project")
|
| 1017 |
+
files = [f.name for f in project_dir.iterdir() if f.is_file()]
|
| 1018 |
+
return {
|
| 1019 |
+
"project": project,
|
| 1020 |
+
"files": files,
|
| 1021 |
+
"download_url": f"/api/test/artifact/{project}/",
|
| 1022 |
+
"instructions": "Use the download URL above to retrieve build artifacts for local testing.",
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
|
| 1026 |
+
@app.post("/api/test/webhook/{project}")
|
| 1027 |
+
async def trigger_webhook_test(project: str, body: dict):
|
| 1028 |
+
url = body.get("url", "")
|
| 1029 |
+
payload = body.get("payload", {})
|
| 1030 |
+
method = body.get("method", "POST")
|
| 1031 |
+
if not url:
|
| 1032 |
+
raise HTTPException(400, "Webhook URL required")
|
| 1033 |
+
resources = _load_resources()
|
| 1034 |
+
headers = {}
|
| 1035 |
+
for k, v in resources.items():
|
| 1036 |
+
if "WEBHOOK" in k.upper() or "API" in k.upper():
|
| 1037 |
+
headers[k.replace("_", "-").lower()] = v.get("value", "")
|
| 1038 |
+
try:
|
| 1039 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 1040 |
+
if method.upper() == "GET":
|
| 1041 |
+
resp = await client.get(url, headers=headers)
|
| 1042 |
+
else:
|
| 1043 |
+
resp = await client.post(url, json=payload, headers=headers)
|
| 1044 |
+
return {
|
| 1045 |
+
"status": resp.status_code,
|
| 1046 |
+
"body": resp.text[:2000],
|
| 1047 |
+
"project": project,
|
| 1048 |
+
}
|
| 1049 |
+
except Exception as e:
|
| 1050 |
+
raise HTTPException(502, f"Webhook failed: {e}")
|
| 1051 |
+
|
| 1052 |
+
|
| 1053 |
+
@app.post("/api/test/github-actions/{project}")
|
| 1054 |
+
async def trigger_github_actions(project: str, body: dict):
|
| 1055 |
+
repo = body.get("repo", "")
|
| 1056 |
+
workflow = body.get("workflow", "main.yml")
|
| 1057 |
+
ref = body.get("ref", "main")
|
| 1058 |
+
if not repo:
|
| 1059 |
+
raise HTTPException(400, "GitHub repo required (owner/repo)")
|
| 1060 |
+
resources = _load_resources()
|
| 1061 |
+
gh_token = resources.get("GITHUB_TOKEN", {}).get("value", "") or resources.get("GH_TOKEN", {}).get("value", "")
|
| 1062 |
+
if not gh_token:
|
| 1063 |
+
raise HTTPException(400, "GitHub token required — add GH_TOKEN or GITHUB_TOKEN resource")
|
| 1064 |
+
try:
|
| 1065 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 1066 |
+
resp = await client.post(
|
| 1067 |
+
f"https://api.github.com/repos/{repo}/actions/workflows/{workflow}/dispatches",
|
| 1068 |
+
headers={
|
| 1069 |
+
"Authorization": f"Bearer {gh_token}",
|
| 1070 |
+
"Accept": "application/vnd.github.v3+json",
|
| 1071 |
+
},
|
| 1072 |
+
json={"ref": ref, "inputs": body.get("inputs", {})},
|
| 1073 |
+
)
|
| 1074 |
+
if resp.status_code not in (204, 200, 201):
|
| 1075 |
+
raise HTTPException(502, f"GitHub Actions trigger failed: {resp.status_code} {resp.text[:200]}")
|
| 1076 |
+
return {"status": "triggered", "repo": repo, "workflow": workflow, "ref": ref}
|
| 1077 |
+
except HTTPException:
|
| 1078 |
+
raise
|
| 1079 |
+
except Exception as e:
|
| 1080 |
+
raise HTTPException(502, f"GitHub Actions trigger failed: {e}")
|
| 1081 |
|
| 1082 |
# ==================== WORKER INTEGRATION ====================
|
| 1083 |
# mount worker app as sub-app
|
|
|
|
| 1097 |
@app.get("/api/system")
|
| 1098 |
async def system_info():
|
| 1099 |
import platform as _platform
|
| 1100 |
+
sandbox_count = len(await sandbox_manager.list_sandboxes())
|
| 1101 |
return {
|
| 1102 |
"platform": _platform.platform(),
|
| 1103 |
"python": _platform.python_version(),
|
| 1104 |
"hostname": os.uname().nodename,
|
| 1105 |
"cpus": os.cpu_count(),
|
| 1106 |
+
"mode": "AADE",
|
| 1107 |
+
"sandbox_hf_spaces": sandbox_count,
|
| 1108 |
+
"sandbox_local": len(list(SANDBOX_DIR.iterdir())),
|
| 1109 |
"workspace_count": len(list(WORKSPACE_DIR.iterdir())),
|
| 1110 |
"disk_tmp": _disk_usage("/tmp"),
|
| 1111 |
+
"throttle": multi_layer_throttle.to_dict(),
|
| 1112 |
}
|
| 1113 |
|
| 1114 |
+
|
| 1115 |
def _disk_usage(path: str) -> dict:
|
| 1116 |
s = os.statvfs(path)
|
| 1117 |
return {
|
router.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
import time
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import httpx
|
| 6 |
|
|
@@ -11,12 +14,229 @@ OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
|
| 11 |
GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
class CircuitBreakerState:
|
| 15 |
CLOSED = "closed"
|
| 16 |
OPEN = "open"
|
| 17 |
HALF_OPEN = "half_open"
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
class PredictiveContextFilter:
|
| 21 |
def __init__(self, capacity: int = 4096, threshold: float = 0.8):
|
| 22 |
self.capacity = capacity
|
|
@@ -89,6 +309,9 @@ def _summarize_lines(lines: list) -> str:
|
|
| 89 |
return f" ... [{len(lines)} lines omitted: {', '.join(summary_parts)}] ..."
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
| 92 |
class InferenceRouterCircuitBreaker:
|
| 93 |
MAX_RETRIES = 3
|
| 94 |
BLACKOUT_SECONDS = 60.0
|
|
@@ -97,6 +320,7 @@ class InferenceRouterCircuitBreaker:
|
|
| 97 |
self.state = CircuitBreakerState.CLOSED
|
| 98 |
self.failure_count = 0
|
| 99 |
self.blackout_until = 0.0
|
|
|
|
| 100 |
|
| 101 |
def _exponential_backoff(self, attempt: int) -> float:
|
| 102 |
return float(2 ** attempt)
|
|
@@ -153,34 +377,49 @@ class InferenceRouterCircuitBreaker:
|
|
| 153 |
return PAUSED_RETRY
|
| 154 |
self.state = CircuitBreakerState.HALF_OPEN
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
if openrouter_key:
|
| 160 |
-
|
|
|
|
| 161 |
if gemini_key:
|
| 162 |
-
|
|
|
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
for fn, key in providers:
|
| 167 |
-
if not key:
|
| 168 |
-
continue
|
| 169 |
-
try:
|
| 170 |
-
result = await fn(prompt, key)
|
| 171 |
-
self.record_success()
|
| 172 |
-
return result
|
| 173 |
-
except _RateLimitError:
|
| 174 |
-
last_error = "rate_limited"
|
| 175 |
-
continue
|
| 176 |
-
except Exception as e:
|
| 177 |
-
last_error = str(e)
|
| 178 |
-
continue
|
| 179 |
-
backoff = self._exponential_backoff(attempt)
|
| 180 |
-
await asyncio.sleep(backoff)
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
|
| 186 |
class _RateLimitError(Exception):
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import json
|
| 3 |
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from collections import deque, defaultdict
|
| 6 |
+
from typing import Optional, Callable, Any
|
| 7 |
|
| 8 |
import httpx
|
| 9 |
|
|
|
|
| 14 |
GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
| 15 |
|
| 16 |
|
| 17 |
+
# ──────────────────────────────────────────────
|
| 18 |
+
# Layer 1: Decentralized Self-Throttle
|
| 19 |
+
# Agentes controlam seu próprio RPM conforme
|
| 20 |
+
# regras do system prompt + retry local.
|
| 21 |
+
# ──────────────────────────────────────────────
|
| 22 |
+
class DecentralizedThrottle:
|
| 23 |
+
def __init__(self, rpm_limit: int = 40, window: int = 60):
|
| 24 |
+
self.rpm_limit = rpm_limit
|
| 25 |
+
self.window = window
|
| 26 |
+
self._slots: list[float] = []
|
| 27 |
+
self._consecutive_429s = 0
|
| 28 |
+
self._escalated = False
|
| 29 |
+
|
| 30 |
+
def acquire(self) -> float:
|
| 31 |
+
now = time.time()
|
| 32 |
+
cutoff = now - self.window
|
| 33 |
+
self._slots = [t for t in self._slots if t > cutoff]
|
| 34 |
+
if len(self._slots) >= self.rpm_limit:
|
| 35 |
+
wait = self._slots[0] + self.window - now
|
| 36 |
+
if wait > 0:
|
| 37 |
+
return wait
|
| 38 |
+
self._slots.append(now)
|
| 39 |
+
return 0.0
|
| 40 |
+
|
| 41 |
+
async def wait_if_needed(self):
|
| 42 |
+
wait = self.acquire()
|
| 43 |
+
if wait > 0:
|
| 44 |
+
await asyncio.sleep(wait)
|
| 45 |
+
|
| 46 |
+
def record_429(self):
|
| 47 |
+
self._consecutive_429s += 1
|
| 48 |
+
if self._consecutive_429s >= 3:
|
| 49 |
+
self._escalated = True
|
| 50 |
+
|
| 51 |
+
def record_success(self):
|
| 52 |
+
self._consecutive_429s = 0
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def should_escalate(self) -> bool:
|
| 56 |
+
return self._escalated
|
| 57 |
+
|
| 58 |
+
def reset_escalation(self):
|
| 59 |
+
self._escalated = False
|
| 60 |
+
|
| 61 |
+
def usage_pct(self) -> float:
|
| 62 |
+
now = time.time()
|
| 63 |
+
cutoff = now - self.window
|
| 64 |
+
active = sum(1 for t in self._slots if t > cutoff)
|
| 65 |
+
return (active / self.rpm_limit) * 100 if self.rpm_limit else 0
|
| 66 |
+
|
| 67 |
+
def to_dict(self) -> dict:
|
| 68 |
+
return {
|
| 69 |
+
"rpm_limit": self.rpm_limit,
|
| 70 |
+
"current_rpm": len([t for t in self._slots if t > time.time() - self.window]),
|
| 71 |
+
"usage_pct": round(self.usage_pct(), 1),
|
| 72 |
+
"consecutive_429s": self._consecutive_429s,
|
| 73 |
+
"escalated": self._escalated,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ──────────────────────────────────────────────
|
| 78 |
+
# Layer 2: Centralized Queue
|
| 79 |
+
# Quando Layer 1 escala (muitos 429s),
|
| 80 |
+
# as requisicoes passam por um dispatcher
|
| 81 |
+
# central que libera uma por vez.
|
| 82 |
+
# ──────────────────────────────────────────────
|
| 83 |
+
class CentralizedQueue:
|
| 84 |
+
def __init__(self, max_concurrent: int = 1):
|
| 85 |
+
self._queue: asyncio.Queue = asyncio.Queue()
|
| 86 |
+
self._active = 0
|
| 87 |
+
self._max_concurrent = max_concurrent
|
| 88 |
+
self._lock = asyncio.Lock()
|
| 89 |
+
self._total_queued = 0
|
| 90 |
+
self._total_processed = 0
|
| 91 |
+
|
| 92 |
+
async def enqueue(self, request_id: str, coro: Callable) -> Any:
|
| 93 |
+
self._total_queued += 1
|
| 94 |
+
future = asyncio.get_event_loop().create_future()
|
| 95 |
+
await self._queue.put((request_id, coro, future))
|
| 96 |
+
await self._process_queue()
|
| 97 |
+
return await future
|
| 98 |
+
|
| 99 |
+
async def _process_queue(self):
|
| 100 |
+
async with self._lock:
|
| 101 |
+
if self._active >= self._max_concurrent:
|
| 102 |
+
return
|
| 103 |
+
self._active += 1
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
while not self._queue.empty():
|
| 107 |
+
request_id, coro, future = await self._queue.get()
|
| 108 |
+
try:
|
| 109 |
+
result = await coro()
|
| 110 |
+
future.set_result(result)
|
| 111 |
+
self._total_processed += 1
|
| 112 |
+
except Exception as e:
|
| 113 |
+
future.set_exception(e)
|
| 114 |
+
finally:
|
| 115 |
+
async with self._lock:
|
| 116 |
+
self._active -= 1
|
| 117 |
+
|
| 118 |
+
def to_dict(self) -> dict:
|
| 119 |
+
return {
|
| 120 |
+
"queue_size": self._queue.qsize(),
|
| 121 |
+
"active": self._active,
|
| 122 |
+
"max_concurrent": self._max_concurrent,
|
| 123 |
+
"total_queued": self._total_queued,
|
| 124 |
+
"total_processed": self._total_processed,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ──────────────────────────────────────────────
|
| 129 |
+
# Layer 3: Multi-Provider Failover
|
| 130 |
+
# Se um provider esgota cota, automaticamente
|
| 131 |
+
# troca para backup e reinjeta estado da sessao.
|
| 132 |
+
# ──────────────────────────────────────────────
|
| 133 |
+
class ProviderSessionState:
|
| 134 |
+
def __init__(self):
|
| 135 |
+
self._sessions: dict[str, list[dict]] = defaultdict(list)
|
| 136 |
+
|
| 137 |
+
def append(self, provider: str, entry: dict):
|
| 138 |
+
self._sessions[provider].append(entry)
|
| 139 |
+
if len(self._sessions[provider]) > 50:
|
| 140 |
+
self._sessions[provider] = self._sessions[provider][-50:]
|
| 141 |
+
|
| 142 |
+
def get_context(self, provider: str, max_entries: int = 5) -> str:
|
| 143 |
+
entries = self._sessions.get(provider, [])[-max_entries:]
|
| 144 |
+
if not entries:
|
| 145 |
+
return ""
|
| 146 |
+
lines = [f"{e.get('role', 'user')}: {e.get('content', '')[:200]}" for e in entries]
|
| 147 |
+
return "\n".join(lines)
|
| 148 |
+
|
| 149 |
+
def to_dict(self) -> dict:
|
| 150 |
+
return {k: len(v) for k, v in self._sessions.items()}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class ProviderFailover:
|
| 154 |
+
def __init__(self, session_state: Optional[ProviderSessionState] = None):
|
| 155 |
+
self._session = session_state or ProviderSessionState()
|
| 156 |
+
self._provider_quotas: dict[str, int] = defaultdict(lambda: 100)
|
| 157 |
+
self._provider_usage: dict[str, int] = defaultdict(int)
|
| 158 |
+
|
| 159 |
+
def set_quota(self, provider: str, quota: int):
|
| 160 |
+
self._provider_quotas[provider] = quota
|
| 161 |
+
|
| 162 |
+
def is_exhausted(self, provider: str) -> bool:
|
| 163 |
+
return self._provider_usage[provider] >= self._provider_quotas[provider]
|
| 164 |
+
|
| 165 |
+
def record_usage(self, provider: str):
|
| 166 |
+
self._provider_usage[provider] += 1
|
| 167 |
+
|
| 168 |
+
def get_failover_chain(self, available_providers: list[str]) -> list[str]:
|
| 169 |
+
return [p for p in available_providers if not self.is_exhausted(p)]
|
| 170 |
+
|
| 171 |
+
def to_dict(self) -> dict:
|
| 172 |
+
return {
|
| 173 |
+
"quotas": dict(self._provider_quotas),
|
| 174 |
+
"usage": dict(self._provider_usage),
|
| 175 |
+
"sessions": self._session.to_dict(),
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ──────────────────────────────────────────────
|
| 180 |
+
# Orquestrador Multi-Camada
|
| 181 |
+
# ──────────────────────────────────────────────
|
| 182 |
+
class MultiLayerThrottle:
|
| 183 |
+
def __init__(self):
|
| 184 |
+
self.layer1 = DecentralizedThrottle()
|
| 185 |
+
self.layer2 = CentralizedQueue()
|
| 186 |
+
self.layer3 = ProviderFailover()
|
| 187 |
+
self._active_layer = 1
|
| 188 |
+
|
| 189 |
+
async def execute(self, request_id: str, provider_fn: Callable,
|
| 190 |
+
available_providers: list[str]) -> str:
|
| 191 |
+
if self.layer1.should_escalate:
|
| 192 |
+
self._active_layer = 2
|
| 193 |
+
self.layer1.reset_escalation()
|
| 194 |
+
return await self.layer2.enqueue(request_id, provider_fn)
|
| 195 |
+
|
| 196 |
+
await self.layer1.wait_if_needed()
|
| 197 |
+
chain = self.layer3.get_failover_chain(available_providers)
|
| 198 |
+
if not chain and available_providers:
|
| 199 |
+
self._active_layer = 3
|
| 200 |
+
for p in available_providers:
|
| 201 |
+
self.layer3.set_quota(p, self.layer3._provider_quotas[p] + 50)
|
| 202 |
+
chain = available_providers
|
| 203 |
+
|
| 204 |
+
last_error = None
|
| 205 |
+
for provider in chain:
|
| 206 |
+
try:
|
| 207 |
+
result = await provider_fn(provider)
|
| 208 |
+
self.layer1.record_success()
|
| 209 |
+
self._active_layer = 1
|
| 210 |
+
return result
|
| 211 |
+
except Exception as e:
|
| 212 |
+
self.layer3.record_usage(provider)
|
| 213 |
+
last_error = e
|
| 214 |
+
continue
|
| 215 |
+
|
| 216 |
+
self.layer1.record_429()
|
| 217 |
+
return PAUSED_RETRY
|
| 218 |
+
|
| 219 |
+
def to_dict(self) -> dict:
|
| 220 |
+
return {
|
| 221 |
+
"active_layer": self._active_layer,
|
| 222 |
+
"layer1": self.layer1.to_dict(),
|
| 223 |
+
"layer2": self.layer2.to_dict(),
|
| 224 |
+
"layer3": self.layer3.to_dict(),
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ──────────────────────────────────────────────
|
| 229 |
+
# Circuit Breaker State
|
| 230 |
+
# ──────────────────────────────────────────────
|
| 231 |
class CircuitBreakerState:
|
| 232 |
CLOSED = "closed"
|
| 233 |
OPEN = "open"
|
| 234 |
HALF_OPEN = "half_open"
|
| 235 |
|
| 236 |
|
| 237 |
+
# ──────────────────────────────────────────────
|
| 238 |
+
# Predictive Context Filter (existing)
|
| 239 |
+
# ──────────────────────────────────────────────
|
| 240 |
class PredictiveContextFilter:
|
| 241 |
def __init__(self, capacity: int = 4096, threshold: float = 0.8):
|
| 242 |
self.capacity = capacity
|
|
|
|
| 309 |
return f" ... [{len(lines)} lines omitted: {', '.join(summary_parts)}] ..."
|
| 310 |
|
| 311 |
|
| 312 |
+
# ──────────────────────────────────────────────
|
| 313 |
+
# Inference Router Circuit Breaker (updated)
|
| 314 |
+
# ──────────────────────────────────────────────
|
| 315 |
class InferenceRouterCircuitBreaker:
|
| 316 |
MAX_RETRIES = 3
|
| 317 |
BLACKOUT_SECONDS = 60.0
|
|
|
|
| 320 |
self.state = CircuitBreakerState.CLOSED
|
| 321 |
self.failure_count = 0
|
| 322 |
self.blackout_until = 0.0
|
| 323 |
+
self.multi_layer = MultiLayerThrottle()
|
| 324 |
|
| 325 |
def _exponential_backoff(self, attempt: int) -> float:
|
| 326 |
return float(2 ** attempt)
|
|
|
|
| 377 |
return PAUSED_RETRY
|
| 378 |
self.state = CircuitBreakerState.HALF_OPEN
|
| 379 |
|
| 380 |
+
providers_list = []
|
| 381 |
+
provider_map = {}
|
| 382 |
+
if nvidia_key:
|
| 383 |
+
providers_list.append("nvidia")
|
| 384 |
+
provider_map["nvidia"] = (self._call_nvidia, nvidia_key)
|
| 385 |
if openrouter_key:
|
| 386 |
+
providers_list.append("openrouter")
|
| 387 |
+
provider_map["openrouter"] = (self._call_openrouter, openrouter_key)
|
| 388 |
if gemini_key:
|
| 389 |
+
providers_list.append("gemini")
|
| 390 |
+
provider_map["gemini"] = (self._call_gemini, gemini_key)
|
| 391 |
|
| 392 |
+
if not providers_list:
|
| 393 |
+
return PAUSED_RETRY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
+
request_id = f"req-{uuid.uuid4().hex[:8]}"
|
| 396 |
+
|
| 397 |
+
async def try_providers() -> str:
|
| 398 |
+
for attempt in range(self.MAX_RETRIES):
|
| 399 |
+
chain = self.multi_layer.layer3.get_failover_chain(providers_list)
|
| 400 |
+
if not chain:
|
| 401 |
+
chain = providers_list
|
| 402 |
+
for provider_name in chain:
|
| 403 |
+
fn, key = provider_map[provider_name]
|
| 404 |
+
try:
|
| 405 |
+
result = await fn(prompt, key)
|
| 406 |
+
self.record_success()
|
| 407 |
+
self.multi_layer.layer3._session.append(provider_name, {"role": "assistant", "content": result[:200]})
|
| 408 |
+
return result
|
| 409 |
+
except _RateLimitError:
|
| 410 |
+
self.multi_layer.layer3.record_usage(provider_name)
|
| 411 |
+
self.multi_layer.layer1.record_429()
|
| 412 |
+
continue
|
| 413 |
+
except Exception as e:
|
| 414 |
+
self.multi_layer.layer3.record_usage(provider_name)
|
| 415 |
+
continue
|
| 416 |
+
backoff = self._exponential_backoff(attempt)
|
| 417 |
+
await asyncio.sleep(backoff)
|
| 418 |
+
self.record_failure()
|
| 419 |
+
return PAUSED_RETRY
|
| 420 |
+
|
| 421 |
+
result = await self.multi_layer.execute(request_id, try_providers, providers_list)
|
| 422 |
+
return result
|
| 423 |
|
| 424 |
|
| 425 |
class _RateLimitError(Exception):
|
sandbox_manager.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import subprocess
|
| 7 |
+
import tempfile
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from huggingface_hub import HfApi
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger("altamira.sandbox")
|
| 16 |
+
|
| 17 |
+
SANDBOX_TEMPLATE = '''import os, json, subprocess, sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 20 |
+
from fastapi.responses import JSONResponse
|
| 21 |
+
import uvicorn
|
| 22 |
+
|
| 23 |
+
app = FastAPI(title="Altamira Sandbox")
|
| 24 |
+
|
| 25 |
+
WORKSPACE = Path("/workspace")
|
| 26 |
+
WORKSPACE.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
@app.post("/exec")
|
| 29 |
+
async def exec_command(body: dict):
|
| 30 |
+
command = body.get("command", "")
|
| 31 |
+
timeout = int(body.get("timeout", 30))
|
| 32 |
+
env = {**os.environ}
|
| 33 |
+
extra_env = body.get("env", {})
|
| 34 |
+
env.update(extra_env)
|
| 35 |
+
try:
|
| 36 |
+
result = subprocess.run(
|
| 37 |
+
command, shell=True, capture_output=True, text=True,
|
| 38 |
+
timeout=timeout, cwd=str(WORKSPACE), env=env,
|
| 39 |
+
)
|
| 40 |
+
return {"stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}
|
| 41 |
+
except subprocess.TimeoutExpired:
|
| 42 |
+
raise HTTPException(408, "Command timed out")
|
| 43 |
+
except Exception as e:
|
| 44 |
+
raise HTTPException(500, str(e))
|
| 45 |
+
|
| 46 |
+
@app.post("/write")
|
| 47 |
+
async def write_file(body: dict):
|
| 48 |
+
path = WORKSPACE / body.get("path", "")
|
| 49 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
path.write_text(body.get("content", ""))
|
| 51 |
+
return {"path": str(path), "size": len(body.get("content", ""))}
|
| 52 |
+
|
| 53 |
+
@app.post("/read")
|
| 54 |
+
async def read_file(body: dict):
|
| 55 |
+
path = WORKSPACE / body.get("path", "")
|
| 56 |
+
if not path.exists():
|
| 57 |
+
raise HTTPException(404)
|
| 58 |
+
return {"path": str(path), "content": path.read_text()}
|
| 59 |
+
|
| 60 |
+
@app.post("/files")
|
| 61 |
+
async def list_files():
|
| 62 |
+
files = []
|
| 63 |
+
for f in WORKSPACE.rglob("*"):
|
| 64 |
+
if f.is_file():
|
| 65 |
+
files.append({"name": str(f.relative_to(WORKSPACE)), "size": f.stat().st_size})
|
| 66 |
+
return {"files": files}
|
| 67 |
+
|
| 68 |
+
@app.get("/health")
|
| 69 |
+
async def health():
|
| 70 |
+
return {"status": "healthy", "sandbox": True}
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 74 |
+
'''
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class SandboxManager:
|
| 78 |
+
def __init__(self, hf_token: str = ""):
|
| 79 |
+
self._hf_token = hf_token or os.environ.get("HF_TOKEN", "")
|
| 80 |
+
self._api = HfApi()
|
| 81 |
+
self._sandbox_registry: dict[str, dict] = {}
|
| 82 |
+
self._registry_file = Path("/tmp/altamira-sandbox-registry.json")
|
| 83 |
+
self._load_registry()
|
| 84 |
+
|
| 85 |
+
def _load_registry(self):
|
| 86 |
+
if self._registry_file.exists():
|
| 87 |
+
try:
|
| 88 |
+
self._sandbox_registry = json.loads(self._registry_file.read_text())
|
| 89 |
+
except Exception:
|
| 90 |
+
self._sandbox_registry = {}
|
| 91 |
+
|
| 92 |
+
def _save_registry(self):
|
| 93 |
+
self._registry_file.parent.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
self._registry_file.write_text(json.dumps(self._sandbox_registry, indent=2))
|
| 95 |
+
|
| 96 |
+
async def create_sandbox_space(
|
| 97 |
+
self,
|
| 98 |
+
project_name: str,
|
| 99 |
+
sandbox_name: str = "",
|
| 100 |
+
hf_token: str = "",
|
| 101 |
+
) -> dict:
|
| 102 |
+
token = hf_token or self._hf_token
|
| 103 |
+
if not token:
|
| 104 |
+
return {"status": "error", "error": "HF token required to create sandbox Spaces"}
|
| 105 |
+
|
| 106 |
+
if not sandbox_name:
|
| 107 |
+
safe_name = project_name.lower().replace(" ", "-").replace("_", "-")
|
| 108 |
+
sandbox_name = f"altamira-sandbox-{safe_name}-{uuid.uuid4().hex[:6]}"
|
| 109 |
+
|
| 110 |
+
username = self._get_username(token)
|
| 111 |
+
if not username:
|
| 112 |
+
return {"status": "error", "error": "Could not determine HF username"}
|
| 113 |
+
|
| 114 |
+
repo_id = f"{username}/{sandbox_name}"
|
| 115 |
+
try:
|
| 116 |
+
self._api.create_repo(
|
| 117 |
+
repo_id=repo_id,
|
| 118 |
+
repo_type="space",
|
| 119 |
+
token=token,
|
| 120 |
+
private=False,
|
| 121 |
+
space_sdk="docker",
|
| 122 |
+
)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
if "already exists" in str(e).lower():
|
| 125 |
+
pass
|
| 126 |
+
else:
|
| 127 |
+
return {"status": "error", "error": f"Failed to create space: {e}"}
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
dockerfile_content = f"""FROM python:3.9
|
| 131 |
+
WORKDIR /code
|
| 132 |
+
RUN pip install fastapi uvicorn httpx
|
| 133 |
+
COPY sandbox_server.py /code/sandbox_server.py
|
| 134 |
+
CMD ["python", "sandbox_server.py"]
|
| 135 |
+
"""
|
| 136 |
+
server_content = SANDBOX_TEMPLATE
|
| 137 |
+
|
| 138 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 139 |
+
tmp_path = Path(tmpdir)
|
| 140 |
+
(tmp_path / "Dockerfile").write_text(dockerfile_content)
|
| 141 |
+
(tmp_path / "sandbox_server.py").write_text(server_content)
|
| 142 |
+
(tmp_path / "README.md").write_text(
|
| 143 |
+
f"---\ntitle: {sandbox_name}\nemojj: 🔵\nsdk: docker\npinned: false\n---\n\nAltamira sandbox for project: {project_name}"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
self._api.upload_file(
|
| 147 |
+
path_or_fileobj=str(tmp_path / "Dockerfile"),
|
| 148 |
+
path_in_repo="Dockerfile",
|
| 149 |
+
repo_id=repo_id,
|
| 150 |
+
repo_type="space",
|
| 151 |
+
token=token,
|
| 152 |
+
)
|
| 153 |
+
self._api.upload_file(
|
| 154 |
+
path_or_fileobj=str(tmp_path / "sandbox_server.py"),
|
| 155 |
+
path_in_repo="sandbox_server.py",
|
| 156 |
+
repo_id=repo_id,
|
| 157 |
+
repo_type="space",
|
| 158 |
+
token=token,
|
| 159 |
+
)
|
| 160 |
+
self._api.upload_file(
|
| 161 |
+
path_or_fileobj=str(tmp_path / "README.md"),
|
| 162 |
+
path_in_repo="README.md",
|
| 163 |
+
repo_id=repo_id,
|
| 164 |
+
repo_type="space",
|
| 165 |
+
token=token,
|
| 166 |
+
)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
return {"status": "error", "error": f"Failed to upload files: {e}"}
|
| 169 |
+
|
| 170 |
+
entry = {
|
| 171 |
+
"project": project_name,
|
| 172 |
+
"sandbox_name": sandbox_name,
|
| 173 |
+
"repo_id": repo_id,
|
| 174 |
+
"created": time.time(),
|
| 175 |
+
"status": "created",
|
| 176 |
+
"url": f"https://huggingface.co/spaces/{repo_id}",
|
| 177 |
+
"endpoint": f"https://{repo_id.replace('/', '-')}.hf.space",
|
| 178 |
+
}
|
| 179 |
+
self._sandbox_registry[project_name] = entry
|
| 180 |
+
self._save_registry()
|
| 181 |
+
return {"status": "created", **entry}
|
| 182 |
+
|
| 183 |
+
async def delete_sandbox_space(self, project_name: str, hf_token: str = "") -> dict:
|
| 184 |
+
token = hf_token or self._hf_token
|
| 185 |
+
entry = self._sandbox_registry.get(project_name)
|
| 186 |
+
if not entry:
|
| 187 |
+
return {"status": "error", "error": f"No sandbox for project: {project_name}"}
|
| 188 |
+
|
| 189 |
+
repo_id = entry["repo_id"]
|
| 190 |
+
try:
|
| 191 |
+
self._api.delete_repo(repo_id=repo_id, repo_type="space", token=token)
|
| 192 |
+
except Exception as e:
|
| 193 |
+
return {"status": "error", "error": f"Failed to delete space: {e}"}
|
| 194 |
+
|
| 195 |
+
del self._sandbox_registry[project_name]
|
| 196 |
+
self._save_registry()
|
| 197 |
+
return {"status": "deleted", "repo_id": repo_id}
|
| 198 |
+
|
| 199 |
+
async def get_sandbox_status(self, project_name: str) -> Optional[dict]:
|
| 200 |
+
return self._sandbox_registry.get(project_name)
|
| 201 |
+
|
| 202 |
+
async def list_sandboxes(self) -> dict:
|
| 203 |
+
return dict(self._sandbox_registry)
|
| 204 |
+
|
| 205 |
+
async def exec_in_sandbox(
|
| 206 |
+
self,
|
| 207 |
+
project_name: str,
|
| 208 |
+
command: str,
|
| 209 |
+
env: Optional[dict] = None,
|
| 210 |
+
timeout: int = 60,
|
| 211 |
+
) -> dict:
|
| 212 |
+
entry = self._sandbox_registry.get(project_name)
|
| 213 |
+
if not entry:
|
| 214 |
+
return {"status": "error", "error": f"No sandbox for project: {project_name}"}
|
| 215 |
+
|
| 216 |
+
endpoint = entry["endpoint"]
|
| 217 |
+
import httpx
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 221 |
+
resp = await client.post(
|
| 222 |
+
f"{endpoint}/exec",
|
| 223 |
+
json={"command": command, "timeout": timeout, "env": env or {}},
|
| 224 |
+
)
|
| 225 |
+
if resp.status_code != 200:
|
| 226 |
+
return {"status": "error", "error": f"Sandbox returned {resp.status_code}: {resp.text}"}
|
| 227 |
+
return {"status": "ok", **resp.json()}
|
| 228 |
+
except Exception as e:
|
| 229 |
+
return {"status": "error", "error": f"Sandbox connection failed: {e}"}
|
| 230 |
+
|
| 231 |
+
async def write_to_sandbox(self, project_name: str, path: str, content: str) -> dict:
|
| 232 |
+
entry = self._sandbox_registry.get(project_name)
|
| 233 |
+
if not entry:
|
| 234 |
+
return {"status": "error", "error": f"No sandbox for project: {project_name}"}
|
| 235 |
+
|
| 236 |
+
import httpx
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 240 |
+
resp = await client.post(
|
| 241 |
+
f"{entry['endpoint']}/write",
|
| 242 |
+
json={"path": path, "content": content},
|
| 243 |
+
)
|
| 244 |
+
if resp.status_code != 200:
|
| 245 |
+
return {"status": "error", "error": f"Write failed: {resp.text}"}
|
| 246 |
+
return {"status": "ok", **resp.json()}
|
| 247 |
+
except Exception as e:
|
| 248 |
+
return {"status": "error", "error": f"Sandbox write failed: {e}"}
|
| 249 |
+
|
| 250 |
+
async def read_from_sandbox(self, project_name: str, path: str) -> dict:
|
| 251 |
+
entry = self._sandbox_registry.get(project_name)
|
| 252 |
+
if not entry:
|
| 253 |
+
return {"status": "error", "error": f"No sandbox for project: {project_name}"}
|
| 254 |
+
|
| 255 |
+
import httpx
|
| 256 |
+
|
| 257 |
+
try:
|
| 258 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 259 |
+
resp = await client.post(
|
| 260 |
+
f"{entry['endpoint']}/read",
|
| 261 |
+
json={"path": path},
|
| 262 |
+
)
|
| 263 |
+
if resp.status_code != 200:
|
| 264 |
+
return {"status": "error", "error": f"Read failed: {resp.text}"}
|
| 265 |
+
return {"status": "ok", **resp.json()}
|
| 266 |
+
except Exception as e:
|
| 267 |
+
return {"status": "error", "error": f"Sandbox read failed: {e}"}
|
| 268 |
+
|
| 269 |
+
async def list_sandbox_files(self, project_name: str) -> dict:
|
| 270 |
+
entry = self._sandbox_registry.get(project_name)
|
| 271 |
+
if not entry:
|
| 272 |
+
return {"status": "error", "error": f"No sandbox for project: {project_name}"}
|
| 273 |
+
|
| 274 |
+
import httpx
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 278 |
+
endpoint = entry["endpoint"]
|
| 279 |
+
resp = await client.get(f"{endpoint}/files")
|
| 280 |
+
if resp.status_code != 200:
|
| 281 |
+
return {"status": "error", "error": f"List files failed: {resp.text}"}
|
| 282 |
+
return {"status": "ok", **resp.json()}
|
| 283 |
+
except Exception as e:
|
| 284 |
+
return {"status": "error", "error": f"Sandbox list files failed: {e}"}
|
| 285 |
+
|
| 286 |
+
def _get_username(self, token: str) -> Optional[str]:
|
| 287 |
+
try:
|
| 288 |
+
who = self._api.whoami(token=token)
|
| 289 |
+
return who.get("name", who.get("login", ""))
|
| 290 |
+
except Exception:
|
| 291 |
+
return None
|
templates/index.html
CHANGED
|
@@ -1,537 +1,612 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
<head>
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
select, input, textarea { background: #1E293B; border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 8px 12px; font-size: 13px; color: #E2E8F0; outline: none; }
|
| 29 |
-
select:focus, input:focus, textarea:focus { border-color: rgba(99,102,241,0.5); }
|
| 30 |
-
button { transition: all 0.15s; }
|
| 31 |
-
</style>
|
| 32 |
</head>
|
| 33 |
-
<body>
|
| 34 |
-
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
<div
|
| 39 |
-
|
| 40 |
-
<div class="w-12 h-12 rounded-xl bg-indigo-600 flex items-center justify-center text-xl font-bold mx-auto mb-4">A</div>
|
| 41 |
-
<h1 class="text-xl font-bold text-indigo-400 mb-1">Altamira</h1>
|
| 42 |
-
<p class="text-xs text-gray-500 mb-6">Sign in to continue</p>
|
| 43 |
-
<button @click="oauthLogin()" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg px-4 py-3 text-sm font-medium flex items-center justify-center gap-3">
|
| 44 |
-
<i class="fa-regular fa-face-smile text-lg"></i> Login with Hugging Face
|
| 45 |
-
</button>
|
| 46 |
-
</div>
|
| 47 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
</template>
|
| 96 |
-
<div x-show="chatMessages.length === 0" class="text-gray-600 text-center py-12 text-xs">Agent ready. Describe what to build or run.</div>
|
| 97 |
-
<div x-show="chatLoading" class="flex justify-start"><div class="bg-gray-800 text-gray-400 rounded-2xl rounded-bl-sm px-4 py-2 text-sm"><i class="fas fa-spinner fa-spin mr-2"></i>Thinking...</div></div>
|
| 98 |
-
</div>
|
| 99 |
-
|
| 100 |
-
<!-- chat input -->
|
| 101 |
-
<div class="flex gap-2 pt-2 border-t border-white/5">
|
| 102 |
-
<div class="relative flex-1">
|
| 103 |
-
<i class="fas fa-robot absolute left-3 top-2.5 text-gray-600 text-xs"></i>
|
| 104 |
-
<input x-model="chatInput" @keydown.enter="sendChat()" class="w-full pl-8 pr-3 py-2 text-sm" placeholder="Ask the agent...">
|
| 105 |
-
</div>
|
| 106 |
-
<button @click="sendChat()" class="bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 rounded-lg text-sm"><i class="fas fa-paper-plane"></i></button>
|
| 107 |
-
</div>
|
| 108 |
</div>
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</div>
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<input x-model="newProjectName" class="w-full" placeholder="Project name">
|
| 123 |
-
<input x-model="newProjectRepo" class="w-full font-mono" placeholder="GitHub repo URL">
|
| 124 |
-
<div class="flex gap-2">
|
| 125 |
-
<button @click="createProject(); showAddProject = false" class="bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-lg text-xs">Create</button>
|
| 126 |
-
<button @click="showAddProject = false" class="text-xs text-gray-500">Cancel</button>
|
| 127 |
-
</div>
|
| 128 |
</div>
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
<div class="card">
|
| 134 |
-
<div class="flex items-center justify-between">
|
| 135 |
-
<div class="flex items-center gap-2 min-w-0">
|
| 136 |
-
<div class="w-2 h-2 rounded-full flex-shrink-0" :class="proj.active ? 'bg-green-500' : 'bg-gray-600'"></div>
|
| 137 |
-
<span class="text-sm font-medium truncate" x-text="name"></span>
|
| 138 |
-
<span class="text-xs px-1.5 py-0.5 rounded-full" :class="proj.active ? 'bg-green-900/50 text-green-300' : 'bg-gray-800 text-gray-500'" x-text="proj.active ? 'running' : 'sleeping'"></span>
|
| 139 |
-
</div>
|
| 140 |
-
<div class="flex gap-2 flex-shrink-0">
|
| 141 |
-
<button @click="openTestLink(name)" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded" title="Open Repo"><i class="fas fa-external-link-alt"></i></button>
|
| 142 |
-
<button @click="activateProject(name)" class="text-xs px-1" :class="projectLoading[name + '-activate'] ? 'text-gray-600' : 'text-indigo-400 hover:text-indigo-300'" title="Activate"><i :class="projectLoading[name + '-activate'] ? 'fas fa-spinner fa-spin' : 'fas fa-play'"></i></button>
|
| 143 |
-
<button @click="gitSyncProject(name)" class="text-xs px-1" :class="projectLoading[name + '-sync'] ? 'text-gray-600' : 'text-emerald-400 hover:text-emerald-300'" title="Git Sync"><i :class="projectLoading[name + '-sync'] ? 'fas fa-spinner fa-spin' : 'fas fa-sync'"></i></button>
|
| 144 |
-
<button @click="toggleProjectLogs(name)" class="text-xs text-gray-500 hover:text-gray-300 px-1" title="Workspace Path"><i class="fas fa-folder"></i></button>
|
| 145 |
-
</div>
|
| 146 |
-
</div>
|
| 147 |
-
<div x-show="proj.repo_url" class="mt-1 text-xs text-gray-600 font-mono truncate" x-text="proj.repo_url"></div>
|
| 148 |
-
<!-- expandable terminal -->
|
| 149 |
-
<div x-show="expandedProject === name" class="mt-2 pt-2 border-t border-white/5">
|
| 150 |
-
<div class="bg-gray-950 rounded-lg p-2 max-h-32 overflow-y-auto term-line text-xs space-y-0.5">
|
| 151 |
-
<div class="text-gray-600">─ project logs ─</div>
|
| 152 |
-
<template x-for="(line, j) in (projectLogs[name] || [])" :key="j">
|
| 153 |
-
<div x-text="line" :class="line.includes('error') ? 'text-red-400' : 'text-gray-400'"></div>
|
| 154 |
-
</template>
|
| 155 |
-
<div x-show="!(projectLogs[name] || []).length" class="text-gray-700">No output yet. Activate and run commands.</div>
|
| 156 |
-
</div>
|
| 157 |
-
</div>
|
| 158 |
-
</div>
|
| 159 |
-
</template>
|
| 160 |
-
<div x-show="Object.keys(projects).length === 0" class="text-gray-600 text-center py-12 text-xs">No projects. Click <span class="text-indigo-400">New</span> to create one.</div>
|
| 161 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</div>
|
| 165 |
|
| 166 |
-
<
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
</div>
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
</div>
|
| 178 |
-
<
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
</div>
|
| 185 |
-
<div class="flex items-center gap-3">
|
| 186 |
-
<span class="text-xs font-mono text-gray-600" x-text="cred.value ? '********' : 'not set'"></span>
|
| 187 |
-
<button @click="openResourceEditor(key)" class="text-xs text-indigo-400 hover:text-indigo-300"><i class="fas fa-pen"></i></button>
|
| 188 |
-
</div>
|
| 189 |
-
</div>
|
| 190 |
-
</template>
|
| 191 |
-
<div x-show="Object.keys(credentials).length === 0" class="text-gray-600 text-center py-8 text-sm">No resources configured.</div>
|
| 192 |
-
</div>
|
| 193 |
-
|
| 194 |
-
<!-- LLM provider status -->
|
| 195 |
-
<div class="panel mt-4">
|
| 196 |
-
<h3 class="text-sm font-medium text-indigo-300 mb-3"><i class="fas fa-bolt mr-2"></i>LLM Router Status</h3>
|
| 197 |
-
<div class="grid grid-cols-3 gap-3 text-sm">
|
| 198 |
-
<div class="bg-gray-900 rounded-lg p-3">
|
| 199 |
-
<div class="flex items-center gap-2">
|
| 200 |
-
<div class="w-2 h-2 rounded-full" :class="nvidiaKey ? 'bg-green-500' : 'bg-gray-600'"></div>
|
| 201 |
-
<span class="text-xs text-gray-400">NVIDIA NIM</span>
|
| 202 |
-
</div>
|
| 203 |
-
<div class="text-xs font-mono mt-1" :class="nvidiaKey ? 'text-green-400' : 'text-gray-600'" x-text="nvidiaKey ? 'key set' : 'not set'"></div>
|
| 204 |
-
</div>
|
| 205 |
-
<div class="bg-gray-900 rounded-lg p-3">
|
| 206 |
-
<div class="flex items-center gap-2">
|
| 207 |
-
<div class="w-2 h-2 rounded-full" :class="openrouterKey ? 'bg-green-500' : 'bg-gray-600'"></div>
|
| 208 |
-
<span class="text-xs text-gray-400">OpenRouter</span>
|
| 209 |
-
</div>
|
| 210 |
-
<div class="text-xs font-mono mt-1" :class="openrouterKey ? 'text-green-400' : 'text-gray-600'" x-text="openrouterKey ? 'key set' : 'not set'"></div>
|
| 211 |
-
</div>
|
| 212 |
-
<div class="bg-gray-900 rounded-lg p-3">
|
| 213 |
-
<div class="flex items-center gap-2">
|
| 214 |
-
<div class="w-2 h-2 rounded-full" :class="geminiKey ? 'bg-green-500' : 'bg-gray-600'"></div>
|
| 215 |
-
<span class="text-xs text-gray-400">Gemini</span>
|
| 216 |
-
</div>
|
| 217 |
-
<div class="text-xs font-mono mt-1" :class="geminiKey ? 'text-green-400' : 'text-gray-600'" x-text="geminiKey ? 'key set' : 'not set'"></div>
|
| 218 |
-
</div>
|
| 219 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
</div>
|
| 222 |
|
| 223 |
-
<
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
</div>
|
| 265 |
-
</div>
|
| 266 |
-
<div class="flex gap-3 pt-2">
|
| 267 |
-
<button @click="saveResource()" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg px-4 py-2 text-sm font-medium">Save Resource</button>
|
| 268 |
-
<button @click="resourcePhase = 1; resourceType = ''" class="text-sm text-gray-500 hover:text-gray-300">Back</button>
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
</div>
|
|
|
|
|
|
|
| 272 |
</div>
|
|
|
|
|
|
|
| 273 |
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
</div>
|
| 277 |
|
| 278 |
<script>
|
| 279 |
-
function app()
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
},
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
},
|
| 367 |
-
|
| 368 |
-
//
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
this.scrollChat();
|
| 411 |
-
try {
|
| 412 |
-
const d = await this.api('/api/agent/submit', {method: 'POST', body: JSON.stringify({prompt: text, project: this.activeProject || 'default'})});
|
| 413 |
-
const dagId = d.dag_id;
|
| 414 |
-
this.chatMessages.push({role: 'system', content: 'Planning tasks...'});
|
| 415 |
-
// Poll until tasks complete
|
| 416 |
-
let done = false;
|
| 417 |
-
let attempts = 0;
|
| 418 |
-
while (!done && attempts < 60) {
|
| 419 |
-
await new Promise(r => setTimeout(r, 1000));
|
| 420 |
-
await this.api('/api/agent/cycle', {method: 'POST'});
|
| 421 |
-
const status = await this.api('/api/agent/status');
|
| 422 |
-
const s = status.tasks || {};
|
| 423 |
-
const pending = s.pending || 0;
|
| 424 |
-
const running = s.running || 0;
|
| 425 |
-
const reviewing = s.reviewing || 0;
|
| 426 |
-
if (running > 0 || reviewing > 0) {
|
| 427 |
-
this.chatMessages[this.chatMessages.length - 1] = {role: 'system', content: 'Working... (' + running + ' running, ' + reviewing + ' reviewing)'};
|
| 428 |
-
}
|
| 429 |
-
if (pending === 0 && running === 0 && reviewing === 0) {
|
| 430 |
-
done = true;
|
| 431 |
-
}
|
| 432 |
-
attempts++;
|
| 433 |
-
this.scrollChat();
|
| 434 |
-
}
|
| 435 |
-
// Fetch results
|
| 436 |
-
const tasks = await this.api('/api/agent/tasks', {method: 'GET'});
|
| 437 |
-
const taskList = tasks.tasks || [];
|
| 438 |
-
const results = taskList.filter(t => t.dag_id === dagId && t.output);
|
| 439 |
-
for (const t of results) {
|
| 440 |
-
this.chatMessages.push({role: 'output', content: t.output || '(no output)'});
|
| 441 |
-
if (t.error) this.chatMessages.push({role: 'error', content: t.error});
|
| 442 |
-
}
|
| 443 |
-
this.chatMessages.push({role: 'agent', content: 'Done. ' + results.length + ' tasks completed.'});
|
| 444 |
-
} catch(e) {
|
| 445 |
-
this.chatMessages.push({role: 'error', content: e.message});
|
| 446 |
-
}
|
| 447 |
-
this.chatLoading = false;
|
| 448 |
-
this.scrollChat();
|
| 449 |
-
},
|
| 450 |
-
scrollChat() {
|
| 451 |
-
this.$nextTick(() => { const el = this.$refs.chatBox; if (el) el.scrollTop = el.scrollHeight; });
|
| 452 |
-
},
|
| 453 |
-
|
| 454 |
-
// ---- projects ----
|
| 455 |
-
async loadProjects() {
|
| 456 |
-
try { this.projects = await this.api('/api/projects'); } catch {}
|
| 457 |
-
},
|
| 458 |
-
async createProject() {
|
| 459 |
-
if (!this.newProjectName) return;
|
| 460 |
-
try {
|
| 461 |
-
await this.api('/api/projects', {method: 'POST', body: JSON.stringify({name: this.newProjectName, repo_url: this.newProjectRepo})});
|
| 462 |
-
this.newProjectName = ''; this.newProjectRepo = '';
|
| 463 |
-
await this.loadProjects();
|
| 464 |
-
} catch(e) {}
|
| 465 |
-
},
|
| 466 |
-
async activateProject(name) {
|
| 467 |
-
this.projectLoading[name + '-activate'] = true;
|
| 468 |
-
try {
|
| 469 |
-
await this.api('/api/projects/' + name + '/activate', {method: 'POST'});
|
| 470 |
-
await this.loadProjects();
|
| 471 |
-
this.activeProject = name;
|
| 472 |
-
} catch(e) {} finally { this.projectLoading[name + '-activate'] = false; }
|
| 473 |
-
},
|
| 474 |
-
async gitSyncProject(name) {
|
| 475 |
-
this.projectLoading[name + '-sync'] = true;
|
| 476 |
-
try { await this.api('/api/projects/' + name + '/git-sync', {method: 'POST'}); } catch(e) {} finally { this.projectLoading[name + '-sync'] = false; }
|
| 477 |
-
},
|
| 478 |
-
toggleProjectLogs(name) {
|
| 479 |
-
this.expandedProject = this.expandedProject === name ? null : name;
|
| 480 |
-
if (!this.projectLogs[name]) {
|
| 481 |
-
this.projectLogs[name] = ['[' + new Date().toLocaleTimeString() + '] connected', '[' + new Date().toLocaleTimeString() + '] workspace: /tmp/altamira-workspace/' + name];
|
| 482 |
-
}
|
| 483 |
-
},
|
| 484 |
-
openTestLink(name) {
|
| 485 |
-
const proj = this.projects[name];
|
| 486 |
-
if (proj && proj.repo_url) window.open(proj.repo_url, '_blank');
|
| 487 |
-
},
|
| 488 |
-
|
| 489 |
-
// ---- resources ----
|
| 490 |
-
async loadCredentials() {
|
| 491 |
-
try {
|
| 492 |
-
const d = await this.api('/api/resources');
|
| 493 |
-
this.credentials = d.credentials || {};
|
| 494 |
-
this.nvidiaKey = !!(d.credentials || {}).NVIDIA_NIM_API_KEY;
|
| 495 |
-
this.openrouterKey = !!(d.credentials || {}).OPENROUTER_API_KEY;
|
| 496 |
-
this.geminiKey = !!(d.credentials || {}).GEMINI_API_KEY;
|
| 497 |
-
} catch {}
|
| 498 |
-
},
|
| 499 |
-
|
| 500 |
-
// ---- resource builder modal ----
|
| 501 |
-
openResourceBuilder() {
|
| 502 |
-
this.resourceModal = true;
|
| 503 |
-
this.resourcePhase = 1;
|
| 504 |
-
this.resourceType = '';
|
| 505 |
-
this.resourceName = '';
|
| 506 |
-
this.resourceValue = '';
|
| 507 |
-
this.resourceAuth = 'manual';
|
| 508 |
-
this.resourceScope = 'core';
|
| 509 |
-
this.resourceProjects = [];
|
| 510 |
-
},
|
| 511 |
-
openResourceEditor(key) {
|
| 512 |
-
this.resourceModal = true;
|
| 513 |
-
this.resourcePhase = 2;
|
| 514 |
-
this.resourceType = key;
|
| 515 |
-
this.resourceName = key;
|
| 516 |
-
this.resourceValue = this.credentials[key]?.value || '';
|
| 517 |
-
this.resourceRequiresAuth = true;
|
| 518 |
-
},
|
| 519 |
-
selectResourceClass(key) {
|
| 520 |
-
this.resourceType = key;
|
| 521 |
-
this.resourcePhase = 2;
|
| 522 |
-
this.resourceRequiresAuth = key !== 'storage';
|
| 523 |
-
this.resourceName = key === 'databases' ? 'my-db' : key === 'ai' ? 'my-ai-key' : key === 'saas' ? 'my-saas' : 'my-resource';
|
| 524 |
-
},
|
| 525 |
-
async saveResource() {
|
| 526 |
-
if (!this.resourceName || !this.resourceValue) return;
|
| 527 |
-
try {
|
| 528 |
-
await this.api('/api/resources/' + this.resourceName, {method: 'PUT', body: JSON.stringify({value: this.resourceValue, description: this.resourceType})});
|
| 529 |
-
this.resourceModal = false;
|
| 530 |
-
await this.loadCredentials();
|
| 531 |
-
} catch(e) {}
|
| 532 |
-
},
|
| 533 |
-
};
|
| 534 |
}
|
| 535 |
</script>
|
| 536 |
</body>
|
| 537 |
-
</html>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html class="dark" lang="en" style="width: 1280px; height: 1024px; overflow: hidden; position: relative;">
|
| 3 |
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
| 6 |
+
<title>ALTAMIRA-COMMAND | AADE</title>
|
| 7 |
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
| 8 |
+
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;600;700&family=JetBrains+Mono:wght@500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
| 11 |
+
<style>
|
| 12 |
+
.material-symbols-outlined{font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24}
|
| 13 |
+
.custom-scrollbar::-webkit-scrollbar{width:4px}
|
| 14 |
+
.custom-scrollbar::-webkit-scrollbar-track{background:#121416}
|
| 15 |
+
.custom-scrollbar::-webkit-scrollbar-thumb{background:#424754;border-radius:2px}
|
| 16 |
+
.scanline{width:100%;height:2px;background:rgba(173,198,255,0.05);position:absolute;animation:scanline 6s linear infinite;pointer-events:none}
|
| 17 |
+
@keyframes scanline{0%{top:0}100%{top:100%}}
|
| 18 |
+
.pulse-dot{animation:pulse-dot 2s ease-in-out infinite}
|
| 19 |
+
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.3}}
|
| 20 |
+
.swing{animation:swing .35s ease-out}
|
| 21 |
+
@keyframes swing{0%{opacity:0;transform:scale(.97) translateY(6px)}100%{opacity:1;transform:scale(1) translateY(0)}}
|
| 22 |
+
.log-entry{animation:fadeIn .3s ease-out}
|
| 23 |
+
@keyframes fadeIn{0%{opacity:0;transform:translateX(-8px)}100%{opacity:1;transform:translateX(0)}}
|
| 24 |
+
</style>
|
| 25 |
+
<script>
|
| 26 |
+
tailwind.config={darkMode:"class",theme:{extend:{colors:{"surface-tint":"#adc6ff","on-surface-variant":"#c2c6d6","on-primary-fixed":"#001a42","tertiary-container":"#df7412","secondary-fixed":"#d3e4fe","surface-container-low":"#1a1c1e","secondary-container":"#3a4a5f","on-error-container":"#ffdad6","tertiary":"#ffb786","surface-bright":"#37393b","error-container":"#93000a","surface-variant":"#333537","on-surface":"#e2e2e5","outline":"#8c909f","on-tertiary-container":"#461f00","primary-fixed":"#d8e2ff","on-tertiary-fixed":"#311400","on-secondary":"#213145","tertiary-fixed-dim":"#ffb786","on-secondary-fixed":"#0b1c30","primary-container":"#4d8eff","on-tertiary":"#502400","on-primary-container":"#00285d","tertiary-fixed":"#ffdcc6","surface-container-lowest":"#0c0e10","on-tertiary-fixed-variant":"#723600","on-primary-fixed-variant":"#004395","error":"#ffb4ab","background":"#121416","surface-dim":"#121416","primary-fixed-dim":"#adc6ff","outline-variant":"#424754","surface-container-high":"#282a2c","on-secondary-fixed-variant":"#38485d","on-background":"#e2e2e5","inverse-on-surface":"#2f3133","primary":"#adc6ff","secondary-fixed-dim":"#b7c8e1","surface":"#121416","on-secondary-container":"#a9bad3","inverse-surface":"#e2e2e5","surface-container-highest":"#333537","on-error":"#690005","secondary":"#b7c8e1","surface-container":"#1e2022","on-primary":"#002e6a","inverse-primary":"#005ac2"},borderRadius:{DEFAULT:"0.125rem",lg:"0.25rem",xl:"0.5rem",full:"0.75rem"},spacing:{gutter:"12px",xl:"32px",sm:"8px",base_unit:"4px",xs:"4px",lg:"24px",md:"16px",margin:"16px"},fontFamily:{"display-lg":["Hanken Grotesk"],"status-sm":["JetBrains Mono"],"label-caps":["JetBrains Mono"],"headline-md":["Hanken Grotesk"],"data-mono":["JetBrains Mono"],"body-md":["Hanken Grotesk"]},fontSize:{"display-lg":["32px",{"lineHeight":"40px","letterSpacing":"-0.02em","fontWeight":"700"}],"status-sm":["12px",{"lineHeight":"16px","fontWeight":"600"}],"label-caps":["11px",{"lineHeight":"16px","letterSpacing":"0.05em","fontWeight":"700"}],"headline-md":["20px",{"lineHeight":"28px","letterSpacing":"-0.01em","fontWeight":"600"}],"data-mono":["13px",{"lineHeight":"18px","letterSpacing":"-0.01em","fontWeight":"500"}],"body-md":["14px",{"lineHeight":"20px","fontWeight":"400"}]}}}}
|
| 27 |
+
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</head>
|
| 29 |
+
<body x-data="app()" class="bg-background text-on-background font-body-md h-screen overflow-hidden">
|
| 30 |
+
|
| 31 |
+
<!-- ===== ONBOARDING OVERLAY ===== -->
|
| 32 |
+
<div x-show="screen==='onboarding'" class="fixed inset-0 bg-background z-[9999] flex items-center justify-center" style="background:radial-gradient(ellipse at 50% 0%,rgba(77,142,255,.08),transparent 60%)">
|
| 33 |
+
<div class="w-full max-w-sm bg-surface-container-low border border-outline-variant rounded-xl p-8 text-center swing">
|
| 34 |
+
<div class="w-14 h-14 rounded-full bg-primary/20 text-primary flex items-center justify-center mx-auto mb-5">
|
| 35 |
+
<span class="material-symbols-outlined text-[32px]">terminal</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
+
<h1 class="font-display-lg text-display-lg text-primary mb-1">Altamira AADE</h1>
|
| 38 |
+
<p class="font-body-md text-on-surface-variant mb-8">Autonomous AI Development Environment</p>
|
| 39 |
+
<button @click="oauthLogin()" class="w-full bg-primary text-on-primary font-status-sm py-3.5 rounded-lg flex items-center justify-center gap-3 hover:opacity-90 transition-opacity mb-3">
|
| 40 |
+
<span class="material-symbols-outlined text-[18px]">fingerprint</span> Login with Hugging Face
|
| 41 |
+
</button>
|
| 42 |
+
<p class="font-data-mono text-[11px] text-on-surface-variant/50">Free · No credit card needed</p>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
|
| 46 |
+
<!-- ===== SIDENAV ===== -->
|
| 47 |
+
<nav x-show="screen!=='onboarding'" class="fixed left-0 top-0 h-full w-64 bg-surface-container-low border-r border-outline-variant z-40 hidden md:flex flex-col">
|
| 48 |
+
<div class="h-14 px-md flex items-center gap-3 border-b border-outline-variant bg-surface-container">
|
| 49 |
+
<span class="material-symbols-outlined text-primary text-[22px]">terminal</span>
|
| 50 |
+
<span class="font-headline-md text-headline-md text-primary">Altamira</span>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="flex-1 py-sm px-xs space-y-0.5 overflow-y-auto">
|
| 53 |
+
<template x-for="(item,key) in navItems" :key="key">
|
| 54 |
+
<button @click="navigateTo(key)" class="w-full flex items-center gap-3 px-sm py-2.5 rounded-lg transition-all font-data-mono text-[13px]" :class="screen===key?'bg-primary/20 text-primary border-l-2 border-primary':'text-on-surface-variant hover:bg-surface-container hover:text-on-surface'">
|
| 55 |
+
<span class="material-symbols-outlined text-[20px]" x-text="item.icon"></span>
|
| 56 |
+
<span x-text="item.label"></span>
|
| 57 |
+
<span x-show="item.badge" class="ml-auto bg-primary/20 text-primary text-[10px] font-bold px-1.5 py-0.5 rounded" x-text="item.badge"></span>
|
| 58 |
+
</button>
|
| 59 |
+
</template>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="p-sm border-t border-outline-variant bg-surface-container">
|
| 62 |
+
<div class="flex items-center gap-2.5 px-sm py-2">
|
| 63 |
+
<span class="w-7 h-7 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-bold" x-text="(sessionUser||'?')[0].toUpperCase()"></span>
|
| 64 |
+
<div class="flex-1 min-w-0">
|
| 65 |
+
<p class="font-data-mono text-[12px] truncate text-on-surface" x-text="sessionUser||'anonymous'"></p>
|
| 66 |
+
<p class="font-label-caps text-[9px] text-on-surface-variant/50">OPERATOR</p>
|
| 67 |
+
</div>
|
| 68 |
+
<button @click="logout()" class="text-on-surface-variant hover:text-error transition-colors"><span class="material-symbols-outlined text-[18px]">logout</span></button>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</nav>
|
| 72 |
+
|
| 73 |
+
<!-- ===== MAIN CANVAS ===== -->
|
| 74 |
+
<main x-show="screen!=='onboarding'" class="ml-0 md:ml-64 pt-0 h-screen bg-surface-container-lowest grid grid-cols-12 grid-rows-12 gap-base_unit p-base_unit">
|
| 75 |
+
|
| 76 |
+
<!-- ===== SCREEN: AADE ===== -->
|
| 77 |
+
<template x-if="screen==='aade'">
|
| 78 |
+
<div class="contents">
|
| 79 |
+
<!-- Panel A: Terminal / AI Chat -->
|
| 80 |
+
<section class="col-span-12 md:col-span-5 row-span-12 bg-surface-container-low border border-outline-variant flex flex-col overflow-hidden relative">
|
| 81 |
+
<div class="scanline"></div>
|
| 82 |
+
<div class="h-10 px-md flex items-center justify-between border-b border-outline-variant bg-surface-container bg-opacity-50">
|
| 83 |
+
<div class="flex items-center gap-sm">
|
| 84 |
+
<span class="material-symbols-outlined text-primary text-[18px]">terminal</span>
|
| 85 |
+
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">Command_Interface</span>
|
| 86 |
+
</div>
|
| 87 |
+
<div class="flex items-center gap-sm">
|
| 88 |
+
<span x-show="activeProject" class="font-data-mono text-[10px] text-primary/70">[<span x-text="activeProject"></span>]</span>
|
| 89 |
+
<span class="font-data-mono text-[10px] text-on-surface-variant opacity-40">v2.0</span>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<!-- Chat Messages -->
|
| 93 |
+
<div class="flex-1 overflow-y-auto p-md space-y-md font-data-mono custom-scrollbar" x-ref="chatBox">
|
| 94 |
+
<template x-for="(msg,i) in chatMessages" :key="i">
|
| 95 |
+
<div class="swing">
|
| 96 |
+
<div x-show="msg.role==='user'||msg.role==='agent'">
|
| 97 |
+
<div class="flex items-center gap-sm">
|
| 98 |
+
<span class="text-[11px] font-bold" :class="msg.role==='user'?'text-tertiary':'text-primary-fixed-dim'" x-text="msg.role==='user'?'[OPERATOR]':'[OS_CORE]'"></span>
|
| 99 |
+
<span class="text-on-surface-variant text-[10px] opacity-50" x-text="msg.time||''"></span>
|
| 100 |
+
</div>
|
| 101 |
+
<p class="text-on-surface text-data-mono leading-relaxed" x-text="msg.content"></p>
|
| 102 |
</div>
|
| 103 |
+
<div x-show="msg.role==='output'">
|
| 104 |
+
<div class="bg-surface-container-lowest border border-outline-variant p-sm rounded-lg overflow-x-auto mt-xs">
|
| 105 |
+
<pre class="text-[12px] text-secondary leading-tight whitespace-pre-wrap"><span x-text="msg.content"></span></pre>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
<div x-show="msg.role==='error'" class="flex items-center gap-sm bg-error/10 border-l-2 border-error p-xs">
|
| 109 |
+
<span class="material-symbols-outlined text-error text-[16px]">error</span>
|
| 110 |
+
<span class="text-on-surface text-[12px]" x-text="msg.content"></span>
|
| 111 |
+
</div>
|
| 112 |
+
<div x-show="msg.role==='system'">
|
| 113 |
+
<div class="flex items-center gap-sm bg-primary/10 border-l-2 border-primary p-xs">
|
| 114 |
+
<span class="material-symbols-outlined text-primary text-[16px]">sync</span>
|
| 115 |
+
<span class="text-on-surface text-[12px]" x-text="msg.content"></span>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div x-show="msg.role==='manifest'">
|
| 119 |
+
<div class="flex items-center gap-sm bg-tertiary/10 border-l-2 border-tertiary p-xs">
|
| 120 |
+
<span class="material-symbols-outlined text-tertiary text-[16px]">check_circle</span>
|
| 121 |
+
<span class="text-on-surface text-[12px]" x-text="msg.content"></span>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</template>
|
| 126 |
+
<div x-show="!chatMessages.length&&!chatLoading" class="flex flex-col items-center justify-center py-16 text-on-surface-variant/40">
|
| 127 |
+
<span class="material-symbols-outlined text-[40px]">terminal</span>
|
| 128 |
+
<p class="font-data-mono text-[12px] mt-2">Awaiting deployment parameters.</p>
|
| 129 |
</div>
|
| 130 |
+
<div x-show="chatLoading" class="flex items-center gap-sm bg-primary/10 border-l-2 border-primary p-xs">
|
| 131 |
+
<span class="material-symbols-outlined text-primary text-[16px] animate-spin">sync</span>
|
| 132 |
+
<span class="text-on-surface text-[12px]">Processing...</span>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
<!-- Command Input -->
|
| 136 |
+
<div class="p-sm bg-surface-container border-t border-outline-variant">
|
| 137 |
+
<div class="relative group">
|
| 138 |
+
<textarea x-model="chatInput" @keydown.enter.prevent="sendChat()" class="w-full bg-surface-container-lowest border border-outline-variant rounded-lg p-sm font-data-mono text-on-surface focus:outline-none focus:border-primary transition-all resize-none h-20 placeholder:text-on-surface-variant/30" placeholder="Type command or prompt..."></textarea>
|
| 139 |
+
<div class="absolute bottom-xs right-xs flex items-center gap-xs">
|
| 140 |
+
<kbd class="px-xs py-[2px] bg-surface-variant rounded text-[10px] font-data-mono text-on-surface-variant">CMD</kbd>
|
| 141 |
+
<kbd class="px-xs py-[2px] bg-surface-variant rounded text-[10px] font-data-mono text-on-surface-variant">ENTER</kbd>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</section>
|
| 146 |
+
|
| 147 |
+
<!-- Right Stack -->
|
| 148 |
+
<div class="col-span-12 md:col-span-7 row-span-12 flex flex-col gap-base_unit h-full overflow-hidden">
|
| 149 |
+
<!-- Panel B: Fleet Monitor -->
|
| 150 |
+
<section class="h-[58%] bg-surface-container-low border border-outline-variant flex flex-col overflow-hidden">
|
| 151 |
+
<div class="h-10 px-md flex items-center justify-between border-b border-outline-variant bg-surface-container">
|
| 152 |
+
<div class="flex items-center gap-sm">
|
| 153 |
+
<span class="material-symbols-outlined text-secondary text-[18px]">grid_view</span>
|
| 154 |
+
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">Fleet_Monitor</span>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="flex gap-md items-center">
|
| 157 |
+
<div class="flex items-center gap-xs">
|
| 158 |
+
<span class="w-2 h-2 rounded-full bg-primary pulse-dot"></span>
|
| 159 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant" x-text="runningCount+' RUNNING'"></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
</div>
|
| 161 |
+
<div class="flex items-center gap-xs">
|
| 162 |
+
<span class="w-2 h-2 rounded-full bg-error"></span>
|
| 163 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant" x-text="errorCount+' FAILED'"></span>
|
| 164 |
+
</div>
|
| 165 |
+
<button @click="showAddProject=true" class="font-label-caps text-[10px] text-primary hover:text-primary-fixed-dim transition-colors">+ NEW</button>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="flex-1 overflow-y-auto p-md custom-scrollbar">
|
| 169 |
+
<div x-show="showAddProject" class="bg-surface-container-lowest border border-outline-variant p-md rounded-lg mb-md swing">
|
| 170 |
+
<div class="flex gap-sm">
|
| 171 |
+
<input x-model="newProjectName" class="flex-1 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[12px] focus:outline-none focus:border-primary" placeholder="Project name">
|
| 172 |
+
<input x-model="newProjectRepo" class="flex-1 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[12px] focus:outline-none focus:border-primary" placeholder="GitHub URL (optional)">
|
| 173 |
+
</div>
|
| 174 |
+
<div class="flex gap-sm mt-sm">
|
| 175 |
+
<button @click="createProject();showAddProject=false" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded">Create</button>
|
| 176 |
+
<button @click="showAddProject=false" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded">Cancel</button>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-md">
|
| 180 |
+
<template x-for="(proj,name) in projects" :key="name">
|
| 181 |
+
<div class="bg-surface-container-lowest border border-outline-variant p-md flex flex-col gap-sm relative group hover:border-primary/50 transition-colors cursor-pointer" @click="selectProject(name)">
|
| 182 |
+
<div class="flex justify-between items-start">
|
| 183 |
+
<div>
|
| 184 |
+
<h4 class="font-headline-md text-headline-md text-on-surface leading-tight" x-text="name"></h4>
|
| 185 |
+
<p class="font-data-mono text-[10px] text-on-surface-variant" x-text="'ID: '+(proj.repo_url?proj.repo_url.split('/').pop():'LOCAL')"></p>
|
| 186 |
+
</div>
|
| 187 |
+
<div class="flex items-center gap-xs px-xs py-[2px] rounded" :class="projectStatusClass(proj)">
|
| 188 |
+
<span class="w-1.5 h-1.5 rounded-full" :class="proj.active?'bg-primary':'bg-on-surface-variant'"></span>
|
| 189 |
+
<span class="font-status-sm text-[10px] font-bold" x-text="proj.active?'RUNNING':'SLEEPING'"></span>
|
| 190 |
+
</div>
|
| 191 |
</div>
|
| 192 |
+
<div x-show="proj.active" class="grid grid-cols-2 gap-xs border-y border-outline-variant py-sm my-xs">
|
| 193 |
+
<div><span class="block font-label-caps text-[9px] text-on-surface-variant opacity-50">STATUS</span><span class="font-data-mono text-on-surface text-[12px]">Active</span></div>
|
| 194 |
+
<div><span class="block font-label-caps text-[9px] text-on-surface-variant opacity-50">NODE</span><span class="font-data-mono text-on-surface text-[12px]" x-text="'Sandbox-'+(sandboxStatus[name]?'LIVE':'NONE')"></span></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
</div>
|
| 196 |
+
<div class="flex items-center gap-xs" x-show="selectedProject===name">
|
| 197 |
+
<button @click.stop="activateProject(name)" class="flex-1 font-status-sm text-[10px] py-sm border border-outline-variant hover:bg-surface-variant transition-colors rounded">CONTROL</button>
|
| 198 |
+
<button @click.stop="createSandbox(name)" class="flex-1 font-status-sm text-[10px] py-sm border border-outline-variant hover:bg-surface-variant transition-colors rounded">SANDBOX</button>
|
| 199 |
+
<button @click.stop="openProjectDetail(name)" class="material-symbols-outlined p-sm text-on-surface-variant hover:text-primary transition-colors text-[16px] border border-outline-variant rounded">open_in_new</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</template>
|
| 203 |
+
<div x-show="!Object.keys(projects).length" class="col-span-full flex flex-col items-center justify-center py-16 text-on-surface-variant/40">
|
| 204 |
+
<span class="material-symbols-outlined text-[40px]">grid_view</span>
|
| 205 |
+
<p class="font-data-mono text-[12px] mt-2">No active projects.</p>
|
| 206 |
+
<button @click="showAddProject=true" class="font-status-sm text-[10px] mt-sm px-md py-sm bg-primary/20 text-primary rounded">BOOTSTRAP</button>
|
| 207 |
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</section>
|
| 211 |
+
<!-- Panel C: Runtime Stream -->
|
| 212 |
+
<section class="flex-1 bg-surface-container-lowest border border-outline-variant flex flex-col overflow-hidden">
|
| 213 |
+
<div class="h-10 px-md flex items-center justify-between border-b border-outline-variant bg-surface-container-high">
|
| 214 |
+
<div class="flex items-center gap-sm">
|
| 215 |
+
<span class="material-symbols-outlined text-tertiary text-[18px]">history_edu</span>
|
| 216 |
+
<span class="font-label-caps text-label-caps text-on-surface-variant uppercase">Runtime_Stream</span>
|
| 217 |
+
</div>
|
| 218 |
+
<div class="flex items-center gap-sm">
|
| 219 |
+
<span class="font-data-mono text-[10px] text-on-surface-variant uppercase bg-surface-variant px-xs">LIVE_FEED</span>
|
| 220 |
+
<button @click="clearLogs" class="material-symbols-outlined text-on-surface-variant text-[16px] hover:text-primary">delete_sweep</button>
|
| 221 |
+
</div>
|
| 222 |
</div>
|
| 223 |
+
<div class="flex-1 overflow-y-auto p-md font-data-mono text-[11px] leading-tight custom-scrollbar bg-black" x-ref="logBox">
|
| 224 |
+
<template x-for="(log,i) in runtimeLogs" :key="i">
|
| 225 |
+
<p class="log-entry" :class="log.color" x-text="log.text"></p>
|
| 226 |
+
</template>
|
| 227 |
+
<p x-show="!runtimeLogs.length" class="text-on-surface/30">[system] Awaiting process output...</p>
|
| 228 |
+
</div>
|
| 229 |
+
</section>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</template>
|
| 233 |
+
|
| 234 |
+
<!-- ===== SCREEN: SETTINGS ===== -->
|
| 235 |
+
<template x-if="screen==='settings'">
|
| 236 |
+
<div class="col-span-12 row-span-12 flex flex-col gap-base_unit p-md overflow-y-auto custom-scrollbar">
|
| 237 |
+
<div class="flex gap-1 p-1 bg-surface-container-low border border-outline-variant rounded-lg w-fit">
|
| 238 |
+
<button @click="settingsTab='resources'" class="px-4 py-2 rounded-lg font-status-sm text-[11px] transition-all" :class="settingsTab==='resources'?'bg-primary text-on-primary':'text-on-surface-variant hover:text-on-surface'">RESOURCES</button>
|
| 239 |
+
<button @click="settingsTab='providers'" class="px-4 py-2 rounded-lg font-status-sm text-[11px] transition-all" :class="settingsTab==='providers'?'bg-primary text-on-primary':'text-on-surface-variant hover:text-on-surface'">PROVIDERS</button>
|
| 240 |
+
<button @click="settingsTab='monitor'" class="px-4 py-2 rounded-lg font-status-sm text-[11px] transition-all" :class="settingsTab==='monitor'?'bg-primary text-on-primary':'text-on-surface-variant hover:text-on-surface'">MONITOR</button>
|
| 241 |
</div>
|
| 242 |
|
| 243 |
+
<div x-show="settingsTab==='resources'">
|
| 244 |
+
<div class="flex items-center justify-between mb-3"><h2 class="font-headline-md text-headline-md text-primary">Resource Vault</h2><button @click="openResourceBuilder()" class="font-status-sm text-[11px] py-sm px-md bg-primary text-on-primary rounded-lg">+ ADD</button></div>
|
| 245 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg overflow-hidden">
|
| 246 |
+
<div class="grid grid-cols-12 gap-2 px-md py-2.5 font-label-caps text-[10px] text-on-surface-variant uppercase border-b border-outline-variant bg-surface-container">
|
| 247 |
+
<div class="col-span-4">Resource</div><div class="col-span-2">Scope</div><div class="col-span-2">Status</div><div class="col-span-4 text-right">Actions</div>
|
| 248 |
</div>
|
| 249 |
+
<template x-for="(cred,key) in credentials" :key="key">
|
| 250 |
+
<div class="grid grid-cols-12 gap-2 items-center px-md py-2.5 border-b border-outline-variant last:border-0 hover:bg-surface-container transition-colors">
|
| 251 |
+
<div class="col-span-4 flex items-center gap-2 min-w-0">
|
| 252 |
+
<span class="material-symbols-outlined text-[16px] text-on-surface-variant">key</span>
|
| 253 |
+
<span class="font-data-mono text-[12px] truncate" x-text="key"></span>
|
| 254 |
</div>
|
| 255 |
+
<div class="col-span-2"><span class="font-label-caps text-[9px] px-1.5 py-0.5 rounded" :class="cred.scope==='core'?'bg-primary/20 text-primary':'bg-tertiary/20 text-tertiary'" x-text="cred.scope||'core'"></span></div>
|
| 256 |
+
<div class="col-span-2"><span class="font-data-mono text-[11px]" :class="pingResults[key]==='ok'?'text-primary':'text-on-surface-variant/50'" x-text="pingResults[key]==='ok'?'verified':(pingResults[key]||'—')"></span></div>
|
| 257 |
+
<div class="col-span-4 flex justify-end gap-1">
|
| 258 |
+
<button @click="pingResource(key)" class="material-symbols-outlined text-[16px] p-1 rounded hover:bg-surface-variant text-on-surface-variant hover:text-primary">link</button>
|
| 259 |
+
<button @click="openResourceEditor(key)" class="material-symbols-outlined text-[16px] p-1 rounded hover:bg-surface-variant text-on-surface-variant hover:text-primary">edit</button>
|
| 260 |
+
<button @click="deleteResource(key)" class="material-symbols-outlined text-[16px] p-1 rounded hover:bg-surface-variant text-on-surface-variant hover:text-error">delete</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</template>
|
| 264 |
+
<div x-show="!Object.keys(credentials).length" class="text-center py-12 font-data-mono text-[12px] text-on-surface-variant/40">No resources configured.</div>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
|
| 268 |
+
<div x-show="settingsTab==='providers'">
|
| 269 |
+
<h2 class="font-headline-md text-headline-md text-primary mb-3">LLM Providers</h2>
|
| 270 |
+
<div class="grid grid-cols-2 sm:grid-cols-4 gap-md">
|
| 271 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
|
| 272 |
+
<span class="material-symbols-outlined text-[28px] text-primary">memory</span>
|
| 273 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">NVIDIA NIM</span>
|
| 274 |
+
<div class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full" :class="nvidiaKey?'bg-primary':'bg-on-surface-variant'"></span><span class="font-data-mono text-[11px]" :class="nvidiaKey?'text-primary':'text-on-surface-variant/50'" x-text="nvidiaKey?'ready':'not set'"></span></div>
|
| 275 |
+
</div>
|
| 276 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
|
| 277 |
+
<span class="material-symbols-outlined text-[28px] text-secondary">alt_route</span>
|
| 278 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">OpenRouter</span>
|
| 279 |
+
<div class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full" :class="openrouterKey?'bg-primary':'bg-on-surface-variant'"></span><span class="font-data-mono text-[11px]" :class="openrouterKey?'text-primary':'text-on-surface-variant/50'" x-text="openrouterKey?'ready':'not set'"></span></div>
|
| 280 |
</div>
|
| 281 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
|
| 282 |
+
<span class="material-symbols-outlined text-[28px] text-tertiary">psychology</span>
|
| 283 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Gemini</span>
|
| 284 |
+
<div class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full" :class="geminiKey?'bg-primary':'bg-on-surface-variant'"></span><span class="font-data-mono text-[11px]" :class="geminiKey?'text-primary':'text-on-surface-variant/50'" x-text="geminiKey?'ready':'not set'"></span></div>
|
| 285 |
+
</div>
|
| 286 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg p-md flex flex-col items-center text-center gap-sm">
|
| 287 |
+
<span class="material-symbols-outlined text-[28px] text-on-surface-variant">layers</span>
|
| 288 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Throttle</span>
|
| 289 |
+
<div class="font-data-mono text-[11px] text-primary" x-text="'Layer '+throttleLayer"></div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
</div>
|
| 293 |
|
| 294 |
+
<div x-show="settingsTab==='monitor'">
|
| 295 |
+
<h2 class="font-headline-md text-headline-md text-primary mb-3">System Monitor</h2>
|
| 296 |
+
<div class="grid grid-cols-2 gap-md">
|
| 297 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg p-md">
|
| 298 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Active Projects</span>
|
| 299 |
+
<p class="font-display-lg text-display-lg text-primary mt-1" x-text="Object.keys(projects).length"></p>
|
| 300 |
+
</div>
|
| 301 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-lg p-md">
|
| 302 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Sandbox Spaces</span>
|
| 303 |
+
<p class="font-display-lg text-display-lg text-tertiary mt-1" x-text="Object.keys(sandboxStatus).length"></p>
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
</template>
|
| 309 |
+
</main>
|
| 310 |
+
|
| 311 |
+
<!-- ===== FLOATING COMMAND RING ===== -->
|
| 312 |
+
<div x-show="screen==='aade'" class="fixed bottom-lg right-lg z-50" x-data="{open:false}">
|
| 313 |
+
<div x-show="open" @click.away="open=false" class="flex flex-col gap-sm mb-sm">
|
| 314 |
+
<button @click="showAddProject=true;open=false" class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-on-surface-variant hover:text-primary hover:border-primary transition-all shadow-xl">
|
| 315 |
+
<span class="material-symbols-outlined">add_box</span>
|
| 316 |
+
</button>
|
| 317 |
+
<button @click="clearChat();open=false" class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-on-surface-variant hover:text-tertiary hover:border-tertiary transition-all shadow-xl">
|
| 318 |
+
<span class="material-symbols-outlined">delete_sweep</span>
|
| 319 |
+
</button>
|
| 320 |
+
<button @click="navigateTo('settings');open=false" class="w-12 h-12 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center text-on-surface-variant hover:text-secondary hover:border-secondary transition-all shadow-xl">
|
| 321 |
+
<span class="material-symbols-outlined">settings</span>
|
| 322 |
+
</button>
|
| 323 |
+
</div>
|
| 324 |
+
<div @click="open=!open" class="rounded-full w-16 h-16 bg-primary text-on-primary shadow-lg flex items-center justify-center hover:scale-105 active:scale-90 transition-transform cursor-pointer">
|
| 325 |
+
<span class="material-symbols-outlined text-[32px] transition-transform duration-300" :class="open?'rotate-45':''">add</span>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
|
| 329 |
+
<!-- ===== MOBILE BOTTOM NAV ===== -->
|
| 330 |
+
<nav x-show="screen!=='onboarding'" class="md:hidden fixed bottom-0 left-0 w-full bg-surface-container-high border-t border-outline-variant h-14 flex items-center justify-around z-50">
|
| 331 |
+
<button @click="navigateTo('aade')" class="flex flex-col items-center" :class="screen==='aade'?'text-primary':'text-on-surface-variant/60'">
|
| 332 |
+
<span class="material-symbols-outlined">terminal</span>
|
| 333 |
+
<span class="font-label-caps text-[10px]">AADE</span>
|
| 334 |
+
</button>
|
| 335 |
+
<button @click="navigateTo('settings')" class="flex flex-col items-center" :class="screen==='settings'?'text-primary':'text-on-surface-variant/60'">
|
| 336 |
+
<span class="material-symbols-outlined">settings</span>
|
| 337 |
+
<span class="font-label-caps text-[10px]">Settings</span>
|
| 338 |
+
</button>
|
| 339 |
+
<button @click="logout()" class="flex flex-col items-center text-on-surface-variant/60">
|
| 340 |
+
<span class="material-symbols-outlined">logout</span>
|
| 341 |
+
<span class="font-label-caps text-[10px]">Exit</span>
|
| 342 |
+
</button>
|
| 343 |
+
</nav>
|
| 344 |
+
|
| 345 |
+
<!-- ===== MODAL: PROJECT DETAIL ===== -->
|
| 346 |
+
<div x-show="projectDetailModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="projectDetailModal=false">
|
| 347 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
|
| 348 |
+
<div class="flex items-center justify-between mb-4">
|
| 349 |
+
<h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">folder_open</span><span x-text="detailProjectName"></span></h3>
|
| 350 |
+
<button @click="projectDetailModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
|
| 351 |
+
</div>
|
| 352 |
+
<div class="space-y-4">
|
| 353 |
+
<div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
|
| 354 |
+
<span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Status</span>
|
| 355 |
+
<div class="flex gap-3 mt-1">
|
| 356 |
+
<span class="font-data-mono text-[12px] flex items-center gap-1.5"><span class="w-2 h-2 rounded-full" :class="detailProject?.active?'bg-primary':'bg-on-surface-variant'"></span><span x-text="detailProject?.active?'Running':'Sleeping'"></span></span>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
<div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
|
| 360 |
+
<div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase">Sandbox Files</span><span class="font-data-mono text-[10px] text-on-surface-variant/50" x-text="sandboxFiles.length+' files'"></span></div>
|
| 361 |
+
<div class="max-h-40 overflow-y-auto space-y-1 custom-scrollbar">
|
| 362 |
+
<template x-if="!sandboxFiles.length"><p class="font-data-mono text-[11px] text-on-surface-variant/40 text-center py-4">No sandbox files. Create a sandbox first.</p></template>
|
| 363 |
+
<template x-for="f in sandboxFiles" :key="f.name">
|
| 364 |
+
<div @click="viewSandboxFileContent(detailProjectName,f.name)" class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-container cursor-pointer transition-colors font-data-mono text-[11px]">
|
| 365 |
+
<span class="material-symbols-outlined text-[14px] text-on-surface-variant">description</span>
|
| 366 |
+
<span class="flex-1 truncate text-on-surface" x-text="f.name"></span>
|
| 367 |
+
<span class="text-on-surface-variant/40" x-text="(f.size/1024).toFixed(1)+'KB'"></span>
|
| 368 |
</div>
|
| 369 |
+
</template>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
<div class="flex gap-2 flex-wrap">
|
| 373 |
+
<button @click="createSandbox(detailProjectName);projectDetailModal=false" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded-lg">CREATE SANDBOX</button>
|
| 374 |
+
<button @click="openResourceBinding(detailProjectName);projectDetailModal=false" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg hover:bg-surface-container">BIND RESOURCES</button>
|
| 375 |
+
<button @click="openExternalTesting(detailProjectName);projectDetailModal=false" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg hover:bg-surface-container">EXTERNAL TEST</button>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
|
| 381 |
+
<!-- ===== MODAL: RESOURCE BINDING ===== -->
|
| 382 |
+
<div x-show="resourceBindingModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="resourceBindingModal=false">
|
| 383 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
|
| 384 |
+
<div class="flex items-center justify-between mb-4">
|
| 385 |
+
<h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">key</span>Resources: <span class="text-on-surface" x-text="bindProjectName"></span></h3>
|
| 386 |
+
<button @click="resourceBindingModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="space-y-2">
|
| 389 |
+
<template x-for="(cred,key) in credentials" :key="key">
|
| 390 |
+
<div class="flex items-center justify-between px-sm py-2 rounded-lg hover:bg-surface-container transition-colors border-b border-outline-variant last:border-0">
|
| 391 |
+
<div class="flex items-center gap-2">
|
| 392 |
+
<span class="material-symbols-outlined text-[16px]" :class="projectBoundKeys[key]?'text-primary':'text-on-surface-variant'">key</span>
|
| 393 |
+
<span class="font-data-mono text-[12px]" x-text="key"></span>
|
| 394 |
+
<span x-show="cred.shareable" class="font-label-caps text-[9px] px-1.5 py-0.5 rounded bg-secondary/20 text-secondary">shareable</span>
|
| 395 |
+
</div>
|
| 396 |
+
<div class="flex gap-2">
|
| 397 |
+
<button x-show="!projectBoundKeys[key]" @click="bindResource(bindProjectName,key)" class="font-status-sm text-[10px] py-1 px-2.5 bg-primary/20 text-primary rounded">BIND</button>
|
| 398 |
+
<button x-show="projectBoundKeys[key]" @click="unbindResource(bindProjectName,key)" class="font-status-sm text-[10px] py-1 px-2.5 border border-error/30 text-error rounded">UNBIND</button>
|
| 399 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
</div>
|
| 401 |
+
</template>
|
| 402 |
+
<div x-show="!Object.keys(credentials).length" class="text-center py-8 font-data-mono text-[12px] text-on-surface-variant/40">No global resources.</div>
|
| 403 |
</div>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
|
| 407 |
+
<!-- ===== MODAL: RESOURCE BUILDER ===== -->
|
| 408 |
+
<div x-show="resourceModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="resourceModal=false">
|
| 409 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
|
| 410 |
+
<div class="flex items-center justify-between mb-4">
|
| 411 |
+
<h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">add_circle</span><span x-text="resourcePhase===1?'Add Resource':'Configure'"></span></h3>
|
| 412 |
+
<button @click="resourceModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
|
| 413 |
+
</div>
|
| 414 |
+
<div x-show="resourcePhase===1">
|
| 415 |
+
<p class="font-data-mono text-[11px] text-on-surface-variant/60 mb-4">Select resource class.</p>
|
| 416 |
+
<div class="grid grid-cols-2 gap-2">
|
| 417 |
+
<template x-for="(cls,key) in resourceClasses" :key="key">
|
| 418 |
+
<div @click="selectResourceClass(key)" class="bg-surface-container-lowest border border-outline-variant rounded-lg p-3 cursor-pointer hover:border-primary/40 transition-all hover:translate-y-[-1px]">
|
| 419 |
+
<span class="material-symbols-outlined text-[20px] text-on-surface-variant" x-text="cls.icon"></span>
|
| 420 |
+
<div class="font-data-mono text-[12px] mt-1" x-text="cls.label"></div>
|
| 421 |
+
<div class="font-data-mono text-[10px] text-on-surface-variant/50" x-text="cls.desc"></div>
|
| 422 |
+
</div>
|
| 423 |
+
</template>
|
| 424 |
+
</div>
|
| 425 |
+
</div>
|
| 426 |
+
<div x-show="resourcePhase===2" class="space-y-4">
|
| 427 |
+
<div><label class="font-label-caps text-[10px] text-on-surface-variant uppercase block mb-1">Name</label><input x-model="resourceName" class="w-full bg-surface-container-lowest border border-outline-variant rounded px-sm py-2 font-data-mono text-[12px] focus:outline-none focus:border-primary" placeholder="e.g. HF_TOKEN"></div>
|
| 428 |
+
<div x-show="resourceRequiresAuth"><label class="font-label-caps text-[10px] text-on-surface-variant uppercase block mb-1">Value</label><textarea x-model="resourceValue" class="w-full bg-surface-container-lowest border border-outline-variant rounded px-sm py-2 font-data-mono text-[12px] h-20 focus:outline-none focus:border-primary" placeholder="Paste key..."></textarea></div>
|
| 429 |
+
<div class="pt-2 border-t border-outline-variant">
|
| 430 |
+
<label class="font-label-caps text-[10px] text-on-surface-variant uppercase block mb-2">Scope</label>
|
| 431 |
+
<div class="flex gap-3 font-data-mono text-[12px]">
|
| 432 |
+
<label class="flex items-center gap-1.5 cursor-pointer"><input type="radio" x-model="resourceScope" value="core" class="accent-primary"> Core</label>
|
| 433 |
+
<label class="flex items-center gap-1.5 cursor-pointer"><input type="radio" x-model="resourceScope" value="sandbox" class="accent-primary"> Per-Project</label>
|
| 434 |
+
</div>
|
| 435 |
+
<label class="flex items-center gap-1.5 font-data-mono text-[12px] mt-2 cursor-pointer"><input type="checkbox" x-model="resourceShareable" class="accent-primary"> Shareable</label>
|
| 436 |
+
</div>
|
| 437 |
+
<div class="flex gap-2 pt-2">
|
| 438 |
+
<button @click="saveResource()" class="flex-1 font-status-sm text-[11px] py-sm bg-primary text-on-primary rounded-lg">Save & Verify</button>
|
| 439 |
+
<button @click="resourcePhase=1;resourceType=''" class="font-status-sm text-[11px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg">Back</button>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
</div>
|
| 443 |
+
</div>
|
| 444 |
|
| 445 |
+
<!-- ===== MODAL: EXTERNAL TESTING ===== -->
|
| 446 |
+
<div x-show="externalTestingModal" class="fixed inset-0 bg-black/70 z-[9999] flex items-center justify-center" @click.self="externalTestingModal=false">
|
| 447 |
+
<div class="bg-surface-container-low border border-outline-variant rounded-xl p-lg swing max-w-lg w-[92%] max-h-[85vh] overflow-y-auto custom-scrollbar">
|
| 448 |
+
<div class="flex items-center justify-between mb-4">
|
| 449 |
+
<h3 class="font-headline-md text-headline-md text-primary flex items-center gap-2"><span class="material-symbols-outlined">science</span>Testing: <span class="text-on-surface" x-text="testProjectName"></span></h3>
|
| 450 |
+
<button @click="externalTestingModal=false" class="text-on-surface-variant hover:text-on-surface"><span class="material-symbols-outlined">close</span></button>
|
| 451 |
+
</div>
|
| 452 |
+
<div class="space-y-3">
|
| 453 |
+
<div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
|
| 454 |
+
<div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase"><span class="text-primary">A</span> Webhook</span><span class="font-data-mono text-[10px] text-primary">auto</span></div>
|
| 455 |
+
<input x-model="webhookUrl" class="w-full bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[11px] focus:outline-none focus:border-primary mb-2" placeholder="https://hook.example.com/endpoint">
|
| 456 |
+
<button @click="triggerWebhook(testProjectName)" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded-lg">SEND</button>
|
| 457 |
+
<div x-show="webhookResult" class="mt-2 font-data-mono text-[11px] text-on-surface-variant/80 bg-black rounded p-2" x-text="webhookResult"></div>
|
| 458 |
+
</div>
|
| 459 |
+
<div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
|
| 460 |
+
<div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase"><span class="text-tertiary">B</span> Download</span><span class="font-data-mono text-[10px] text-tertiary">manual</span></div>
|
| 461 |
+
<button @click="generateDownloadUrl(testProjectName)" class="font-status-sm text-[10px] py-sm px-md border border-outline-variant text-on-surface-variant rounded-lg">GENERATE</button>
|
| 462 |
+
<div x-show="downloadUrl" class="mt-2 font-data-mono text-[11px] text-primary break-all bg-black rounded p-2" x-text="downloadUrl"></div>
|
| 463 |
+
</div>
|
| 464 |
+
<div class="bg-surface-container-lowest border border-outline-variant rounded-lg p-md">
|
| 465 |
+
<div class="flex items-center justify-between mb-2"><span class="font-label-caps text-[10px] text-on-surface-variant uppercase"><span class="text-secondary">C</span> GitHub Actions</span><span class="font-data-mono text-[10px] text-secondary">ci/cd</span></div>
|
| 466 |
+
<div class="flex gap-2 mb-2">
|
| 467 |
+
<input x-model="ghaRepo" class="flex-1 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[11px] focus:outline-none focus:border-primary" placeholder="owner/repo">
|
| 468 |
+
<input x-model="ghaWorkflow" class="w-24 bg-surface-container border border-outline-variant rounded px-sm py-1.5 font-data-mono text-[11px] focus:outline-none focus:border-primary" placeholder="main.yml">
|
| 469 |
+
</div>
|
| 470 |
+
<button @click="triggerGHA(testProjectName)" class="font-status-sm text-[10px] py-sm px-md bg-primary text-on-primary rounded-lg">TRIGGER</button>
|
| 471 |
+
<div x-show="ghaResult" class="mt-2 font-data-mono text-[11px] text-on-surface-variant/80 bg-black rounded p-2" x-text="ghaResult"></div>
|
| 472 |
+
</div>
|
| 473 |
+
</div>
|
| 474 |
+
</div>
|
| 475 |
</div>
|
| 476 |
|
| 477 |
<script>
|
| 478 |
+
function app(){
|
| 479 |
+
return {
|
| 480 |
+
screen:'onboarding',navTargets:{aade:{label:'Workspace',icon:'fas fa-cubes'},settings:{label:'Settings',icon:'fas fa-cog'}},
|
| 481 |
+
navItems:{aade:{label:'Command',icon:'terminal',badge:'LIVE'},settings:{label:'Settings',icon:'settings'}},
|
| 482 |
+
settingsTab:'resources',
|
| 483 |
+
sessionToken:'',sessionUser:'',activeProject:'',
|
| 484 |
+
chatInput:'',chatMessages:[],chatLoading:false,
|
| 485 |
+
newProjectName:'',newProjectRepo:'',projects:{},showAddProject:false,
|
| 486 |
+
projectLoading:{},selectedProject:null,throttleLayer:1,
|
| 487 |
+
credentials:{},nvidiaKey:false,openrouterKey:false,geminiKey:false,
|
| 488 |
+
pingResults:{},resourceModal:false,resourcePhase:1,
|
| 489 |
+
resourceType:'',resourceName:'',resourceValue:'',resourceRequiresAuth:true,
|
| 490 |
+
resourceScope:'core',resourceShareable:false,resourceProjects:[],
|
| 491 |
+
resourceClasses:{
|
| 492 |
+
databases:{label:'Databases',icon:'database',desc:'Postgres,MySQL,Snowflake'},
|
| 493 |
+
saas:{label:'SaaS / APIs',icon:'cloud',desc:'Slack,Jira,Google'},
|
| 494 |
+
ai:{label:'AI Providers',icon:'psychology',desc:'OpenAI,Gemini,NVIDIA'},
|
| 495 |
+
hf:{label:'Hugging Face',icon:'face',desc:'Token,Spaces'},
|
| 496 |
+
github:{label:'GitHub',icon:'code',desc:'PAT, Actions'},
|
| 497 |
+
custom:{label:'Custom REST',icon:'api',desc:'HTTP endpoints'},
|
| 498 |
+
custom_ai:{label:'Custom AI',icon:'memory',desc:'Ollama, vLLM'},
|
| 499 |
+
storage:{label:'Altamira KV',icon:'inventory',desc:'Key-value store'},
|
| 500 |
+
},
|
| 501 |
+
sandboxStatus:{},sandboxFiles:[],
|
| 502 |
+
projectDetailModal:false,detailProjectName:'',detailProject:null,
|
| 503 |
+
resourceBindingModal:false,bindProjectName:'',projectBoundKeys:{},
|
| 504 |
+
externalTestingModal:false,testProjectName:'',
|
| 505 |
+
webhookUrl:'',webhookResult:'',downloadUrl:'',
|
| 506 |
+
ghaRepo:'',ghaWorkflow:'main.yml',ghaResult:'',
|
| 507 |
+
runtimeLogs:[],logTimer:null,
|
| 508 |
+
|
| 509 |
+
get runningCount(){return Object.values(this.projects).filter(p=>p.active).length},
|
| 510 |
+
get errorCount(){return 0},
|
| 511 |
+
|
| 512 |
+
init(){
|
| 513 |
+
const t=localStorage.getItem('altamira_session'),u=localStorage.getItem('altamira_user');
|
| 514 |
+
if(t) try{const s=await this.api('/api/auth/session',{headers:{'Authorization':'Bearer '+t}});this.sessionToken=t;this.sessionUser=s.username||u;this.screen='aade'}catch{localStorage.removeItem('altamira_session');localStorage.removeItem('altamira_user')}
|
| 515 |
+
if(this.screen!=='onboarding'){this.loadProjects();this.loadCredentials();this.startLogSimulator()}
|
| 516 |
+
},
|
| 517 |
+
|
| 518 |
+
async api(path,opts={}){
|
| 519 |
+
const h={'Content-Type':'application/json',...opts.headers};
|
| 520 |
+
if(this.sessionToken&&!path.startsWith('/api/auth/')&&!path.startsWith('/api/gatekeeper/')&&path!=='/health')h['Authorization']='Bearer '+this.sessionToken;
|
| 521 |
+
const r=await fetch(path,{...opts,headers:h});const d=await r.json();if(!r.ok)throw new Error(d.detail||'HTTP '+r.status);return d
|
| 522 |
+
},
|
| 523 |
+
|
| 524 |
+
navigateTo(k){this.screen=k;if(k==='aade'){this.loadProjects();this.startLogSimulator()}if(k==='settings'){this.loadCredentials();if(this.logTimer)clearInterval(this.logTimer)}},
|
| 525 |
+
async oauthLogin(){try{const d=await this.api('/api/auth/oauth/login');if(d.configured&&d.redirect)window.location.href=d.redirect}catch{}},
|
| 526 |
+
async logout(){try{await this.api('/api/auth/logout',{method:'POST'})}catch{}localStorage.removeItem('altamira_session');localStorage.removeItem('altamira_user');this.sessionToken='';this.sessionUser='';this.screen='onboarding';if(this.logTimer)clearInterval(this.logTimer)},
|
| 527 |
+
|
| 528 |
+
selectProject(n){this.selectedProject=this.selectedProject===n?null:n;if(this.selectedProject){this.activeProject=n;this.loadSandboxStatus(n)}this.addLog('info',`Selected project: ${n}`)},
|
| 529 |
+
|
| 530 |
+
projectStatusClass(p){return p.active?'bg-primary/20 text-primary border border-primary/30':'bg-on-surface-variant/20 text-on-surface-variant border border-on-surface-variant/30'},
|
| 531 |
+
|
| 532 |
+
addLog(level,msg){
|
| 533 |
+
const now=new Date();const t=`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}:${now.getSeconds().toString().padStart(2,'0')}`;
|
| 534 |
+
const colors={info:'text-primary/70',stdout:'text-on-surface/70',stderr:'text-error/70',debug:'text-on-surface/40'};
|
| 535 |
+
const labels={info:'INFO',stdout:'STDOUT',stderr:'STDERR',debug:'DEBUG'};
|
| 536 |
+
this.runtimeLogs.push({text:`[${t}] ${labels[level]||'INFO'}: ${msg}`,color:colors[level]||colors.info});this.$nextTick(()=>{const el=this.$refs.logBox;if(el)el.scrollTop=el.scrollHeight});if(this.runtimeLogs.length>100)this.runtimeLogs=this.runtimeLogs.slice(-100)
|
| 537 |
+
},
|
| 538 |
+
|
| 539 |
+
clearLogs(){this.runtimeLogs=[];this.addLog('info','Runtime stream cleared')},
|
| 540 |
+
|
| 541 |
+
startLogSimulator(){
|
| 542 |
+
if(this.logTimer)clearInterval(this.logTimer);
|
| 543 |
+
this.addLog('info','Runtime sequence initiated');
|
| 544 |
+
const msgs=['Pulse verified for instance 0x44A1.','Re-synching telemetry data with orbital node.','Garbage collection completed: freed 45MB.','Worker process PID spawned successfully.','Connection request accepted.','Buffer allocation stable.','Cache hit ratio: 94.2%.','Memory controller report: nominal.'];
|
| 545 |
+
this.logTimer=setInterval(()=>{
|
| 546 |
+
if(this.screen!=='aade')return;
|
| 547 |
+
const levels=['info','info','info','stdout','stdout','debug'];const lev=levels[Math.floor(Math.random()*levels.length)];
|
| 548 |
+
this.addLog(lev,msgs[Math.floor(Math.random()*msgs.length)])
|
| 549 |
+
},4000)
|
| 550 |
+
},
|
| 551 |
+
|
| 552 |
+
// CHAT
|
| 553 |
+
async sendChat(){
|
| 554 |
+
const t=this.chatInput.trim();if(!t||this.chatLoading)return;
|
| 555 |
+
this.chatInput='';this.chatMessages.push({role:'user',content:t,time:new Date().toLocaleTimeString()});this.chatLoading=true;this.scrollChat();this.addLog('stdout','Command submitted: '+t.slice(0,60));
|
| 556 |
+
try{
|
| 557 |
+
const d=await this.api('/api/agent/submit',{method:'POST',body:JSON.stringify({prompt:t,project:this.activeProject||'default'})});
|
| 558 |
+
const dagId=d.dag_id;this.chatMessages.push({role:'system',content:'Planning tasks...'});let done=false,att=0;
|
| 559 |
+
while(!done&&att<60){await new Promise(r=>setTimeout(r,1000));await this.api('/api/agent/cycle',{method:'POST'});
|
| 560 |
+
const s=await this.api('/api/agent/status'),st=s.tasks||{},p=st.pending||0,rn=st.running||0,rv=st.reviewing||0;
|
| 561 |
+
if(rn>0||rv>0)this.chatMessages[this.chatMessages.length-1]={role:'system',content:`Working... (${rn} running, ${rv} reviewing)`};
|
| 562 |
+
if(!p&&!rn&&!rv)done=true;att++;this.scrollChat()}
|
| 563 |
+
const tasks=await this.api('/api/agent/tasks'),list=tasks.tasks||[],results=list.filter(t=>t.dag_id===dagId&&t.output);
|
| 564 |
+
for(const r of results){this.chatMessages.push({role:'output',content:r.output||'(no output)'});if(r.error)this.chatMessages.push({role:'error',content:r.error})}
|
| 565 |
+
this.chatMessages.push({role:'agent',content:`Done. ${results.length} tasks completed.`,time:new Date().toLocaleTimeString()});
|
| 566 |
+
this.addLog('info',`Task DAG ${dagId} completed: ${results.length} tasks`);
|
| 567 |
+
try{await this.api('/api/monitor/manifest/'+(this.activeProject||'default'),{method:'POST',body:JSON.stringify({status:'completed',version:dagId,message:results.length+' tasks'})})}catch{}
|
| 568 |
+
}catch(e){this.chatMessages.push({role:'error',content:e.message});this.addLog('stderr',e.message)}
|
| 569 |
+
this.chatLoading=false;this.scrollChat()
|
| 570 |
+
},
|
| 571 |
+
scrollChat(){this.$nextTick(()=>{const el=this.$refs.chatBox;if(el)el.scrollTop=el.scrollHeight})},
|
| 572 |
+
clearChat(){this.chatMessages=[];this.addLog('info','Chat history cleared')},
|
| 573 |
+
|
| 574 |
+
// PROJECTS
|
| 575 |
+
async loadProjects(){try{this.projects=await this.api('/api/projects');this.addLog('info','Loaded '+Object.keys(this.projects).length+' projects')}catch{}},
|
| 576 |
+
async createProject(){if(!this.newProjectName)return;try{await this.api('/api/projects',{method:'POST',body:JSON.stringify({name:this.newProjectName,repo_url:this.newProjectRepo})});this.newProjectName='';this.newProjectRepo='';await this.loadProjects();this.addLog('info','Project created')}catch(e){this.addLog('stderr','Create failed: '+e.message)}},
|
| 577 |
+
async activateProject(n){this.projectLoading[n+'-act']=true;try{await this.api('/api/projects/'+n+'/activate',{method:'POST'});await this.loadProjects();this.activeProject=n;this.addLog('info','Activated: '+n)}catch(e){this.addLog('stderr','Activate failed: '+e.message)}finally{this.projectLoading[n+'-act']=false}},
|
| 578 |
+
async gitSyncProject(n){this.projectLoading[n+'-sync']=true;try{await this.api('/api/projects/'+n+'/git-sync',{method:'POST'});this.addLog('info','Git sync: '+n)}catch(e){this.addLog('stderr','Sync failed: '+e.message)}finally{this.projectLoading[n+'-sync']=false}},
|
| 579 |
+
|
| 580 |
+
// RESOURCES
|
| 581 |
+
async loadCredentials(){try{const d=await this.api('/api/resources');this.credentials=d.credentials||{};this.nvidiaKey=!!(d.credentials||{}).NVIDIA_NIM_API_KEY;this.openrouterKey=!!(d.credentials||{}).OPENROUTER_API_KEY;this.geminiKey=!!(d.credentials||{}).GEMINI_API_KEY}catch{}},
|
| 582 |
+
async pingResource(k){try{const d=await this.api('/api/resources/'+k+'/ping',{method:'POST'});this.pingResults[k]=d.ping?.status||'error';this.addLog('info',`Ping ${k}: ${d.ping?.status}`)}catch{this.pingResults[k]='error'}},
|
| 583 |
+
async deleteResource(k){try{await this.api('/api/resources/'+k,{method:'DELETE'});await this.loadCredentials();this.addLog('info','Deleted resource: '+k)}catch{}},
|
| 584 |
+
|
| 585 |
+
openResourceBuilder(){this.resourceModal=true;this.resourcePhase=1;this.resourceType='';this.resourceName='';this.resourceValue='';this.resourceScope='core';this.resourceShareable=false;this.resourceProjects=[]},
|
| 586 |
+
openResourceEditor(k){this.resourceModal=true;this.resourcePhase=2;this.resourceType=k;this.resourceName=k;this.resourceValue=this.credentials[k]?.value||'';this.resourceRequiresAuth=true},
|
| 587 |
+
selectResourceClass(k){this.resourceType=k;this.resourcePhase=2;this.resourceRequiresAuth=k!=='storage';const m={databases:'DB_KEY',ai:'AI_API_KEY',saas:'SAAS_KEY',hf:'HF_TOKEN',github:'GITHUB_TOKEN',custom:'MY_API_KEY',custom_ai:'CUSTOM_AI_KEY',storage:'my-store'};this.resourceName=m[k]||'MY_KEY'},
|
| 588 |
+
async saveResource(){if(!this.resourceName||!this.resourceValue)return;try{const d=await this.api('/api/resources/'+this.resourceName,{method:'PUT',body:JSON.stringify({value:this.resourceValue,description:this.resourceType,scope:this.resourceScope,shareable:this.resourceShareable,projects:this.resourceProjects})});this.pingResults[this.resourceName]=d.ping?.status||'unknown';this.resourceModal=false;await this.loadCredentials();this.addLog('info','Resource saved: '+this.resourceName)}catch(e){this.addLog('stderr','Save failed: '+e.message)}},
|
| 589 |
+
|
| 590 |
+
openProjectDetail(n){this.detailProjectName=n;this.detailProject=this.projects[n]||null;this.sandboxFiles=[];this.projectDetailModal=true;this.viewSandboxFiles(n)},
|
| 591 |
+
|
| 592 |
+
// SANDBOX
|
| 593 |
+
async loadSandboxStatus(n){try{const d=await this.api('/api/sandboxes'),hf=d.hf_spaces||{};Object.assign(this.sandboxStatus,Object.fromEntries(Object.entries(hf).filter(([k])=>k===n).map(([k,v])=>[k,v])))}catch{}},
|
| 594 |
+
async createSandbox(n){try{const d=await this.api('/api/sandboxes/create',{method:'POST',body:JSON.stringify({project:n})});this.sandboxStatus[n]=d;this.chatMessages.push({role:'manifest',content:'Sandbox created: '+(d.url||n)});this.addLog('info','Sandbox created for: '+n)}catch(e){this.chatMessages.push({role:'error',content:'Sandbox creation failed: '+e.message});this.addLog('stderr','Sandbox failed: '+e.message)}},
|
| 595 |
+
async viewSandboxFiles(n){try{const d=await this.api('/api/sandboxes/'+n+'/files');this.sandboxFiles=d.files||[]}catch{this.sandboxFiles=[]}},
|
| 596 |
+
async viewSandboxFileContent(p,fp){try{const d=await this.api('/api/sandboxes/'+p+'/read',{method:'POST',body:JSON.stringify({path:fp})});this.chatMessages.push({role:'output',content:'=== '+fp+' ===\n'+(d.content||'(empty)')});this.scrollChat()}catch(e){this.chatMessages.push({role:'error',content:'Read failed: '+e.message})}},
|
| 597 |
+
|
| 598 |
+
// RESOURCE BINDING
|
| 599 |
+
async openResourceBinding(n){this.bindProjectName=n;this.projectBoundKeys={};this.resourceBindingModal=true;await this.loadCredentials();try{const d=await this.api('/api/resources/projects'),bs=d[n]||{};this.projectBoundKeys=Object.fromEntries(Object.keys(bs).map(k=>[k,true]))}catch{}},
|
| 600 |
+
async bindResource(p,k){try{await this.api('/api/resources/bind/'+p,{method:'POST',body:JSON.stringify({key:k,value:this.credentials[k]?.value||''})});this.projectBoundKeys[k]=true;this.addLog('info','Bound '+k+' to '+p)}catch{}},
|
| 601 |
+
async unbindResource(p,k){try{await this.api('/api/resources/unbind/'+p,{method:'POST',body:JSON.stringify({key:k})});delete this.projectBoundKeys[k];this.addLog('info','Unbound '+k+' from '+p)}catch{}},
|
| 602 |
+
|
| 603 |
+
// EXTERNAL TESTING
|
| 604 |
+
openExternalTesting(n){this.testProjectName=n;this.webhookUrl='';this.webhookResult='';this.downloadUrl='';this.ghaResult='';this.externalTestingModal=true},
|
| 605 |
+
async triggerWebhook(p){if(!this.webhookUrl)return;try{const d=await this.api('/api/test/webhook/'+p,{method:'POST',body:JSON.stringify({url:this.webhookUrl,payload:{project:p,timestamp:Date.now()}})});this.webhookResult='Status: '+d.status;this.addLog('info','Webhook test: '+d.status)}catch(e){this.webhookResult='Error: '+e.message;this.addLog('stderr','Webhook error: '+e.message)}},
|
| 606 |
+
async generateDownloadUrl(p){try{const d=await this.api('/api/test/download-url/'+p);this.downloadUrl=d.files.length?window.location.origin+d.download_url+d.files[0]:'No artifacts';this.addLog('info','Download URL generated')}catch(e){this.downloadUrl='Error: '+e.message}},
|
| 607 |
+
async triggerGHA(p){if(!this.ghaRepo)return;try{const d=await this.api('/api/test/github-actions/'+p,{method:'POST',body:JSON.stringify({repo:this.ghaRepo,workflow:this.ghaWorkflow})});this.ghaResult='Triggered: '+d.status;this.addLog('info','GHA trigger: '+d.status)}catch(e){this.ghaResult='Error: '+e.message;this.addLog('stderr','GHA error: '+e.message)}},
|
| 608 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
}
|
| 610 |
</script>
|
| 611 |
</body>
|
| 612 |
+
</html>
|
test_integration.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests using FastAPI TestClient."""
|
| 2 |
+
import pytest
|
| 3 |
+
import os, json, time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
|
| 7 |
+
os.environ["HF_TOKEN"] = ""
|
| 8 |
+
os.environ["OAUTH_CLIENT_ID"] = ""
|
| 9 |
+
os.environ["OAUTH_CLIENT_SECRET"] = ""
|
| 10 |
+
|
| 11 |
+
from app import app, session_store, STATE_DIR
|
| 12 |
+
|
| 13 |
+
client = TestClient(app)
|
| 14 |
+
|
| 15 |
+
for f in STATE_DIR.glob("*"):
|
| 16 |
+
if f.is_file():
|
| 17 |
+
f.unlink()
|
| 18 |
+
|
| 19 |
+
def auth():
|
| 20 |
+
token = session_store.create("tester", "tester")
|
| 21 |
+
return {"Authorization": f"Bearer {token}"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestPublicEndpoints:
|
| 25 |
+
def test_health(self):
|
| 26 |
+
r = client.get("/health")
|
| 27 |
+
assert r.status_code == 200
|
| 28 |
+
data = r.json()
|
| 29 |
+
assert data["status"] == "healthy"
|
| 30 |
+
assert data["app"] == "altamira-aade"
|
| 31 |
+
|
| 32 |
+
def test_gatekeeper_check(self):
|
| 33 |
+
r = client.post("/api/gatekeeper/check")
|
| 34 |
+
assert r.status_code == 200
|
| 35 |
+
assert r.json()["hf_authenticated"] == False
|
| 36 |
+
|
| 37 |
+
def test_index_html(self):
|
| 38 |
+
r = client.get("/")
|
| 39 |
+
assert r.status_code == 200
|
| 40 |
+
assert "text/html" in r.headers["content-type"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestAuthEndpoints:
|
| 44 |
+
def test_oauth_login_not_configured(self):
|
| 45 |
+
r = client.get("/api/auth/oauth/login")
|
| 46 |
+
assert r.status_code == 200
|
| 47 |
+
assert r.json()["configured"] == False
|
| 48 |
+
|
| 49 |
+
def test_login_requires_token(self):
|
| 50 |
+
r = client.post("/api/auth/login", json={})
|
| 51 |
+
assert r.status_code == 400
|
| 52 |
+
|
| 53 |
+
def test_login_invalid_token(self):
|
| 54 |
+
r = client.post("/api/auth/login", json={"hf_token": "bad_token"})
|
| 55 |
+
assert r.status_code == 401
|
| 56 |
+
|
| 57 |
+
def test_unauthorized_access(self):
|
| 58 |
+
r = client.get("/api/projects")
|
| 59 |
+
assert r.status_code == 401
|
| 60 |
+
|
| 61 |
+
def test_authorized_access(self):
|
| 62 |
+
r = client.get("/api/projects", headers=auth())
|
| 63 |
+
assert r.status_code == 200
|
| 64 |
+
|
| 65 |
+
def test_logout(self):
|
| 66 |
+
h = auth()
|
| 67 |
+
r = client.post("/api/auth/logout", headers=h)
|
| 68 |
+
assert r.status_code == 200
|
| 69 |
+
assert r.json()["status"] == "logged_out"
|
| 70 |
+
|
| 71 |
+
def test_session_invalid_after_logout(self):
|
| 72 |
+
h = auth()
|
| 73 |
+
client.post("/api/auth/logout", headers=h)
|
| 74 |
+
r = client.get("/api/projects", headers=h)
|
| 75 |
+
assert r.status_code == 401
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class TestProjectCRUD:
|
| 79 |
+
def test_create_project(self):
|
| 80 |
+
r = client.post("/api/projects", headers=auth(), json={"name": "test-project"})
|
| 81 |
+
assert r.status_code == 200
|
| 82 |
+
assert r.json()["name"] == "test-project"
|
| 83 |
+
|
| 84 |
+
def test_create_duplicate(self):
|
| 85 |
+
r = client.post("/api/projects", headers=auth(), json={"name": "test-project"})
|
| 86 |
+
assert r.status_code == 409
|
| 87 |
+
|
| 88 |
+
def test_list_projects(self):
|
| 89 |
+
r = client.get("/api/projects", headers=auth())
|
| 90 |
+
assert r.status_code == 200
|
| 91 |
+
assert "test-project" in r.json()
|
| 92 |
+
|
| 93 |
+
def test_delete_project(self):
|
| 94 |
+
r = client.delete("/api/projects/test-project", headers=auth())
|
| 95 |
+
assert r.status_code == 200
|
| 96 |
+
assert r.json()["deleted"] == "test-project"
|
| 97 |
+
|
| 98 |
+
def test_list_after_delete(self):
|
| 99 |
+
r = client.get("/api/projects", headers=auth())
|
| 100 |
+
assert r.status_code == 200
|
| 101 |
+
assert "test-project" not in r.json()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TestResourceEndpoints:
|
| 105 |
+
def test_save_resource(self):
|
| 106 |
+
r = client.put("/api/resources/MY_CUSTOM_KEY", headers=auth(), json={
|
| 107 |
+
"value": "test-value-123", "scope": "core", "description": "Test key"
|
| 108 |
+
})
|
| 109 |
+
assert r.status_code == 200
|
| 110 |
+
assert r.json()["saved"] == "MY_CUSTOM_KEY"
|
| 111 |
+
assert r.json()["ping"]["status"] == "skipped"
|
| 112 |
+
|
| 113 |
+
def test_list_resources(self):
|
| 114 |
+
r = client.get("/api/resources", headers=auth())
|
| 115 |
+
assert r.status_code == 200
|
| 116 |
+
creds = r.json()["credentials"]
|
| 117 |
+
assert "MY_CUSTOM_KEY" in creds
|
| 118 |
+
|
| 119 |
+
def test_ping_resource(self):
|
| 120 |
+
r = client.post("/api/resources/MY_CUSTOM_KEY/ping", headers=auth())
|
| 121 |
+
assert r.status_code == 200
|
| 122 |
+
assert r.json()["key"] == "MY_CUSTOM_KEY"
|
| 123 |
+
|
| 124 |
+
def test_ping_unknown(self):
|
| 125 |
+
r = client.post("/api/resources/NONEXISTENT/ping", headers=auth())
|
| 126 |
+
assert r.status_code == 404
|
| 127 |
+
|
| 128 |
+
def test_project_binding(self):
|
| 129 |
+
r = client.post("/api/resources/bind/testproj2", headers=auth(), json={
|
| 130 |
+
"key": "MY_KEY", "value": "my_value"
|
| 131 |
+
})
|
| 132 |
+
assert r.status_code == 200
|
| 133 |
+
assert r.json()["bound"] == "MY_KEY"
|
| 134 |
+
|
| 135 |
+
def test_project_unbinding(self):
|
| 136 |
+
r = client.post("/api/resources/unbind/testproj2", headers=auth(), json={
|
| 137 |
+
"key": "MY_KEY"
|
| 138 |
+
})
|
| 139 |
+
assert r.status_code == 200
|
| 140 |
+
assert r.json()["unbound"] == "MY_KEY"
|
| 141 |
+
|
| 142 |
+
def test_list_project_bindings(self):
|
| 143 |
+
r = client.get("/api/resources/projects", headers=auth())
|
| 144 |
+
assert r.status_code == 200
|
| 145 |
+
|
| 146 |
+
def test_delete_resource(self):
|
| 147 |
+
r = client.delete("/api/resources/MY_CUSTOM_KEY", headers=auth())
|
| 148 |
+
assert r.status_code == 200
|
| 149 |
+
assert r.json()["deleted"] == "MY_CUSTOM_KEY"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class TestMonitorEndpoints:
|
| 153 |
+
def test_get_manifest(self):
|
| 154 |
+
r = client.get("/api/monitor/manifest", headers=auth())
|
| 155 |
+
assert r.status_code == 200
|
| 156 |
+
|
| 157 |
+
def test_update_manifest(self):
|
| 158 |
+
r = client.post("/api/monitor/manifest/myproject", headers=auth(), json={
|
| 159 |
+
"status": "running", "version": "1.0", "message": "All good"
|
| 160 |
+
})
|
| 161 |
+
assert r.status_code == 200
|
| 162 |
+
assert r.json()["saved"] == "myproject"
|
| 163 |
+
|
| 164 |
+
def test_heartbeat(self):
|
| 165 |
+
r = client.post("/api/monitor/heartbeat/myproject", headers=auth(), json={
|
| 166 |
+
"status": "running"
|
| 167 |
+
})
|
| 168 |
+
assert r.status_code == 200
|
| 169 |
+
assert "pong" in r.json()
|
| 170 |
+
|
| 171 |
+
def test_monitor_summary(self):
|
| 172 |
+
r = client.get("/api/monitor/summary", headers=auth())
|
| 173 |
+
assert r.status_code == 200
|
| 174 |
+
assert "myproject" in r.json()["projects"]
|
| 175 |
+
|
| 176 |
+
def test_throttle_status(self):
|
| 177 |
+
r = client.get("/api/monitor/throttle", headers=auth())
|
| 178 |
+
assert r.status_code == 200
|
| 179 |
+
assert "multi_layer" in r.json()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class TestAgentEndpoints:
|
| 183 |
+
def test_agent_status(self):
|
| 184 |
+
r = client.get("/api/agent/status", headers=auth())
|
| 185 |
+
assert r.status_code == 200
|
| 186 |
+
assert "key_pool" in r.json()
|
| 187 |
+
|
| 188 |
+
def test_agent_tasks(self):
|
| 189 |
+
r = client.get("/api/agent/tasks", headers=auth())
|
| 190 |
+
assert r.status_code == 200
|
| 191 |
+
assert "tasks" in r.json()
|
| 192 |
+
|
| 193 |
+
def test_submit_task_no_prompt(self):
|
| 194 |
+
r = client.post("/api/agent/submit", headers=auth(), json={})
|
| 195 |
+
assert r.status_code == 400
|
| 196 |
+
|
| 197 |
+
def test_submit_task(self):
|
| 198 |
+
r = client.post("/api/agent/submit", headers=auth(), json={
|
| 199 |
+
"prompt": "Write hello world in Python"
|
| 200 |
+
})
|
| 201 |
+
assert r.status_code == 200
|
| 202 |
+
assert "dag_id" in r.json()
|
| 203 |
+
assert r.json()["status"] == "submitted"
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class TestSystemEndpoint:
|
| 207 |
+
def test_system_info(self):
|
| 208 |
+
r = client.get("/api/system", headers=auth())
|
| 209 |
+
assert r.status_code == 200
|
| 210 |
+
data = r.json()
|
| 211 |
+
assert "platform" in data
|
| 212 |
+
assert "python" in data
|
| 213 |
+
assert "mode" in data
|
| 214 |
+
assert data["mode"] == "AADE"
|
| 215 |
+
|
| 216 |
+
def test_router_status(self):
|
| 217 |
+
r = client.get("/api/router", headers=auth())
|
| 218 |
+
assert r.status_code == 200
|
| 219 |
+
assert "circuit_breaker" in r.json()
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class TestSandboxEndpoints:
|
| 223 |
+
def test_list_sandboxes(self):
|
| 224 |
+
r = client.get("/api/sandboxes", headers=auth())
|
| 225 |
+
assert r.status_code == 200
|
| 226 |
+
assert "local" in r.json()
|
| 227 |
+
assert "hf_spaces" in r.json()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class TestExternalTestingEndpoints:
|
| 231 |
+
def test_artifact_upload(self):
|
| 232 |
+
import base64
|
| 233 |
+
data = base64.b64encode(b"hello world").decode()
|
| 234 |
+
r = client.post("/api/test/artifact/myproj", headers=auth(), json={
|
| 235 |
+
"filename": "test.txt", "data": data
|
| 236 |
+
})
|
| 237 |
+
assert r.status_code == 200
|
| 238 |
+
assert r.json()["size"] == 11
|
| 239 |
+
|
| 240 |
+
def test_download_url(self):
|
| 241 |
+
r = client.get("/api/test/download-url/myproj", headers=auth())
|
| 242 |
+
assert r.status_code == 200
|
| 243 |
+
assert "files" in r.json()
|
| 244 |
+
|
| 245 |
+
def test_artifact_download(self):
|
| 246 |
+
r = client.get("/api/test/artifact/myproj/test.txt", headers=auth())
|
| 247 |
+
assert r.status_code == 200
|
| 248 |
+
assert r.json()["filename"] == "test.txt"
|
| 249 |
+
|
| 250 |
+
def test_webhook_no_url(self):
|
| 251 |
+
r = client.post("/api/test/webhook/myproj", headers=auth(), json={})
|
| 252 |
+
assert r.status_code == 400
|
| 253 |
+
|
| 254 |
+
def test_github_actions_no_token(self):
|
| 255 |
+
r = client.post("/api/test/github-actions/myproj", headers=auth(), json={
|
| 256 |
+
"repo": "testuser/testrepo"
|
| 257 |
+
})
|
| 258 |
+
assert r.status_code == 400
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
class TestConsoleEndpoints:
|
| 262 |
+
def test_console_exec(self):
|
| 263 |
+
r = client.post("/api/console/exec", headers=auth(), json={
|
| 264 |
+
"command": "echo hello", "timeout": 5
|
| 265 |
+
})
|
| 266 |
+
assert r.status_code == 200
|
| 267 |
+
assert "hello" in r.json()["stdout"]
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
class TestStateEndpoints:
|
| 271 |
+
def test_get_state(self):
|
| 272 |
+
r = client.get("/api/state", headers=auth())
|
| 273 |
+
assert r.status_code == 200
|
| 274 |
+
assert "state" in r.json()
|
| 275 |
+
|
| 276 |
+
def test_sync_state(self):
|
| 277 |
+
r = client.post("/api/state/sync", headers=auth())
|
| 278 |
+
assert r.status_code == 200
|
| 279 |
+
assert r.json()["status"] == "synced"
|
| 280 |
+
|
| 281 |
+
def test_encrypt_decrypt(self):
|
| 282 |
+
r = client.post("/api/state/encrypt", headers=auth(), json={"data": {"secret": "value"}})
|
| 283 |
+
assert r.status_code == 200
|
| 284 |
+
encrypted = r.json()["encrypted"]
|
| 285 |
+
r2 = client.post("/api/state/decrypt", headers=auth(), json={"token": encrypted})
|
| 286 |
+
assert r2.status_code == 200
|
| 287 |
+
assert r2.json()["data"]["secret"] == "value"
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class TestCircuitBreaker:
|
| 291 |
+
def test_reset_circuit(self):
|
| 292 |
+
r = client.post("/api/router/circuit/reset", headers=auth())
|
| 293 |
+
assert r.status_code == 200
|
| 294 |
+
assert r.json()["status"] == "reset"
|
| 295 |
+
|
| 296 |
+
def test_filter_context(self):
|
| 297 |
+
r = client.post("/api/router/filter", headers=auth(), json={"text": "a" * 1000})
|
| 298 |
+
assert r.status_code == 200
|
| 299 |
+
assert r.json()["original_length"] == 1000
|