mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
47
Commits
No files matched your search
@@ -0,0 +1,69 @@
|
||||
# Knowledge Embedding Model Configuration
|
||||
|
||||
Knowledge / RAG search uses vector **embeddings**. The default embedding model is
|
||||
`text-embedding-3-small`. Many OpenAI keys or projects do not have access to that
|
||||
model (organization tier, model allowlist, or regional restrictions), which caused
|
||||
knowledge search to silently degrade to local substring matching.
|
||||
|
||||
PraisonAIUI now makes this failure mode configurable and observable.
|
||||
|
||||
## Configure the embedding model
|
||||
|
||||
Set an accessible model via environment variable:
|
||||
|
||||
```bash
|
||||
export AIUI_EMBEDDING_MODEL=text-embedding-ada-002
|
||||
```
|
||||
|
||||
`OPENAI_EMBEDDING_MODEL` is also honoured as a fallback if `AIUI_EMBEDDING_MODEL`
|
||||
is not set.
|
||||
|
||||
## Fallback chain
|
||||
|
||||
The OpenAI-compatible `/v1/embeddings` endpoint tries the requested (or default)
|
||||
model first, then each model in the fallback chain if the primary returns a
|
||||
`model_not_found` / 403 access error:
|
||||
|
||||
```bash
|
||||
export AIUI_EMBEDDING_FALLBACK_MODELS="text-embedding-ada-002,text-embedding-3-small"
|
||||
```
|
||||
|
||||
If every candidate is inaccessible, the endpoint returns `503` with an actionable
|
||||
`model_not_found` error instead of a generic `500`.
|
||||
|
||||
## Observing degraded search
|
||||
|
||||
`GET /api/knowledge/status` reports embedding availability:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 12,
|
||||
"backend": "SDKKnowledgeManager",
|
||||
"status": "degraded",
|
||||
"embedding": {"available": false, "error": "Project does not have access to model `text-embedding-3-small`"},
|
||||
"warnings": ["Embedding model unavailable; knowledge search degraded to local text matching. Set AIUI_EMBEDDING_MODEL to an accessible model."]
|
||||
}
|
||||
```
|
||||
|
||||
`POST /api/knowledge/search` indicates whether results came from vector search or a
|
||||
degraded local fallback:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"search_mode": "fallback",
|
||||
"warning": "Vector search unavailable: Project does not have access to model text-embedding-3-small. Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding models in your provider project."
|
||||
}
|
||||
```
|
||||
|
||||
A dashboard can show a warning banner whenever `search_mode == "fallback"` or
|
||||
`embedding.available == false`.
|
||||
|
||||
## Model reference
|
||||
|
||||
| Model | Notes |
|
||||
|-------|-------|
|
||||
| `text-embedding-3-small` | Default — best cost/quality when available |
|
||||
| `text-embedding-3-large` | Higher quality — may also be restricted |
|
||||
| `text-embedding-ada-002` | Legacy — widely available on older projects |
|
||||
@@ -44,15 +44,18 @@ import sys
|
||||
# ── Import praisonaiui ──────────────────────────────────────
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies missing: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode.")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode.")
|
||||
GATEWAY_OK = False
|
||||
|
||||
# ── Set dashboard style ─────────────────────────────────────
|
||||
@@ -149,15 +152,15 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
# Create the gateway
|
||||
gateway = AIUIGateway(
|
||||
@@ -174,22 +177,22 @@ async def main():
|
||||
self_reflect=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# ── Register agents in dashboard CRUD storage ───────
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:8083")
|
||||
print(f" WebSocket: ws://localhost:8083/ws")
|
||||
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(" Dashboard: http://localhost:8083")
|
||||
safe_print(" WebSocket: ws://localhost:8083/ws")
|
||||
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
|
||||
safe_print()
|
||||
|
||||
# Start the gateway — this runs the full server
|
||||
await gateway.start()
|
||||
else:
|
||||
# Fallback to standalone mode (same as example 15)
|
||||
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
from praisonaiagents import Agent
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -115,7 +118,7 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
@@ -125,8 +128,8 @@ async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
@@ -139,32 +142,32 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
@@ -25,9 +25,13 @@ Usage:
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import praisonaiui as aiui
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check gateway availability ──────────────────────────────
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
@@ -35,9 +39,9 @@ try:
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
GATEWAY_OK = True
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Gateway dependencies not found: {e}")
|
||||
print(" Install with: pip install praisonai praisonaiagents")
|
||||
print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
|
||||
safe_print(" Install with: pip install praisonai praisonaiagents")
|
||||
safe_print(" Falling back to standalone mode (REST APIs only).\n")
|
||||
GATEWAY_OK = False
|
||||
|
||||
aiui.set_style("dashboard")
|
||||
@@ -127,7 +131,7 @@ def _register_agents_in_dashboard():
|
||||
"model": agent_def["model"],
|
||||
"icon": agent_def["icon"],
|
||||
})
|
||||
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
|
||||
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────
|
||||
@@ -137,8 +141,8 @@ async def main():
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
|
||||
if GATEWAY_OK:
|
||||
print("🚀 Starting with Gateway mode (full execution + streaming)")
|
||||
print()
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
|
||||
safe_print()
|
||||
|
||||
gateway = AIUIGateway(host=host, port=port)
|
||||
|
||||
@@ -151,21 +155,21 @@ async def main():
|
||||
reflection=False,
|
||||
)
|
||||
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
|
||||
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
|
||||
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
|
||||
|
||||
# Also register in dashboard CRUD
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(f" WebSocket: ws://localhost:{port}/ws")
|
||||
safe_print()
|
||||
|
||||
await gateway.start()
|
||||
else:
|
||||
# Standalone: REST APIs only, no agent execution
|
||||
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
|
||||
import uvicorn
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
@@ -173,11 +177,11 @@ async def main():
|
||||
_register_agents_in_dashboard()
|
||||
|
||||
app = create_app()
|
||||
print()
|
||||
print(f" Dashboard: http://localhost:{port}")
|
||||
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
|
||||
print(" Note: POST endpoints may fail without gateway")
|
||||
print()
|
||||
safe_print()
|
||||
safe_print(f" Dashboard: http://localhost:{port}")
|
||||
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
|
||||
safe_print(" Note: POST endpoints may fail without gateway")
|
||||
safe_print()
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
@@ -32,6 +32,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -40,8 +43,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai[email] praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai[email] praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -68,8 +71,8 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start email bot with automatic env var resolution."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -96,9 +99,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -118,11 +121,11 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📧 Starting email bot with custom handlers")
|
||||
print(f" Email: {bot._email_address}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
|
||||
safe_print(f" Email: {bot._email_address}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -131,17 +134,17 @@ async def run_with_handlers():
|
||||
async def run_probe():
|
||||
"""Test email server connectivity."""
|
||||
bot = Bot("email", agent=agent)
|
||||
print("🔍 Testing email server connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +42,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -91,11 +93,11 @@ def seed_email_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ Email channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ Email channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
|
||||
|
||||
# Also seed other channels for comparison in dashboard
|
||||
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
|
||||
@@ -106,7 +108,7 @@ def seed_email_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify email platform support ──────────────────────────────────
|
||||
@@ -122,8 +124,8 @@ def verify_email_support():
|
||||
if r.status_code == 200:
|
||||
platforms = r.json().get("platforms", [])
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
|
||||
return email_supported
|
||||
return False
|
||||
|
||||
@@ -137,51 +139,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = "📧" if ch.get("platform") == "email" else "💬"
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get email channel status
|
||||
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
|
||||
if email_ch:
|
||||
ch_id = email_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📧 PraisonAIUI — Email Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying email platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
|
||||
verify_email_support()
|
||||
print()
|
||||
print("🌱 Seeding email channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
|
||||
seed_email_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_email_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -33,6 +33,9 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from _shared.console import icon, safe_print
|
||||
|
||||
# ── Check prerequisites ─────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
@@ -41,8 +44,8 @@ try:
|
||||
SDK_OK = True
|
||||
except ImportError:
|
||||
SDK_OK = False
|
||||
print("⚠️ Missing dependencies. Install with:")
|
||||
print(" pip install praisonai agentmail praisonaiagents")
|
||||
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
|
||||
safe_print(" pip install praisonai agentmail praisonaiagents")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -69,9 +72,9 @@ agent = Agent(
|
||||
async def run_simple():
|
||||
"""Start agentmail bot with automatic env var resolution."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print(f"📩 Starting AgentMail bot")
|
||||
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
|
||||
print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
|
||||
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
|
||||
safe_print(" Listening for new emails... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -99,9 +102,9 @@ async def run_with_handlers():
|
||||
@bot.on_message
|
||||
async def log_email(message):
|
||||
subject = message.metadata.get("subject", "(no subject)")
|
||||
print(f"📨 From: {message.sender.username}")
|
||||
print(f" Subject: {subject}")
|
||||
print(f" Preview: {message.text[:100]}...")
|
||||
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
|
||||
safe_print(f" Subject: {subject}")
|
||||
safe_print(f" Preview: {message.text[:100]}...")
|
||||
|
||||
# Custom command: emails with subject starting with "/status"
|
||||
@bot.on_command("status")
|
||||
@@ -121,10 +124,10 @@ async def run_with_handlers():
|
||||
reply_to=message.message_id,
|
||||
)
|
||||
|
||||
print(f"📩 Starting AgentMail bot with custom handlers")
|
||||
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
print(f" Poll interval: {config.polling_interval}s")
|
||||
print(" Listening... (Ctrl+C to stop)")
|
||||
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
|
||||
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
|
||||
safe_print(f" Poll interval: {config.polling_interval}s")
|
||||
safe_print(" Listening... (Ctrl+C to stop)")
|
||||
await bot.start()
|
||||
|
||||
|
||||
@@ -136,30 +139,30 @@ async def run_inbox_demo():
|
||||
token=os.environ.get("AGENTMAIL_API_KEY", ""),
|
||||
)
|
||||
|
||||
print("📩 AgentMail Inbox Lifecycle Demo")
|
||||
print("=" * 50)
|
||||
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
|
||||
safe_print("=" * 50)
|
||||
|
||||
# Create inbox
|
||||
print("\n1. Creating inbox...")
|
||||
safe_print("\n1. Creating inbox...")
|
||||
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
|
||||
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
|
||||
print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
|
||||
safe_print(f" ID: {inbox.get('id', 'unknown')}")
|
||||
|
||||
# List inboxes
|
||||
print("\n2. Listing inboxes...")
|
||||
safe_print("\n2. Listing inboxes...")
|
||||
inboxes = await bot.list_inboxes()
|
||||
print(f" Found: {len(inboxes)} inbox(es)")
|
||||
safe_print(f" Found: {len(inboxes)} inbox(es)")
|
||||
for ib in inboxes:
|
||||
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
|
||||
|
||||
# Delete inbox
|
||||
inbox_id = inbox.get("id", "")
|
||||
if inbox_id:
|
||||
print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
safe_print(f"\n3. Deleting inbox {inbox_id}...")
|
||||
deleted = await bot.delete_inbox(inbox_id)
|
||||
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
|
||||
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
|
||||
|
||||
print("\n✅ Inbox lifecycle complete")
|
||||
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
|
||||
|
||||
|
||||
# ── Probe: Test connectivity ────────────────────────────────────────
|
||||
@@ -167,17 +170,17 @@ async def run_inbox_demo():
|
||||
async def run_probe():
|
||||
"""Test AgentMail API connectivity."""
|
||||
bot = Bot("agentmail", agent=agent)
|
||||
print("🔍 Testing AgentMail API connectivity...")
|
||||
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
|
||||
result = await bot.probe()
|
||||
|
||||
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
|
||||
print(f" Platform: {result.platform}")
|
||||
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
|
||||
safe_print(f" Platform: {result.platform}")
|
||||
if result.bot_username:
|
||||
print(f" Email: {result.bot_username}")
|
||||
safe_print(f" Email: {result.bot_username}")
|
||||
if result.elapsed_ms:
|
||||
print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
|
||||
if result.error:
|
||||
print(f" Error: {result.error}")
|
||||
safe_print(f" Error: {result.error}")
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,7 +40,9 @@ import sys
|
||||
# ── Imports ─────────────────────────────────────────────────────────
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from _shared.console import icon, safe_print
|
||||
from praisonaiui.server import create_app
|
||||
import uvicorn
|
||||
|
||||
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
|
||||
})
|
||||
if r.status_code in (200, 201):
|
||||
channel_id = r.json().get("id", "unknown")
|
||||
print(f" ✅ AgentMail channel registered: {channel_id}")
|
||||
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
|
||||
else:
|
||||
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
|
||||
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
|
||||
else:
|
||||
print(" ✓ AgentMail channel already exists")
|
||||
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
|
||||
|
||||
# Also seed email + telegram for comparison in dashboard
|
||||
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
|
||||
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
|
||||
"platform": platform,
|
||||
"config": {},
|
||||
})
|
||||
print(f" ✓ Seeded {platform} channel")
|
||||
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
|
||||
|
||||
|
||||
# ── Verify agentmail platform support ──────────────────────────────
|
||||
@@ -120,9 +122,9 @@ def verify_agentmail_support():
|
||||
platforms = r.json().get("platforms", [])
|
||||
am_supported = "agentmail" in platforms
|
||||
email_supported = "email" in platforms
|
||||
print(f" Supported platforms: {platforms}")
|
||||
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
|
||||
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
|
||||
safe_print(f" Supported platforms: {platforms}")
|
||||
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
|
||||
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
|
||||
return am_supported
|
||||
return False
|
||||
|
||||
@@ -136,51 +138,51 @@ def test_channel_api():
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
print("\n📋 Channel API Test")
|
||||
print("=" * 40)
|
||||
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
|
||||
safe_print("=" * 40)
|
||||
|
||||
# List channels
|
||||
r = client.get("/api/channels")
|
||||
channels = r.json().get("channels", [])
|
||||
print(f" GET /api/channels: {len(channels)} channels")
|
||||
safe_print(f" GET /api/channels: {len(channels)} channels")
|
||||
for ch in channels:
|
||||
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
|
||||
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
|
||||
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
|
||||
|
||||
# Get agentmail channel status
|
||||
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
|
||||
if am_ch:
|
||||
ch_id = am_ch.get("id")
|
||||
r = client.get(f"/api/channels/{ch_id}/status")
|
||||
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
|
||||
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("📩 PraisonAIUI — AgentMail Channel Integration")
|
||||
print("=" * 50)
|
||||
print()
|
||||
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
|
||||
safe_print("=" * 50)
|
||||
safe_print()
|
||||
|
||||
if "--test" in sys.argv:
|
||||
# Test mode: verify APIs + seed data
|
||||
print("🔍 Verifying agentmail platform support...")
|
||||
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
|
||||
verify_agentmail_support()
|
||||
print()
|
||||
print("🌱 Seeding agentmail channel...")
|
||||
safe_print()
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
|
||||
seed_agentmail_channel()
|
||||
test_channel_api()
|
||||
else:
|
||||
# Run the dashboard
|
||||
print("🌱 Seeding channels...")
|
||||
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
|
||||
seed_agentmail_channel()
|
||||
print()
|
||||
safe_print()
|
||||
|
||||
app = create_app()
|
||||
print(f"✅ Dashboard at http://localhost:8084")
|
||||
print(f" Channels: http://localhost:8084/api/channels")
|
||||
print(f" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
|
||||
safe_print(" Channels: http://localhost:8084/api/channels")
|
||||
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
uvicorn.run(app, host=host, port=8084, log_level="info")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Cross-platform console output for examples.
|
||||
|
||||
Legacy Windows consoles default stdout to a non-UTF code page (cp1252/cp437)
|
||||
that cannot encode emoji such as U+1F680. Calling ``print`` with those
|
||||
characters raises ``UnicodeEncodeError`` and crashes the example before the
|
||||
server starts. These helpers mirror the ``_supports_unicode``/``_icon`` pattern
|
||||
in ``praisonaiui.cli`` so examples degrade to ASCII instead of crashing while
|
||||
preserving emoji on UTF-aware terminals.
|
||||
|
||||
Usage:
|
||||
from _shared.console import safe_print, icon
|
||||
|
||||
safe_print(f"{icon('🚀', '[START]')} Starting gateway...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def supports_unicode() -> bool:
|
||||
try:
|
||||
encoding = sys.stdout.encoding or ""
|
||||
except Exception:
|
||||
return False
|
||||
return "utf" in encoding.lower()
|
||||
|
||||
|
||||
def icon(symbol: str, fallback: str) -> str:
|
||||
return symbol if supports_unicode() else fallback
|
||||
|
||||
|
||||
def safe_print(*args: Any, **kwargs: Any) -> None:
|
||||
sep = kwargs.pop("sep", " ")
|
||||
text = sep.join(str(a) for a in args)
|
||||
try:
|
||||
print(text, **kwargs)
|
||||
except UnicodeEncodeError:
|
||||
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.129"
|
||||
version = "0.3.135"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.129"
|
||||
__version__ = "0.3.135"
|
||||
@@ -1596,10 +1596,18 @@ def run(
|
||||
# Run gateway - check if user module defines its own main function
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
|
||||
if hasattr(user_module, 'main') and callable(getattr(user_module, 'main')):
|
||||
# Call user's main function directly (gateway pattern)
|
||||
main_func = getattr(user_module, 'main')
|
||||
# Propagate CLI --port/--host to gateway examples that read the
|
||||
# PORT/HOST environment variables. Explicit env set by the user
|
||||
# takes precedence over the CLI flag.
|
||||
if "PORT" not in os.environ:
|
||||
os.environ["PORT"] = str(actual_port)
|
||||
if "HOST" not in os.environ:
|
||||
os.environ["HOST"] = host
|
||||
if inspect.iscoroutinefunction(main_func):
|
||||
console.print("[yellow]ℹ️[/yellow] Delegating to user-defined async main()")
|
||||
asyncio.run(main_func())
|
||||
|
||||
@@ -476,6 +476,18 @@ class JSONFileDataStore(BaseDataStore):
|
||||
|
||||
return await asyncio.to_thread(_get)
|
||||
|
||||
async def update_session(self, session_id: str, **kwargs: Any) -> None:
|
||||
def _update() -> None:
|
||||
path = self._session_path(session_id)
|
||||
data = self._read_session(path)
|
||||
if data is None:
|
||||
return
|
||||
data.update(kwargs)
|
||||
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
self._write_session(path, data)
|
||||
|
||||
await asyncio.to_thread(_update)
|
||||
|
||||
def _read_feedback(self) -> list[dict[str, Any]]:
|
||||
"""Read feedback from JSON file."""
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,8 @@ logger = logging.getLogger(__name__)
|
||||
# Sentinel key used to identify the AIUI metadata message inside SDK sessions
|
||||
_META_KEY = "_aiui_meta"
|
||||
_TITLE_KEY = "_aiui_title"
|
||||
# Bucket holding arbitrary session-level metadata (e.g. fork lineage)
|
||||
_CUSTOM_META_KEY = "_aiui_custom"
|
||||
|
||||
|
||||
class SDKFileDataStore(BaseDataStore):
|
||||
@@ -116,6 +118,10 @@ class SDKFileDataStore(BaseDataStore):
|
||||
result["platform"] = meta["platform"]
|
||||
if meta.get("icon"):
|
||||
result["icon"] = meta["icon"]
|
||||
# Surface any custom session-level metadata (e.g. fork lineage)
|
||||
custom = meta.get(_CUSTOM_META_KEY)
|
||||
if isinstance(custom, dict) and custom:
|
||||
result["metadata"] = custom
|
||||
return result
|
||||
|
||||
async def create_session(self, session_id: Optional[str] = None) -> dict[str, Any]:
|
||||
@@ -183,6 +189,8 @@ class SDKFileDataStore(BaseDataStore):
|
||||
await self._persist_meta(session_id, "platform", kwargs["platform"])
|
||||
if "icon" in kwargs:
|
||||
await self._persist_meta(session_id, "icon", kwargs["icon"])
|
||||
if "metadata" in kwargs and isinstance(kwargs["metadata"], dict):
|
||||
await self._persist_meta(session_id, _CUSTOM_META_KEY, kwargs["metadata"])
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
@@ -671,6 +671,93 @@ async def _run_and_broadcast(
|
||||
pass
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_LIMIT = 128000
|
||||
_TOKENS_PER_CHAR = 0.25
|
||||
_SYSTEM_BASELINE_TOKENS = 800
|
||||
_TOOLS_BASELINE_TOKENS = 400
|
||||
|
||||
|
||||
def _estimate_context_stats(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
session_id: str,
|
||||
limit: int = DEFAULT_CONTEXT_LIMIT,
|
||||
model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compute a char-based context usage estimate for a session.
|
||||
|
||||
No tokenizer dependency: approximates tokens as ``chars * 0.25`` and
|
||||
adds fixed system/tools baselines. Returns a wire-safe dict with a
|
||||
deterministic, sorted breakdown. ``estimate`` is always ``True`` since
|
||||
this is a heuristic, not a provider-reported count.
|
||||
"""
|
||||
message_chars = 0
|
||||
tool_chars = 0
|
||||
memory_chars = 0
|
||||
for m in messages:
|
||||
content = m.get("content") or ""
|
||||
message_chars += len(str(content))
|
||||
tool_calls = m.get("toolCalls") or m.get("tool_calls") or []
|
||||
for tc in tool_calls:
|
||||
tool_chars += len(str(tc.get("args", "")))
|
||||
tool_chars += len(str(tc.get("result", "")))
|
||||
for el in m.get("elements") or []:
|
||||
memory_chars += len(str(el.get("url", "")))
|
||||
|
||||
system_tokens = _SYSTEM_BASELINE_TOKENS
|
||||
tools_tokens = _TOOLS_BASELINE_TOKENS + int(tool_chars * _TOKENS_PER_CHAR)
|
||||
messages_tokens = int(message_chars * _TOKENS_PER_CHAR)
|
||||
memory_tokens = int(memory_chars * _TOKENS_PER_CHAR)
|
||||
|
||||
breakdown = {
|
||||
"memory": memory_tokens,
|
||||
"messages": messages_tokens,
|
||||
"system": system_tokens,
|
||||
"tools": tools_tokens,
|
||||
}
|
||||
used = system_tokens + tools_tokens + messages_tokens + memory_tokens
|
||||
safe_limit = limit if limit and limit > 0 else DEFAULT_CONTEXT_LIMIT
|
||||
usage_pct = round((used / safe_limit) * 100, 1) if safe_limit else 0.0
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"breakdown": breakdown,
|
||||
"estimate": True,
|
||||
"limit": limit,
|
||||
"session_id": session_id,
|
||||
"usage_pct": usage_pct,
|
||||
"used": used,
|
||||
}
|
||||
if model:
|
||||
result["model"] = model
|
||||
return result
|
||||
|
||||
|
||||
async def _chat_context_stats(request: Request) -> JSONResponse:
|
||||
"""GET /api/chat/context-stats?session_id= — context budget estimate.
|
||||
|
||||
Config-driven fallback for the composer token ring. Computes a
|
||||
char-based estimate from persisted messages; unknown sessions return
|
||||
a zero-usage estimate rather than 404 so the UI can always render.
|
||||
"""
|
||||
session_id = request.query_params.get("session_id", "")
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", str(DEFAULT_CONTEXT_LIMIT)))
|
||||
except (TypeError, ValueError):
|
||||
limit = DEFAULT_CONTEXT_LIMIT
|
||||
|
||||
messages: List[Dict[str, Any]] = []
|
||||
if session_id:
|
||||
try:
|
||||
from praisonaiui.server import _datastore
|
||||
|
||||
messages = await _datastore.get_messages(session_id) or []
|
||||
except Exception:
|
||||
messages = []
|
||||
|
||||
stats = _estimate_context_stats(messages, session_id=session_id, limit=limit)
|
||||
return JSONResponse(stats)
|
||||
|
||||
|
||||
async def _chat_history(request: Request) -> JSONResponse:
|
||||
"""GET /api/chat/history/{session_id} — get message history."""
|
||||
session_id = request.path_params["session_id"]
|
||||
@@ -854,6 +941,7 @@ class ChatFeature(BaseFeatureProtocol):
|
||||
def routes(self) -> List[Route]:
|
||||
return [
|
||||
Route("/api/chat/send", _chat_send, methods=["POST"]),
|
||||
Route("/api/chat/context-stats", _chat_context_stats, methods=["GET"]),
|
||||
Route("/api/chat/history/{session_id}", _chat_history, methods=["GET"]),
|
||||
Route("/api/chat/abort", _chat_abort, methods=["POST"]),
|
||||
WebSocketRoute("/api/chat/ws", _chat_ws),
|
||||
|
||||
@@ -176,6 +176,49 @@ CONFIG_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"finops": {
|
||||
"type": "object",
|
||||
"title": "FinOps & Budgets",
|
||||
"description": (
|
||||
"Alerts appear on Overview and Chat. Does not block agent runs."
|
||||
),
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enable FinOps",
|
||||
"default": True,
|
||||
},
|
||||
"daily_token_budget": {
|
||||
"type": "integer",
|
||||
"title": "Daily Token Budget",
|
||||
"minimum": 0,
|
||||
},
|
||||
"daily_cost_budget_usd": {
|
||||
"type": "number",
|
||||
"title": "Daily Cost Budget (USD)",
|
||||
"minimum": 0,
|
||||
},
|
||||
"warn_pct": {
|
||||
"type": "integer",
|
||||
"title": "Warn Threshold (%)",
|
||||
"minimum": 1,
|
||||
"maximum": 99,
|
||||
"default": 80,
|
||||
},
|
||||
"critical_pct": {
|
||||
"type": "integer",
|
||||
"title": "Critical Threshold (%)",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 95,
|
||||
},
|
||||
"show_session_chip": {
|
||||
"type": "boolean",
|
||||
"title": "Show Session Cost Chip",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -216,6 +259,24 @@ def validate_config(config: Dict[str, Any], schema: Dict[str, Any] = None) -> Li
|
||||
if "maximum" in prop_schema and value > prop_schema["maximum"]:
|
||||
errors.append(f"{key}: must be <= {prop_schema['maximum']}")
|
||||
|
||||
errors.extend(_validate_finops(config.get("finops")))
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_finops(finops: Any) -> List[str]:
|
||||
"""Cross-field validation for the FinOps section (E4/E21)."""
|
||||
if not isinstance(finops, dict):
|
||||
return []
|
||||
errors: List[str] = []
|
||||
warn = finops.get("warn_pct", 80)
|
||||
critical = finops.get("critical_pct", 95)
|
||||
if isinstance(warn, (int, float)) and isinstance(critical, (int, float)):
|
||||
if critical <= warn:
|
||||
errors.append("finops.critical_pct: must be greater than warn_pct")
|
||||
for field in ("daily_token_budget", "daily_cost_budget_usd"):
|
||||
val = finops.get(field)
|
||||
if isinstance(val, (int, float)) and val < 0:
|
||||
errors.append(f"finops.{field}: must be >= 0")
|
||||
return errors
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,31 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.placeholder": "Type your message...",
|
||||
"chat.send": "Send",
|
||||
"chat.thinking": "Thinking...",
|
||||
"chat.context.healthy": "Context healthy",
|
||||
"chat.context.warning": "Approaching context limit",
|
||||
"chat.context.critical": "Context nearly full",
|
||||
"chat.context.estimate": "Estimate",
|
||||
"chat.compaction.banner": "Context nearly full \u2014 older messages may be summarized on next send",
|
||||
"chat.compaction.compacted": "Context compacted",
|
||||
"chat.memory.chip": "{count} memories",
|
||||
"chat.memory.empty_turn": "No memories for this turn",
|
||||
"skills.title": "Skills Studio",
|
||||
"skills.subtitle": "Review self_improve proposals",
|
||||
"skills.tab.pending": "Pending",
|
||||
"skills.tab.installed": "Installed",
|
||||
"skills.tab.history": "History",
|
||||
"skills.diff.current": "Current",
|
||||
"skills.diff.proposed": "Proposed",
|
||||
"skills.diff.no_existing": "No existing skill",
|
||||
"skills.action.approve": "Approve",
|
||||
"skills.action.reject": "Reject",
|
||||
"skills.pending.empty": "No pending skill writes",
|
||||
"finops.strip.title": "Cost FinOps",
|
||||
"finops.strip.today": "Today: {tokens} tokens",
|
||||
"finops.strip.view_usage": "View usage",
|
||||
"finops.banner.warn": "Daily token budget at {pct}% \u2014 consider lighter models",
|
||||
"finops.banner.critical": "Daily token budget at {pct}% \u2014 review usage",
|
||||
"finops.chip.label": "{tokens} \u00b7 {model}",
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Settings",
|
||||
@@ -86,6 +111,31 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.placeholder": "Escribe tu mensaje...",
|
||||
"chat.send": "Enviar",
|
||||
"chat.thinking": "Pensando...",
|
||||
"chat.context.healthy": "Contexto saludable",
|
||||
"chat.context.warning": "Acercándose al límite de contexto",
|
||||
"chat.context.critical": "Contexto casi lleno",
|
||||
"chat.context.estimate": "Estimación",
|
||||
"chat.compaction.banner": "Contexto casi lleno \u2014 los mensajes antiguos pueden resumirse al enviar",
|
||||
"chat.compaction.compacted": "Contexto compactado",
|
||||
"chat.memory.chip": "{count} recuerdos",
|
||||
"chat.memory.empty_turn": "Sin recuerdos para este turno",
|
||||
"skills.title": "Estudio de habilidades",
|
||||
"skills.subtitle": "Revisar propuestas de self_improve",
|
||||
"skills.tab.pending": "Pendientes",
|
||||
"skills.tab.installed": "Instaladas",
|
||||
"skills.tab.history": "Historial",
|
||||
"skills.diff.current": "Actual",
|
||||
"skills.diff.proposed": "Propuesto",
|
||||
"skills.diff.no_existing": "No existe la habilidad",
|
||||
"skills.action.approve": "Aprobar",
|
||||
"skills.action.reject": "Rechazar",
|
||||
"skills.pending.empty": "No hay escrituras de habilidades pendientes",
|
||||
"finops.strip.title": "FinOps de costos",
|
||||
"finops.strip.today": "Hoy: {tokens} tokens",
|
||||
"finops.strip.view_usage": "Ver uso",
|
||||
"finops.banner.warn": "Presupuesto diario de tokens al {pct}% \u2014 considere modelos más ligeros",
|
||||
"finops.banner.critical": "Presupuesto diario de tokens al {pct}% \u2014 revise el uso",
|
||||
"finops.chip.label": "{tokens} \u00b7 {model}",
|
||||
"nav.dashboard": "Panel",
|
||||
"nav.agents": "Agentes",
|
||||
"nav.settings": "Configuración",
|
||||
@@ -99,6 +149,31 @@ class JSONLocaleManager(I18nProtocol):
|
||||
"chat.placeholder": "Tapez votre message...",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.thinking": "Réflexion...",
|
||||
"chat.context.healthy": "Contexte sain",
|
||||
"chat.context.warning": "Approche de la limite de contexte",
|
||||
"chat.context.critical": "Contexte presque plein",
|
||||
"chat.context.estimate": "Estimation",
|
||||
"chat.compaction.banner": "Contexte presque plein \u2014 les anciens messages peuvent être résumés au prochain envoi",
|
||||
"chat.compaction.compacted": "Contexte compacté",
|
||||
"chat.memory.chip": "{count} souvenirs",
|
||||
"chat.memory.empty_turn": "Aucun souvenir pour ce tour",
|
||||
"skills.title": "Studio de comp\u00e9tences",
|
||||
"skills.subtitle": "Examiner les propositions de self_improve",
|
||||
"skills.tab.pending": "En attente",
|
||||
"skills.tab.installed": "Install\u00e9es",
|
||||
"skills.tab.history": "Historique",
|
||||
"skills.diff.current": "Actuel",
|
||||
"skills.diff.proposed": "Propos\u00e9",
|
||||
"skills.diff.no_existing": "Aucune comp\u00e9tence existante",
|
||||
"skills.action.approve": "Approuver",
|
||||
"skills.action.reject": "Rejeter",
|
||||
"skills.pending.empty": "Aucune \u00e9criture de comp\u00e9tence en attente",
|
||||
"finops.strip.title": "FinOps des coûts",
|
||||
"finops.strip.today": "Aujourd'hui : {tokens} tokens",
|
||||
"finops.strip.view_usage": "Voir l'utilisation",
|
||||
"finops.banner.warn": "Budget quotidien de tokens à {pct}% \u2014 envisagez des modèles plus légers",
|
||||
"finops.banner.critical": "Budget quotidien de tokens à {pct}% \u2014 vérifiez l'utilisation",
|
||||
"finops.chip.label": "{tokens} \u00b7 {model}",
|
||||
"nav.dashboard": "Tableau de bord",
|
||||
"nav.agents": "Agents",
|
||||
"nav.settings": "Paramètres",
|
||||
|
||||
@@ -27,6 +27,17 @@ from ._base import BaseFeatureProtocol
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_embedding_unavailable(err: BaseException) -> bool:
|
||||
"""Detect an embedding model access / not-found failure."""
|
||||
msg = str(err).lower()
|
||||
return (
|
||||
"does not have access" in msg
|
||||
or "model_not_found" in msg
|
||||
or "does not exist" in msg
|
||||
or "notfounderror" in msg
|
||||
)
|
||||
|
||||
|
||||
# ── Knowledge Protocol ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -95,6 +106,10 @@ class KnowledgeProtocol(ABC):
|
||||
"""Health check for this backend."""
|
||||
return {"status": "ok", "provider": self.__class__.__name__}
|
||||
|
||||
def last_search_meta(self) -> Dict[str, Any]:
|
||||
"""Metadata about the most recent search (mode, error)."""
|
||||
return {"mode": "vector", "error": None}
|
||||
|
||||
|
||||
# ── Simple Knowledge Manager (Default, no deps) ─────────────────────
|
||||
|
||||
@@ -171,8 +186,12 @@ class SimpleKnowledgeManager(KnowledgeProtocol):
|
||||
"status": "ok",
|
||||
"provider": "SimpleKnowledgeManager",
|
||||
"total_entries": len(self._entries),
|
||||
"embedding": {"available": False, "reason": "no_vector_backend"},
|
||||
}
|
||||
|
||||
def last_search_meta(self) -> Dict[str, Any]:
|
||||
return {"mode": "local", "error": None}
|
||||
|
||||
|
||||
# ── SDK Knowledge Manager (wraps praisonaiagents.knowledge) ──────────
|
||||
|
||||
@@ -191,6 +210,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
|
||||
self._sdk_knowledge = None
|
||||
self._sdk_probed = False # True once we've verified SDK actually works
|
||||
self._local_index: Dict[str, Dict[str, Any]] = {}
|
||||
self._embedding_unavailable = False
|
||||
self._embedding_error: Optional[str] = None
|
||||
self._last_search_meta: Dict[str, Any] = {"mode": "vector", "error": None}
|
||||
|
||||
def _get_sdk_knowledge(self) -> Any:
|
||||
"""Lazy-init SDK Knowledge with health probe.
|
||||
@@ -242,6 +264,10 @@ class SDKKnowledgeManager(KnowledgeProtocol):
|
||||
"using local in-memory index"
|
||||
)
|
||||
self._sdk_knowledge = None
|
||||
elif _is_embedding_unavailable(e):
|
||||
self._embedding_unavailable = True
|
||||
self._embedding_error = err_msg
|
||||
logger.warning("Embedding model unavailable: %s", e)
|
||||
else:
|
||||
# Other errors (e.g. empty DB) are fine — SDK works
|
||||
pass
|
||||
@@ -294,6 +320,7 @@ class SDKKnowledgeManager(KnowledgeProtocol):
|
||||
try:
|
||||
results = sdk.search(query, limit=limit, user_id="praisonaiui")
|
||||
if isinstance(results, list) and results:
|
||||
self._last_search_meta = {"mode": "vector", "error": None}
|
||||
return [
|
||||
{
|
||||
"id": str(r.get("id", i)),
|
||||
@@ -305,6 +332,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
|
||||
]
|
||||
except BaseException as e:
|
||||
logger.warning("SDK knowledge search failed: %s; falling back to local", e)
|
||||
if _is_embedding_unavailable(e):
|
||||
self._embedding_unavailable = True
|
||||
self._embedding_error = str(e)
|
||||
|
||||
# Fallback to local text search
|
||||
query_lower = query.lower()
|
||||
@@ -314,8 +344,26 @@ class SDKKnowledgeManager(KnowledgeProtocol):
|
||||
results.append(e)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
if self._embedding_unavailable:
|
||||
self._last_search_meta = {
|
||||
"mode": "fallback",
|
||||
"error": (
|
||||
"Vector search unavailable: "
|
||||
f"{self._embedding_error or 'embedding model not accessible'}. "
|
||||
"Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding "
|
||||
"models in your provider project."
|
||||
),
|
||||
}
|
||||
elif sdk is not None:
|
||||
self._last_search_meta = {"mode": "vector", "error": None}
|
||||
else:
|
||||
self._last_search_meta = {"mode": "local", "error": None}
|
||||
return results
|
||||
|
||||
def last_search_meta(self) -> Dict[str, Any]:
|
||||
return dict(self._last_search_meta)
|
||||
|
||||
def add_file(
|
||||
self,
|
||||
file_path: str,
|
||||
@@ -453,11 +501,25 @@ class SDKKnowledgeManager(KnowledgeProtocol):
|
||||
|
||||
def health(self) -> Dict[str, Any]:
|
||||
sdk = self._get_sdk_knowledge()
|
||||
embedding_available = sdk is not None and not self._embedding_unavailable
|
||||
warnings: List[str] = []
|
||||
embedding: Dict[str, Any] = {"available": embedding_available}
|
||||
if self._embedding_unavailable:
|
||||
embedding["error"] = self._embedding_error
|
||||
warnings.append(
|
||||
"Embedding model unavailable; knowledge search degraded to local text "
|
||||
"matching. Set AIUI_EMBEDDING_MODEL to an accessible model."
|
||||
)
|
||||
status = "ok"
|
||||
if sdk is None or self._embedding_unavailable:
|
||||
status = "degraded"
|
||||
return {
|
||||
"status": "ok" if sdk is not None else "degraded",
|
||||
"status": status,
|
||||
"provider": "SDKKnowledgeManager",
|
||||
"sdk_available": sdk is not None,
|
||||
"total_indexed": len(self._local_index),
|
||||
"embedding": embedding,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
@@ -626,7 +688,15 @@ class KnowledgeFeature(BaseFeatureProtocol):
|
||||
query=query,
|
||||
limit=body.get("limit", 10),
|
||||
)
|
||||
return JSONResponse({"results": results, "count": len(results)})
|
||||
meta = mgr.last_search_meta()
|
||||
return JSONResponse(
|
||||
{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"search_mode": meta.get("mode", "unknown"),
|
||||
"warning": meta.get("error"),
|
||||
}
|
||||
)
|
||||
|
||||
async def _add_file(self, request: Request) -> JSONResponse:
|
||||
"""POST /api/knowledge/add-file — ingest a file."""
|
||||
@@ -675,6 +745,8 @@ class KnowledgeFeature(BaseFeatureProtocol):
|
||||
"files": stats.get("files", 0),
|
||||
"backend": h.get("provider", "unknown"),
|
||||
"status": h.get("status", "ok"),
|
||||
"embedding": h.get("embedding", {"available": True}),
|
||||
"warnings": h.get("warnings", []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Wraps praisonai.capabilities functions as HTTP endpoints.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
@@ -17,6 +18,35 @@ from starlette.routing import Route
|
||||
|
||||
from ._base import BaseFeatureProtocol
|
||||
|
||||
|
||||
def _default_embedding_model() -> str:
|
||||
"""Resolve the default embedding model from environment."""
|
||||
return os.environ.get(
|
||||
"AIUI_EMBEDDING_MODEL",
|
||||
os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
|
||||
)
|
||||
|
||||
|
||||
def _embedding_fallback_models() -> list[str]:
|
||||
"""Resolve the embedding fallback chain from environment."""
|
||||
raw = os.environ.get(
|
||||
"AIUI_EMBEDDING_FALLBACK_MODELS",
|
||||
"text-embedding-ada-002,text-embedding-3-small",
|
||||
)
|
||||
return [m.strip() for m in raw.split(",") if m.strip()]
|
||||
|
||||
|
||||
def _is_model_not_found(err: BaseException) -> bool:
|
||||
"""Heuristically detect embedding model access / not-found errors."""
|
||||
msg = str(err).lower()
|
||||
return (
|
||||
"does not have access" in msg
|
||||
or "model_not_found" in msg
|
||||
or "does not exist" in msg
|
||||
or "notfounderror" in msg
|
||||
)
|
||||
|
||||
|
||||
# Lazy import capabilities
|
||||
_capabilities = None
|
||||
|
||||
@@ -280,29 +310,57 @@ class OpenAIAPIFeature(BaseFeatureProtocol):
|
||||
|
||||
body = await request.json()
|
||||
input_text = body.get("input", "")
|
||||
model = body.get("model", "text-embedding-3-small")
|
||||
model = body.get("model") or _default_embedding_model()
|
||||
|
||||
try:
|
||||
result = await caps.aembed(
|
||||
input=input_text,
|
||||
model=model,
|
||||
)
|
||||
candidates = [model]
|
||||
for fallback in _embedding_fallback_models():
|
||||
if fallback not in candidates:
|
||||
candidates.append(fallback)
|
||||
|
||||
data = _result_to_dict(result)
|
||||
last_err: BaseException | None = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
result = await caps.aembed(
|
||||
input=input_text,
|
||||
model=candidate,
|
||||
)
|
||||
data = _result_to_dict(result)
|
||||
return JSONResponse(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"object": "embedding",
|
||||
"index": 0,
|
||||
"embedding": data.get("embedding", []),
|
||||
}
|
||||
],
|
||||
"model": candidate,
|
||||
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if not _is_model_not_found(e):
|
||||
break
|
||||
|
||||
if last_err is not None and _is_model_not_found(last_err):
|
||||
return JSONResponse(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "index": 0, "embedding": data.get("embedding", [])}
|
||||
],
|
||||
"model": model,
|
||||
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
{"error": {"message": str(e), "type": "api_error"}}, status_code=500
|
||||
"error": {
|
||||
"message": (
|
||||
f"Embedding model unavailable: {last_err}. "
|
||||
"Set AIUI_EMBEDDING_MODEL to a model your project can access "
|
||||
"(e.g. text-embedding-ada-002)."
|
||||
),
|
||||
"type": "model_not_found",
|
||||
}
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"error": {"message": str(last_err), "type": "api_error"}}, status_code=500
|
||||
)
|
||||
|
||||
async def _image_generate(self, request: Request) -> JSONResponse:
|
||||
"""POST /v1/images/generations — Generate images."""
|
||||
|
||||
@@ -116,7 +116,14 @@ class OpenAIRealtimeManager(RealtimeProtocol):
|
||||
logger.debug(f"Audio chunk for session {session_id}: {len(audio_data)} bytes")
|
||||
|
||||
async def receive_audio(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Receive events from OpenAI realtime session."""
|
||||
"""Receive events from OpenAI realtime session.
|
||||
|
||||
First-event contract:
|
||||
- Unknown session -> ``error`` (Session not found)
|
||||
- ``OPENAI_API_KEY`` set -> ``session.ready`` (WebRTC handshake)
|
||||
- Integrated mode, no key -> ``error`` (status=degraded)
|
||||
- Standalone, no key -> ``conversation.item.created`` (mock)
|
||||
"""
|
||||
# In WebRTC mode, events come via WebRTC connection
|
||||
# This method would handle transcript/tool call events
|
||||
session = self._sessions.get(session_id)
|
||||
|
||||
@@ -132,6 +132,14 @@ class _InMemorySessionStore(SessionProtocol):
|
||||
_session_metadata: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
_MESSAGE_COPY_FIELDS = ("role", "content", "text", "tool_calls", "name", "metadata")
|
||||
|
||||
|
||||
def _sanitize_message(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Copy a message for a forked session, stripping ephemeral identifiers."""
|
||||
return {k: message[k] for k in _MESSAGE_COPY_FIELDS if k in message}
|
||||
|
||||
|
||||
def _get_metadata(session_id: str) -> Dict[str, Any]:
|
||||
"""Get session metadata (labels, usage, etc)."""
|
||||
if session_id not in _session_metadata:
|
||||
@@ -166,6 +174,7 @@ class SessionsFeature(BaseFeatureProtocol):
|
||||
Route("/api/sessions/{session_id}/context", self._build_context, methods=["POST"]),
|
||||
Route("/api/sessions/{session_id}/compact", self._compact, methods=["POST"]),
|
||||
Route("/api/sessions/{session_id}/reset", self._reset, methods=["POST"]),
|
||||
Route("/api/sessions/{session_id}/fork", self._fork, methods=["POST"]),
|
||||
Route("/api/sessions/{session_id}/preview", self._preview, methods=["GET"]),
|
||||
Route("/api/sessions/{session_id}/labels", self._labels, methods=["GET"]),
|
||||
Route("/api/sessions/{session_id}/labels", self._set_labels, methods=["POST"]),
|
||||
@@ -307,6 +316,16 @@ class SessionsFeature(BaseFeatureProtocol):
|
||||
or s.get("agent")
|
||||
or s.get("metadata", {}).get("agent_id")
|
||||
),
|
||||
"platform": (
|
||||
s.get("platform")
|
||||
or s.get("metadata", {}).get("platform")
|
||||
or meta.get("_platform")
|
||||
),
|
||||
"channel_id": (
|
||||
s.get("channel_id")
|
||||
or s.get("metadata", {}).get("channel_id")
|
||||
or (sid[len("channel-"):] if sid.startswith("channel-") else None)
|
||||
),
|
||||
"source": s.get("source") or s.get("metadata", {}).get("source") or "datastore",
|
||||
}
|
||||
)
|
||||
@@ -519,6 +538,89 @@ class SessionsFeature(BaseFeatureProtocol):
|
||||
}
|
||||
)
|
||||
|
||||
async def _fork(self, request: Request) -> JSONResponse:
|
||||
"""POST /api/sessions/{id}/fork — Branch a session into a new child.
|
||||
|
||||
Body (all optional):
|
||||
up_to_index: int — keep messages[0..up_to_index] inclusive
|
||||
title_suffix: str — appended to the parent title (default " (fork)")
|
||||
agent_id: str — override recorded on the child metadata
|
||||
labels: list[str] — labels applied to the child
|
||||
"""
|
||||
source_id = request.path_params["session_id"]
|
||||
|
||||
if source_id.startswith("agent:"):
|
||||
return JSONResponse(
|
||||
{"error": "Cannot fork a virtual agent session"}, status_code=400
|
||||
)
|
||||
|
||||
content_type = request.headers.get("content-type") or ""
|
||||
body = await request.json() if content_type.startswith("application/json") else {}
|
||||
|
||||
try:
|
||||
from praisonaiui.server import get_datastore
|
||||
|
||||
ds = get_datastore()
|
||||
except Exception as e:
|
||||
logger.warning(f"fork: datastore unavailable: {e}")
|
||||
return JSONResponse({"error": "Datastore unavailable"}, status_code=503)
|
||||
|
||||
source = await ds.get_session(source_id)
|
||||
if source is None:
|
||||
return JSONResponse({"error": "Source session not found"}, status_code=404)
|
||||
|
||||
messages = await ds.get_messages(source_id)
|
||||
total = len(messages)
|
||||
|
||||
up_to_index = body.get("up_to_index")
|
||||
if up_to_index is not None:
|
||||
if not isinstance(up_to_index, int) or up_to_index < 0 or up_to_index >= max(total, 1):
|
||||
return JSONResponse(
|
||||
{"error": "up_to_index out of range", "total_messages": total},
|
||||
status_code=400,
|
||||
)
|
||||
messages = messages[: up_to_index + 1]
|
||||
|
||||
child = await ds.create_session()
|
||||
child_id = child.get("id") or child.get("session_id")
|
||||
|
||||
for msg in messages:
|
||||
await ds.add_message(child_id, _sanitize_message(msg))
|
||||
|
||||
suffix = body.get("title_suffix", " (fork)")
|
||||
parent_title = source.get("title") or ""
|
||||
child_meta = {
|
||||
"parent_session_id": source_id,
|
||||
"forked_at": time.time(),
|
||||
"fork_point_index": up_to_index,
|
||||
"source": "playground-fork",
|
||||
}
|
||||
if body.get("agent_id"):
|
||||
child_meta["agent_id"] = body["agent_id"]
|
||||
|
||||
try:
|
||||
await ds.update_session(
|
||||
child_id,
|
||||
title=f"{parent_title}{suffix}",
|
||||
metadata=child_meta,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"fork: update_session metadata failed: {e}")
|
||||
|
||||
labels = body.get("labels")
|
||||
if isinstance(labels, list) and labels:
|
||||
_get_metadata(child_id)["_labels"] = [str(x) for x in labels]
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"session_id": child_id,
|
||||
"parent_id": source_id,
|
||||
"forked_at_index": up_to_index,
|
||||
"message_count": len(messages),
|
||||
"created_at": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
async def _labels(self, request: Request) -> JSONResponse:
|
||||
sid = request.path_params["session_id"]
|
||||
meta = _get_metadata(sid)
|
||||
|
||||
@@ -3284,6 +3284,14 @@ def create_app(
|
||||
"description": "Messaging platform connections",
|
||||
"order": 5,
|
||||
},
|
||||
{
|
||||
"id": "inbox",
|
||||
"title": "Channel Inbox",
|
||||
"icon": "📥",
|
||||
"group": "Agent",
|
||||
"description": "Unified multi-channel conversation triage",
|
||||
"order": 6,
|
||||
},
|
||||
{
|
||||
"id": "sessions",
|
||||
"title": "Sessions",
|
||||
@@ -3324,6 +3332,14 @@ def create_app(
|
||||
"description": "Async agent jobs",
|
||||
"order": 40,
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"title": "Work Hub",
|
||||
"icon": "🗂️",
|
||||
"group": "Work",
|
||||
"description": "Unified kanban, card detail, chat and job log",
|
||||
"order": 9,
|
||||
},
|
||||
{
|
||||
"id": "jobs-board",
|
||||
"title": "Jobs Board",
|
||||
@@ -3372,6 +3388,14 @@ def create_app(
|
||||
"description": "Agent skills & plugins",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"id": "marketplace",
|
||||
"title": "Marketplace",
|
||||
"icon": "🧩",
|
||||
"group": "Agent",
|
||||
"description": "Browse, install & manage plugins",
|
||||
"order": 22,
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"title": "Memory",
|
||||
@@ -3452,6 +3476,14 @@ def create_app(
|
||||
"description": "Agent evaluation & accuracy",
|
||||
"order": 40,
|
||||
},
|
||||
{
|
||||
"id": "code-studio",
|
||||
"title": "Code Studio",
|
||||
"icon": "💻",
|
||||
"group": "Control",
|
||||
"description": "Write, run, and share code in a sandbox",
|
||||
"order": 45,
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"title": "Telemetry",
|
||||
@@ -3499,6 +3531,8 @@ def create_app(
|
||||
"cron": "/api/schedules",
|
||||
"channels": "/api/channels",
|
||||
"mcp": "/api/mcp/servers",
|
||||
"marketplace": "/api/marketplace/plugins",
|
||||
"code-studio": "/api/code/languages",
|
||||
}
|
||||
|
||||
# Read page whitelist from config.yaml if set_pages() was not called
|
||||
|
||||
@@ -525,6 +525,7 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
selected,
|
||||
onSelect: opts.interactive !== false ? onSelect : null,
|
||||
onOpen: (card) => {
|
||||
if (typeof opts.onOpen === 'function') { opts.onOpen(card); return; }
|
||||
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
|
||||
openDrawer = openTaskDrawer(card, {
|
||||
apiBase: drawerApiBase,
|
||||
|
||||
@@ -156,6 +156,7 @@ const BUILTIN_VIEWS = {
|
||||
approvals: '/plugins/views/approvals.js',
|
||||
usage: '/plugins/views/usage.js',
|
||||
channels: '/plugins/views/channels.js',
|
||||
inbox: '/plugins/views/inbox.js',
|
||||
skills: '/plugins/views/skills.js',
|
||||
tools: '/plugins/views/skills.js',
|
||||
nodes: '/plugins/views/nodes.js',
|
||||
@@ -171,10 +172,13 @@ const BUILTIN_VIEWS = {
|
||||
feedback: '/plugins/views/feedback.js',
|
||||
jobs: '/plugins/views/jobs.js',
|
||||
kanban: '/plugins/views/kanban.js',
|
||||
work: '/plugins/views/work-hub.js',
|
||||
'jobs-board': '/plugins/views/jobs-board.js',
|
||||
auth: '/plugins/views/auth.js',
|
||||
api: '/plugins/views/api.js',
|
||||
mcp: '/plugins/views/mcp.js',
|
||||
marketplace: '/plugins/views/marketplace.js',
|
||||
'code-studio': '/plugins/views/code-studio.js',
|
||||
};
|
||||
|
||||
const SLOT_REGISTRY = {};
|
||||
@@ -1278,7 +1282,316 @@ function initSessionSearch() {
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.openSessionSearch = openSessionSearch;
|
||||
|
||||
// ── First-run Onboarding Gate ────────────────────────────────────────
|
||||
// Shown on first visit when running in gateway mode and unauthenticated.
|
||||
// Replaces the raw HTTP 401 experience with a guided token flow.
|
||||
const ONBOARD_SKIP_KEY = 'aiui_onboard_skip';
|
||||
const ONBOARD_TOKEN_KEY = 'aiui_gateway_token';
|
||||
|
||||
function getStoredToken() {
|
||||
try {
|
||||
return window.sessionStorage.getItem(ONBOARD_TOKEN_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the stored gateway token to same-origin requests so that protected
|
||||
// dashboard APIs (/api/pages, /ui-config.json, view modules, …) authenticate
|
||||
// after onboarding. Wrapping window.fetch once keeps every existing caller
|
||||
// unchanged and is a no-op when no token is stored or in standalone mode.
|
||||
let _authFetchInstalled = false;
|
||||
function installAuthFetch() {
|
||||
if (_authFetchInstalled || typeof window.fetch !== 'function') return;
|
||||
_authFetchInstalled = true;
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
|
||||
function isSameOrigin(url) {
|
||||
try {
|
||||
return new URL(url, window.location.href).origin === window.location.origin;
|
||||
} catch (e) {
|
||||
return true; // relative/opaque URLs are same-origin
|
||||
}
|
||||
}
|
||||
|
||||
window.fetch = function (input, init) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return nativeFetch(input, init);
|
||||
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (!isSameOrigin(url)) return nativeFetch(input, init);
|
||||
|
||||
const opts = { ...(init || {}) };
|
||||
const headers = new Headers(
|
||||
opts.headers || (typeof input !== 'string' && input && input.headers) || undefined
|
||||
);
|
||||
if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
|
||||
opts.headers = headers;
|
||||
if (!opts.credentials) opts.credentials = 'same-origin';
|
||||
return nativeFetch(input, opts);
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAuthStatus() {
|
||||
try {
|
||||
const headers = {};
|
||||
const token = getStoredToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/auth/status', { headers, credentials: 'same-origin' });
|
||||
if (!res.ok) return { authenticated: false, mode: 'gateway', auth_required: true };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeToken(token) {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (data && data.authenticated) {
|
||||
try { window.sessionStorage.setItem(ONBOARD_TOKEN_KEY, token); } catch (e) { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeShowOnboarding() {
|
||||
let params;
|
||||
try {
|
||||
params = new URLSearchParams(window.location.search);
|
||||
} catch (e) {
|
||||
params = null;
|
||||
}
|
||||
|
||||
// Magic link: ?token= auto-consume, then strip from history.
|
||||
const urlToken = params ? params.get('token') : null;
|
||||
if (urlToken) {
|
||||
const ok = await consumeToken(urlToken);
|
||||
try {
|
||||
params.delete('token');
|
||||
const qs = params.toString();
|
||||
const clean = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (ok) return;
|
||||
}
|
||||
|
||||
const status = await fetchAuthStatus();
|
||||
if (!status) return;
|
||||
|
||||
// Standalone / no-auth / already authenticated → straight to dashboard.
|
||||
if (status.authenticated) return;
|
||||
if (status.mode === 'none' || status.mode === 'standalone') return;
|
||||
if (status.auth_required === false) return;
|
||||
|
||||
// "Don't show again" persisted for this device.
|
||||
try {
|
||||
if (window.localStorage.getItem(ONBOARD_SKIP_KEY) === '1') return;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
await renderOnboarding(status);
|
||||
}
|
||||
|
||||
function renderOnboarding(status) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'aiui-onboarding';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Welcome to PraisonAIUI');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;display:flex;align-items:center;justify-content:center;background:var(--db-bg,#0b0e14);padding:16px';
|
||||
|
||||
const gatewayUrl = (status && status.gateway_url) || window.location.origin;
|
||||
const state = { step: 1 };
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
}
|
||||
|
||||
function card(inner) {
|
||||
return `<div style="background:var(--db-card-bg,#151922);border:1px solid var(--db-border,#2a2f3a);border-radius:12px;padding:28px;max-width:440px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.4)">${inner}</div>`;
|
||||
}
|
||||
|
||||
function steps() {
|
||||
let dots = '';
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const on = i === state.step;
|
||||
dots += `<span aria-current="${on ? 'step' : 'false'}" style="width:8px;height:8px;border-radius:50%;background:${on ? 'var(--db-accent,#4f8cff)' : 'var(--db-border,#2a2f3a)'}"></span>`;
|
||||
}
|
||||
return `<div style="display:flex;gap:8px;justify-content:center;margin-bottom:20px">${dots}</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state.step === 1) renderWelcome();
|
||||
else if (state.step === 2) renderAuth();
|
||||
else if (state.step === 3) renderProvider();
|
||||
else renderReady();
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:22px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">Welcome to PraisonAIUI</h1>
|
||||
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Confirm your gateway to get started.</p>
|
||||
<label style="display:block;font-size:13px;color:var(--db-text-muted,#9aa4b2);margin-bottom:6px">Gateway URL</label>
|
||||
<input id="ob-url" type="text" value="${gatewayUrl}" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:14px;margin-bottom:6px">
|
||||
<div id="ob-url-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin-bottom:12px"></div>
|
||||
<button id="ob-continue" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
|
||||
<button id="ob-standalone" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Use standalone mode</button>
|
||||
`);
|
||||
overlay.querySelector('#ob-continue').addEventListener('click', async () => {
|
||||
const url = overlay.querySelector('#ob-url').value.trim();
|
||||
const errEl = overlay.querySelector('#ob-url-err');
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
await fetch(url, { method: 'HEAD', mode: 'no-cors' });
|
||||
} catch (e) {
|
||||
errEl.textContent = `Cannot reach gateway at ${url}`;
|
||||
return;
|
||||
}
|
||||
state.step = 2;
|
||||
render();
|
||||
});
|
||||
overlay.querySelector('#ob-standalone').addEventListener('click', () => {
|
||||
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAuth() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Authenticate</h1>
|
||||
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Paste your gateway token to connect.</p>
|
||||
<textarea id="ob-token" rows="3" placeholder="gw_xxxx…" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:13px;font-family:monospace;resize:vertical"></textarea>
|
||||
<div id="ob-auth-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin:6px 0 10px"></div>
|
||||
<button id="ob-connect" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Connect</button>
|
||||
<button id="ob-copy-link" style="width:100%;padding:8px;margin-top:10px;border:1px solid var(--db-border,#2a2f3a);background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;border-radius:8px">Copy magic link</button>
|
||||
<button id="ob-back" style="width:100%;padding:8px;margin-top:8px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer">Back</button>
|
||||
`);
|
||||
const connectBtn = overlay.querySelector('#ob-connect');
|
||||
connectBtn.addEventListener('click', async () => {
|
||||
const token = overlay.querySelector('#ob-token').value.trim();
|
||||
const errEl = overlay.querySelector('#ob-auth-err');
|
||||
errEl.textContent = '';
|
||||
if (!token) { errEl.textContent = 'Enter a token to continue'; return; }
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Connecting…';
|
||||
const ok = await consumeToken(token);
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Connect';
|
||||
if (ok) {
|
||||
state.step = 3;
|
||||
render();
|
||||
} else {
|
||||
errEl.textContent = 'Invalid token — check ~/.praisonai/.env';
|
||||
}
|
||||
});
|
||||
overlay.querySelector('#ob-copy-link').addEventListener('click', () => {
|
||||
const token = overlay.querySelector('#ob-token').value.trim();
|
||||
const link = `${window.location.origin}${window.location.pathname}?token=${encodeURIComponent(token || 'YOUR_TOKEN')}`;
|
||||
try { navigator.clipboard.writeText(link); } catch (e) { /* ignore */ }
|
||||
});
|
||||
overlay.querySelector('#ob-back').addEventListener('click', () => { state.step = 1; render(); });
|
||||
}
|
||||
|
||||
function renderProvider() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Provider setup</h1>
|
||||
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Optional — configure API keys in Settings.</p>
|
||||
<div id="ob-providers" style="margin-bottom:16px;color:var(--db-text-muted,#9aa4b2);font-size:13px">Loading provider status…</div>
|
||||
<button id="ob-provider-next" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
|
||||
<button id="ob-provider-skip" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Skip</button>
|
||||
`);
|
||||
loadProviderStatus();
|
||||
overlay.querySelector('#ob-provider-next').addEventListener('click', () => { state.step = 4; render(); });
|
||||
overlay.querySelector('#ob-provider-skip').addEventListener('click', () => { state.step = 4; render(); });
|
||||
}
|
||||
|
||||
async function loadProviderStatus() {
|
||||
const el = overlay.querySelector('#ob-providers');
|
||||
if (!el) return;
|
||||
try {
|
||||
const headers = {};
|
||||
const token = getStoredToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/config', { headers, credentials: 'same-origin' });
|
||||
const cfg = res.ok ? await res.json() : {};
|
||||
const providers = ['OpenAI', 'Anthropic', 'Google'];
|
||||
el.innerHTML = providers.map((p) => {
|
||||
const key = `${p.toLowerCase()}`;
|
||||
const configured = cfg && (cfg[`${key}_configured`] || (cfg.providers && cfg.providers[key]));
|
||||
const label = configured ? 'configured' : 'missing';
|
||||
const color = configured ? '#4caf50' : 'var(--db-text-muted,#9aa4b2)';
|
||||
return `<div style="display:flex;justify-content:space-between;padding:6px 0"><span style="color:var(--db-text,#e6e9ef)">${p}</span><span style="color:${color}">${label}</span></div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.textContent = 'Provider status unavailable.';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReady() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<div style="text-align:center;font-size:40px;margin-bottom:8px">✅</div>
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">You're all set</h1>
|
||||
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Gateway connected. Ready to open your dashboard.</p>
|
||||
<button id="ob-open" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Open Dashboard</button>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:14px;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;justify-content:center">
|
||||
<input id="ob-dont-show" type="checkbox"> Don't show again on this device
|
||||
</label>
|
||||
`);
|
||||
overlay.querySelector('#ob-open').addEventListener('click', () => {
|
||||
if (overlay.querySelector('#ob-dont-show').checked) {
|
||||
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
// Focus trap: keep focus within the overlay; ESC disabled on auth step.
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && state.step === 2) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = overlay.querySelectorAll('button, input, textarea, a[href]');
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
render();
|
||||
const firstInput = overlay.querySelector('input, button');
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Attach stored gateway token to same-origin requests (no-op without a token).
|
||||
installAuthFetch();
|
||||
|
||||
// Inject styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = DASHBOARD_STYLE;
|
||||
@@ -1293,6 +1606,13 @@ async function init() {
|
||||
console.warn('[AIUI] Failed to load theme config:', e);
|
||||
}
|
||||
|
||||
// First-run onboarding gate — runs before protected dashboard APIs.
|
||||
try {
|
||||
await maybeShowOnboarding();
|
||||
} catch (e) {
|
||||
console.warn('[AIUI] Onboarding gate error:', e);
|
||||
}
|
||||
|
||||
await buildDashboard();
|
||||
|
||||
// Initialize Ctrl+K / Cmd+K command palette
|
||||
|
||||
@@ -57,9 +57,489 @@ export function searchInput(placeholder = 'Search…', id = 'db-search-input') {
|
||||
return `<input id="${id}" type="search" placeholder="${placeholder}" style="width:100%;max-width:320px;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;box-sizing:border-box" />`;
|
||||
}
|
||||
|
||||
/** Escape a string for safe interpolation into template literals. */
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG sparkline (no external library).
|
||||
*
|
||||
* @param {number[]} values - Series values (empty renders a flat baseline).
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.width=96]
|
||||
* @param {number} [opts.height=32]
|
||||
* @param {string} [opts.color] - Stroke colour (defaults to accent token).
|
||||
* @returns {string} SVG markup.
|
||||
*/
|
||||
export function sparklineSVG(values, { width = 96, height = 32, color = 'var(--db-accent,#6366f1)' } = {}) {
|
||||
const data = Array.isArray(values) ? values.filter((v) => typeof v === 'number' && !isNaN(v)) : [];
|
||||
const pad = 2;
|
||||
const w = width - pad * 2;
|
||||
const h = height - pad * 2;
|
||||
if (data.length < 2) {
|
||||
const y = height / 2;
|
||||
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><line x1="${pad}" y1="${y}" x2="${width - pad}" y2="${y}" stroke="var(--db-border,#3f3f46)" stroke-width="1.5" /></svg>`;
|
||||
}
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const pts = data.map((v, i) => {
|
||||
const x = pad + (i / (data.length - 1)) * w;
|
||||
const yy = pad + h - ((v - min) / range) * h;
|
||||
return `${x.toFixed(1)},${yy.toFixed(1)}`;
|
||||
});
|
||||
const areaPts = `${pad},${(pad + h).toFixed(1)} ${pts.join(' ')} ${(pad + w).toFixed(1)},${(pad + h).toFixed(1)}`;
|
||||
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polygon points="${areaPts}" fill="${color}" opacity="0.12" />
|
||||
<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric card with value, subtitle, optional delta and sparkline.
|
||||
* Renders a clickable card that navigates via data-nav (handled by caller).
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.title - Card label.
|
||||
* @param {string|number} opts.value - Primary value (already formatted).
|
||||
* @param {string} [opts.subtitle] - Secondary label.
|
||||
* @param {number} [opts.delta] - Signed percentage delta.
|
||||
* @param {number[]} [opts.spark] - Sparkline series.
|
||||
* @param {string} [opts.nav] - Page id to navigate to on click.
|
||||
* @param {string} [opts.accent] - Value colour override.
|
||||
* @returns {string} HTML string.
|
||||
*/
|
||||
export function metricCard({ title, value, subtitle = '', delta, spark, nav, accent }) {
|
||||
let deltaHtml = '';
|
||||
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
|
||||
const up = delta > 0;
|
||||
const col = up ? '#22c55e' : '#ef4444';
|
||||
const arrow = up ? '▲' : '▼';
|
||||
deltaHtml = `<span style="font-size:11px;color:${col};margin-left:6px">${arrow} ${Math.abs(delta).toFixed(0)}%</span>`;
|
||||
}
|
||||
const sparkHtml = Array.isArray(spark) ? `<div style="margin-top:8px">${sparklineSVG(spark)}</div>` : '';
|
||||
const clickable = nav ? `data-nav="${esc(nav)}" role="button" tabindex="0" style="cursor:pointer"` : '';
|
||||
return `<div class="db-card db-metric-card" ${clickable}>
|
||||
<div class="db-card-title">${esc(title)}</div>
|
||||
<div class="db-card-value"${accent ? ` style="color:${accent}"` : ''}>${esc(value)}${deltaHtml}</div>
|
||||
<div class="db-card-footer">${esc(subtitle)}</div>
|
||||
${sparkHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket evaluation records into per-day average score series.
|
||||
*
|
||||
* @param {Array} evaluations - Records with `timestamp` (epoch seconds) and `score`.
|
||||
* @param {number} [days=7] - Number of trailing days to include.
|
||||
* @param {string|null} [agentId=null] - Optional agent filter.
|
||||
* @returns {Array<{date: string, avg: number, count: number}>} Ascending by date.
|
||||
*/
|
||||
export function bucketByDay(evaluations, days = 7, agentId = null) {
|
||||
const list = Array.isArray(evaluations) ? evaluations : [];
|
||||
const cutoff = Date.now() / 1000 - days * 86400;
|
||||
const acc = {};
|
||||
for (const ev of list) {
|
||||
if (!ev || typeof ev.timestamp !== 'number') continue;
|
||||
if (ev.timestamp < cutoff) continue;
|
||||
if (agentId && ev.agent_id !== agentId) continue;
|
||||
if (ev.score == null || isNaN(ev.score)) continue;
|
||||
const date = new Date(ev.timestamp * 1000).toISOString().slice(0, 10);
|
||||
if (!acc[date]) acc[date] = { date, sum: 0, count: 0 };
|
||||
acc[date].sum += Number(ev.score);
|
||||
acc[date].count += 1;
|
||||
}
|
||||
return Object.values(acc)
|
||||
.sort((a, b) => (a.date < b.date ? -1 : 1))
|
||||
.map((b) => ({ date: b.date, avg: b.sum / b.count, count: b.count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression check comparing a current value against a pinned baseline.
|
||||
*
|
||||
* @param {number} current
|
||||
* @param {number} baseline
|
||||
* @param {number} [threshold=0.10] - Relative drop that counts as a regression.
|
||||
* @returns {{isRegression: boolean, delta: number, label: string}}
|
||||
*/
|
||||
export function detectRegression(current, baseline, threshold = 0.1) {
|
||||
const c = Number(current);
|
||||
const b = Number(baseline);
|
||||
if (!isFinite(c) || !isFinite(b) || b === 0) {
|
||||
return { isRegression: false, delta: 0, label: '' };
|
||||
}
|
||||
const delta = (c - b) / b;
|
||||
const isRegression = delta <= -threshold;
|
||||
const pct = Math.round(delta * 100);
|
||||
return { isRegression, delta, label: `${pct >= 0 ? '+' : ''}${pct}%` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coloured trend arrow comparing current vs previous value.
|
||||
*
|
||||
* @param {number} current
|
||||
* @param {number} previous
|
||||
* @returns {string} HTML span (empty string when no meaningful change).
|
||||
*/
|
||||
export function trendArrow(current, previous) {
|
||||
const c = Number(current);
|
||||
const p = Number(previous);
|
||||
if (!isFinite(c) || !isFinite(p) || p === 0) return '';
|
||||
const delta = ((c - p) / p) * 100;
|
||||
if (Math.abs(delta) < 0.5) {
|
||||
return '<span style="font-size:11px;color:var(--db-text-dim,#a1a1aa)">→ 0%</span>';
|
||||
}
|
||||
const up = delta > 0;
|
||||
const col = up ? '#22c55e' : '#ef4444';
|
||||
const arrow = up ? '↑' : '↓';
|
||||
return `<span style="font-size:11px;color:${col}">${arrow} ${Math.abs(delta).toFixed(1)}%</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression pill badge, colour-graded by drop severity.
|
||||
*
|
||||
* @param {number} delta - Signed relative delta (e.g. -0.12 for -12%).
|
||||
* @param {string} [suffix='vs baseline']
|
||||
* @returns {string} HTML string (empty when delta is not a regression).
|
||||
*/
|
||||
export function regressionBadge(delta, suffix = 'vs baseline') {
|
||||
const d = Number(delta);
|
||||
if (!isFinite(d) || d > -0.05) return '';
|
||||
const pct = Math.round(d * 100);
|
||||
const red = d <= -0.1;
|
||||
const col = red ? '#ef4444' : '#f59e0b';
|
||||
const bg = red ? 'rgba(239,68,68,.12)' : 'rgba(245,158,11,.12)';
|
||||
return `<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:.7rem;font-weight:600;color:${col};background:${bg}">${pct}% ${esc(suffix)}</span>`;
|
||||
}
|
||||
|
||||
/** Relative "time ago" label from an ISO string or epoch (ms or s). */
|
||||
export function timeAgo(input) {
|
||||
if (input == null) return '';
|
||||
let ms;
|
||||
if (typeof input === 'number') {
|
||||
ms = input < 1e12 ? input * 1000 : input;
|
||||
} else {
|
||||
const t = Date.parse(input);
|
||||
if (isNaN(t)) return '';
|
||||
ms = t;
|
||||
}
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return 'now';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Format a token count into a compact string (mirrors usage.js). */
|
||||
export function formatTokens(tokens) {
|
||||
const n = Number(tokens) || 0;
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
/** Format a USD cost into a string (mirrors usage.js). */
|
||||
export function formatCost(cost) {
|
||||
const n = Number(cost) || 0;
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||
if (n < 1) return `$${n.toFixed(3)}`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Strip a provider prefix from a model id (anthropic/claude-3 → claude-3). */
|
||||
export function shortModelName(model) {
|
||||
const s = model == null ? '' : String(model);
|
||||
const idx = s.lastIndexOf('/');
|
||||
return idx >= 0 ? s.slice(idx + 1) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum tokens from timeseries buckets belonging to the current local day.
|
||||
* Falls back to summing all buckets when no bucket carries a hour_key.
|
||||
*/
|
||||
export function sumTodayTokens(timeseries) {
|
||||
const arr = Array.isArray(timeseries) ? timeseries : [];
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
const dated = arr.filter((p) => typeof p.hour_key === 'string');
|
||||
const scope = dated.length ? dated.filter((p) => p.hour_key.startsWith(todayKey)) : arr;
|
||||
return scope.reduce((sum, p) => sum + (Number(p.tokens) || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent of daily budget consumed, capped at 100.
|
||||
* Returns null when the budget is unset or non-positive (E3/E4).
|
||||
*/
|
||||
export function computeBudgetPct(today, budget) {
|
||||
const b = Number(budget);
|
||||
if (!b || b <= 0) return null;
|
||||
const pct = (Number(today) || 0) / b * 100;
|
||||
if (!isFinite(pct) || pct < 0) return 0;
|
||||
return Math.min(pct, 100);
|
||||
}
|
||||
|
||||
/** Banner level from a budget percentage and thresholds. */
|
||||
export function budgetLevelFor(pct, warnPct = 80, criticalPct = 95) {
|
||||
if (pct == null || isNaN(pct)) return 'none';
|
||||
if (pct >= criticalPct) return 'critical';
|
||||
if (pct >= warnPct) return 'warn';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Gauge fill colour by budget percentage. */
|
||||
export function budgetGaugeColor(pct) {
|
||||
if (pct == null) return 'var(--db-accent,#6366f1)';
|
||||
if (pct >= 95) return '#ef4444';
|
||||
if (pct >= 80) return '#f97316';
|
||||
if (pct >= 60) return '#eab308';
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
const DONUT_PALETTE = ['#3b82f6', '#8b5cf6', '#22c55e', '#64748b'];
|
||||
|
||||
/**
|
||||
* Pure SVG donut (no chart library).
|
||||
*
|
||||
* @param {Array<{label: string, value: number, color?: string}>} slices
|
||||
* @param {number} [size=40]
|
||||
* @returns {string} SVG markup
|
||||
*/
|
||||
export function MiniDonutSVG(slices, size = 40) {
|
||||
const data = (Array.isArray(slices) ? slices : [])
|
||||
.filter((s) => s && (Number(s.value) || 0) > 0);
|
||||
const total = data.reduce((sum, s) => sum + (Number(s.value) || 0), 0);
|
||||
if (!data.length || total <= 0) {
|
||||
const r = size / 2 - 3;
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="No model data"><circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="var(--db-border,#3f3f46)" stroke-width="4" stroke-dasharray="3 3" /></svg>`;
|
||||
}
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 2 - 3;
|
||||
const inner = r * 0.55;
|
||||
let angle = -Math.PI / 2;
|
||||
const parts = [];
|
||||
const labels = [];
|
||||
data.forEach((s, i) => {
|
||||
const frac = (Number(s.value) || 0) / total;
|
||||
const end = angle + frac * Math.PI * 2;
|
||||
const color = s.color || DONUT_PALETTE[i % DONUT_PALETTE.length];
|
||||
const large = end - angle > Math.PI ? 1 : 0;
|
||||
const x1 = cx + r * Math.cos(angle);
|
||||
const y1 = cy + r * Math.sin(angle);
|
||||
const x2 = cx + r * Math.cos(end);
|
||||
const y2 = cy + r * Math.sin(end);
|
||||
const ix2 = cx + inner * Math.cos(end);
|
||||
const iy2 = cy + inner * Math.sin(end);
|
||||
const ix1 = cx + inner * Math.cos(angle);
|
||||
const iy1 = cy + inner * Math.sin(angle);
|
||||
parts.push(`<path d="M${x1.toFixed(2)},${y1.toFixed(2)} A${r},${r} 0 ${large} 1 ${x2.toFixed(2)},${y2.toFixed(2)} L${ix2.toFixed(2)},${iy2.toFixed(2)} A${inner},${inner} 0 ${large} 0 ${ix1.toFixed(2)},${iy1.toFixed(2)} Z" fill="${color}" />`);
|
||||
labels.push(`${esc(s.label)} ${Math.round(frac * 100)}%`);
|
||||
angle = end;
|
||||
});
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="${esc(labels.join(', '))}">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce per-model usage rows to donut slices: top 3 by cost + aggregated Other.
|
||||
*
|
||||
* @param {Array<{model?: string, cost_usd?: number, total_tokens?: number}>} models
|
||||
* @returns {Array<{label: string, value: number, color: string}>}
|
||||
*/
|
||||
export function modelDonutSlices(models) {
|
||||
const rows = (Array.isArray(models) ? models : [])
|
||||
.map((m) => ({ label: shortModelName(m.model), value: Number(m.cost_usd) || Number(m.total_tokens) || 0 }))
|
||||
.filter((m) => m.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const top = rows.slice(0, 3).map((m, i) => ({ ...m, color: DONUT_PALETTE[i] }));
|
||||
const rest = rows.slice(3).reduce((sum, m) => sum + m.value, 0);
|
||||
if (rest > 0) top.push({ label: 'Other', value: rest, color: DONUT_PALETTE[3] });
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Overview Cost FinOps strip markup.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.todayTokens
|
||||
* @param {number} [opts.todayCost]
|
||||
* @param {number|null} [opts.budget]
|
||||
* @param {number} [opts.warnPct]
|
||||
* @param {number} [opts.criticalPct]
|
||||
* @param {Array} [opts.models]
|
||||
* @param {number} [opts.delta] - signed pct vs yesterday
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function costStripHTML({ todayTokens, todayCost, budget = null, warnPct = 80, criticalPct = 95, models = [], delta } = {}) {
|
||||
const pct = computeBudgetPct(todayTokens, budget);
|
||||
const level = budgetLevelFor(pct, warnPct, criticalPct);
|
||||
const gaugeColor = budgetGaugeColor(pct);
|
||||
const slices = modelDonutSlices(models);
|
||||
const topModel = slices.length ? slices[0].label : '';
|
||||
const budgetText = pct == null
|
||||
? `${formatTokens(todayTokens)} tokens`
|
||||
: `${formatTokens(todayTokens)} / ${formatTokens(budget)} tokens (${Math.round(pct)}%)`;
|
||||
const gauge = pct == null ? '' : `<div class="db-finops-gauge" role="progressbar" aria-valuenow="${Math.round(pct)}" aria-valuemin="0" aria-valuemax="100" style="height:8px;border-radius:6px;background:var(--db-border,#3f3f46);overflow:hidden;margin-top:6px">
|
||||
<div class="db-finops-gauge-fill" style="width:${Math.round(pct)}%;height:100%;background:${gaugeColor};transition:width 400ms ease"></div>
|
||||
</div>`;
|
||||
const pill = level === 'none' ? '' : `<span class="db-finops-pill" style="font-size:11px;padding:2px 8px;border-radius:10px;margin-left:8px;background:${level === 'critical' ? 'rgba(239,68,68,.15)' : 'rgba(234,179,8,.15)'};color:${level === 'critical' ? '#ef4444' : '#eab308'}">${Math.round(pct)}%</span>`;
|
||||
const costText = todayCost != null ? `Est. ${formatCost(todayCost)} today` : '';
|
||||
let deltaHtml = '';
|
||||
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
|
||||
const up = delta > 0;
|
||||
deltaHtml = `<span style="font-size:11px;color:${up ? '#ef4444' : '#22c55e'};margin-left:8px">${up ? '↗' : '↘'} ${Math.abs(delta).toFixed(0)}% vs yesterday</span>`;
|
||||
}
|
||||
return `<div id="ov-finops-strip" class="db-card db-finops-strip" role="region" aria-label="Cost and usage summary" tabindex="0" data-nav="usage"
|
||||
style="cursor:pointer;display:flex;align-items:center;gap:20px;padding:14px 18px;margin-top:16px;margin-bottom:8px;flex-wrap:wrap">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:130px">
|
||||
<span aria-hidden="true">💰</span>
|
||||
<span style="font-size:13px;font-weight:600;color:var(--db-text)">Cost FinOps</span>${pill}
|
||||
</div>
|
||||
<div style="flex:1;min-width:200px">
|
||||
<div style="font-size:20px;font-weight:700;color:var(--db-text)">${esc(budgetText)}${deltaHtml}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(costText)}</div>
|
||||
${gauge}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
${MiniDonutSVG(slices, 40)}
|
||||
<span style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(topModel)}</span>
|
||||
</div>
|
||||
<span style="font-size:12px;color:var(--db-accent,#6366f1)">View usage →</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FinOps budget banner (Overview or Chat).
|
||||
*
|
||||
* @param {string} level - 'none' | 'warn' | 'critical'
|
||||
* @param {number} pct
|
||||
* @returns {string} HTML string ('' when level is none)
|
||||
*/
|
||||
export function budgetBannerHTML(level, pct) {
|
||||
if (level === 'none') return '';
|
||||
const critical = level === 'critical';
|
||||
const color = critical ? '#ef4444' : '#eab308';
|
||||
const bg = critical ? 'rgba(239,68,68,.1)' : 'rgba(234,179,8,.1)';
|
||||
const copy = critical
|
||||
? `Daily token budget at ${Math.round(pct)}% — review usage`
|
||||
: `Daily token budget at ${Math.round(pct)}% — consider lighter models`;
|
||||
return `<div class="db-finops-banner" role="${critical ? 'alert' : 'status'}"
|
||||
style="display:flex;align-items:center;gap:8px;padding:8px 14px;border:1px solid ${color};border-radius:10px;background:${bg};color:${color};font-size:12px;font-weight:500;margin-bottom:8px">
|
||||
<span aria-hidden="true">💰</span>
|
||||
<span style="flex:1">${esc(copy)}</span>
|
||||
<button type="button" class="db-finops-banner-usage" style="background:none;border:none;color:inherit;cursor:pointer;font-size:12px;text-decoration:underline">Open usage</button>
|
||||
<button type="button" class="db-finops-banner-dismiss" title="Dismiss" aria-label="Dismiss" style="background:none;border:none;color:inherit;cursor:pointer;font-size:13px;padding:0 4px">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Modal shell for view-local dialogs. */
|
||||
export function modalShell(id, innerHtml) {
|
||||
return `<div id="${id}" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center;padding:20px">
|
||||
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;max-width:520px;width:100%;max-height:90vh;overflow:auto;padding:20px">${innerHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-level diff between two text blocks using an LCS table.
|
||||
* Normalises CRLF/CR to LF before comparing. Bounded for large inputs.
|
||||
*
|
||||
* @param {string} before
|
||||
* @param {string} after
|
||||
* @param {number} [maxLines=2000] - Truncate each side to this many lines.
|
||||
* @returns {{type:('add'|'del'|'ctx'), text:string}[]}
|
||||
*/
|
||||
export function diffLines(before, after, maxLines = 2000) {
|
||||
const norm = (s) => String(s == null ? '' : s).replace(/\r\n?/g, '\n');
|
||||
const a = norm(before).split('\n').slice(0, maxLines);
|
||||
const b = norm(after).split('\n').slice(0, maxLines);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ type: 'ctx', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
out.push({ type: 'del', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ type: 'add', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
|
||||
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal, XSS-safe markdown preview. All content is escaped first; only a
|
||||
* small, fixed set of inline/block constructs are re-enabled. No raw HTML,
|
||||
* scripts, or attributes from source ever reach the DOM.
|
||||
*
|
||||
* @param {string} md
|
||||
* @returns {string} sanitised HTML string
|
||||
*/
|
||||
export function renderMarkdownPreview(md) {
|
||||
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
|
||||
const inline = (s) => esc(s)
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
const html = [];
|
||||
let inList = false;
|
||||
const closeList = () => { if (inList) { html.push('</ul>'); inList = false; } };
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/\t/g, ' ');
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
if (h) {
|
||||
closeList();
|
||||
const lvl = h[1].length;
|
||||
html.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
|
||||
} else if (li) {
|
||||
if (!inList) { html.push('<ul>'); inList = true; }
|
||||
html.push(`<li>${inline(li[1])}</li>`);
|
||||
} else if (line.trim() === '') {
|
||||
closeList();
|
||||
} else {
|
||||
closeList();
|
||||
html.push(`<p>${inline(line)}</p>`);
|
||||
}
|
||||
}
|
||||
closeList();
|
||||
return html.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a pending approval represents a skill write (rules R1-R5).
|
||||
*
|
||||
* @param {Object} a - approval record
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSkillApproval(a) {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const NAMES = ['write_skill', 'skill_write', 'save_skill', 'create_skill'];
|
||||
const tool = String(a.tool_name || '').toLowerCase();
|
||||
const action = String(a.action || '').toLowerCase();
|
||||
if (NAMES.includes(tool) || NAMES.includes(action)) return true;
|
||||
if (String(a.type || '').toLowerCase() === 'skill_write') return true;
|
||||
if (a.metadata && String(a.metadata.kind || '').toLowerCase() === 'skill') return true;
|
||||
const path = String((a.arguments && a.arguments.path) || '');
|
||||
if (path.endsWith('SKILL.md') || path.includes('.praisonai/skills/')) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* Approvals View — execution approval queue
|
||||
* API: /api/approvals
|
||||
*/
|
||||
import { isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
@@ -33,11 +35,13 @@ export async function render(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
|
||||
const isSkill = isSkillApproval(a);
|
||||
card.innerHTML = `
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
|
||||
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
|
||||
|
||||
@@ -90,7 +90,10 @@ export async function render(container) {
|
||||
<h2 style="margin:0;font-size:1.5rem">Messaging Channels</h2>
|
||||
<div style="margin-top:4px;font-size:13px;color:var(--db-text-dim)">${online} Online · ${channels.length} Total</div>
|
||||
</div>
|
||||
<button id="ch-add" style="padding:8px 18px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:.85rem">+ Add Channel</button>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<a href="/inbox" style="font-size:.85rem;color:var(--db-accent,#6366f1);text-decoration:none">Open Inbox →</a>
|
||||
<button id="ch-add" style="padding:8px 18px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:.85rem">+ Add Channel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ch-grid" class="db-columns" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"></div>
|
||||
<div id="ch-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
|
||||
|
||||
@@ -29,6 +29,14 @@ let currentDeltaText = '';
|
||||
let pendingMediaElements = [];
|
||||
let pendingAttachments = []; // { id, filename, content_type, preview_url }
|
||||
|
||||
// ── Context Ring State ───────────────────────────────────────────
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * 20; // r=20 → ~125.66
|
||||
let contextPollTimer = null;
|
||||
let contextRefreshDebounce = null;
|
||||
let lastContextPct = 0;
|
||||
let finopsConfig = null;
|
||||
let sessionCostState = { tokens: 0, model: '', estimate: false };
|
||||
|
||||
// ── Floating Chat State ──────────────────────────────────────────
|
||||
let chatModeConfig = { mode: 'fullpage' };
|
||||
let isMinimized = false;
|
||||
@@ -277,6 +285,72 @@ function injectStyles() {
|
||||
.chat-metrics { display:flex; gap:12px; padding:4px 12px; font-size:11px; color:var(--db-text-dim); }
|
||||
.chat-metrics span { display:flex; align-items:center; gap:4px; }
|
||||
|
||||
/* ── Context Token Ring ─────────────────────────────────────── */
|
||||
.chat-ring-wrap { position:relative; width:48px; height:48px; min-width:48px; flex-shrink:0; }
|
||||
.chat-ring-wrap svg { position:absolute; inset:0; width:48px; height:48px; transform:rotate(-90deg); }
|
||||
.chat-ring-track { fill:none; stroke:var(--db-border,#333); stroke-width:4; }
|
||||
.chat-ring-arc { fill:none; stroke-width:4; stroke-linecap:round; transition:stroke-dashoffset .2s ease, stroke .2s ease; }
|
||||
.chat-ring-healthy .chat-ring-arc { stroke:#22c55e; }
|
||||
.chat-ring-warning .chat-ring-arc { stroke:#eab308; }
|
||||
.chat-ring-critical .chat-ring-arc { stroke:#eab308; }
|
||||
.chat-ring-over .chat-ring-arc { stroke:#ef4444; }
|
||||
.chat-ring-estimate .chat-ring-arc { stroke-dasharray:4 3 !important; }
|
||||
.chat-ring-over .chat-ring-arc { animation:ring-pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes ring-pulse { 50% { opacity:.4; } }
|
||||
.chat-ring-send { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:32px; height:32px; border-radius:50%; border:none; background:var(--db-accent); color:#fff; cursor:pointer; font-size:15px; display:flex; align-items:center; justify-content:center; padding:0; transition:filter .15s; }
|
||||
.chat-ring-send:hover { filter:brightness(1.1); }
|
||||
.chat-ring-send:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.chat-ring-tooltip { position:absolute; bottom:56px; right:0; z-index:20; display:none; min-width:180px; padding:10px 12px; background:var(--db-card-bg,#27272a); border:1px solid var(--db-border); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); font-size:11px; color:var(--db-text); }
|
||||
.chat-ring-wrap:hover .chat-ring-tooltip, .chat-ring-wrap:focus-within .chat-ring-tooltip { display:block; }
|
||||
.chat-ring-tooltip h5 { margin:0 0 6px; font-size:11px; font-weight:600; }
|
||||
.chat-ring-tt-row { display:flex; align-items:center; gap:6px; margin:3px 0; }
|
||||
.chat-ring-tt-row .lbl { width:64px; color:var(--db-text-dim); }
|
||||
.chat-ring-tt-bar { flex:1; height:6px; border-radius:3px; background:rgba(var(--db-accent-rgb,100,100,255),.15); overflow:hidden; }
|
||||
.chat-ring-tt-bar > i { display:block; height:100%; background:var(--db-accent); }
|
||||
.chat-ring-tt-val { width:36px; text-align:right; color:var(--db-text-dim); }
|
||||
.chat-ring-tt-total { margin-top:6px; padding-top:6px; border-top:1px solid var(--db-border); font-weight:600; }
|
||||
|
||||
/* ── Compaction Banner ──────────────────────────────────────── */
|
||||
.chat-compaction-banner { display:none; align-items:center; gap:8px; padding:8px 16px; border-bottom:1px solid rgba(234,179,8,.3); background:rgba(234,179,8,.1); color:#eab308; font-size:12.5px; }
|
||||
.chat-compaction-banner.open { display:flex; }
|
||||
.chat-compaction-banner.critical { border-bottom-color:rgba(239,68,68,.35); background:rgba(239,68,68,.1); color:#ef4444; }
|
||||
.chat-compaction-banner .cb-text { flex:1; }
|
||||
.chat-compaction-banner .cb-dismiss { background:none; border:none; cursor:pointer; color:inherit; font-size:14px; padding:0 4px; line-height:1; }
|
||||
|
||||
/* ── Memory Chip ────────────────────────────────────────────── */
|
||||
.chat-compose-footer { display:flex; align-items:center; gap:12px; padding:6px 2px 0; }
|
||||
.chat-memory-chip { display:none; align-items:center; gap:5px; padding:3px 10px; border-radius:14px; border:1px solid rgba(147,51,234,.25); background:rgba(147,51,234,.08); color:rgba(147,51,234,.9); font-size:12px; cursor:pointer; transition:all .15s; }
|
||||
.chat-memory-chip.visible { display:inline-flex; }
|
||||
.chat-memory-chip:hover { background:rgba(147,51,234,.16); }
|
||||
.chat-memory-chip.loading { opacity:.6; }
|
||||
|
||||
/* ── Session Cost Chip (FinOps) ─────────────────────────────── */
|
||||
.chat-session-cost-chip { display:none; align-items:center; gap:4px; font-size:11px; font-weight:500; padding:4px 10px; border-radius:20px; color:var(--db-text-dim); background:rgba(99,102,241,.08); border:1px solid rgba(99,102,241,.18); white-space:nowrap; cursor:default; }
|
||||
.chat-session-cost-chip.visible { display:inline-flex; }
|
||||
.chat-session-cost-chip.estimate { font-style:italic; }
|
||||
#chat-finops-banner:empty { display:none; }
|
||||
@media (max-width:480px) {
|
||||
.chat-session-cost-chip .cost-chip-model { display:none; }
|
||||
}
|
||||
.chat-floating-container .chat-session-cost-chip { display:none !important; }
|
||||
|
||||
/* ── Enhanced memory cards ──────────────────────────────────── */
|
||||
.chat-mem-item .mem-head { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
|
||||
.chat-mem-item .mem-badge { font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(147,51,234,.12); color:rgba(147,51,234,.85); font-weight:600; text-transform:uppercase; }
|
||||
.chat-mem-item .mem-time { font-size:10px; color:var(--db-text-dim); margin-left:auto; }
|
||||
.chat-mem-item .mem-relevance { height:5px; border-radius:3px; background:rgba(147,51,234,.12); overflow:hidden; margin-top:5px; }
|
||||
.chat-mem-item .mem-relevance > i { display:block; height:100%; background:rgba(147,51,234,.7); }
|
||||
.chat-mem-item .mem-snippet { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||
|
||||
@media (max-width:520px) {
|
||||
.chat-ring-wrap, .chat-ring-wrap svg { width:36px; height:36px; min-width:36px; }
|
||||
.chat-ring-send { width:24px; height:24px; font-size:12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
.chat-ring-arc { transition:none; }
|
||||
.chat-ring-over .chat-ring-arc { animation:none; }
|
||||
}
|
||||
|
||||
.chat-memory-panel { display:none; border-bottom:1px solid var(--db-border); background:var(--db-sidebar-bg,var(--db-card-bg)); max-height:300px; overflow-y:auto; padding:10px 14px; font-size:13px; }
|
||||
.chat-memory-panel.open { display:block; }
|
||||
.chat-memory-panel-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
@@ -685,6 +759,11 @@ export async function render(container) {
|
||||
<button class="chat-icon-btn danger" id="chat-abort-btn" style="display:none" title="Stop">⬛ Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-compaction-banner" id="chat-compaction-banner">
|
||||
<span>⚠</span>
|
||||
<span class="cb-text" id="chat-compaction-text"></span>
|
||||
<button class="cb-dismiss" id="chat-compaction-dismiss" title="Dismiss" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
<div class="chat-memory-panel" id="chat-memory-panel">
|
||||
<div class="chat-memory-panel-header">
|
||||
<h4>🧠 Agent Memories</h4>
|
||||
@@ -703,15 +782,30 @@ export async function render(container) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-compose">
|
||||
<div id="chat-finops-banner"></div>
|
||||
<div class="chat-attach-strip" id="chat-attach-strip"></div>
|
||||
<div class="chat-compose-row">
|
||||
<button class="chat-attach-btn" id="chat-attach-btn" title="Attach file (PDF, image)">📎</button>
|
||||
<input type="file" id="chat-file-input" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,image/*,application/pdf" multiple style="display:none" />
|
||||
<textarea id="chat-input" placeholder="Type a message…" rows="1"></textarea>
|
||||
<div class="chat-compose-actions">
|
||||
<button class="chat-send-btn" id="chat-send-btn">Send ↵</button>
|
||||
<div class="chat-session-cost-chip" id="chat-session-cost-chip" tabindex="0"></div>
|
||||
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap">
|
||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
||||
<circle class="chat-ring-track" cx="24" cy="24" r="20"></circle>
|
||||
<circle class="chat-ring-arc" id="chat-ring-arc" cx="24" cy="24" r="20"
|
||||
stroke-dasharray="125.66" stroke-dashoffset="125.66"></circle>
|
||||
</svg>
|
||||
<button class="chat-ring-send" id="chat-send-btn" title="Send"
|
||||
role="progressbar" aria-label="Send message"
|
||||
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">➤</button>
|
||||
<div class="chat-ring-tooltip" id="chat-ring-tooltip"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-compose-footer">
|
||||
<button class="chat-memory-chip" id="chat-memory-chip" aria-expanded="false"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -738,6 +832,17 @@ export async function render(container) {
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
|
||||
window.addEventListener('aiui:prefill-composer', (e) => {
|
||||
const text = e.detail && e.detail.text;
|
||||
if (!text) return;
|
||||
const el = document.getElementById('chat-input');
|
||||
if (!el) return;
|
||||
el.value = text;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
||||
el.focus();
|
||||
});
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||||
@@ -766,6 +871,16 @@ export async function render(container) {
|
||||
if (panel) panel.classList.remove('open');
|
||||
});
|
||||
|
||||
const memChip = document.getElementById('chat-memory-chip');
|
||||
if (memChip) memChip.addEventListener('click', () => toggleMemoryPanel());
|
||||
const compactionDismiss = document.getElementById('chat-compaction-dismiss');
|
||||
if (compactionDismiss) compactionDismiss.addEventListener('click', dismissCompactionBanner);
|
||||
|
||||
// Initial context ring render (estimate for empty/new session)
|
||||
refreshContextStats();
|
||||
startContextPoll();
|
||||
loadFinopsConfig();
|
||||
|
||||
await loadAgents();
|
||||
|
||||
// Pre-select agent if passed via URL query param (from Agents page "Run" button)
|
||||
@@ -798,6 +913,8 @@ export async function render(container) {
|
||||
|
||||
export function cleanup() {
|
||||
if (ws) { try { ws.close(); } catch(e) {} ws = null; }
|
||||
stopContextPoll();
|
||||
if (contextRefreshDebounce) { clearTimeout(contextRefreshDebounce); contextRefreshDebounce = null; }
|
||||
if (sessionSelectHandler) {
|
||||
window.removeEventListener('aiui:session-select', sessionSelectHandler);
|
||||
sessionSelectHandler = null;
|
||||
@@ -902,6 +1019,9 @@ async function loadSessions() {
|
||||
async function loadSession(sessionId) {
|
||||
currentSessionId = sessionId;
|
||||
refreshStreamingUI();
|
||||
// E13: reset context ring + memory chip for the new session
|
||||
refreshContextStats();
|
||||
refreshMemoryChip();
|
||||
const messagesEl = document.getElementById('chat-messages');
|
||||
const welcome = document.getElementById('chat-welcome');
|
||||
if (welcome) welcome.style.display = 'none';
|
||||
@@ -1087,6 +1207,25 @@ function handleWsMessage(data) {
|
||||
setStatus('connected');
|
||||
setStreaming(false);
|
||||
drainQueue();
|
||||
// Refresh context ring + memory chip after turn completes
|
||||
scheduleContextRefresh();
|
||||
refreshMemoryChip();
|
||||
updateFinopsBanner();
|
||||
break;
|
||||
|
||||
case 'context_update':
|
||||
updateContextRing({
|
||||
used: data.used,
|
||||
limit: data.limit,
|
||||
usage_pct: data.usage_pct,
|
||||
breakdown: data.breakdown,
|
||||
estimate: data.estimate,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'context_compacted':
|
||||
flashCompactionCompacted(data);
|
||||
scheduleContextRefresh();
|
||||
break;
|
||||
|
||||
case 'run_started':
|
||||
@@ -1171,6 +1310,8 @@ function handleWsMessage(data) {
|
||||
setStatus('connected');
|
||||
setStreaming(false);
|
||||
drainQueue();
|
||||
scheduleContextRefresh();
|
||||
refreshMemoryChip();
|
||||
break;
|
||||
|
||||
case 'team_run_error':
|
||||
@@ -1712,6 +1853,192 @@ function completeMemoryIndicator() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context Token Ring ──────────────────────────────────────────
|
||||
function ringStateFor(pct) {
|
||||
if (pct >= 95) return 'over';
|
||||
if (pct >= 85) return 'critical';
|
||||
if (pct >= 60) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
function updateContextRing(stats) {
|
||||
const wrap = document.getElementById('chat-ring-wrap');
|
||||
const arc = document.getElementById('chat-ring-arc');
|
||||
const sendBtn = document.getElementById('chat-send-btn');
|
||||
if (!wrap || !arc) return;
|
||||
|
||||
const used = Number(stats.used) || 0;
|
||||
const limit = Number(stats.limit) || 0;
|
||||
const estimate = !!stats.estimate;
|
||||
|
||||
// E2: unknown/zero limit — hide ring, keep send button usable
|
||||
if (!limit || limit <= 0) {
|
||||
wrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
wrap.style.display = '';
|
||||
|
||||
let pct = stats.usage_pct != null ? Number(stats.usage_pct) : (used / limit) * 100;
|
||||
if (!isFinite(pct) || pct < 0) pct = 0;
|
||||
const clamped = Math.min(pct, 100);
|
||||
lastContextPct = pct;
|
||||
|
||||
const state = ringStateFor(pct);
|
||||
wrap.className = 'chat-ring-wrap chat-ring-' + state + (estimate ? ' chat-ring-estimate' : '');
|
||||
arc.setAttribute('stroke-dasharray', RING_CIRCUMFERENCE.toFixed(2));
|
||||
arc.setAttribute('stroke-dashoffset', (RING_CIRCUMFERENCE * (1 - clamped / 100)).toFixed(2));
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.setAttribute('aria-valuenow', String(Math.round(pct)));
|
||||
sendBtn.setAttribute('aria-valuemax', '100');
|
||||
}
|
||||
|
||||
updateRingTooltip(stats, pct);
|
||||
updateCompactionBanner(pct, estimate);
|
||||
updateSessionCostChip(stats);
|
||||
}
|
||||
|
||||
function updateRingTooltip(stats, pct) {
|
||||
const tip = document.getElementById('chat-ring-tooltip');
|
||||
if (!tip) return;
|
||||
const b = stats.breakdown || {};
|
||||
const used = Number(stats.used) || 0;
|
||||
const limit = Number(stats.limit) || 0;
|
||||
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k' : String(n);
|
||||
const rows = [
|
||||
['System', b.system || 0],
|
||||
['Tools', b.tools || 0],
|
||||
['Messages', b.messages || 0],
|
||||
['Memory', b.memory || 0],
|
||||
];
|
||||
const maxVal = Math.max(1, ...rows.map((r) => r[1]));
|
||||
const title = stats.estimate ? 'Context breakdown (estimate)' : 'Context breakdown';
|
||||
tip.innerHTML =
|
||||
'<h5>' + title + '</h5>' +
|
||||
rows.map(([lbl, val]) =>
|
||||
'<div class="chat-ring-tt-row">' +
|
||||
'<span class="lbl">' + lbl + '</span>' +
|
||||
'<span class="chat-ring-tt-bar"><i style="width:' + Math.round((val / maxVal) * 100) + '%"></i></span>' +
|
||||
'<span class="chat-ring-tt-val">' + fmt(val) + '</span>' +
|
||||
'</div>'
|
||||
).join('') +
|
||||
'<div class="chat-ring-tt-total">Total: ' + fmt(used) + ' / ' + fmt(limit) + ' (' + Math.round(pct) + '%)</div>';
|
||||
}
|
||||
|
||||
function updateCompactionBanner(pct, estimate) {
|
||||
const banner = document.getElementById('chat-compaction-banner');
|
||||
const textEl = document.getElementById('chat-compaction-text');
|
||||
if (!banner || !textEl) return;
|
||||
|
||||
if (pct < 85) {
|
||||
banner.classList.remove('open', 'critical');
|
||||
return;
|
||||
}
|
||||
// E4: respect per-session dismissal until usage drops below 85 then rises
|
||||
if (sessionStorage.getItem('aiui-compaction-dismissed') === (currentSessionId || 'default')) {
|
||||
if (pct < 85) sessionStorage.removeItem('aiui-compaction-dismissed');
|
||||
else return;
|
||||
}
|
||||
banner.classList.add('open');
|
||||
banner.classList.toggle('critical', pct >= 95);
|
||||
banner.setAttribute('role', pct >= 95 ? 'alert' : 'status');
|
||||
textEl.textContent = pct >= 95
|
||||
? 'Context ' + Math.round(pct) + '% — older messages will be summarized on next send'
|
||||
: 'Context ' + Math.round(pct) + '% — older messages may be summarized on next send';
|
||||
}
|
||||
|
||||
function dismissCompactionBanner() {
|
||||
const banner = document.getElementById('chat-compaction-banner');
|
||||
if (banner) banner.classList.remove('open', 'critical');
|
||||
sessionStorage.setItem('aiui-compaction-dismissed', currentSessionId || 'default');
|
||||
}
|
||||
|
||||
// E21: flash a transient "Context compacted" banner after WS event
|
||||
function flashCompactionCompacted(data) {
|
||||
const banner = document.getElementById('chat-compaction-banner');
|
||||
const textEl = document.getElementById('chat-compaction-text');
|
||||
if (!banner || !textEl) return;
|
||||
banner.classList.add('open');
|
||||
banner.classList.remove('critical');
|
||||
banner.setAttribute('role', 'status');
|
||||
const removed = data && data.removed_tokens ? ' (' + data.removed_tokens + ' tokens freed)' : '';
|
||||
textEl.textContent = 'Context compacted' + removed;
|
||||
sessionStorage.removeItem('aiui-compaction-dismissed');
|
||||
setTimeout(() => {
|
||||
if (lastContextPct < 85) banner.classList.remove('open');
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function refreshContextStats() {
|
||||
try {
|
||||
const qs = currentSessionId ? '?session_id=' + encodeURIComponent(currentSessionId) : '';
|
||||
const resp = await fetch('/api/chat/context-stats' + qs);
|
||||
if (!resp.ok) return; // E1: keep last estimate on error
|
||||
const stats = await resp.json();
|
||||
updateContextRing(stats);
|
||||
} catch (e) {
|
||||
// E1/E11: network error — ring keeps prior state
|
||||
}
|
||||
}
|
||||
|
||||
// E10: debounce rapid refreshes (e.g. multiple completions)
|
||||
function scheduleContextRefresh() {
|
||||
if (contextRefreshDebounce) clearTimeout(contextRefreshDebounce);
|
||||
contextRefreshDebounce = setTimeout(refreshContextStats, 100);
|
||||
}
|
||||
|
||||
// E11: poll fallback when WS context_update isn't emitted by backend
|
||||
function startContextPoll() {
|
||||
if (contextPollTimer) clearInterval(contextPollTimer);
|
||||
contextPollTimer = setInterval(refreshContextStats, 5000);
|
||||
}
|
||||
|
||||
function stopContextPoll() {
|
||||
if (contextPollTimer) { clearInterval(contextPollTimer); contextPollTimer = null; }
|
||||
}
|
||||
|
||||
// ── Memory Chip ─────────────────────────────────────────────────
|
||||
function updateMemoryChip(count, loading) {
|
||||
const chip = document.getElementById('chat-memory-chip');
|
||||
if (!chip) return;
|
||||
if (loading) {
|
||||
chip.classList.add('visible', 'loading');
|
||||
chip.innerHTML = '🧠 …';
|
||||
return;
|
||||
}
|
||||
chip.classList.remove('loading');
|
||||
const n = Number(count) || 0;
|
||||
if (n <= 0) {
|
||||
// E5: hide chip when no memories retrieved this turn
|
||||
chip.classList.remove('visible');
|
||||
chip.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
chip.classList.add('visible');
|
||||
chip.innerHTML = '🧠 ' + n + ' ' + (n === 1 ? 'memory' : 'memories');
|
||||
}
|
||||
|
||||
async function refreshMemoryChip() {
|
||||
const chip = document.getElementById('chat-memory-chip');
|
||||
if (!chip) return;
|
||||
updateMemoryChip(0, true);
|
||||
try {
|
||||
const endpoint = currentSessionId
|
||||
? '/api/memory/session/' + encodeURIComponent(currentSessionId)
|
||||
: '/api/memory';
|
||||
let resp = await fetch(endpoint);
|
||||
if (!resp.ok) resp = await fetch('/api/memory');
|
||||
if (!resp.ok) { chip.classList.remove('loading'); chip.innerHTML = '🧠 —'; chip.classList.add('visible'); return; }
|
||||
const data = await resp.json();
|
||||
const items = data.memories || [];
|
||||
updateMemoryChip(items.length, false);
|
||||
} catch (e) {
|
||||
// E6: memory search failed — muted dash
|
||||
chip.classList.remove('loading');
|
||||
chip.innerHTML = '🧠 —';
|
||||
}
|
||||
}
|
||||
|
||||
function showStreamMetrics(metrics) {
|
||||
let metricsEl = document.getElementById('chat-stream-metrics');
|
||||
if (!metricsEl) {
|
||||
@@ -1729,11 +2056,132 @@ function showStreamMetrics(metrics) {
|
||||
metricsEl.innerHTML = parts.join('') || '';
|
||||
}
|
||||
|
||||
// ── Session Cost Chip (FinOps / STITCH-013) ─────────────────────
|
||||
function finopsChipEnabled() {
|
||||
if (!finopsConfig) return true; // default on until config loads
|
||||
if (finopsConfig.enabled === false) return false;
|
||||
return finopsConfig.show_session_chip !== false;
|
||||
}
|
||||
|
||||
function fmtChipTokens(n) {
|
||||
const v = Number(n) || 0;
|
||||
if (v >= 1000000) return (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return (v / 1000).toFixed(1) + 'k';
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
function shortModel(model) {
|
||||
const s = model == null ? '' : String(model);
|
||||
const idx = s.lastIndexOf('/');
|
||||
return idx >= 0 ? s.slice(idx + 1) : s;
|
||||
}
|
||||
|
||||
function updateSessionCostChip(stats) {
|
||||
const chip = document.getElementById('chat-session-cost-chip');
|
||||
if (!chip) return;
|
||||
// E9/E2: hide when no session, feature disabled, or chip toggled off
|
||||
if (!currentSessionId || !finopsChipEnabled()) {
|
||||
chip.classList.remove('visible');
|
||||
chip.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const tokens = Number(stats && stats.used) || sessionCostState.tokens || 0;
|
||||
const model = shortModel((stats && stats.model) || sessionCostState.model || currentAgentName || '');
|
||||
const estimate = !!(stats && stats.estimate);
|
||||
sessionCostState = { tokens, model, estimate };
|
||||
if (tokens <= 0) {
|
||||
chip.classList.remove('visible');
|
||||
chip.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
chip.classList.add('visible');
|
||||
chip.classList.toggle('estimate', estimate);
|
||||
const prefix = estimate ? '~' : '';
|
||||
const modelHtml = model
|
||||
? ` · <span class="cost-chip-model">${escapeChip(model)}</span>`
|
||||
: '';
|
||||
chip.innerHTML = prefix + fmtChipTokens(tokens) + modelHtml;
|
||||
chip.setAttribute('aria-label', 'Session token usage ' + Math.round(tokens) + ' tokens, model ' + (model || 'unknown'));
|
||||
chip.title = 'Session spend\nTokens: ' + Math.round(tokens).toLocaleString() + '\nModel: ' + (model || 'unknown');
|
||||
}
|
||||
|
||||
function escapeChip(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadFinopsConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/config/runtime');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
finopsConfig = (data && data.config && data.config.finops) || finopsConfig;
|
||||
} catch (e) {
|
||||
// graceful: keep default-on behaviour
|
||||
}
|
||||
updateFinopsBanner();
|
||||
}
|
||||
|
||||
function finopsBannerDismissed() {
|
||||
return sessionStorage.getItem('finops-banner-dismissed') === '1';
|
||||
}
|
||||
|
||||
async function updateFinopsBanner() {
|
||||
const host = document.getElementById('chat-finops-banner');
|
||||
if (!host) return;
|
||||
if (!finopsConfig || finopsConfig.enabled === false) { host.innerHTML = ''; return; }
|
||||
const budget = finopsConfig.daily_token_budget != null ? Number(finopsConfig.daily_token_budget) : null;
|
||||
if (!budget || budget <= 0) { host.innerHTML = ''; return; }
|
||||
if (finopsBannerDismissed()) { host.innerHTML = ''; return; }
|
||||
let today = 0;
|
||||
try {
|
||||
const res = await fetch('/api/usage/timeseries?hours=24');
|
||||
if (!res.ok) { host.innerHTML = ''; return; } // E1: hide, no toast
|
||||
const data = await res.json();
|
||||
const ts = Array.isArray(data.timeseries) ? data.timeseries : [];
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
const dated = ts.filter((p) => typeof p.hour_key === 'string');
|
||||
const scope = dated.length ? dated.filter((p) => p.hour_key.startsWith(todayKey)) : ts;
|
||||
today = scope.reduce((sum, p) => sum + (Number(p.tokens) || 0), 0);
|
||||
} catch (e) {
|
||||
host.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const pct = Math.min((today / budget) * 100, 100);
|
||||
const warnPct = Number(finopsConfig.warn_pct) || 80;
|
||||
const criticalPct = Number(finopsConfig.critical_pct) || 95;
|
||||
if (pct < warnPct) { host.innerHTML = ''; return; }
|
||||
const critical = pct >= criticalPct;
|
||||
const color = critical ? '#ef4444' : '#eab308';
|
||||
const bg = critical ? 'rgba(239,68,68,.1)' : 'rgba(234,179,8,.1)';
|
||||
const copy = critical
|
||||
? 'Daily token budget at ' + Math.round(pct) + '% — review usage'
|
||||
: 'Daily token budget at ' + Math.round(pct) + '% — consider lighter models';
|
||||
host.innerHTML =
|
||||
'<div class="chat-finops-banner-inner" role="' + (critical ? 'alert' : 'status') + '"' +
|
||||
' style="display:flex;align-items:center;gap:8px;padding:8px 14px;margin-bottom:8px;border:1px solid ' + color +
|
||||
';border-radius:10px;background:' + bg + ';color:' + color + ';font-size:12px;font-weight:500">' +
|
||||
'<span aria-hidden="true">💰</span>' +
|
||||
'<span style="flex:1">' + escapeChip(copy) + '</span>' +
|
||||
'<button type="button" class="finops-banner-dismiss" title="Dismiss" aria-label="Dismiss"' +
|
||||
' style="background:none;border:none;color:inherit;cursor:pointer;font-size:13px;padding:0 4px">✕</button>' +
|
||||
'</div>';
|
||||
const dismiss = host.querySelector('.finops-banner-dismiss');
|
||||
if (dismiss) dismiss.addEventListener('click', () => {
|
||||
sessionStorage.setItem('finops-banner-dismissed', '1');
|
||||
host.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Memory Panel ────────────────────────────────────────────────
|
||||
function toggleMemoryPanel() {
|
||||
const panel = document.getElementById('chat-memory-panel');
|
||||
if (!panel) return;
|
||||
const isOpen = panel.classList.toggle('open');
|
||||
// E17: brain button + chip open the same panel; keep chip aria in sync
|
||||
const chip = document.getElementById('chat-memory-chip');
|
||||
if (chip) chip.setAttribute('aria-expanded', String(isOpen));
|
||||
if (isOpen) loadMemories();
|
||||
}
|
||||
|
||||
@@ -1763,12 +2211,26 @@ async function loadMemories() {
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = items.map(m => `
|
||||
listEl.innerHTML = items.map(m => {
|
||||
const type = m.memory_type || m.type || 'long';
|
||||
const snippet = m.text || m.value || m.content || '';
|
||||
const relevance = Number(m.relevance != null ? m.relevance : m.score);
|
||||
const relPct = isFinite(relevance) ? Math.round(Math.max(0, Math.min(1, relevance)) * 100) : null;
|
||||
const ts = m.created_at || m.timestamp;
|
||||
const relBar = relPct != null
|
||||
? `<div class="mem-relevance" title="Relevance ${relPct}%"><i style="width:${relPct}%"></i></div>`
|
||||
: '';
|
||||
const timeStr = ts ? timeAgo(ts) : '';
|
||||
return `
|
||||
<div class="chat-mem-item">
|
||||
<div class="mem-type">${m.memory_type || 'long'}</div>
|
||||
<div class="mem-text">${escapeHtml(m.text || '')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
<div class="mem-head">
|
||||
<span class="mem-badge">${escapeHtml(type)}</span>
|
||||
${timeStr ? `<span class="mem-time">${escapeHtml(timeStr)}</span>` : ''}
|
||||
</div>
|
||||
<div class="mem-text mem-snippet">${escapeHtml(snippet)}</div>
|
||||
${relBar}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = `<span class="chat-mem-empty">Error: ${err.message}</span>`;
|
||||
}
|
||||
@@ -2016,7 +2478,7 @@ function refreshStreamingUI() {
|
||||
const abortBtn = document.getElementById('chat-abort-btn');
|
||||
const sendBtn = document.getElementById('chat-send-btn');
|
||||
if (abortBtn) abortBtn.style.display = inStreamingSession ? '' : 'none';
|
||||
if (sendBtn) sendBtn.textContent = inStreamingSession ? 'Queue ↵' : 'Send ↵';
|
||||
if (sendBtn) sendBtn.title = inStreamingSession ? 'Queue message' : 'Send message';
|
||||
// Update status badge too
|
||||
if (!inStreamingSession && isStreaming) {
|
||||
setStatus('connected'); // other session streaming, but we're not in it
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Code Studio View — VS Code-lite in-dashboard IDE.
|
||||
* Write code, run in sandbox, view output, and send snippets to chat.
|
||||
* API: GET /api/code/languages, POST /api/code/execute
|
||||
*/
|
||||
import { helpBanner } from '/plugins/views/_helpers.js';
|
||||
|
||||
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/[email protected]/min/vs';
|
||||
const HISTORY_MAX = 10;
|
||||
const SNIPPET_KEY = 'aiui_snippets';
|
||||
const OUTPUT_MAX = 200000;
|
||||
|
||||
const DEFAULT_CODE = {
|
||||
python: "print('hello from Code Studio')\n",
|
||||
javascript: "console.log('hello from Code Studio');\n",
|
||||
bash: "echo 'hello from Code Studio'\n",
|
||||
};
|
||||
|
||||
const MONACO_LANG = { python: 'python', javascript: 'javascript', bash: 'shell' };
|
||||
const FILE_EXT = { python: 'py', javascript: 'js', bash: 'sh' };
|
||||
|
||||
let editor = null;
|
||||
let monacoNs = null;
|
||||
let running = false;
|
||||
let timerId = null;
|
||||
const history = [];
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function currentCode() {
|
||||
if (editor && typeof editor.getValue === 'function') return editor.getValue();
|
||||
const ta = document.getElementById('cs-fallback');
|
||||
return ta ? ta.value : '';
|
||||
}
|
||||
|
||||
function setCode(code) {
|
||||
if (editor && typeof editor.setValue === 'function') editor.setValue(code);
|
||||
else { const ta = document.getElementById('cs-fallback'); if (ta) ta.value = code; }
|
||||
}
|
||||
|
||||
function setEditorLanguage(lang) {
|
||||
if (monacoNs && editor) {
|
||||
const model = editor.getModel();
|
||||
if (model) monacoNs.editor.setModelLanguage(model, MONACO_LANG[lang] || 'plaintext');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonaco(host, lang) {
|
||||
if (window.monaco) monacoNs = window.monaco;
|
||||
if (!monacoNs) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = MONACO_CDN + '/loader.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
if (!window.require) throw new Error('Monaco loader unavailable');
|
||||
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||
monacoNs = await new Promise((resolve, reject) => {
|
||||
try { window.require(['vs/editor/editor.main'], () => resolve(window.monaco)); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
}
|
||||
editor = monacoNs.editor.create(host, {
|
||||
value: DEFAULT_CODE[lang] || '',
|
||||
language: MONACO_LANG[lang] || 'plaintext',
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono','SF Mono',Monaco,Consolas,monospace",
|
||||
lineNumbers: 'on',
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
function mountFallback(host, lang) {
|
||||
host.innerHTML = `<textarea id="cs-fallback" spellcheck="false"
|
||||
style="width:100%;height:100%;box-sizing:border-box;resize:none;border:0;outline:none;padding:12px;background:#1e1e1e;color:#d4d4d4;font-family:'JetBrains Mono','SF Mono',Monaco,Consolas,monospace;font-size:13px;line-height:1.5">${escapeHtml(DEFAULT_CODE[lang] || '')}</textarea>`;
|
||||
}
|
||||
|
||||
function pickTab(container, tab) {
|
||||
container.querySelectorAll('.cs-tab').forEach((b) => {
|
||||
const on = b.dataset.tab === tab;
|
||||
b.style.color = on ? 'var(--db-text,#e4e4e7)' : 'var(--db-text-dim,#a1a1aa)';
|
||||
b.style.borderBottom = on ? '2px solid var(--db-accent,#6366f1)' : '2px solid transparent';
|
||||
});
|
||||
container.querySelectorAll('.cs-tab-panel').forEach((p) => {
|
||||
p.style.display = p.dataset.panel === tab ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(text) {
|
||||
const s = String(text == null ? '' : text);
|
||||
if (s.length <= OUTPUT_MAX) return { text: s, truncated: false };
|
||||
return { text: s.slice(0, OUTPUT_MAX), truncated: true };
|
||||
}
|
||||
|
||||
function renderOutput(container, result) {
|
||||
const out = result.output != null ? result.output : (result.stdout || '');
|
||||
const err = result.error != null ? result.error : (result.stderr || '');
|
||||
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
|
||||
const failed = ['error', 'failed', 'degraded'].includes(result.status);
|
||||
|
||||
const banner = container.querySelector('#cs-banner');
|
||||
if (failed) {
|
||||
banner.style.display = 'flex';
|
||||
const code = result.exit_code != null ? ` (exit ${result.exit_code})` : '';
|
||||
banner.querySelector('#cs-banner-text').textContent = `Execution ${result.status}${code}`;
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
|
||||
const o = truncate(out);
|
||||
const e = truncate(err);
|
||||
container.querySelector('#cs-out-body').innerHTML =
|
||||
`<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:#22c55e">${escapeHtml(o.text) || '<span style="color:#71717a">(no output)</span>'}</pre>` +
|
||||
(o.truncated ? '<div style="color:#71717a;font-size:11px;margin-top:6px">Output truncated</div>' : '');
|
||||
container.querySelector('#cs-err-body').innerHTML =
|
||||
`<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:#ef4444">${escapeHtml(e.text) || '<span style="color:#71717a">(no errors)</span>'}</pre>` +
|
||||
(e.truncated ? '<div style="color:#71717a;font-size:11px;margin-top:6px">Output truncated</div>' : '');
|
||||
container.querySelector('#cs-art-body').innerHTML = artifacts.length
|
||||
? `<pre style="margin:0;white-space:pre-wrap">${escapeHtml(JSON.stringify(artifacts, null, 2))}</pre>`
|
||||
: '<span style="color:#71717a">(no artifacts)</span>';
|
||||
|
||||
pickTab(container, failed && err ? 'errors' : 'output');
|
||||
}
|
||||
|
||||
function renderHistory(container) {
|
||||
const list = container.querySelector('#cs-history');
|
||||
if (!history.length) {
|
||||
list.innerHTML = '<div style="color:#71717a;font-size:12px;padding:8px">No runs yet</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = history.map((h, i) => {
|
||||
const ok = !['error', 'failed', 'degraded'].includes(h.result.status);
|
||||
const icon = ok ? '<span style="color:#22c55e">●</span>' : '<span style="color:#ef4444">●</span>';
|
||||
const dur = h.duration != null ? `${(h.duration / 1000).toFixed(1)}s` : '';
|
||||
return `<div class="cs-history-row" data-idx="${i}" style="display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;font-size:12px" onmouseover="this.style.background='rgba(255,255,255,.05)'" onmouseout="this.style.background='transparent'">
|
||||
${icon}<span style="flex:1">${escapeHtml(h.language)}</span><span style="color:#71717a">${dur}</span></div>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.cs-history-row').forEach((row) => {
|
||||
row.addEventListener('click', () => {
|
||||
const h = history[Number(row.dataset.idx)];
|
||||
if (!h) return;
|
||||
const sel = container.querySelector('#cs-language');
|
||||
if (sel) { sel.value = h.language; setEditorLanguage(h.language); }
|
||||
setCode(h.code);
|
||||
renderOutput(container, h.result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pushHistory(container, entry) {
|
||||
history.unshift(entry);
|
||||
if (history.length > HISTORY_MAX) history.length = HISTORY_MAX;
|
||||
renderHistory(container);
|
||||
}
|
||||
|
||||
async function runCode(container) {
|
||||
if (running) return;
|
||||
const code = currentCode();
|
||||
if (!code.trim()) {
|
||||
alert('Enter code to run');
|
||||
return;
|
||||
}
|
||||
const lang = container.querySelector('#cs-language').value;
|
||||
const runBtn = container.querySelector('#cs-run');
|
||||
running = true;
|
||||
runBtn.disabled = true;
|
||||
runBtn.textContent = 'Running…';
|
||||
const start = Date.now();
|
||||
timerId = setInterval(() => {
|
||||
runBtn.textContent = `Executing… ${((Date.now() - start) / 1000).toFixed(1)}s`;
|
||||
}, 100);
|
||||
|
||||
let result;
|
||||
try {
|
||||
const res = await fetch('/api/code/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: lang, code, timeout: 30 }),
|
||||
});
|
||||
result = await res.json();
|
||||
} catch (e) {
|
||||
result = { status: 'failed', error: String(e), language: lang };
|
||||
} finally {
|
||||
running = false;
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
runBtn.disabled = false;
|
||||
runBtn.textContent = '▶ Run';
|
||||
}
|
||||
|
||||
const duration = result.duration_ms != null ? result.duration_ms : (Date.now() - start);
|
||||
renderOutput(container, result);
|
||||
pushHistory(container, { language: lang, code, result, duration });
|
||||
}
|
||||
|
||||
function saveSnippet(container) {
|
||||
const code = currentCode();
|
||||
const lang = container.querySelector('#cs-language').value;
|
||||
try {
|
||||
const raw = localStorage.getItem(SNIPPET_KEY);
|
||||
const snippets = raw ? JSON.parse(raw) : [];
|
||||
snippets.push({ language: lang, code, ts: Date.now() });
|
||||
localStorage.setItem(SNIPPET_KEY, JSON.stringify(snippets));
|
||||
const btn = container.querySelector('#cs-save');
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(() => { btn.textContent = 'Save'; }, 1500);
|
||||
} catch (e) {
|
||||
alert('Could not save snippet (storage full)');
|
||||
}
|
||||
}
|
||||
|
||||
function sendToAgent(container) {
|
||||
const code = currentCode();
|
||||
const lang = container.querySelector('#cs-language').value;
|
||||
const text = '```' + lang + '\n' + code + '\n```\nPlease review this code.';
|
||||
if (window.aiui && typeof window.aiui.selectPage === 'function') {
|
||||
Promise.resolve(window.aiui.selectPage('chat')).then(() => {
|
||||
window.dispatchEvent(new CustomEvent('aiui:prefill-composer', { detail: { text } }));
|
||||
});
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('aiui:prefill-composer', { detail: { text } }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'code-studio');
|
||||
|
||||
let languages = [];
|
||||
try {
|
||||
const r = await fetch('/api/code/languages');
|
||||
const d = await r.json();
|
||||
languages = d.languages || [];
|
||||
} catch (e) {}
|
||||
if (!languages.length) {
|
||||
languages = [{ id: 'python', name: 'Python' }, { id: 'javascript', name: 'JavaScript' }, { id: 'bash', name: 'Bash' }];
|
||||
}
|
||||
const firstLang = languages[0].id;
|
||||
|
||||
const langOptions = languages
|
||||
.map((l) => `<option value="${escapeHtml(l.id)}">${escapeHtml(l.name || l.id)}</option>`)
|
||||
.join('');
|
||||
|
||||
container.innerHTML = helpBanner({
|
||||
title: 'Code Studio',
|
||||
what: 'Write code, run it in a sandbox, inspect output, and hand snippets to your agent.',
|
||||
howToUse: 'Pick a language, edit the code, then click Run. Use "Send to Agent" to continue in chat.',
|
||||
tip: 'Code runs server-side in a sandbox — never in your browser.',
|
||||
collapsed: true,
|
||||
}) + `
|
||||
<div class="cs-root" style="display:flex;gap:12px;height:calc(100vh - 220px);min-height:480px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;border:1px solid var(--db-border);border-radius:10px;overflow:hidden;background:#1e1e1e">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px;background:#18181b;border-bottom:1px solid var(--db-border);flex-wrap:wrap">
|
||||
<select id="cs-language" style="padding:6px 10px;background:var(--db-card-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;font-size:13px">${langOptions}</select>
|
||||
<input id="cs-filename" type="text" value="script.${FILE_EXT[firstLang] || 'txt'}" style="padding:6px 10px;background:var(--db-card-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;font-size:13px;width:140px" />
|
||||
<button id="cs-run" style="padding:6px 16px;background:#22c55e;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">▶ Run</button>
|
||||
<button id="cs-save" style="padding:6px 14px;background:transparent;color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;cursor:pointer;font-size:13px">Save</button>
|
||||
</div>
|
||||
<div id="cs-editor" style="flex:3;min-height:180px"></div>
|
||||
<div style="flex:2;display:flex;flex-direction:column;border-top:1px solid var(--db-border);background:#0d1117;min-height:120px">
|
||||
<div id="cs-banner" style="display:none;align-items:center;gap:10px;padding:8px 12px;background:rgba(239,68,68,.12);border-bottom:1px solid rgba(239,68,68,.3);font-size:12px">
|
||||
<span id="cs-banner-text" style="color:#ef4444;flex:1"></span>
|
||||
<button id="cs-fix" style="padding:4px 12px;background:transparent;color:#ef4444;border:1px solid rgba(239,68,68,.4);border-radius:6px;cursor:pointer;font-size:12px">Ask agent to fix</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 8px;border-bottom:1px solid var(--db-border)">
|
||||
<button class="cs-tab" data-tab="output" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Output</button>
|
||||
<button class="cs-tab" data-tab="errors" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Errors</button>
|
||||
<button class="cs-tab" data-tab="artifacts" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Artifacts</button>
|
||||
</div>
|
||||
<div style="flex:1;overflow:auto;padding:12px;font-family:'JetBrains Mono','SF Mono',Monaco,Consolas,monospace;font-size:12px">
|
||||
<div class="cs-tab-panel" data-panel="output" id="cs-out-body"><span style="color:#71717a">Run code to see output</span></div>
|
||||
<div class="cs-tab-panel" data-panel="errors" id="cs-err-body" style="display:none"></div>
|
||||
<div class="cs-tab-panel" data-panel="artifacts" id="cs-art-body" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:240px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="border:1px solid var(--db-border);border-radius:10px;padding:12px;background:var(--db-card-bg)">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">History</div>
|
||||
<div id="cs-history"></div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--db-border);border-radius:10px;padding:12px;background:var(--db-card-bg);font-size:12px;color:var(--db-text-dim)">
|
||||
<div style="font-weight:600;color:var(--db-text);margin-bottom:6px">Environment</div>
|
||||
<div>Timeout: 30s</div>
|
||||
<div>Sandbox: server-side</div>
|
||||
</div>
|
||||
<button id="cs-send-agent" style="padding:10px;background:var(--db-accent,#6366f1);color:#fff;border:0;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600">→ Send to Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const editorHost = container.querySelector('#cs-editor');
|
||||
try {
|
||||
await loadMonaco(editorHost, firstLang);
|
||||
} catch (e) {
|
||||
console.warn('[code-studio] Monaco load failed, using fallback textarea:', e);
|
||||
mountFallback(editorHost, firstLang);
|
||||
}
|
||||
|
||||
renderHistory(container);
|
||||
pickTab(container, 'output');
|
||||
|
||||
const langSel = container.querySelector('#cs-language');
|
||||
langSel.addEventListener('change', () => {
|
||||
const lang = langSel.value;
|
||||
setEditorLanguage(lang);
|
||||
const fn = container.querySelector('#cs-filename');
|
||||
if (fn) fn.value = `script.${FILE_EXT[lang] || 'txt'}`;
|
||||
});
|
||||
|
||||
container.querySelector('#cs-run').addEventListener('click', () => runCode(container));
|
||||
container.querySelector('#cs-save').addEventListener('click', () => saveSnippet(container));
|
||||
container.querySelector('#cs-send-agent').addEventListener('click', () => sendToAgent(container));
|
||||
container.querySelector('#cs-fix').addEventListener('click', () => sendToAgent(container));
|
||||
|
||||
container.querySelectorAll('.cs-tab').forEach((b) => {
|
||||
b.addEventListener('click', () => pickTab(container, b.dataset.tab));
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
if (timerId) { clearInterval(timerId); timerId = null; }
|
||||
if (editor && typeof editor.dispose === 'function') { try { editor.dispose(); } catch (e) {} }
|
||||
editor = null;
|
||||
running = false;
|
||||
}
|
||||
@@ -1,102 +1,413 @@
|
||||
/**
|
||||
* Eval View — Agent evaluation and accuracy monitoring.
|
||||
* Shows evaluation scores, judge results, quality metrics.
|
||||
* API: /api/eval, /api/eval/status, /api/eval/scores, /api/eval/judges
|
||||
* Eval Quality Cockpit (STITCH-009) — regression dashboard for agent quality.
|
||||
*
|
||||
* Upgrades the read-only Evaluation table into a quality cockpit that answers:
|
||||
* are agents getting better/worse, which suite regressed, what failed, and can
|
||||
* I trigger a run — all client-derived from existing endpoints (no new backend).
|
||||
*
|
||||
* Data sources (parallel, fault-tolerant via Promise.allSettled):
|
||||
* GET /api/eval/status /api/eval/scores /api/eval?limit=200 /api/eval/judges
|
||||
* POST /api/eval/run
|
||||
*
|
||||
* Failed rows deep-link to Run Flight Recorder (/runs?session=) when a
|
||||
* session_id is present, otherwise fall back to /traces (STITCH-007 soft dep).
|
||||
*/
|
||||
import { showToast } from '../toast.js';
|
||||
import {
|
||||
esc, timeAgo, sparklineSVG, bucketByDay, detectRegression, trendArrow, regressionBadge,
|
||||
} from '/plugins/views/_helpers.js';
|
||||
|
||||
const HISTORY_LIMIT = 200;
|
||||
const BASELINE_ID_KEY = 'aiui_eval_baseline_id';
|
||||
const BASELINE_SNAP_KEY = 'aiui_eval_baseline_snapshot';
|
||||
|
||||
function navigate(pageId) {
|
||||
if (window.aiui?.selectPage) window.aiui.selectPage(pageId);
|
||||
}
|
||||
|
||||
function loadBaseline() {
|
||||
try {
|
||||
const raw = localStorage.getItem(BASELINE_SNAP_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function saveBaseline(snapshot) {
|
||||
try {
|
||||
localStorage.setItem(BASELINE_ID_KEY, snapshot.id);
|
||||
localStorage.setItem(BASELINE_SNAP_KEY, JSON.stringify(snapshot));
|
||||
} catch (e) { /* storage disabled */ }
|
||||
}
|
||||
|
||||
function clearBaseline() {
|
||||
try {
|
||||
localStorage.removeItem(BASELINE_ID_KEY);
|
||||
localStorage.removeItem(BASELINE_SNAP_KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function passRateFor(s) {
|
||||
const denom = (s.passed || 0) + (s.failed || 0);
|
||||
return denom > 0 ? (s.passed || 0) / denom : null;
|
||||
}
|
||||
|
||||
function statusDot(rate) {
|
||||
if (rate == null) return '<span style="color:#71717a">●</span>';
|
||||
if (rate >= 0.9) return '<span style="color:#22c55e">●</span>';
|
||||
if (rate >= 0.7) return '<span style="color:#f59e0b">●</span>';
|
||||
return '<span style="color:#ef4444">●</span>';
|
||||
}
|
||||
|
||||
function statusLabel(rate) {
|
||||
if (rate == null) return 'No data';
|
||||
if (rate >= 0.9) return 'Healthy';
|
||||
if (rate >= 0.7) return 'Watch';
|
||||
return 'Regressed';
|
||||
}
|
||||
|
||||
function heroColor(rate) {
|
||||
if (rate == null) return undefined;
|
||||
if (rate >= 0.9) return '#22c55e';
|
||||
if (rate >= 0.7) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'eval');
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let statusData = {}, scores = [], evaluations = [], judges = [];
|
||||
let partialError = false;
|
||||
try {
|
||||
const [sRes, scRes, eRes, jRes] = await Promise.all([
|
||||
fetch('/api/eval/status'), fetch('/api/eval/scores'), fetch('/api/eval?limit=20'), fetch('/api/eval/judges')
|
||||
const [sRes, scRes, eRes, jRes] = await Promise.allSettled([
|
||||
fetch('/api/eval/status'),
|
||||
fetch('/api/eval/scores'),
|
||||
fetch(`/api/eval?limit=${HISTORY_LIMIT}`),
|
||||
fetch('/api/eval/judges'),
|
||||
]);
|
||||
statusData = await sRes.json();
|
||||
scores = (await scRes.json()).scores || [];
|
||||
evaluations = (await eRes.json()).evaluations || [];
|
||||
judges = (await jRes.json()).judges || [];
|
||||
} catch(e) { statusData = { status: 'error' }; }
|
||||
if (sRes.status === 'fulfilled') statusData = await sRes.value.json(); else partialError = true;
|
||||
if (scRes.status === 'fulfilled') scores = (await scRes.value.json()).scores || []; else partialError = true;
|
||||
if (eRes.status === 'fulfilled') evaluations = (await eRes.value.json()).evaluations || []; else partialError = true;
|
||||
if (jRes.status === 'fulfilled') judges = (await jRes.value.json()).judges || []; else partialError = true;
|
||||
} catch (e) {
|
||||
statusData = { status: 'error' };
|
||||
partialError = true;
|
||||
}
|
||||
|
||||
const gwBadge = statusData.gateway_connected
|
||||
const sdkAvailable = statusData.sdk_available === true;
|
||||
const gatewayConnected = statusData.gateway_connected === true;
|
||||
const baseline = loadBaseline();
|
||||
|
||||
// ── Hero aggregates ────────────────────────────────────────────
|
||||
let totalPassed = 0, totalFailed = 0, totalCases = 0, weightedScore = 0, scoredCases = 0;
|
||||
for (const s of scores) {
|
||||
totalPassed += s.passed || 0;
|
||||
totalFailed += s.failed || 0;
|
||||
totalCases += s.total || 0;
|
||||
if (s.avg_score != null && s.scored) {
|
||||
weightedScore += s.avg_score * s.scored;
|
||||
scoredCases += s.scored;
|
||||
}
|
||||
}
|
||||
const passDenom = totalPassed + totalFailed;
|
||||
const passRate = passDenom > 0 ? (totalPassed / passDenom) : null;
|
||||
const avgScore = scoredCases > 0 ? (weightedScore / scoredCases) : null;
|
||||
|
||||
// ── Regression detection (per agent vs baseline snapshot) ──────
|
||||
let regressionCount = 0;
|
||||
const agentDelta = {};
|
||||
if (baseline?.agents) {
|
||||
for (const s of scores) {
|
||||
const base = baseline.agents[s.agent_id];
|
||||
if (base != null && s.avg_score != null) {
|
||||
const r = detectRegression(s.avg_score, base);
|
||||
agentDelta[s.agent_id] = r.delta;
|
||||
if (r.isRegression) regressionCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastEval = evaluations.length ? evaluations[evaluations.length - 1] : null;
|
||||
const lastRun = lastEval ? timeAgo(lastEval.timestamp) : '—';
|
||||
|
||||
const heroCard = (title, value, subtitle, color) => `
|
||||
<div class="db-card" style="padding:16px;border-radius:12px">
|
||||
<div style="font-size:.8rem;opacity:.6">${esc(title)}</div>
|
||||
<div style="font-size:1.8rem;font-weight:700${color ? `;color:${color}` : ''}">${value}</div>
|
||||
<div style="font-size:.75rem;opacity:.5;margin-top:2px">${esc(subtitle)}</div>
|
||||
</div>`;
|
||||
|
||||
// ── Suite cards ────────────────────────────────────────────────
|
||||
const suiteCards = scores.map((s) => {
|
||||
const rate = passRateFor(s);
|
||||
const buckets = bucketByDay(evaluations, 7, s.agent_id);
|
||||
const series = buckets.map((b) => b.avg);
|
||||
const half = Math.max(1, Math.floor(series.length / 2));
|
||||
const recent = series.slice(-half).reduce((a, b) => a + b, 0) / half;
|
||||
const prior = series.slice(0, half).reduce((a, b) => a + b, 0) / half;
|
||||
const arrow = series.length >= 2 ? trendArrow(recent, prior) : '';
|
||||
const badge = agentDelta[s.agent_id] != null ? regressionBadge(agentDelta[s.agent_id]) : '';
|
||||
const isReg = agentDelta[s.agent_id] != null && agentDelta[s.agent_id] <= -0.1;
|
||||
const sparkColor = isReg ? '#ef4444' : 'var(--db-accent,#a855f7)';
|
||||
return `
|
||||
<div class="db-card db-suite-card" style="padding:14px;border-radius:12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
||||
<span style="font-weight:600;font-size:.95rem">${esc(s.agent_id)}</span>
|
||||
<span style="font-size:.75rem;opacity:.7">${statusDot(rate)} ${statusLabel(rate)}</span>
|
||||
</div>
|
||||
<div style="font-size:.8rem;opacity:.75;margin:6px 0">Pass ${rate != null ? Math.round(rate * 100) + '%' : '—'} · Avg ${s.avg_score != null ? s.avg_score.toFixed(2) : '—'} · ${s.total || 0} cases</div>
|
||||
<div aria-label="7-day score trend for ${esc(s.agent_id)}">${sparklineSVG(series, { width: 220, height: 34, color: sparkColor })}</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px">
|
||||
<span>${arrow || badge || '<span style="font-size:11px;opacity:.4">no trend</span>'}</span>
|
||||
<button class="ev-filter-btn" data-agent="${esc(s.agent_id)}" style="font-size:11px;padding:3px 10px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-text,#e0e0e0);border-radius:6px;cursor:pointer">Filter →</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// ── Baseline compare panel ─────────────────────────────────────
|
||||
let compareHtml;
|
||||
if (!baseline) {
|
||||
compareHtml = '<div style="padding:20px;text-align:center;opacity:.5;font-size:.85rem">No baseline pinned. Use "Pin baseline" on a history row to compare.</div>';
|
||||
} else {
|
||||
const rows = scores.map((s) => {
|
||||
const base = baseline.agents ? baseline.agents[s.agent_id] : null;
|
||||
const cur = s.avg_score;
|
||||
let delta = '—';
|
||||
if (base != null && cur != null) {
|
||||
const d = cur - base;
|
||||
const col = d >= 0 ? '#22c55e' : '#ef4444';
|
||||
delta = `<span style="color:${col}">${d >= 0 ? '+' : ''}${d.toFixed(2)}</span>`;
|
||||
}
|
||||
return `<tr style="border-bottom:1px solid rgba(128,128,128,.1)">
|
||||
<td style="padding:8px 12px;font-size:.85rem">${esc(s.agent_id)}</td>
|
||||
<td style="padding:8px 12px;text-align:center;font-size:.85rem">${base != null ? base.toFixed(2) : '—'}</td>
|
||||
<td style="padding:8px 12px;text-align:center;font-size:.85rem">${cur != null ? cur.toFixed(2) : '—'}</td>
|
||||
<td style="padding:8px 12px;text-align:center;font-weight:600">${delta}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
compareHtml = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;font-size:.75rem;opacity:.6">
|
||||
<span>Baseline: ${esc(baseline.id || '')} · ${timeAgo(baseline.timestamp)}</span>
|
||||
<button id="ev-clear-baseline" style="font-size:11px;padding:3px 10px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-text-dim,#888);border-radius:6px;cursor:pointer">Clear baseline</button>
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead><tr style="border-bottom:1px solid rgba(128,128,128,.2)">
|
||||
<th style="padding:8px 12px;text-align:left;font-size:.75rem;opacity:.6">Agent</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-size:.75rem;opacity:.6">Baseline</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-size:.75rem;opacity:.6">Latest</th>
|
||||
<th style="padding:8px 12px;text-align:center;font-size:.75rem;opacity:.6">Delta</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// ── Run action bar ─────────────────────────────────────────────
|
||||
const agentOptions = scores.map((s) => `<option value="${esc(s.agent_id)}">${esc(s.agent_id)}</option>`).join('');
|
||||
const judgeOptions = judges.map((j) => `<option value="${esc(j.type || j.name)}">${esc(j.name)}${j.type ? ` (${esc(j.type)})` : ''}</option>`).join('');
|
||||
const runDisabled = !sdkAvailable;
|
||||
|
||||
// ── History table rows (newest first) ──────────────────────────
|
||||
const ordered = [...evaluations].reverse();
|
||||
const historyRows = ordered.map((e, i) => {
|
||||
const scoreColor = e.score == null ? 'inherit' : (e.score >= 0.8 ? '#22c55e' : e.score >= 0.5 ? '#f59e0b' : '#ef4444');
|
||||
const passCell = e.passed === true ? '✅' : e.passed === false ? '❌' : '—';
|
||||
const rowBorder = e.passed === false ? 'border-left:3px solid #ef4444;' : '';
|
||||
let debugCta = '';
|
||||
if (e.passed === false) {
|
||||
debugCta = e.session_id
|
||||
? `<button class="ev-debug-btn" data-session="${esc(e.session_id)}" style="font-size:11px;padding:3px 8px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-accent,#a855f7);border-radius:6px;cursor:pointer">Debug run →</button>`
|
||||
: '<span title="No session linked" style="font-size:11px;opacity:.4">Find traces</span>';
|
||||
}
|
||||
return `
|
||||
<tr class="ev-history-row" data-idx="${i}" style="border-bottom:1px solid rgba(128,128,128,.1);cursor:pointer;${rowBorder}">
|
||||
<td style="padding:8px 10px;width:28px;opacity:.5">▸</td>
|
||||
<td style="padding:8px 10px;font-size:.75rem;opacity:.7" title="${esc(new Date((e.timestamp || 0) * 1000).toLocaleString())}">${timeAgo(e.timestamp)}</td>
|
||||
<td style="padding:8px 10px;font-size:.85rem">${esc(e.agent_id)}</td>
|
||||
<td style="padding:8px 10px;font-size:.8rem;font-family:monospace">${esc(e.evaluator)}</td>
|
||||
<td style="padding:8px 10px;text-align:center;font-weight:600;color:${scoreColor}">${e.score != null ? e.score : '—'}</td>
|
||||
<td style="padding:8px 10px;text-align:center">${passCell}</td>
|
||||
<td style="padding:8px 10px;text-align:right">${debugCta}<button class="ev-pin-btn" data-idx="${i}" style="font-size:11px;padding:3px 8px;margin-left:6px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-text-dim,#888);border-radius:6px;cursor:pointer">Pin</button></td>
|
||||
</tr>
|
||||
<tr class="ev-detail-row" data-detail="${i}" style="display:none">
|
||||
<td colspan="7" style="padding:0 10px 12px">
|
||||
<div style="background:rgba(128,128,128,.06);border-radius:8px;padding:12px;font-size:.82rem;line-height:1.7">
|
||||
<div><strong>Input:</strong> ${esc((e.input || '').slice(0, 500))}${(e.input || '').length > 500 ? '…' : ''}</div>
|
||||
<div><strong>Expected:</strong> ${esc((e.expected || '').slice(0, 500))}${(e.expected || '').length > 500 ? '…' : ''}</div>
|
||||
<div><strong>Output:</strong> ${esc((e.output || '').slice(0, 500))}${(e.output || '').length > 500 ? '…' : ''}</div>
|
||||
${e.feedback ? `<div><strong>Feedback:</strong> ${esc(e.feedback)}</div>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const gwBadge = gatewayConnected
|
||||
? '<span style="color:#22c55e">● Connected</span>'
|
||||
: '<span style="color:#ef4444">● Not Connected</span>';
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:24px;max-width:1200px;margin:0 auto">
|
||||
<h2 style="margin:0 0 20px;font-size:1.5rem">📊 Evaluation</h2>
|
||||
<h2 style="margin:0 0 16px;font-size:1.5rem">📊 Eval Quality Cockpit</h2>
|
||||
|
||||
${partialError ? '<div style="margin-bottom:16px;padding:10px 14px;border-radius:8px;background:rgba(245,158,11,.1);color:#f59e0b;font-size:.85rem">⚠ Some data failed to load — showing partial results.</div>' : ''}
|
||||
${!sdkAvailable ? '<div style="margin-bottom:16px;padding:10px 14px;border-radius:8px;background:rgba(239,68,68,.08);color:#ef4444;font-size:.85rem">Eval SDK not available — run trigger disabled. Install <code>praisonaiagents[eval]</code> to enable in-UI runs.</div>' : ''}
|
||||
|
||||
<div style="display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin-bottom:20px;padding:10px 14px;border:1px solid var(--db-border,#333);border-radius:10px;font-size:.82rem">
|
||||
<span>Eval SDK ${sdkAvailable ? '✅' : '❌'}</span>
|
||||
<span>Gateway ${gwBadge}</span>
|
||||
<span>${judges.length} judge${judges.length === 1 ? '' : 's'}</span>
|
||||
<span style="opacity:.7">Last run ${esc(lastRun)}</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:24px">
|
||||
<div class="db-card" style="padding:16px;border-radius:12px">
|
||||
<div style="font-size:.8rem;opacity:.6">Total Evaluations</div>
|
||||
<div style="font-size:1.8rem;font-weight:700">${statusData.total_evaluations || 0}</div>
|
||||
${heroCard('Pass Rate', passRate != null ? Math.round(passRate * 100) + '%' : '—', 'of scored cases', heroColor(passRate))}
|
||||
${heroCard('Avg Score', avgScore != null ? avgScore.toFixed(2) : '—', 'weighted by cases')}
|
||||
${heroCard('Regressions', String(regressionCount), 'vs baseline', regressionCount > 0 ? '#ef4444' : undefined)}
|
||||
${heroCard('Total Cases', String(totalCases), 'all time')}
|
||||
</div>
|
||||
|
||||
${scores.length === 0 ? `
|
||||
<div class="db-card" style="border-radius:12px;padding:40px;text-align:center">
|
||||
<div style="font-size:2rem;margin-bottom:8px">🧪</div>
|
||||
<div style="font-size:1.1rem;font-weight:600;margin-bottom:6px">No evaluations recorded yet</div>
|
||||
<div style="opacity:.6;font-size:.85rem;margin-bottom:16px">Run your first eval below or via the API to populate the cockpit.</div>
|
||||
</div>` : `
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Suite Health</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin-bottom:24px">
|
||||
${suiteCards}
|
||||
</div>`}
|
||||
|
||||
<div style="display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px;margin-bottom:24px" class="ev-mid-grid">
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Baseline Compare</h3>
|
||||
<div class="db-card" style="border-radius:12px;overflow:hidden">${compareHtml}</div>
|
||||
</div>
|
||||
<div class="db-card" style="padding:16px;border-radius:12px">
|
||||
<div style="font-size:.8rem;opacity:.6">Active Judges</div>
|
||||
<div style="font-size:1.8rem;font-weight:700">${statusData.active_judges || 0}</div>
|
||||
</div>
|
||||
<div class="db-card" style="padding:16px;border-radius:12px">
|
||||
<div style="font-size:.8rem;opacity:.6">Eval SDK</div>
|
||||
<div style="font-size:1rem;margin-top:4px">${statusData.eval_available ? '✅ Available' : '❌ Not Installed'}</div>
|
||||
</div>
|
||||
<div class="db-card" style="padding:16px;border-radius:12px">
|
||||
<div style="font-size:.8rem;opacity:.6">Gateway</div>
|
||||
<div style="font-size:1rem;margin-top:4px">${gwBadge}</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Run Eval</h3>
|
||||
<div class="db-card" style="border-radius:12px;padding:14px">
|
||||
<select id="ev-run-agent" style="width:100%;padding:8px;margin-bottom:8px;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px">
|
||||
<option value="">Agent…</option>${agentOptions}
|
||||
</select>
|
||||
<select id="ev-run-judge" style="width:100%;padding:8px;margin-bottom:8px;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px">
|
||||
<option value="">Judge…</option>${judgeOptions}
|
||||
</select>
|
||||
<textarea id="ev-run-input" placeholder="Input prompt" rows="2" style="width:100%;padding:8px;margin-bottom:8px;box-sizing:border-box;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px"></textarea>
|
||||
<textarea id="ev-run-output" placeholder="Agent output" rows="2" style="width:100%;padding:8px;margin-bottom:8px;box-sizing:border-box;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px"></textarea>
|
||||
<textarea id="ev-run-expected" placeholder="Expected answer" rows="2" style="width:100%;padding:8px;margin-bottom:8px;box-sizing:border-box;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px"></textarea>
|
||||
<div id="ev-run-error" style="display:none;color:#ef4444;font-size:.8rem;margin-bottom:8px"></div>
|
||||
<button id="ev-run-submit" ${runDisabled ? 'disabled' : ''} style="width:100%;padding:9px;background:${runDisabled ? 'rgba(168,85,247,.3)' : 'var(--db-accent,#a855f7)'};color:#fff;border:none;border-radius:8px;cursor:${runDisabled ? 'not-allowed' : 'pointer'};font-weight:600">Run eval</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Agent Scores</h3>
|
||||
<div class="db-card" style="border-radius:12px;overflow:hidden;margin-bottom:24px">
|
||||
${scores.length === 0
|
||||
? '<div style="padding:24px;text-align:center;opacity:.5">No evaluation scores yet. Run evaluations via API or gateway hooks.</div>'
|
||||
: `<table style="width:100%;border-collapse:collapse">
|
||||
<thead><tr style="border-bottom:1px solid rgba(128,128,128,.2)">
|
||||
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Agent</th>
|
||||
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Total</th>
|
||||
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Avg Score</th>
|
||||
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Passed</th>
|
||||
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Failed</th>
|
||||
</tr></thead>
|
||||
<tbody>${scores.map(s => `
|
||||
<tr style="border-bottom:1px solid rgba(128,128,128,.1)">
|
||||
<td style="padding:10px 14px;font-size:.85rem">${s.agent_id}</td>
|
||||
<td style="padding:10px 14px;text-align:center">${s.total}</td>
|
||||
<td style="padding:10px 14px;text-align:center;font-weight:600">${s.avg_score !== null ? s.avg_score.toFixed(2) : '—'}</td>
|
||||
<td style="padding:10px 14px;text-align:center;color:#22c55e">${s.passed}</td>
|
||||
<td style="padding:10px 14px;text-align:center;color:#ef4444">${s.failed}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
}
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Recent Evaluations</h3>
|
||||
<div class="db-card" style="border-radius:12px;overflow:hidden;margin-bottom:24px">
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Run History${evaluations.length ? ` (${evaluations.length})` : ''}</h3>
|
||||
<div class="db-card" style="border-radius:12px;overflow:hidden;overflow-x:auto">
|
||||
${evaluations.length === 0
|
||||
? '<div style="padding:24px;text-align:center;opacity:.5">No evaluations recorded yet.</div>'
|
||||
: `<table style="width:100%;border-collapse:collapse">
|
||||
: `<table style="width:100%;border-collapse:collapse;min-width:640px">
|
||||
<thead><tr style="border-bottom:1px solid rgba(128,128,128,.2)">
|
||||
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Time</th>
|
||||
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Agent</th>
|
||||
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Evaluator</th>
|
||||
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Score</th>
|
||||
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Pass</th>
|
||||
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Feedback</th>
|
||||
<th style="padding:8px 10px"></th>
|
||||
<th style="padding:8px 10px;text-align:left;font-size:.75rem;opacity:.6">Time</th>
|
||||
<th style="padding:8px 10px;text-align:left;font-size:.75rem;opacity:.6">Agent</th>
|
||||
<th style="padding:8px 10px;text-align:left;font-size:.75rem;opacity:.6">Evaluator</th>
|
||||
<th style="padding:8px 10px;text-align:center;font-size:.75rem;opacity:.6">Score</th>
|
||||
<th style="padding:8px 10px;text-align:center;font-size:.75rem;opacity:.6">Pass</th>
|
||||
<th style="padding:8px 10px;text-align:right;font-size:.75rem;opacity:.6">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>${evaluations.map(e => `
|
||||
<tr style="border-bottom:1px solid rgba(128,128,128,.1)">
|
||||
<td style="padding:10px 14px;font-size:.75rem;opacity:.6">${new Date(e.timestamp*1000).toLocaleTimeString()}</td>
|
||||
<td style="padding:10px 14px;font-size:.85rem">${e.agent_id}</td>
|
||||
<td style="padding:10px 14px;font-size:.8rem;font-family:monospace">${e.evaluator}</td>
|
||||
<td style="padding:10px 14px;text-align:center;font-weight:600">${e.score !== null ? e.score : '—'}</td>
|
||||
<td style="padding:10px 14px;text-align:center">${e.passed === true ? '✅' : e.passed === false ? '❌' : '—'}</td>
|
||||
<td style="padding:10px 14px;font-size:.8rem;max-width:200px;overflow:hidden;text-overflow:ellipsis">${e.feedback || ''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`
|
||||
<tbody>${historyRows}</tbody>
|
||||
</table>`
|
||||
}
|
||||
</div>
|
||||
|
||||
${judges.length > 0 ? `
|
||||
<h3 style="margin:0 0 12px;font-size:1.1rem">Judges</h3>
|
||||
<div class="db-card" style="border-radius:12px;padding:16px">
|
||||
${judges.map(j => `<span style="display:inline-block;padding:4px 12px;margin:4px;border-radius:8px;font-size:.8rem;background:rgba(168,85,247,.1)">${j.name} (${j.type})</span>`).join('')}
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
wire(container, ordered, scores);
|
||||
}
|
||||
|
||||
function wire(container, ordered, scores) {
|
||||
container.querySelectorAll('.ev-filter-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const agent = btn.dataset.agent;
|
||||
const target = container.querySelector(`.ev-history-row td:nth-child(3)`);
|
||||
const first = [...container.querySelectorAll('.ev-history-row')].find(
|
||||
(r) => r.children[2]?.textContent?.trim() === agent);
|
||||
(first || target)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.ev-history-row').forEach((row) => {
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const idx = row.dataset.idx;
|
||||
const detail = container.querySelector(`.ev-detail-row[data-detail="${idx}"]`);
|
||||
if (detail) {
|
||||
const open = detail.style.display !== 'none';
|
||||
detail.style.display = open ? 'none' : 'table-row';
|
||||
row.children[0].textContent = open ? '▸' : '▾';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.ev-debug-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const session = btn.dataset.session;
|
||||
const hasRuns = window.aiui?.selectPage
|
||||
&& document.querySelector('[data-page="runs"], [data-nav="runs"]');
|
||||
try {
|
||||
history.pushState({}, '', `/runs?session=${encodeURIComponent(session)}`);
|
||||
} catch (err) { /* ignore */ }
|
||||
navigate(hasRuns ? 'runs' : 'traces');
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.ev-pin-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const ev = ordered[Number(btn.dataset.idx)];
|
||||
if (!ev) return;
|
||||
const agents = {};
|
||||
for (const s of scores) if (s.avg_score != null) agents[s.agent_id] = s.avg_score;
|
||||
saveBaseline({ id: ev.id, timestamp: ev.timestamp, agents });
|
||||
showToast(`Baseline set to ${ev.id}`, 'success');
|
||||
render(container);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelector('#ev-clear-baseline')?.addEventListener('click', () => {
|
||||
clearBaseline();
|
||||
showToast('Baseline cleared', 'info');
|
||||
render(container);
|
||||
});
|
||||
|
||||
const submit = container.querySelector('#ev-run-submit');
|
||||
submit?.addEventListener('click', async () => {
|
||||
if (submit.disabled) return;
|
||||
const errBox = container.querySelector('#ev-run-error');
|
||||
const agent = container.querySelector('#ev-run-agent')?.value || '';
|
||||
const evaluator = container.querySelector('#ev-run-judge')?.value || '';
|
||||
const input = container.querySelector('#ev-run-input')?.value?.trim() || '';
|
||||
const output = container.querySelector('#ev-run-output')?.value?.trim() || '';
|
||||
const expected = container.querySelector('#ev-run-expected')?.value?.trim() || '';
|
||||
if (!input) {
|
||||
if (errBox) { errBox.textContent = 'Input is required.'; errBox.style.display = 'block'; }
|
||||
return;
|
||||
}
|
||||
if (errBox) errBox.style.display = 'none';
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Running…';
|
||||
try {
|
||||
const res = await fetch('/api/eval/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent_id: agent || 'ui', evaluator, input, output, expected }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
showToast('Evaluation complete', 'success');
|
||||
render(container);
|
||||
} catch (err) {
|
||||
submit.disabled = false;
|
||||
submit.textContent = 'Run eval';
|
||||
if (errBox) { errBox.textContent = 'Run failed: ' + err.message; errBox.style.display = 'block'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Channel Inbox (/inbox) — unified multi-channel conversation triage (STITCH-010).
|
||||
*
|
||||
* Track B (session-filter fallback): derives threads from channel-backed
|
||||
* sessions + chat history, groups messages by sender, and persists status +
|
||||
* assignee via /api/sessions/{id}/state. Replies use the same chat transport
|
||||
* as chat.js (/api/chat/send). Deep-links to /chat and /work.
|
||||
*
|
||||
* Three panes: Queue (list + filters) · Conversation (history + composer) ·
|
||||
* Context rail (customer meta + assign agent + status + actions).
|
||||
*/
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
const STYLE_ID = 'channel-inbox-styles';
|
||||
|
||||
const PLATFORM_ICONS = {
|
||||
discord: '🎮', slack: '💬', telegram: '✈️', whatsapp: '📱',
|
||||
imessage: '🍎', signal: '🔒', googlechat: '💼', nostr: '🟣',
|
||||
};
|
||||
|
||||
const STATUS_META = {
|
||||
open: { label: 'Open', color: '#6366f1', priority: 2 },
|
||||
waiting_on_agent: { label: 'Waiting on agent', color: '#f59e0b', priority: 1 },
|
||||
waiting_on_customer: { label: 'Waiting on customer', color: '#a1a1aa', priority: 3 },
|
||||
resolved: { label: 'Resolved', color: '#22c55e', priority: 4 },
|
||||
};
|
||||
|
||||
const STATUS_ORDER = ['open', 'waiting_on_agent', 'waiting_on_customer', 'resolved'];
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function platformIcon(p) {
|
||||
return PLATFORM_ICONS[String(p || '').toLowerCase()] || '📡';
|
||||
}
|
||||
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return '';
|
||||
const t = typeof ts === 'number' ? ts * (ts < 1e12 ? 1000 : 1) : Date.parse(ts);
|
||||
if (!t || isNaN(t)) return '';
|
||||
const diff = Math.floor((Date.now() - t) / 1000);
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
function statusMeta(status) {
|
||||
return STATUS_META[status] || STATUS_META.open;
|
||||
}
|
||||
|
||||
// ── Pure thread-building helpers (unit-tested via source assertions) ──
|
||||
|
||||
export function senderKey(channelId, sender) {
|
||||
const s = (sender == null || sender === '') ? 'general' : String(sender);
|
||||
return `${channelId || 'unknown'}:${s}`;
|
||||
}
|
||||
|
||||
export function groupMessagesBySender(sessionId, channelId, messages) {
|
||||
const groups = new Map();
|
||||
(messages || []).forEach((m) => {
|
||||
const sender = m.sender || m.sender_id || m.user || '';
|
||||
const key = senderKey(channelId, sender);
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { key, sessionId, channelId, sender, messages: [] });
|
||||
}
|
||||
groups.get(key).messages.push(m);
|
||||
});
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
export function buildThreadRow(group, platform, stateThreads) {
|
||||
const msgs = group.messages || [];
|
||||
const last = msgs[msgs.length - 1] || {};
|
||||
const persisted = (stateThreads && stateThreads[group.key]) || {};
|
||||
const preview = String(last.content || last.text || '').slice(0, 120);
|
||||
const senderDisplay = last.sender_display || group.sender || 'General';
|
||||
const updatedAt = last.timestamp || last.created_at || last.ts || 0;
|
||||
return {
|
||||
threadId: group.key,
|
||||
sessionId: group.sessionId,
|
||||
channelId: group.channelId,
|
||||
platform: platform || last.platform || '',
|
||||
sender: group.sender || '',
|
||||
senderDisplay,
|
||||
preview,
|
||||
status: persisted.status || 'open',
|
||||
assignedAgent: persisted.assigned_agent || '',
|
||||
updatedAt,
|
||||
messageCount: msgs.length,
|
||||
messages: msgs,
|
||||
};
|
||||
}
|
||||
|
||||
export function sortThreads(rows) {
|
||||
return rows.slice().sort((a, b) => {
|
||||
const pa = statusMeta(a.status).priority;
|
||||
const pb = statusMeta(b.status).priority;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.inbox { display:flex; height:100%; min-height:0; gap:1px; background:var(--db-border,#3f3f46); }
|
||||
.inbox-pane { background:var(--db-bg,#18181b); overflow:auto; min-height:0; display:flex; flex-direction:column; }
|
||||
.inbox-queue { width:300px; min-width:240px; flex:0 0 auto; }
|
||||
.inbox-conv { flex:1 1 auto; min-width:0; }
|
||||
.inbox-rail { width:300px; min-width:260px; flex:0 0 auto; }
|
||||
.inbox-head { padding:12px 14px; border-bottom:1px solid var(--db-border,#3f3f46);
|
||||
font-size:14px; font-weight:600; position:sticky; top:0; background:var(--db-bg,#18181b); z-index:2; }
|
||||
.inbox-filters { padding:10px 12px; border-bottom:1px solid var(--db-border,#3f3f46);
|
||||
display:flex; flex-direction:column; gap:8px; }
|
||||
.inbox-search { width:100%; box-sizing:border-box; padding:6px 10px; border-radius:8px;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text,#e4e4e7); font-size:13px; }
|
||||
.inbox-chips { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.inbox-chip { padding:4px 10px; font-size:12px; border-radius:999px; cursor:pointer;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text-dim,#a1a1aa); }
|
||||
.inbox-chip.active { color:#fff; background:var(--db-accent,#6366f1); border-color:var(--db-accent,#6366f1); }
|
||||
.inbox-list { flex:1 1 auto; overflow:auto; }
|
||||
.inbox-row { display:flex; gap:10px; padding:10px 12px; cursor:pointer; border-bottom:1px solid var(--db-border,#3f3f46); }
|
||||
.inbox-row:hover { background:var(--db-card-bg,#27272a); }
|
||||
.inbox-row.active { background:var(--db-card-bg,#27272a); border-left:3px solid var(--db-accent,#6366f1); }
|
||||
.inbox-row-icon { font-size:18px; flex:0 0 auto; }
|
||||
.inbox-row-body { flex:1 1 auto; min-width:0; }
|
||||
.inbox-row-top { display:flex; justify-content:space-between; gap:6px; align-items:center; }
|
||||
.inbox-row-sender { font-weight:600; font-size:13px; color:var(--db-text,#e4e4e7); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.inbox-row-time { font-size:11px; color:var(--db-text-dim,#a1a1aa); flex:0 0 auto; }
|
||||
.inbox-row-preview { font-size:12px; color:var(--db-text-dim,#a1a1aa); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.inbox-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; margin-top:5px; }
|
||||
.inbox-empty { padding:32px 20px; text-align:center; color:var(--db-text-dim,#a1a1aa); font-size:13px; }
|
||||
.inbox-empty a { color:var(--db-accent,#6366f1); }
|
||||
.inbox-msgs { flex:1 1 auto; overflow:auto; padding:14px; display:flex; flex-direction:column; gap:8px; }
|
||||
.inbox-msg { max-width:80%; padding:8px 12px; border-radius:10px; font-size:13px; line-height:1.5; }
|
||||
.inbox-msg-in { align-self:flex-start; background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); }
|
||||
.inbox-msg-out { align-self:flex-end; background:var(--db-accent,#6366f1); color:#fff; }
|
||||
.inbox-composer { display:flex; gap:8px; padding:12px; border-top:1px solid var(--db-border,#3f3f46); }
|
||||
.inbox-composer textarea { flex:1; resize:none; min-height:40px; max-height:96px; padding:8px 10px;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); border-radius:8px; color:var(--db-text,#e4e4e7); font-size:13px; box-sizing:border-box; }
|
||||
.inbox-btn { padding:8px 16px; background:var(--db-accent,#6366f1); color:#fff; border:none; border-radius:8px; cursor:pointer; font-weight:600; font-size:13px; }
|
||||
.inbox-btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.inbox-rail-sec { padding:14px; border-bottom:1px solid var(--db-border,#3f3f46); }
|
||||
.inbox-rail-label { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--db-text-dim,#a1a1aa); margin-bottom:6px; }
|
||||
.inbox-select { width:100%; box-sizing:border-box; padding:6px 10px; border-radius:8px;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text,#e4e4e7); font-size:13px; }
|
||||
.inbox-pill-group { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.inbox-pill { padding:4px 10px; font-size:12px; border-radius:999px; cursor:pointer;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text-dim,#a1a1aa); }
|
||||
.inbox-pill.active { color:#fff; }
|
||||
.inbox-action { display:block; width:100%; box-sizing:border-box; text-align:center; margin-top:8px; padding:8px;
|
||||
border-radius:8px; font-size:13px; cursor:pointer; text-decoration:none;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text,#e4e4e7); }
|
||||
.inbox-banner { padding:8px 12px; background:rgba(245,158,11,.15); border:1px solid rgba(245,158,11,.35);
|
||||
color:#f59e0b; font-size:12px; border-radius:8px; margin:12px; }
|
||||
.inbox-strip { font-size:12px; color:var(--db-text-dim,#a1a1aa); font-weight:400; }
|
||||
@media (max-width:768px) {
|
||||
.inbox { flex-direction:column; }
|
||||
.inbox-queue, .inbox-conv, .inbox-rail { width:auto; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
async function fetchJSON(url, opts) {
|
||||
try {
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'inbox');
|
||||
injectStyles();
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
const [sessRes, chRes, agentsRes, defsRes] = await Promise.allSettled([
|
||||
fetchJSON('/api/sessions'),
|
||||
fetchJSON('/api/channels'),
|
||||
fetchJSON('/agents'),
|
||||
fetchJSON('/api/agents/definitions'),
|
||||
]);
|
||||
|
||||
const val = (r) => (r.status === 'fulfilled' ? r.value : null) || {};
|
||||
const sessions = (val(sessRes).sessions || val(sessRes) || []);
|
||||
let channels = val(chRes).channels || val(chRes) || [];
|
||||
if (!Array.isArray(channels)) {
|
||||
channels = Object.entries(channels).map(([id, c]) => ({ id, ...(typeof c === 'object' ? c : { platform: c }) }));
|
||||
}
|
||||
const agentList = new Set();
|
||||
const av = val(agentsRes);
|
||||
(av.agents || av || []).forEach((a) => agentList.add(typeof a === 'string' ? a : (a.name || a.id)));
|
||||
const dv = val(defsRes);
|
||||
(dv.definitions || dv.agents || dv || []).forEach((a) => agentList.add(typeof a === 'string' ? a : (a.name || a.id)));
|
||||
const agents = Array.from(agentList).filter(Boolean);
|
||||
|
||||
const channelById = {};
|
||||
channels.forEach((c) => { channelById[c.id || c.channel_id] = c; });
|
||||
|
||||
const channelSessions = sessions.filter((s) => {
|
||||
const sid = s.id || s.session_id || '';
|
||||
return String(sid).startsWith('channel-') || !!s.platform;
|
||||
});
|
||||
|
||||
const runningChannels = channels.filter((c) => c.running === true).length;
|
||||
const totalChannels = channels.length;
|
||||
|
||||
// Load state + history for each channel session, build threads.
|
||||
let threads = [];
|
||||
await Promise.allSettled(channelSessions.map(async (s) => {
|
||||
const sid = s.id || s.session_id;
|
||||
const channelId = s.channel_id || (String(sid).startsWith('channel-') ? String(sid).slice('channel-'.length) : sid);
|
||||
const [hist, state] = await Promise.all([
|
||||
fetchJSON('/api/chat/history/' + encodeURIComponent(sid)),
|
||||
fetchJSON('/api/sessions/' + encodeURIComponent(sid) + '/state'),
|
||||
]);
|
||||
const messages = (hist && (hist.messages || hist.history || hist)) || [];
|
||||
const stateThreads = (state && state.state && state.state.inbox_threads) || {};
|
||||
const groups = groupMessagesBySender(sid, channelId, Array.isArray(messages) ? messages : []);
|
||||
if (groups.length === 0) {
|
||||
threads.push(buildThreadRow(
|
||||
{ key: senderKey(channelId, ''), sessionId: sid, channelId, sender: '', messages: [] },
|
||||
s.platform, stateThreads));
|
||||
} else {
|
||||
groups.forEach((g) => threads.push(buildThreadRow(g, s.platform, stateThreads)));
|
||||
}
|
||||
}));
|
||||
|
||||
const state = {
|
||||
platform: 'all',
|
||||
status: 'open',
|
||||
search: '',
|
||||
selected: null,
|
||||
threads,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('status')) state.status = params.get('status');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="inbox">
|
||||
<div class="inbox-pane inbox-queue">
|
||||
<div class="inbox-head">Channel Inbox
|
||||
<div class="inbox-strip">${runningChannels}/${totalChannels} channels online · ${threads.length} threads</div>
|
||||
</div>
|
||||
<div class="inbox-filters">
|
||||
<input class="inbox-search" id="inbox-search" placeholder="Search conversations" />
|
||||
<div class="inbox-chips" id="inbox-platform-chips"></div>
|
||||
<div class="inbox-chips" id="inbox-status-chips"></div>
|
||||
</div>
|
||||
<div class="inbox-list" id="inbox-list"></div>
|
||||
</div>
|
||||
<div class="inbox-pane inbox-conv" id="inbox-conv"></div>
|
||||
<div class="inbox-pane inbox-rail" id="inbox-rail"></div>
|
||||
</div>`;
|
||||
|
||||
const listEl = container.querySelector('#inbox-list');
|
||||
const convEl = container.querySelector('#inbox-conv');
|
||||
const railEl = container.querySelector('#inbox-rail');
|
||||
const platEl = container.querySelector('#inbox-platform-chips');
|
||||
const statEl = container.querySelector('#inbox-status-chips');
|
||||
const searchEl = container.querySelector('#inbox-search');
|
||||
|
||||
const platformsPresent = Array.from(new Set(threads.map((t) => t.platform).filter(Boolean)));
|
||||
const platformChips = ['all', ...platformsPresent];
|
||||
platEl.innerHTML = platformChips.map((p) =>
|
||||
`<span class="inbox-chip ${p === state.platform ? 'active' : ''}" data-platform="${esc(p)}">${p === 'all' ? 'All' : platformIcon(p) + ' ' + esc(p)}</span>`
|
||||
).join('');
|
||||
|
||||
const statusTabs = ['open', 'waiting_on_agent', 'all', 'resolved'];
|
||||
statEl.innerHTML = statusTabs.map((s) =>
|
||||
`<span class="inbox-chip ${s === state.status ? 'active' : ''}" data-status="${esc(s)}">${s === 'all' ? 'All' : statusMeta(s).label}</span>`
|
||||
).join('');
|
||||
|
||||
function filtered() {
|
||||
let rows = state.threads.filter((t) => {
|
||||
if (state.platform !== 'all' && t.platform !== state.platform) return false;
|
||||
if (state.status === 'all') { /* show all */ }
|
||||
else if (state.status === 'open') { if (t.status === 'resolved') return false; }
|
||||
else if (t.status !== state.status) return false;
|
||||
if (state.search) {
|
||||
const q = state.search.toLowerCase();
|
||||
if (!(t.senderDisplay + ' ' + t.preview).toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return sortThreads(rows);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
if (channels.length === 0) {
|
||||
listEl.innerHTML = `<div class="inbox-empty">No channels configured<br><a href="/channels">Configure channels →</a></div>`;
|
||||
} else {
|
||||
listEl.innerHTML = `<div class="inbox-empty">No conversations in this view</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = rows.map((t) => {
|
||||
const active = state.selected === t.threadId ? 'active' : '';
|
||||
const sm = statusMeta(t.status);
|
||||
return `<div class="inbox-row ${active}" data-thread="${esc(t.threadId)}">
|
||||
<div class="inbox-row-icon">${platformIcon(t.platform)}</div>
|
||||
<div class="inbox-row-body">
|
||||
<div class="inbox-row-top">
|
||||
<span class="inbox-row-sender">${esc(t.senderDisplay)}</span>
|
||||
<span class="inbox-row-time">${relativeTime(t.updatedAt)}</span>
|
||||
</div>
|
||||
<div class="inbox-row-preview">${esc(t.preview) || '<em>No messages yet</em>'}</div>
|
||||
</div>
|
||||
<div class="inbox-dot" style="background:${sm.color}" title="${sm.label}"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
listEl.querySelectorAll('.inbox-row').forEach((row) => {
|
||||
row.addEventListener('click', () => selectThread(row.getAttribute('data-thread')));
|
||||
});
|
||||
}
|
||||
|
||||
function findThread(id) {
|
||||
return state.threads.find((t) => t.threadId === id) || null;
|
||||
}
|
||||
|
||||
async function persistThread(t) {
|
||||
const body = { state: { inbox_threads: { [t.threadId]: { status: t.status, assigned_agent: t.assignedAgent } } } };
|
||||
const r = await fetchJSON('/api/sessions/' + encodeURIComponent(t.sessionId) + '/state', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
return r != null;
|
||||
}
|
||||
|
||||
function renderConversation(t) {
|
||||
if (!t) {
|
||||
convEl.innerHTML = `<div class="inbox-empty">Select a conversation to view messages</div>`;
|
||||
return;
|
||||
}
|
||||
const ch = channelById[t.channelId] || {};
|
||||
const offline = ch.running === false;
|
||||
const sm = statusMeta(t.status);
|
||||
convEl.innerHTML = `
|
||||
<div class="inbox-head">${platformIcon(t.platform)} ${esc(t.senderDisplay)}
|
||||
<span class="inbox-strip">${esc(t.platform || 'channel')} · <span style="color:${sm.color}">${sm.label}</span></span>
|
||||
</div>
|
||||
${offline ? `<div class="inbox-banner">This channel is offline. Replies are disabled until it reconnects.</div>` : ''}
|
||||
<div class="inbox-msgs" id="inbox-msgs"></div>
|
||||
<div class="inbox-composer">
|
||||
<textarea id="inbox-reply" placeholder="Reply${t.assignedAgent ? ' as ' + esc(t.assignedAgent) : ''}…" ${offline ? 'disabled' : ''}></textarea>
|
||||
<button class="inbox-btn" id="inbox-send" ${offline ? 'disabled' : ''}>Send</button>
|
||||
</div>`;
|
||||
const msgsEl = convEl.querySelector('#inbox-msgs');
|
||||
if (!t.messages || t.messages.length === 0) {
|
||||
msgsEl.innerHTML = `<div class="inbox-empty">No messages yet</div>`;
|
||||
} else {
|
||||
msgsEl.innerHTML = t.messages.map((m) => {
|
||||
const role = String(m.role || '').toLowerCase();
|
||||
const out = role === 'assistant' || role === 'agent' || m.direction === 'outbound';
|
||||
return `<div class="inbox-msg ${out ? 'inbox-msg-out' : 'inbox-msg-in'}">${esc(m.content || m.text || '')}</div>`;
|
||||
}).join('');
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
const sendBtn = convEl.querySelector('#inbox-send');
|
||||
const replyEl = convEl.querySelector('#inbox-reply');
|
||||
if (sendBtn) sendBtn.addEventListener('click', () => sendReply(t, replyEl, msgsEl, sendBtn));
|
||||
}
|
||||
|
||||
async function sendReply(t, replyEl, msgsEl, sendBtn) {
|
||||
const content = (replyEl.value || '').trim();
|
||||
if (!content) return;
|
||||
sendBtn.disabled = true;
|
||||
const data = await fetchJSON('/api/chat/send', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, session_id: t.sessionId, agent_name: t.assignedAgent || undefined }),
|
||||
});
|
||||
sendBtn.disabled = false;
|
||||
if (data == null || data.error) {
|
||||
showToast(data && data.error ? data.error : 'Failed to send reply', 'error');
|
||||
return;
|
||||
}
|
||||
const msg = { role: 'agent', content, direction: 'outbound' };
|
||||
t.messages.push(msg);
|
||||
t.preview = content.slice(0, 120);
|
||||
t.updatedAt = Date.now() / 1000;
|
||||
t.status = 'waiting_on_customer';
|
||||
replyEl.value = '';
|
||||
persistThread(t);
|
||||
renderConversation(t);
|
||||
renderRail(t);
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderRail(t) {
|
||||
if (!t) {
|
||||
railEl.innerHTML = `<div class="inbox-empty">Details</div>`;
|
||||
return;
|
||||
}
|
||||
const ch = channelById[t.channelId] || {};
|
||||
const chStatus = ch.running === true ? 'Connected' : ch.start_error ? 'Error' : 'Stopped';
|
||||
const opts = ['', ...agents].map((a) =>
|
||||
`<option value="${esc(a)}" ${a === t.assignedAgent ? 'selected' : ''}>${a ? esc(a) : 'Unassigned'}</option>`
|
||||
).join('');
|
||||
const pills = STATUS_ORDER.map((s) => {
|
||||
const sm = STATUS_META[s];
|
||||
const active = s === t.status ? 'active' : '';
|
||||
const style = s === t.status ? `background:${sm.color};border-color:${sm.color}` : '';
|
||||
return `<span class="inbox-pill ${active}" data-status="${s}" style="${style}">${sm.label}</span>`;
|
||||
}).join('');
|
||||
railEl.innerHTML = `
|
||||
<div class="inbox-head">Details</div>
|
||||
<div class="inbox-rail-sec">
|
||||
<div class="inbox-rail-label">Customer</div>
|
||||
<div style="font-size:13px">${esc(t.senderDisplay)}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(t.platform || 'channel')} · ${esc(t.sender || 'general')}</div>
|
||||
</div>
|
||||
<div class="inbox-rail-sec">
|
||||
<div class="inbox-rail-label">Assigned agent</div>
|
||||
<select class="inbox-select" id="inbox-assign">${opts}</select>
|
||||
</div>
|
||||
<div class="inbox-rail-sec">
|
||||
<div class="inbox-rail-label">Status</div>
|
||||
<div class="inbox-pill-group" id="inbox-status-pills">${pills}</div>
|
||||
</div>
|
||||
<div class="inbox-rail-sec">
|
||||
<div class="inbox-rail-label">Actions</div>
|
||||
<a class="inbox-action" href="/chat?session=${encodeURIComponent(t.sessionId)}">Open in Chat</a>
|
||||
<a class="inbox-action" href="/work?card=${encodeURIComponent(t.sessionId)}">Escalate to Work Hub</a>
|
||||
<button class="inbox-action" id="inbox-resolve">Mark resolved</button>
|
||||
</div>
|
||||
<div class="inbox-rail-sec">
|
||||
<div class="inbox-rail-label">Channel</div>
|
||||
<div style="font-size:12px">${platformIcon(t.platform)} ${esc(t.channelId)} · ${esc(chStatus)}</div>
|
||||
</div>`;
|
||||
const assignEl = railEl.querySelector('#inbox-assign');
|
||||
assignEl.addEventListener('change', async () => {
|
||||
t.assignedAgent = assignEl.value;
|
||||
const ok = await persistThread(t);
|
||||
if (ok) showToast(t.assignedAgent ? `Assigned to ${t.assignedAgent}` : 'Unassigned', 'success');
|
||||
renderList();
|
||||
renderConversation(t);
|
||||
});
|
||||
railEl.querySelectorAll('#inbox-status-pills .inbox-pill').forEach((pill) => {
|
||||
pill.addEventListener('click', async () => {
|
||||
t.status = pill.getAttribute('data-status');
|
||||
await persistThread(t);
|
||||
renderRail(t);
|
||||
renderConversation(t);
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
const resolveBtn = railEl.querySelector('#inbox-resolve');
|
||||
resolveBtn.addEventListener('click', async () => {
|
||||
t.status = 'resolved';
|
||||
await persistThread(t);
|
||||
renderRail(t);
|
||||
renderList();
|
||||
showToast('Thread resolved', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function selectThread(id) {
|
||||
const t = findThread(id);
|
||||
state.selected = id;
|
||||
renderList();
|
||||
renderConversation(t);
|
||||
renderRail(t);
|
||||
}
|
||||
|
||||
platEl.querySelectorAll('.inbox-chip').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
state.platform = chip.getAttribute('data-platform');
|
||||
platEl.querySelectorAll('.inbox-chip').forEach((c) => c.classList.toggle('active', c === chip));
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
statEl.querySelectorAll('.inbox-chip').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
state.status = chip.getAttribute('data-status');
|
||||
statEl.querySelectorAll('.inbox-chip').forEach((c) => c.classList.toggle('active', c === chip));
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
searchEl.addEventListener('input', () => { state.search = searchEl.value; renderList(); });
|
||||
|
||||
renderList();
|
||||
renderConversation(null);
|
||||
renderRail(null);
|
||||
|
||||
const first = filtered()[0];
|
||||
if (first) selectThread(first.threadId);
|
||||
}
|
||||
@@ -10,7 +10,11 @@ export async function render(container) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'aiui-board-page-header';
|
||||
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>';
|
||||
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>'
|
||||
+ '<div style="margin:0 0 12px;padding:8px 12px;border:1px solid rgba(99,102,241,.3);'
|
||||
+ 'border-radius:8px;background:rgba(99,102,241,.06);font-size:13px">'
|
||||
+ 'Try the new <a href="/work" style="color:var(--db-accent,#6366f1);font-weight:600">Work Hub</a>'
|
||||
+ ' — kanban, card detail, chat and job logs on one screen.</div>';
|
||||
container.appendChild(header);
|
||||
|
||||
const root = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Marketplace View — browse, inspect, install, and uninstall plugins.
|
||||
* API: /api/marketplace/plugins, /api/marketplace/search,
|
||||
* /api/marketplace/install, /api/marketplace/uninstall,
|
||||
* /api/marketplace/plugins/{id}
|
||||
*/
|
||||
import { helpBanner, pageToolbar, filterChips, searchInput } from './_helpers.js';
|
||||
import { showToast, showConfirm } from '../toast.js';
|
||||
|
||||
const CATEGORY_ICONS = { tools: '🔧', memory: '🧠', all: '📦' };
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function catIcon(cat) {
|
||||
return CATEGORY_ICONS[cat] || '🧩';
|
||||
}
|
||||
|
||||
function permissionsFor(plugin) {
|
||||
if (Array.isArray(plugin.permissions) && plugin.permissions.length) return plugin.permissions;
|
||||
const map = {
|
||||
web_search: ['network:outbound'],
|
||||
code_executor: ['process:spawn', 'filesystem:temp'],
|
||||
file_manager: ['filesystem:read', 'filesystem:write'],
|
||||
memory_plugin: ['filesystem:read', 'filesystem:write', 'network:outbound'],
|
||||
};
|
||||
return map[plugin.id] || [];
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let plugins = [];
|
||||
try {
|
||||
const r = await fetch('/api/marketplace/plugins');
|
||||
const d = await r.json();
|
||||
plugins = d.plugins || [];
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="db-viewer"><pre>Failed to load marketplace: ' + e.message + '</pre></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const state = { query: '', category: 'all', tab: 'browse' };
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category).filter(Boolean)))];
|
||||
|
||||
function dedupe(list) {
|
||||
const seen = new Map();
|
||||
list.forEach((p) => { seen.set(p.id, { ...seen.get(p.id), ...p }); });
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const all = dedupe(plugins);
|
||||
return all.filter((p) => {
|
||||
if (state.tab === 'installed' && !p.installed) return false;
|
||||
if (state.category !== 'all' && p.category !== state.category) return false;
|
||||
if (state.query) {
|
||||
const q = state.query.toLowerCase();
|
||||
return (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function cardHtml(p) {
|
||||
const installed = !!p.installed;
|
||||
const id = esc(p.id);
|
||||
return `
|
||||
<div class="db-card" data-plugin="${id}" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;cursor:pointer">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font-size:22px">${catIcon(p.category)}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:14px;font-weight:600">${esc(p.name || p.id)}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(p.category || 'plugin')} · v${esc(p.version || '1.0.0')}</div>
|
||||
</div>
|
||||
${installed ? '<span style="font-size:10px;font-weight:600;color:#22c55e;padding:2px 8px;border-radius:6px;background:rgba(34,197,94,.12)">Installed</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);line-height:1.5;min-height:34px">${esc(p.description || '')}</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button type="button" class="mkt-open" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:7px;cursor:pointer;font-size:12px">Details</button>
|
||||
${installed
|
||||
? `<button type="button" class="mkt-uninstall" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.08);color:#f87171;border-radius:7px;cursor:pointer;font-size:12px">Uninstall</button>`
|
||||
: `<button type="button" class="mkt-install" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid var(--db-accent);background:var(--db-accent);color:#fff;border-radius:7px;cursor:pointer;font-size:12px">Install</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const list = filtered();
|
||||
const installedCount = dedupe(plugins).filter((p) => p.installed).length;
|
||||
container.innerHTML = `
|
||||
${helpBanner({
|
||||
title: 'Marketplace',
|
||||
what: 'Browse agent plugins, review the permissions they request, then install or uninstall them.',
|
||||
howToUse: 'Search or filter by category, click <strong>Details</strong> to inspect permissions, then <strong>Install</strong>. Switch to the <strong>Installed</strong> tab to manage your active plugins.',
|
||||
tip: 'The <strong>Code Executor</strong> plugin pairs with Code Studio for sandboxed execution.',
|
||||
collapsed: true,
|
||||
})}
|
||||
${pageToolbar('Plugin Marketplace', `
|
||||
<button type="button" class="db-filter-chip mkt-tab" data-tab="browse" style="padding:5px 14px;border:1px solid var(--db-border);background:${state.tab === 'browse' ? 'var(--db-accent)' : 'transparent'};color:${state.tab === 'browse' ? '#fff' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Browse</button>
|
||||
<button type="button" class="db-filter-chip mkt-tab" data-tab="installed" style="padding:5px 14px;border:1px solid var(--db-border);background:${state.tab === 'installed' ? 'var(--db-accent)' : 'transparent'};color:${state.tab === 'installed' ? '#fff' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Installed (${installedCount})</button>
|
||||
`)}
|
||||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px">
|
||||
${searchInput('Search plugins…', 'mkt-search')}
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">${filterChips(categories, state.category, 'data-cat')}</div>
|
||||
</div>
|
||||
<div id="mkt-grid" class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">
|
||||
${list.map(cardHtml).join('') || '<div class="db-viewer"><pre>No plugins match your filters</pre></div>'}
|
||||
</div>
|
||||
<div id="mkt-drawer"></div>`;
|
||||
|
||||
wire();
|
||||
}
|
||||
|
||||
function drawerHtml(p) {
|
||||
const perms = permissionsFor(p);
|
||||
const installed = !!p.installed;
|
||||
const id = esc(p.id);
|
||||
return `
|
||||
<div class="mkt-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:120;display:flex;justify-content:flex-end">
|
||||
<div style="width:min(480px,100%);height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);overflow-y:auto;padding:24px;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:16px">
|
||||
<span style="font-size:32px">${catIcon(p.category)}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:18px;font-weight:700">${esc(p.name || p.id)}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim)">${esc(p.category || 'plugin')} · v${esc(p.version || '1.0.0')}</div>
|
||||
</div>
|
||||
<button type="button" class="mkt-close" style="background:transparent;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer;line-height:1">×</button>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.6;color:var(--db-text-dim);margin-bottom:20px">${esc(p.description || 'No description provided.')}</div>
|
||||
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--db-text-dim);margin-bottom:8px">Permissions</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:24px">
|
||||
${perms.length
|
||||
? perms.map((perm) => `<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--db-border);border-radius:8px;font-size:12px"><span>🔐</span><code style="font-size:12px">${esc(perm)}</code></div>`).join('')
|
||||
: '<div style="font-size:12px;color:var(--db-text-dim)">No special permissions requested.</div>'}
|
||||
</div>
|
||||
${installed
|
||||
? `<button type="button" class="mkt-uninstall" data-plugin="${id}" style="width:100%;padding:11px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.08);color:#f87171;border-radius:9px;cursor:pointer;font-size:14px;font-weight:600">Uninstall</button>`
|
||||
: `<button type="button" class="mkt-install" data-plugin="${id}" style="width:100%;padding:11px;border:1px solid var(--db-accent);background:var(--db-accent);color:#fff;border-radius:9px;cursor:pointer;font-size:14px;font-weight:600">Install ${perms.length ? '(' + perms.length + ' permissions)' : ''}</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openDrawer(id) {
|
||||
const p = dedupe(plugins).find((x) => x.id === id);
|
||||
if (!p) return;
|
||||
const drawer = container.querySelector('#mkt-drawer');
|
||||
drawer.innerHTML = drawerHtml(p);
|
||||
drawer.querySelector('.mkt-close').addEventListener('click', () => { drawer.innerHTML = ''; });
|
||||
drawer.querySelector('.mkt-drawer-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('mkt-drawer-overlay')) drawer.innerHTML = '';
|
||||
});
|
||||
wireActions(drawer);
|
||||
}
|
||||
|
||||
async function install(id) {
|
||||
const p = dedupe(plugins).find((x) => x.id === id);
|
||||
const perms = p ? permissionsFor(p) : [];
|
||||
const ok = await showConfirm(
|
||||
`Install ${p ? p.name || id : id}?`,
|
||||
perms.length ? 'This plugin requests: ' + perms.join(', ') : 'This plugin requests no special permissions.'
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await fetch('/api/marketplace/install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: id }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || d.status !== 'installed') throw new Error(d.error || `HTTP ${r.status}`);
|
||||
plugins = plugins.map((x) => (x.id === id ? { ...x, installed: true } : x));
|
||||
showToast(`Installed ${id}`, 'success');
|
||||
paint();
|
||||
} catch (e) {
|
||||
showToast('Install failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstall(id) {
|
||||
const ok = await showConfirm(`Uninstall ${id}?`, 'This will remove the plugin from your installed set.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await fetch('/api/marketplace/uninstall', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: id }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || d.status !== 'uninstalled') throw new Error(d.error || `HTTP ${r.status}`);
|
||||
plugins = plugins.map((x) => (x.id === id ? { ...x, installed: false } : x));
|
||||
showToast(`Uninstalled ${id}`, 'success');
|
||||
paint();
|
||||
} catch (e) {
|
||||
showToast('Uninstall failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function wireActions(root) {
|
||||
root.querySelectorAll('.mkt-install').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); install(b.dataset.plugin); })
|
||||
);
|
||||
root.querySelectorAll('.mkt-uninstall').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); uninstall(b.dataset.plugin); })
|
||||
);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const search = container.querySelector('#mkt-search');
|
||||
if (search) {
|
||||
search.value = state.query;
|
||||
search.addEventListener('input', () => {
|
||||
state.query = search.value;
|
||||
const grid = container.querySelector('#mkt-grid');
|
||||
grid.innerHTML = filtered().map(cardHtml).join('') || '<div class="db-viewer"><pre>No plugins match your filters</pre></div>';
|
||||
wireActions(grid);
|
||||
grid.querySelectorAll('.mkt-open').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(b.dataset.plugin); })
|
||||
);
|
||||
});
|
||||
}
|
||||
container.querySelectorAll('.mkt-tab').forEach((b) =>
|
||||
b.addEventListener('click', () => { state.tab = b.dataset.tab; paint(); })
|
||||
);
|
||||
container.querySelectorAll('.db-filter-chip[data-cat]').forEach((b) =>
|
||||
b.addEventListener('click', () => { state.category = b.dataset.cat; paint(); })
|
||||
);
|
||||
container.querySelectorAll('.mkt-open').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(b.dataset.plugin); })
|
||||
);
|
||||
container.querySelectorAll('.db-card[data-plugin]').forEach((c) =>
|
||||
c.addEventListener('click', () => openDrawer(c.dataset.plugin))
|
||||
);
|
||||
wireActions(container);
|
||||
}
|
||||
|
||||
paint();
|
||||
}
|
||||
@@ -1,59 +1,481 @@
|
||||
/**
|
||||
* Overview View — system stats, uptime, feature health
|
||||
* Renders into [data-page="overview"]
|
||||
* Overview View — Agent Command Center (STITCH-001).
|
||||
*
|
||||
* Mission-control home: live metric cards, agent table, needs-attention feed,
|
||||
* activity timeline, and an omnibar that opens the existing command palette.
|
||||
*
|
||||
* Data sources (parallel, fault-tolerant via Promise.allSettled):
|
||||
* /api/agents, /api/sessions, /api/approvals/pending, /api/usage/summary,
|
||||
* /api/usage/timeseries, /api/channels, /api/gateway/status,
|
||||
* /api/features, /api/traces
|
||||
*
|
||||
* Renders into [data-page="overview"].
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let features = [], startTime = Date.now();
|
||||
try {
|
||||
const res = await fetch('/api/features');
|
||||
const data = await res.json();
|
||||
features = data.features || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
import {
|
||||
metricCard, esc, timeAgo,
|
||||
sumTodayTokens, computeBudgetPct, budgetLevelFor,
|
||||
costStripHTML, budgetBannerHTML,
|
||||
} from '/plugins/views/_helpers.js';
|
||||
|
||||
const healthy = features.filter(f => f.health?.status === 'ok').length;
|
||||
const total = features.length;
|
||||
const uptimeMs = Date.now() - (window.__aiuiLoadTime || Date.now());
|
||||
const uptime = uptimeMs < 60000 ? `${Math.round(uptimeMs/1000)}s` : `${Math.round(uptimeMs/60000)}m`;
|
||||
const METRICS_POLL_MS = 30000;
|
||||
const APPROVALS_POLL_MS = 10000;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Status</div>
|
||||
<div class="db-card-value" style="color:#22c55e">● Online</div>
|
||||
<div class="db-card-footer">Uptime: ${uptime}</div>
|
||||
let _metricsTimer = null;
|
||||
let _approvalsTimer = null;
|
||||
let _visibilityHandler = null;
|
||||
let _container = null;
|
||||
|
||||
const STATUS_COLORS = { running: '#22c55e', active: '#22c55e', idle: '#71717a', error: '#ef4444' };
|
||||
|
||||
function navigate(pageId) {
|
||||
if (window.aiui?.selectPage) window.aiui.selectPage(pageId);
|
||||
}
|
||||
|
||||
function openPalette() {
|
||||
if (window.aiui?.openCommandPalette) window.aiui.openCommandPalette();
|
||||
else if (window.openCommandPalette) window.openCommandPalette();
|
||||
}
|
||||
|
||||
function chatWithAgent(agentName) {
|
||||
if (agentName) {
|
||||
const url = `/chat?agent=${encodeURIComponent(agentName)}`;
|
||||
history.pushState({ pageId: 'chat' }, '', url);
|
||||
}
|
||||
navigate('chat');
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function settled(results, idx, fallback) {
|
||||
const r = results[idx];
|
||||
return r && r.status === 'fulfilled' ? r.value : fallback;
|
||||
}
|
||||
|
||||
function toArray(data, ...keys) {
|
||||
if (Array.isArray(data)) return data;
|
||||
for (const k of keys) {
|
||||
if (data && Array.isArray(data[k])) return data[k];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function statusDot(status) {
|
||||
const s = (status || 'idle').toLowerCase();
|
||||
const color = STATUS_COLORS[s] || STATUS_COLORS.idle;
|
||||
return `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color}" title="${esc(s)}"></span>`;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson('/api/agents'),
|
||||
fetchJson('/api/sessions'),
|
||||
fetchJson('/api/approvals/pending'),
|
||||
fetchJson('/api/usage/summary'),
|
||||
fetchJson('/api/usage/timeseries?hours=24'),
|
||||
fetchJson('/api/channels'),
|
||||
fetchJson('/api/gateway/status'),
|
||||
fetchJson('/api/features'),
|
||||
fetchJson('/api/traces?limit=10'),
|
||||
fetchJson('/api/usage/models'),
|
||||
fetchJson('/api/config/runtime'),
|
||||
]);
|
||||
|
||||
const agents = toArray(settled(results, 0, {}), 'agents');
|
||||
const sessions = toArray(settled(results, 1, {}), 'sessions');
|
||||
const pending = toArray(settled(results, 2, {}), 'approvals', 'pending');
|
||||
const usage = settled(results, 3, null);
|
||||
const timeseries = settled(results, 4, null);
|
||||
const channels = toArray(settled(results, 5, {}), 'channels');
|
||||
const gateway = settled(results, 6, null);
|
||||
const features = toArray(settled(results, 7, {}), 'features');
|
||||
const traces = toArray(settled(results, 8, {}), 'traces');
|
||||
const models = toArray(settled(results, 9, {}), 'models');
|
||||
const runtime = settled(results, 10, null);
|
||||
|
||||
return {
|
||||
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces, models,
|
||||
finops: (runtime && runtime.config && runtime.config.finops) || null,
|
||||
errors: {
|
||||
agents: results[0].status === 'rejected',
|
||||
sessions: results[1].status === 'rejected',
|
||||
usage: results[3].status === 'rejected',
|
||||
timeseries: results[4].status === 'rejected',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function usageSpark(timeseries) {
|
||||
const ts = timeseries && Array.isArray(timeseries.timeseries) ? timeseries.timeseries : [];
|
||||
return ts.map((p) => Number(p.tokens) || 0);
|
||||
}
|
||||
|
||||
function todayTokens(usage) {
|
||||
if (!usage) return null;
|
||||
const s = usage.summary || usage;
|
||||
return s.today_tokens ?? s.total_tokens ?? s.tokens ?? null;
|
||||
}
|
||||
|
||||
function gatewayLabel(gateway) {
|
||||
if (!gateway) return { value: 'Standalone', accent: '#71717a' };
|
||||
if (gateway.status === 'unavailable' || gateway.connected === false) {
|
||||
if (gateway.auth_required || gateway.status === 'auth_required') {
|
||||
return { value: 'Auth required', accent: '#eab308' };
|
||||
}
|
||||
return { value: 'Standalone', accent: '#71717a' };
|
||||
}
|
||||
return { value: 'OK', accent: '#22c55e' };
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
const { agents, sessions, pending, usage, timeseries, channels, gateway, errors } = data;
|
||||
const channelsOnline = channels.filter((c) => c.running === true || c.connected === true).length;
|
||||
const tokens = todayTokens(usage);
|
||||
const gw = gatewayLabel(gateway);
|
||||
|
||||
const cards = [
|
||||
metricCard({
|
||||
title: 'Active Agents', value: agents.length, subtitle: 'registered', nav: 'agents',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Live Sessions', value: errors.sessions ? '—' : sessions.length, subtitle: 'open', nav: 'sessions',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Pending Approvals', value: pending.length, subtitle: 'awaiting action', nav: 'approvals',
|
||||
accent: pending.length > 0 ? '#eab308' : undefined,
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Tokens Today', value: tokens == null ? '—' : Number(tokens).toLocaleString(),
|
||||
subtitle: 'tokens', nav: 'usage', spark: usageSpark(timeseries),
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Channel Health', value: `${channelsOnline}/${channels.length}`, subtitle: 'channels online', nav: 'channels',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Gateway Status', value: gw.value, subtitle: gateway?.runtime_type || 'runtime', nav: 'config', accent: gw.accent,
|
||||
}),
|
||||
];
|
||||
|
||||
return `<div class="db-metric-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px">${cards.join('')}</div>`;
|
||||
}
|
||||
|
||||
function finopsState(data) {
|
||||
const finops = data.finops || {};
|
||||
// E1: usage timeseries unreachable → hide all FinOps UI (no error toast)
|
||||
if (data.errors.usage || data.errors.timeseries) return null;
|
||||
// E2: master toggle off → hide
|
||||
if (finops.enabled === false) return null;
|
||||
const ts = data.timeseries && Array.isArray(data.timeseries.timeseries) ? data.timeseries.timeseries : [];
|
||||
const today = sumTodayTokens(ts);
|
||||
const todayCost = ts.reduce((sum, p) => sum + (Number(p.cost) || 0), 0);
|
||||
const budget = finops.daily_token_budget != null ? Number(finops.daily_token_budget) : null;
|
||||
const warnPct = Number(finops.warn_pct) || 80;
|
||||
const criticalPct = Number(finops.critical_pct) || 95;
|
||||
const pct = computeBudgetPct(today, budget);
|
||||
const level = budgetLevelFor(pct, warnPct, criticalPct);
|
||||
return { today, todayCost, budget, warnPct, criticalPct, pct, level };
|
||||
}
|
||||
|
||||
function renderFinOps(data) {
|
||||
const st = finopsState(data);
|
||||
if (!st) return '';
|
||||
const bannerDismissed = sessionStorage.getItem('finops-banner-dismissed') === '1';
|
||||
const banner = (st.level !== 'none' && !bannerDismissed) ? budgetBannerHTML(st.level, st.pct) : '';
|
||||
const strip = costStripHTML({
|
||||
todayTokens: st.today, todayCost: st.todayCost, budget: st.budget,
|
||||
warnPct: st.warnPct, criticalPct: st.criticalPct, models: data.models,
|
||||
});
|
||||
return `<div id="ov-finops-banner">${banner}</div>${strip}`;
|
||||
}
|
||||
|
||||
function renderAgentTable(agents) {
|
||||
if (agents.length === 0) {
|
||||
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">🤖</div>
|
||||
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No agents registered yet</div>
|
||||
<div style="font-size:13px;margin-bottom:1rem">Configure agents to see them here.</div>
|
||||
<button data-nav="agents" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px">Configure agents</button>
|
||||
</div>`;
|
||||
}
|
||||
const rows = agents.slice(0, 20).map((a) => {
|
||||
const id = a.id || a.name || '';
|
||||
const preview = a.instructions || a.description || '';
|
||||
return `<tr data-agent-id="${esc(id)}" class="db-agent-row" style="cursor:pointer;border-top:1px solid var(--db-border)">
|
||||
<td style="padding:10px 12px">${statusDot(a.status)}</td>
|
||||
<td style="padding:10px 12px;font-weight:500">${esc(a.name || id)}</td>
|
||||
<td style="padding:10px 12px"><span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(a.model || '—')}</span></td>
|
||||
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px">${esc(timeAgo(a.last_active || a.updated_at) || (a.status || 'idle'))}</td>
|
||||
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview.slice(0, 80))}</td>
|
||||
<td style="padding:10px 12px"><button class="db-agent-chat" data-agent-id="${esc(id)}" data-agent-name="${esc(a.name || id)}" style="font-size:11px;padding:4px 12px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:6px;cursor:pointer">Chat</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
return `<div class="db-card" style="padding:0;overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="text-align:left;color:var(--db-text-dim);font-size:11px;text-transform:uppercase">
|
||||
<th style="padding:10px 12px"></th><th style="padding:10px 12px">Name</th><th style="padding:10px 12px">Model</th>
|
||||
<th style="padding:10px 12px">Last active</th><th style="padding:10px 12px">Preview</th><th style="padding:10px 12px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildAttention(data) {
|
||||
const items = [];
|
||||
data.pending.forEach((a) => {
|
||||
items.push({
|
||||
priority: 0, border: '#ef4444',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Approve: ${esc(a.tool_name || a.tool || a.action || 'request')}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim)">${esc(a.agent_name || a.agent || '')}</div></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="atn-approve" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:pointer">Approve</button>
|
||||
<button class="atn-deny" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer">Deny</button>
|
||||
</div>`,
|
||||
});
|
||||
});
|
||||
data.features.filter((f) => f.health && f.health.status !== 'ok').forEach((f) => {
|
||||
items.push({
|
||||
priority: 1, border: '#eab308',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Feature degraded: ${esc(f.name || 'unknown')}</div></div>
|
||||
<button data-nav="debug" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">View</button>`,
|
||||
});
|
||||
});
|
||||
data.channels.filter((c) => c.enabled !== false && c.running !== true && c.connected !== true).forEach((c) => {
|
||||
items.push({
|
||||
priority: 2, border: '#eab308',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Channel offline: ${esc(c.name || c.id || c.platform || 'channel')}</div></div>
|
||||
<button data-nav="channels" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Open</button>`,
|
||||
});
|
||||
});
|
||||
if (data.agents.length === 0) {
|
||||
items.push({
|
||||
priority: 4, border: 'var(--db-accent,#6366f1)',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">No agents configured</div></div>
|
||||
<button data-nav="agents" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Configure</button>`,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => a.priority - b.priority);
|
||||
return items.slice(0, 8);
|
||||
}
|
||||
|
||||
function renderAttention(data) {
|
||||
const items = buildAttention(data);
|
||||
if (items.length === 0) {
|
||||
return `<div style="padding:20px;color:var(--db-text-dim);font-size:13px;text-align:center">✓ All clear — nothing needs attention</div>`;
|
||||
}
|
||||
return items.map((it) => `<div class="db-card" style="display:flex;align-items:center;gap:10px;padding:12px 14px;margin-bottom:8px;border-left:3px solid ${it.border}">${it.html}</div>`).join('');
|
||||
}
|
||||
|
||||
function renderTimeline(traces) {
|
||||
if (traces.length === 0) {
|
||||
return `<div style="padding:16px;color:var(--db-text-dim);font-size:13px">No recent activity</div>`;
|
||||
}
|
||||
return traces.slice(0, 10).map((t) => {
|
||||
const label = t.name || t.event || t.tool || t.type || 'event';
|
||||
const agent = t.agent_name || t.agent || '';
|
||||
const when = timeAgo(t.timestamp || t.created_at || t.start_time);
|
||||
return `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px">
|
||||
<span style="color:var(--db-text-dim);font-size:11px;min-width:64px">${esc(when)}</span>
|
||||
<span style="flex:1">${esc(label)}</span>
|
||||
${agent ? `<span style="color:var(--db-text-dim);font-size:12px">${esc(agent)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const OVERVIEW_STYLE = `<style id="ov-style">
|
||||
@media (max-width: 768px) {
|
||||
.db-metric-grid { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
.db-ov-columns { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.db-sparkline polyline, .db-sparkline polygon { transition: none !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
function shell(data) {
|
||||
return `${OVERVIEW_STYLE}
|
||||
<div class="db-omnibar" data-omnibar="1" role="button" tabindex="0" style="display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:18px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:10px;cursor:pointer;color:var(--db-text-dim);max-width:520px">
|
||||
<span>🔍</span><span style="flex:1;font-size:13px">Search pages, agents, sessions…</span>
|
||||
<span style="font-size:11px;padding:2px 8px;border:1px solid var(--db-border);border-radius:6px">Ctrl+K</span>
|
||||
</div>
|
||||
|
||||
<div id="ov-metrics">${renderMetrics(data)}</div>
|
||||
|
||||
<div id="ov-finops">${renderFinOps(data)}</div>
|
||||
|
||||
<div class="db-ov-columns" style="display:grid;grid-template-columns:3fr 2fr;gap:16px;margin-top:20px">
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Active Agents</h3>
|
||||
<div id="ov-agents">${renderAgentTable(data.agents)}</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Features</div>
|
||||
<div class="db-card-value">${total}</div>
|
||||
<div class="db-card-footer">${healthy} active</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Health</div>
|
||||
<div class="db-card-value">${total > 0 ? Math.round(healthy/total*100) : 0}%</div>
|
||||
<div class="db-card-footer">${total - healthy} degraded</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">API Latency</div>
|
||||
<div class="db-card-value">${Date.now() - startTime}ms</div>
|
||||
<div class="db-card-footer">/api/features</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Needs Attention</h3>
|
||||
<div id="ov-attention">${renderAttention(data)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:28px 0 16px;font-size:15px;font-weight:600">Feature Health</h3>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">
|
||||
${features.map(f => `
|
||||
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500">${f.name || 'unknown'}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${f.description || ''}</div>
|
||||
</div>
|
||||
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
|
||||
? 'background:rgba(34,197,94,0.15);color:#22c55e'
|
||||
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<h3 style="margin:24px 0 12px;font-size:15px;font-weight:600">Recent Activity</h3>
|
||||
<div id="ov-timeline" class="db-card" style="padding:6px 16px">${renderTimeline(data.traces)}</div>
|
||||
|
||||
<details style="margin-top:24px">
|
||||
<summary style="cursor:pointer;font-size:15px;font-weight:600;margin-bottom:12px">Feature Health</summary>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:12px">
|
||||
${data.features.map((f) => `
|
||||
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div><div style="font-size:13px;font-weight:500">${esc(f.name || 'unknown')}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(f.description || '')}</div></div>
|
||||
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
|
||||
? 'background:rgba(34,197,94,0.15);color:#22c55e'
|
||||
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="ov-drawer"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openDrawer(agent) {
|
||||
const host = _container?.querySelector('#ov-drawer');
|
||||
if (!host) return;
|
||||
const id = agent.id || agent.name || '';
|
||||
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
||||
host.innerHTML = `
|
||||
<div class="ov-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:200">
|
||||
<div style="position:absolute;top:0;right:0;height:100%;width:400px;max-width:92vw;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);padding:24px;overflow-y:auto;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:18px">${esc(agent.name || id)}</h2>
|
||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(agent.model || '—')}</span>
|
||||
${statusDot(agent.status)}<span style="font-size:12px;color:var(--db-text-dim)">${esc(agent.status || 'idle')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ov-drawer-close" style="background:none;border:none;color:var(--db-text-dim);font-size:20px;cursor:pointer">×</button>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Instructions</div>
|
||||
<div style="font-size:13px;line-height:1.6;color:var(--db-text)">${esc((agent.instructions || agent.description || '—').slice(0, 400))}</div>
|
||||
</div>
|
||||
${tools.length ? `<div style="margin-top:18px">
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Tools</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">${tools.map((t) => `<span style="font-size:11px;padding:3px 9px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(typeof t === 'string' ? t : t.name || 'tool')}</span>`).join('')}</div>
|
||||
</div>` : ''}
|
||||
<div style="margin-top:24px;display:flex;gap:10px">
|
||||
<button class="ov-drawer-chat" data-agent-name="${esc(agent.name || id)}" style="flex:1;padding:9px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Chat now</button>
|
||||
<button class="ov-drawer-traces" style="padding:9px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">View traces</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => { host.innerHTML = ''; };
|
||||
host.querySelector('.ov-drawer-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
host.querySelector('.ov-drawer-close').addEventListener('click', close);
|
||||
host.querySelector('.ov-drawer-chat')?.addEventListener('click', (e) => {
|
||||
close();
|
||||
chatWithAgent(e.currentTarget.dataset.agentName);
|
||||
});
|
||||
host.querySelector('.ov-drawer-traces')?.addEventListener('click', () => { close(); navigate('traces'); });
|
||||
}
|
||||
|
||||
function bindEvents(container, data) {
|
||||
const agentById = (id) => data.agents.find((a) => (a.id || a.name) === id);
|
||||
|
||||
container.querySelectorAll('[data-nav]').forEach((el) => {
|
||||
const go = () => navigate(el.getAttribute('data-nav'));
|
||||
el.addEventListener('click', go);
|
||||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||
});
|
||||
|
||||
container.querySelector('[data-omnibar]')?.addEventListener('click', openPalette);
|
||||
|
||||
container.querySelector('.db-finops-banner-usage')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
navigate('usage');
|
||||
});
|
||||
container.querySelector('.db-finops-banner-dismiss')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
sessionStorage.setItem('finops-banner-dismissed', '1');
|
||||
const host = container.querySelector('#ov-finops-banner');
|
||||
if (host) host.innerHTML = '';
|
||||
});
|
||||
|
||||
container.querySelectorAll('.db-agent-row').forEach((row) => {
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.db-agent-chat')) return;
|
||||
const a = agentById(row.dataset.agentId);
|
||||
if (a) openDrawer(a);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.db-agent-chat').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
chatWithAgent(btn.dataset.agentName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.atn-approve').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await fetch(`/api/approvals/${btn.dataset.id}/approve`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
refreshApprovals();
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.atn-deny').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await fetch(`/api/approvals/${btn.dataset.id}/deny`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
refreshApprovals();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
if (!_container) return;
|
||||
const data = await loadAll();
|
||||
_container.__ovData = data;
|
||||
_container.innerHTML = shell(data);
|
||||
bindEvents(_container, data);
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
if (!_container || document.hidden) return;
|
||||
let pending = [];
|
||||
try { pending = toArray(await fetchJson('/api/approvals/pending'), 'approvals', 'pending'); } catch (e) { return; }
|
||||
const data = _container.__ovData;
|
||||
if (!data) return;
|
||||
data.pending = pending;
|
||||
const metricsEl = _container.querySelector('#ov-metrics');
|
||||
const attnEl = _container.querySelector('#ov-attention');
|
||||
if (metricsEl) metricsEl.innerHTML = renderMetrics(data);
|
||||
if (attnEl) attnEl.innerHTML = renderAttention(data);
|
||||
bindEvents(_container, data);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
_metricsTimer = setInterval(() => { if (!document.hidden) paint(); }, METRICS_POLL_MS);
|
||||
_approvalsTimer = setInterval(refreshApprovals, APPROVALS_POLL_MS);
|
||||
_visibilityHandler = () => { if (!document.hidden) refreshApprovals(); };
|
||||
document.addEventListener('visibilitychange', _visibilityHandler);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_metricsTimer) { clearInterval(_metricsTimer); _metricsTimer = null; }
|
||||
if (_approvalsTimer) { clearInterval(_approvalsTimer); _approvalsTimer = null; }
|
||||
if (_visibilityHandler) { document.removeEventListener('visibilitychange', _visibilityHandler); _visibilityHandler = null; }
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
await paint();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
stopPolling();
|
||||
_container = null;
|
||||
}
|
||||
@@ -1,72 +1,354 @@
|
||||
/**
|
||||
* Skills View — tool/skill catalog with enable/disable toggles
|
||||
* API: /api/skills
|
||||
* Skills Studio (STITCH-008) — unified Pending → Diff → Approve → Test →
|
||||
* Installed lifecycle for self_improve skill writes.
|
||||
*
|
||||
* Tabs: Pending (skill-filtered approvals) · Installed (toggle catalog) · History.
|
||||
* URL state: /skills?tab=pending|installed|history&approval_id={id}
|
||||
*
|
||||
* Reuses existing /api/approvals and /api/skills endpoints; no new backend.
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
import { esc, timeAgo, diffLines, renderMarkdownPreview, isSkillApproval } from '/plugins/views/_helpers.js';
|
||||
|
||||
let tools = [], categories = [];
|
||||
try { const r = await fetch('/api/skills'); const d = await r.json(); tools = d.tools || d.skills || d || []; if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id,t]) => ({id,...(typeof t==='object'?t:{name:t})})); } catch(e) {}
|
||||
try { const r = await fetch('/api/skills/categories'); const d = await r.json(); categories = d.categories || d || []; } catch(e) {}
|
||||
const RISK_ICONS = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
|
||||
|
||||
// Group by category
|
||||
const grouped = {};
|
||||
tools.forEach(t => { const cat = t.category || 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(t); });
|
||||
let _container = null;
|
||||
|
||||
const enabledCount = tools.filter(t => t.enabled !== false).length;
|
||||
function currentTab() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
return p.get('tab') || '';
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
|
||||
<div class="db-card"><div class="db-card-title">Total Skills</div><div class="db-card-value">${tools.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${Object.keys(grouped).length}</div></div>
|
||||
function currentApprovalId() {
|
||||
return new URLSearchParams(location.search).get('approval_id') || '';
|
||||
}
|
||||
|
||||
function setUrlState(tab, approvalId) {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (tab) p.set('tab', tab); else p.delete('tab');
|
||||
if (approvalId) p.set('approval_id', approvalId); else p.delete('approval_id');
|
||||
history.replaceState(history.state, '', `${location.pathname}?${p.toString()}`);
|
||||
}
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function approvalId(a) {
|
||||
return a.id || a.approval_id || '';
|
||||
}
|
||||
|
||||
function skillName(a) {
|
||||
const args = a.arguments || {};
|
||||
if (args.skill_name) return args.skill_name;
|
||||
if (args.name) return args.name;
|
||||
const path = String(args.path || '');
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
if (path.endsWith('SKILL.md') && parts.length >= 2) return parts[parts.length - 2];
|
||||
return a.tool_name || a.action || 'skill';
|
||||
}
|
||||
|
||||
function proposedContent(a) {
|
||||
const args = a.arguments || {};
|
||||
return a.proposed_content || args.content || args.skill_md || '';
|
||||
}
|
||||
|
||||
function changeType(a) {
|
||||
if (a.current_content) return 'Update';
|
||||
const args = a.arguments || {};
|
||||
if (String(args.op || a.type || '').toLowerCase().includes('delete')) return 'Delete';
|
||||
return 'New';
|
||||
}
|
||||
|
||||
// ── Diff overlay ────────────────────────────────────────────────────
|
||||
|
||||
function renderDiffRows(before, after) {
|
||||
const rows = diffLines(before, after);
|
||||
const colorFor = { add: 'rgba(34,197,94,.12)', del: 'rgba(239,68,68,.12)', ctx: 'transparent' };
|
||||
const signFor = { add: '+', del: '-', ctx: ' ' };
|
||||
let adds = 0;
|
||||
let dels = 0;
|
||||
const body = rows.map((r) => {
|
||||
if (r.type === 'add') adds++;
|
||||
if (r.type === 'del') dels++;
|
||||
return `<div style="display:flex;font-family:monospace;font-size:12px;background:${colorFor[r.type]}">
|
||||
<span style="width:16px;text-align:center;color:var(--db-text-dim);flex-shrink:0">${signFor[r.type]}</span>
|
||||
<span style="white-space:pre-wrap;word-break:break-word">${esc(r.text)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
return { html: body, adds, dels };
|
||||
}
|
||||
|
||||
function diffOverlayHtml(a, skills) {
|
||||
const before = a.current_content || '';
|
||||
const after = proposedContent(a);
|
||||
const hasProposed = after !== '';
|
||||
const { html: rawRows, adds, dels } = renderDiffRows(before, after);
|
||||
const name = skillName(a);
|
||||
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
|
||||
const leftEmpty = before === '';
|
||||
return `
|
||||
<div class="ss-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;display:flex;justify-content:flex-end">
|
||||
<div class="ss-panel" style="width:60%;max-width:920px;height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);display:flex;flex-direction:column;box-sizing:border-box">
|
||||
<div style="padding:16px 20px;border-bottom:1px solid var(--db-border);display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600">${esc(name)}
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
|
||||
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">${RISK_ICONS[a.risk_level] || ''} ${esc(a.agent_name || '')} · +${adds} −${dels}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="ss-mode" data-mode="raw" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:var(--db-accent);color:#fff;border-radius:6px;cursor:pointer">Raw</button>
|
||||
<button class="ss-mode" data-mode="preview" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Preview</button>
|
||||
<button class="ss-close" style="background:none;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer">×</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasProposed ? '' : '<div style="padding:12px 20px;background:rgba(239,68,68,.1);color:#ef4444;font-size:13px">Content unavailable — approval disabled</div>'}
|
||||
<div class="ss-diff-body" style="flex:1;overflow:auto;padding:16px 20px">
|
||||
<div class="ss-columns" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Current</div>
|
||||
<div class="ss-left">${leftEmpty ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Proposed</div>
|
||||
<div class="ss-right">${rawRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:14px 20px;border-top:1px solid var(--db-border);display:flex;gap:10px;justify-content:flex-end">
|
||||
<button class="ss-deny" data-id="${esc(approvalId(a))}" style="padding:8px 18px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer;font-size:13px">Reject</button>
|
||||
<button class="ss-approve" data-id="${esc(approvalId(a))}" ${hasProposed ? '' : 'disabled'} style="padding:8px 18px;background:${hasProposed ? 'rgba(34,197,94,.15)' : 'var(--db-card-bg)'};color:${hasProposed ? '#22c55e' : 'var(--db-text-dim)'};border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:${hasProposed ? 'pointer' : 'not-allowed'};font-size:13px">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openDiff(a, skills) {
|
||||
const host = _container.querySelector('#ss-overlay-host');
|
||||
if (!host) return;
|
||||
host.innerHTML = diffOverlayHtml(a, skills);
|
||||
setUrlState('pending', approvalId(a));
|
||||
const before = a.current_content || '';
|
||||
const after = proposedContent(a);
|
||||
|
||||
const close = () => { host.innerHTML = ''; setUrlState('pending', ''); };
|
||||
host.querySelector('.ss-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
host.querySelector('.ss-close').addEventListener('click', close);
|
||||
|
||||
host.querySelectorAll('.ss-mode').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.mode;
|
||||
host.querySelectorAll('.ss-mode').forEach((b) => {
|
||||
const on = b === btn;
|
||||
b.style.background = on ? 'var(--db-accent)' : 'transparent';
|
||||
b.style.color = on ? '#fff' : 'var(--db-text)';
|
||||
});
|
||||
const left = host.querySelector('.ss-left');
|
||||
const right = host.querySelector('.ss-right');
|
||||
if (mode === 'preview') {
|
||||
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : renderMarkdownPreview(before);
|
||||
right.innerHTML = renderMarkdownPreview(after);
|
||||
} else {
|
||||
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`;
|
||||
right.innerHTML = renderDiffRows(before, after).html;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const resolve = async (verb, id) => {
|
||||
try { await fetch(`/api/approvals/${id}/${verb}`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
close();
|
||||
paint();
|
||||
};
|
||||
host.querySelector('.ss-approve')?.addEventListener('click', (e) => {
|
||||
if (e.currentTarget.disabled) return;
|
||||
resolve('approve', e.currentTarget.dataset.id);
|
||||
});
|
||||
host.querySelector('.ss-deny')?.addEventListener('click', (e) => resolve('deny', e.currentTarget.dataset.id));
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
function pendingCard(a, skills) {
|
||||
const name = skillName(a);
|
||||
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
|
||||
const preview = proposedContent(a).slice(0, 200);
|
||||
return `<div class="ss-pending-card db-card" data-id="${esc(approvalId(a))}" style="margin-bottom:10px;padding:16px 20px;cursor:pointer">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:14px">${esc(name.slice(0, 40))}
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
|
||||
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">🤖 ${esc(a.agent_name || '—')} · ${esc((a.metadata && a.metadata.source) || 'self_improve')} · ${esc(timeAgo(a.created_at))}</div>
|
||||
</div>
|
||||
<div style="font-size:12px">${RISK_ICONS[a.risk_level] || ''}</div>
|
||||
</div>
|
||||
<div id="skills-list"></div>
|
||||
`;
|
||||
${preview ? `<pre style="margin:10px 0 0;font-size:11px;color:var(--db-text-dim);white-space:pre-wrap;max-height:44px;overflow:hidden">${esc(preview)}</pre>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const list = container.querySelector('#skills-list');
|
||||
for (const [category, catTools] of Object.entries(grouped)) {
|
||||
const section = document.createElement('div');
|
||||
section.style.marginBottom = '20px';
|
||||
section.innerHTML = `<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${category} (${catTools.length})</h3>`;
|
||||
function pendingTab(pending, skills) {
|
||||
if (pending.length === 0) {
|
||||
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">📖</div>
|
||||
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No pending skill writes</div>
|
||||
<div style="font-size:13px">Set <code>SKILL_WRITE_APPROVAL=1</code> and run an agent with <code>self_improve</code> to stage proposals here.</div>
|
||||
</div>`;
|
||||
}
|
||||
return pending.map((a) => pendingCard(a, skills)).join('');
|
||||
}
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'db-columns';
|
||||
grid.style.gridTemplateColumns = 'repeat(auto-fill,minmax(280px,1fr))';
|
||||
|
||||
catTools.forEach(t => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'padding:14px 18px;display:flex;align-items:center;justify-content:space-between';
|
||||
function installedTab(tools, pendingNames) {
|
||||
const grouped = {};
|
||||
tools.forEach((t) => { const c = t.category || 'other'; (grouped[c] = grouped[c] || []).push(t); });
|
||||
if (tools.length === 0) return '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
|
||||
return Object.entries(grouped).map(([category, catTools]) => {
|
||||
const cards = catTools.map((t) => {
|
||||
const enabled = t.enabled !== false;
|
||||
const icon = t.icon || '🔧';
|
||||
const keyStatus = t.api_key_set ? '🔑' : '';
|
||||
card.innerHTML = `
|
||||
const hasPending = pendingNames.has(t.name || t.id);
|
||||
return `<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex:1">
|
||||
<span style="font-size:20px">${icon}</span>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500">${t.name || t.id} ${keyStatus}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${t.description || ''}</div>
|
||||
<div style="font-size:13px;font-weight:500">${esc(t.name || t.id)} ${keyStatus}${hasPending ? ' <span title="Pending change" style="color:#eab308">●</span>' : ''}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(t.description || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label style="position:relative;display:inline-block;width:40px;height:22px;cursor:pointer">
|
||||
<input type="checkbox" class="skill-toggle" data-id="${t.id || t.name}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
|
||||
<input type="checkbox" class="skill-toggle" data-id="${esc(t.id || t.name)}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
|
||||
<span style="position:absolute;inset:0;background:${enabled ? 'var(--db-accent)' : 'var(--db-border)'};border-radius:22px;transition:0.3s"></span>
|
||||
<span style="position:absolute;top:2px;left:${enabled ? '20px' : '2px'};width:18px;height:18px;background:#fff;border-radius:50%;transition:0.3s"></span>
|
||||
</label>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin-bottom:20px">
|
||||
<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${esc(category)} (${catTools.length})</h3>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">${cards}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
section.appendChild(grid);
|
||||
list.appendChild(section);
|
||||
function historyTab(history) {
|
||||
const skillHist = (Array.isArray(history) ? history : []).filter(isSkillApproval);
|
||||
if (skillHist.length === 0) return '<div style="font-size:13px;color:var(--db-text-dim)">No skill approval history</div>';
|
||||
return skillHist.slice(0, 25).map((h) => {
|
||||
const approved = h.status === 'approved' || h.approved;
|
||||
return `<div style="padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between;gap:10px">
|
||||
<span>${esc(skillName(h))} — ${esc(h.agent_name || '')}</span>
|
||||
<span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'} · ${esc(timeAgo(h.resolved_at || h.created_at))}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const STUDIO_STYLE = `<style id="ss-style">
|
||||
@media (max-width: 768px) {
|
||||
.ss-panel { width: 100% !important; }
|
||||
.ss-columns { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
if (tools.length === 0) list.innerHTML = '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
|
||||
function shell(data) {
|
||||
const { pending, tools } = data;
|
||||
const tab = data.activeTab;
|
||||
const enabledCount = tools.filter((t) => t.enabled !== false).length;
|
||||
const catCount = new Set(tools.map((t) => t.category || 'other')).size;
|
||||
const tabBtn = (id, label, badge) => {
|
||||
const on = tab === id;
|
||||
return `<button class="ss-tab" data-tab="${id}" style="padding:8px 16px;border:none;border-bottom:2px solid ${on ? 'var(--db-accent)' : 'transparent'};background:none;color:${on ? 'var(--db-text)' : 'var(--db-text-dim)'};font-size:13px;font-weight:${on ? '600' : '400'};cursor:pointer">${label}${badge ? ` <span style="background:rgba(234,179,8,.2);color:#eab308;padding:1px 7px;border-radius:10px;font-size:11px;margin-left:4px">${badge}</span>` : ''}</button>`;
|
||||
};
|
||||
let body = '';
|
||||
if (tab === 'installed') body = installedTab(tools, data.pendingNames);
|
||||
else if (tab === 'history') body = historyTab(data.history);
|
||||
else body = pendingTab(pending, tools);
|
||||
|
||||
container.querySelectorAll('.skill-toggle').forEach(cb => {
|
||||
return `${STUDIO_STYLE}
|
||||
<div style="margin-bottom:8px">
|
||||
<h2 style="margin:0;font-size:1.1rem;font-weight:600">Skills Studio</h2>
|
||||
<div style="font-size:13px;color:var(--db-text-dim)">Review self_improve proposals</div>
|
||||
</div>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin:16px 0 20px">
|
||||
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
|
||||
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${catCount}</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;border-bottom:1px solid var(--db-border);margin-bottom:16px">
|
||||
${tabBtn('pending', 'Pending', pending.length || '')}
|
||||
${tabBtn('installed', 'Installed', '')}
|
||||
${tabBtn('history', 'History', '')}
|
||||
</div>
|
||||
<div id="ss-body">${body}</div>
|
||||
<div id="ss-overlay-host"></div>`;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson('/api/skills'),
|
||||
fetchJson('/api/approvals/pending'),
|
||||
fetchJson('/api/approvals/history'),
|
||||
]);
|
||||
const val = (i, f) => (results[i].status === 'fulfilled' ? results[i].value : f);
|
||||
const sk = val(0, {});
|
||||
let tools = sk.tools || sk.skills || sk || [];
|
||||
if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id, t]) => ({ id, ...(typeof t === 'object' ? t : { name: t }) }));
|
||||
const rawPending = (val(1, {}).approvals) || [];
|
||||
const history = (val(2, {}).approvals) || (val(2, {}).history) || [];
|
||||
const pending = rawPending.filter(isSkillApproval);
|
||||
const pendingNames = new Set(pending.map((a) => skillName(a)));
|
||||
return { tools, pending, history, pendingNames };
|
||||
}
|
||||
|
||||
function bindEvents(data) {
|
||||
_container.querySelectorAll('.ss-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
data.activeTab = btn.dataset.tab;
|
||||
setUrlState(data.activeTab, '');
|
||||
renderShell(data);
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('.ss-pending-card').forEach((card) => {
|
||||
card.addEventListener('click', () => {
|
||||
const a = data.pending.find((x) => approvalId(x) === card.dataset.id);
|
||||
if (a) openDiff(a, data.tools);
|
||||
});
|
||||
});
|
||||
_container.querySelectorAll('.skill-toggle').forEach((cb) => {
|
||||
cb.addEventListener('change', async () => {
|
||||
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, {method:'POST'}); render(container); } catch(e) {}
|
||||
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
paint();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderShell(data) {
|
||||
_container.innerHTML = shell(data);
|
||||
bindEvents(data);
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
if (!_container) return;
|
||||
const data = await loadAll();
|
||||
const tab = currentTab() || (data.pending.length > 0 ? 'pending' : 'installed');
|
||||
data.activeTab = tab;
|
||||
renderShell(data);
|
||||
const deepId = currentApprovalId();
|
||||
if (deepId && tab === 'pending') {
|
||||
const a = data.pending.find((x) => approvalId(x) === deepId);
|
||||
if (a) openDiff(a, data.tools);
|
||||
}
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
await paint();
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
_container = null;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Unified Work Hub (/work) — three-pane operator surface (STITCH-003).
|
||||
*
|
||||
* Pane 1: Kanban board (reuses createInteractiveBoard from board.js).
|
||||
* Pane 2: Selected card detail with Description / Chat / Tools / Traces tabs.
|
||||
* Pane 3: Activity — job runs, live log, trace link for the card's latest run.
|
||||
*
|
||||
* Card selection updates panes 2+3 without a page navigation. URL state
|
||||
* (?board=&card=&tab=) is kept in sync for deep links.
|
||||
*/
|
||||
import { createInteractiveBoard, relativeTime, escapeHtml } from '../board.js';
|
||||
|
||||
const STYLE_ID = 'work-hub-styles';
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.work-hub { display:flex; height:100%; min-height:0; gap:1px; background:var(--db-border,#3f3f46); }
|
||||
.work-hub-pane { background:var(--db-bg,#18181b); overflow:auto; min-height:0; }
|
||||
.work-hub-p1 { width:300px; min-width:240px; flex:0 0 auto; }
|
||||
.work-hub-p2 { flex:1 1 auto; min-width:0; }
|
||||
.work-hub-p3 { width:320px; min-width:280px; flex:0 0 auto; }
|
||||
.work-hub-pane-head { padding:10px 14px; border-bottom:1px solid var(--db-border,#3f3f46);
|
||||
font-size:13px; font-weight:600; position:sticky; top:0; background:var(--db-bg,#18181b); z-index:2; }
|
||||
.work-hub-empty { padding:24px; text-align:center; color:var(--db-text-dim,#a1a1aa); font-size:13px; }
|
||||
.work-hub-tabs { display:flex; gap:4px; padding:8px 14px 0; border-bottom:1px solid var(--db-border,#3f3f46); }
|
||||
.work-hub-tab { padding:6px 12px; font-size:13px; cursor:pointer; border:none; background:none;
|
||||
color:var(--db-text-dim,#a1a1aa); border-bottom:2px solid transparent; }
|
||||
.work-hub-tab.active { color:var(--db-text,#e4e4e7); border-bottom-color:var(--db-accent,#6366f1); }
|
||||
.work-hub-tab:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.work-hub-tabbody { padding:14px; }
|
||||
.work-hub-chat-log { display:flex; flex-direction:column; gap:8px; margin-bottom:12px; }
|
||||
.work-hub-chat-msg { padding:8px 10px; border-radius:8px; font-size:13px; line-height:1.5;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); }
|
||||
.work-hub-chat-msg .role { font-size:11px; font-weight:600; color:var(--db-accent,#6366f1); }
|
||||
.work-hub-composer { display:flex; gap:8px; align-items:flex-end; }
|
||||
.work-hub-composer textarea { flex:1; resize:none; min-height:40px; max-height:64px; padding:8px 10px;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); border-radius:8px;
|
||||
color:var(--db-text,#e4e4e7); font-size:13px; box-sizing:border-box; }
|
||||
.work-hub-log { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;
|
||||
line-height:1.6; white-space:pre-wrap; background:var(--db-card-bg,#27272a);
|
||||
border:1px solid var(--db-border,#3f3f46); border-radius:8px; padding:10px; max-height:40vh; overflow:auto; }
|
||||
.work-hub-job { display:flex; align-items:center; gap:8px; padding:6px 0; font-size:13px; }
|
||||
.work-hub-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
|
||||
@media (max-width:1024px) {
|
||||
.work-hub { flex-direction:column; }
|
||||
.work-hub-p1, .work-hub-p2, .work-hub-p3 { width:auto; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
if (s === 'done' || s === 'completed' || s === 'success') return '#22c55e';
|
||||
if (s === 'running' || s === 'in_progress') return '#f59e0b';
|
||||
if (s === 'failed' || s === 'error' || s === 'blocked') return '#ef4444';
|
||||
return '#6366f1';
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'work');
|
||||
container.classList.add('work-hub-page');
|
||||
container.innerHTML = '';
|
||||
injectStyles();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const boardState = { id: params.get('board') || 'default' };
|
||||
let selectedCard = null;
|
||||
let activeTab = params.get('tab') || 'description';
|
||||
const pendingCardId = params.get('card');
|
||||
|
||||
const hub = document.createElement('div');
|
||||
hub.className = 'work-hub';
|
||||
container.appendChild(hub);
|
||||
|
||||
const p1 = document.createElement('div');
|
||||
p1.className = 'work-hub-pane work-hub-p1';
|
||||
const p1Body = document.createElement('div');
|
||||
p1.appendChild(p1Body);
|
||||
hub.appendChild(p1);
|
||||
|
||||
const p2 = document.createElement('div');
|
||||
p2.className = 'work-hub-pane work-hub-p2';
|
||||
hub.appendChild(p2);
|
||||
|
||||
const p3 = document.createElement('div');
|
||||
p3.className = 'work-hub-pane work-hub-p3';
|
||||
hub.appendChild(p3);
|
||||
|
||||
function syncUrl() {
|
||||
const q = new URLSearchParams();
|
||||
q.set('board', boardState.id);
|
||||
if (selectedCard) { q.set('card', selectedCard.id); q.set('tab', activeTab); }
|
||||
const url = `${window.location.pathname}?${q.toString()}`;
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!selectedCard) {
|
||||
p2.innerHTML = '<div class="work-hub-empty">Select a card to see its details.</div>';
|
||||
return;
|
||||
}
|
||||
const c = selectedCard;
|
||||
const meta = c.meta || {};
|
||||
const hasAgent = !!(c.assignee || meta.agent_id);
|
||||
const tabs = [
|
||||
['description', 'Description', false],
|
||||
['chat', 'Chat', !hasAgent],
|
||||
['tools', 'Tools', false],
|
||||
['traces', 'Traces', false],
|
||||
];
|
||||
p2.innerHTML = `
|
||||
<div class="work-hub-pane-head">${escapeHtml(c.title || 'Card')}
|
||||
<span class="db-text-dim" style="font-weight:400;font-size:12px;margin-left:8px">${escapeHtml(c.status || '')}</span>
|
||||
</div>
|
||||
<div class="work-hub-tabs">
|
||||
${tabs.map(([id, label, disabled]) =>
|
||||
`<button class="work-hub-tab ${id === activeTab ? 'active' : ''}" data-tab="${id}" ${disabled ? 'disabled' : ''}>${label}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div class="work-hub-tabbody" id="work-hub-tabbody"></div>`;
|
||||
|
||||
p2.querySelectorAll('.work-hub-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return;
|
||||
activeTab = btn.getAttribute('data-tab');
|
||||
syncUrl();
|
||||
renderDetail();
|
||||
});
|
||||
});
|
||||
|
||||
const body = p2.querySelector('#work-hub-tabbody');
|
||||
if (activeTab === 'description') {
|
||||
body.innerHTML = c.body
|
||||
? `<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${escapeHtml(c.body)}</div>`
|
||||
: '<div class="work-hub-empty">No description.</div>';
|
||||
} else if (activeTab === 'chat') {
|
||||
renderChat(body, c);
|
||||
} else if (activeTab === 'tools') {
|
||||
const tools = meta.tools || [];
|
||||
body.innerHTML = tools.length
|
||||
? tools.map((t) => `<span class="db-badge" style="display:inline-block;margin:2px;padding:2px 8px;border-radius:6px;background:var(--db-card-bg,#27272a);font-size:12px">${escapeHtml(String(t))}</span>`).join('')
|
||||
: '<div class="work-hub-empty">No tools listed for this card.</div>';
|
||||
} else if (activeTab === 'traces') {
|
||||
const sessionId = meta.session_id;
|
||||
body.innerHTML = sessionId
|
||||
? `<a href="/traces?session_id=${encodeURIComponent(sessionId)}" style="color:var(--db-accent,#6366f1)">Open traces for this session →</a>`
|
||||
: '<div class="work-hub-empty">No traces linked.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderChat(body, card) {
|
||||
const agentId = card.assignee || (card.meta || {}).agent_id || '';
|
||||
body.innerHTML = `
|
||||
<div class="work-hub-chat-log" id="work-hub-chat-log">
|
||||
<div class="work-hub-empty">Ask the assigned agent (${escapeHtml(agentId)}) about this card.</div>
|
||||
</div>
|
||||
<div class="work-hub-composer">
|
||||
<textarea id="work-hub-composer" rows="2" placeholder="Message ${escapeHtml(agentId)}…"></textarea>
|
||||
<button class="db-btn db-btn-sm" id="work-hub-send">Send</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px"><a href="/chat" style="color:var(--db-accent,#6366f1)">Open full chat →</a></div>`;
|
||||
|
||||
const log = body.querySelector('#work-hub-chat-log');
|
||||
const ta = body.querySelector('#work-hub-composer');
|
||||
const send = body.querySelector('#work-hub-send');
|
||||
send.addEventListener('click', () => {
|
||||
const text = ta.value.trim();
|
||||
if (!text) return;
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'work-hub-chat-msg';
|
||||
msg.innerHTML = `<div class="role">You</div>${escapeHtml(text)}`;
|
||||
if (log.querySelector('.work-hub-empty')) log.innerHTML = '';
|
||||
log.appendChild(msg);
|
||||
ta.value = '';
|
||||
log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderActivity() {
|
||||
if (!selectedCard) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No card selected.</div>';
|
||||
return;
|
||||
}
|
||||
const meta = selectedCard.meta || {};
|
||||
const jobId = meta.job_id;
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Loading…</div>';
|
||||
if (!jobId) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No jobs linked to this card.</div>';
|
||||
return;
|
||||
}
|
||||
let job = null;
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`);
|
||||
if (res.ok) job = await res.json();
|
||||
} catch (_) { /* offline */ }
|
||||
if (!job) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Could not load job.</div>';
|
||||
return;
|
||||
}
|
||||
const logLines = (job.log || job.output || '').split('\n').slice(-50).join('\n');
|
||||
p3.innerHTML = `
|
||||
<div class="work-hub-pane-head">Activity</div>
|
||||
<div style="padding:14px">
|
||||
<div class="work-hub-job">
|
||||
<span class="work-hub-dot" style="background:${statusColor(job.status)}"></span>
|
||||
<span>${escapeHtml(job.id || jobId)}</span>
|
||||
<span class="db-text-dim" style="margin-left:auto;font-size:12px">${escapeHtml(job.status || '')}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim,#a1a1aa);margin:4px 0 10px">${job.updated_at ? relativeTime(job.updated_at) : ''}</div>
|
||||
<div class="work-hub-log">${escapeHtml(logLines) || 'No log output.'}</div>
|
||||
${meta.session_id ? `<div style="margin-top:10px"><a href="/traces?session_id=${encodeURIComponent(meta.session_id)}" style="color:var(--db-accent,#6366f1);font-size:12px">Open trace →</a></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function selectCard(card) {
|
||||
selectedCard = card;
|
||||
if (card && (!activeTab)) activeTab = 'description';
|
||||
syncUrl();
|
||||
renderDetail();
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
let pendingHandled = !pendingCardId;
|
||||
function tryDeepLink(data) {
|
||||
for (const col of data.columns || []) {
|
||||
for (const card of col.cards || []) {
|
||||
if (card.id === pendingCardId) { pendingHandled = true; selectCard(card); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const board = createInteractiveBoard(p1Body, {
|
||||
apiBase: '/api/kanban',
|
||||
eventsUrl: '/api/kanban/events',
|
||||
pollMs: 8000,
|
||||
debounceMs: 500,
|
||||
interactive: true,
|
||||
laneByProfile: true,
|
||||
boardSwitcher: true,
|
||||
boardState,
|
||||
onOpen: (card) => {
|
||||
pendingHandled = true;
|
||||
selectCard(card);
|
||||
},
|
||||
fetch: async () => {
|
||||
const [boardRes, boardsRes] = await Promise.all([
|
||||
fetch(`/api/kanban/board?board=${encodeURIComponent(boardState.id)}`),
|
||||
fetch('/api/kanban/boards'),
|
||||
]);
|
||||
if (!boardRes.ok) throw new Error(await boardRes.text());
|
||||
const data = await boardRes.json();
|
||||
if (boardsRes.ok) {
|
||||
const b = await boardsRes.json();
|
||||
data.boards = b.boards || [{ id: 'default', title: 'Default' }];
|
||||
}
|
||||
if (!pendingHandled && pendingCardId) tryDeepLink(data);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
renderActivity();
|
||||
|
||||
return () => board.destroy();
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class TestBuildCommand:
|
||||
["build", "--config", str(project_dir / "aiui.template.yaml"), "--output", str(output)],
|
||||
)
|
||||
|
||||
ui_config = json.loads((output / "ui-config.json").read_text())
|
||||
ui_config = json.loads((output / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["site"]["title"] == "Integration Test Site"
|
||||
assert "header_main" in ui_config["components"]
|
||||
assert "footer_main" in ui_config["components"]
|
||||
@@ -111,7 +111,7 @@ class TestBuildCommand:
|
||||
],
|
||||
)
|
||||
|
||||
content = (output / "ui-config.json").read_text()
|
||||
content = (output / "ui-config.json").read_text(encoding="utf-8")
|
||||
assert "\n" not in content # Minified = no newlines
|
||||
|
||||
def test_build_invalid_config(self, tmp_path: Path):
|
||||
@@ -215,8 +215,8 @@ class TestFullPipeline:
|
||||
assert (output / "index.html").exists()
|
||||
|
||||
# Verify content
|
||||
ui_config = json.loads((output / "ui-config.json").read_text())
|
||||
ui_config = json.loads((output / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["site"]["title"] == "Integration Test Site"
|
||||
|
||||
routes = json.loads((output / "route-manifest.json").read_text())
|
||||
routes = json.loads((output / "route-manifest.json").read_text(encoding="utf-8"))
|
||||
assert len(routes["routes"]) >= 1
|
||||
@@ -488,12 +488,16 @@ class TestTelemetryAPI:
|
||||
|
||||
class TestTracingAPI:
|
||||
def test_list_traces(self, client):
|
||||
r = client.get("/api/tracing")
|
||||
r = client.get("/api/traces")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, (list, dict))
|
||||
|
||||
def test_tracing_status(self, client):
|
||||
r = client.get("/api/tracing/status")
|
||||
r = client.get("/api/traces/status")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "status" in data or "feature" in data
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -516,9 +520,33 @@ class TestProtocolAPI:
|
||||
|
||||
class TestMarketplaceAPI:
|
||||
def test_list_marketplace(self, client):
|
||||
r = client.get("/api/marketplace")
|
||||
r = client.get("/api/marketplace/plugins")
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_list_plugins(self, client):
|
||||
r = client.get("/api/marketplace/plugins")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "plugins" in data
|
||||
assert isinstance(data["plugins"], list)
|
||||
|
||||
def test_install_uninstall_roundtrip(self, client):
|
||||
r = client.post("/api/marketplace/install", json={"plugin_id": "web_search"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "installed"
|
||||
|
||||
r = client.get("/api/marketplace/plugins")
|
||||
installed = [p for p in r.json()["plugins"] if p.get("installed")]
|
||||
assert any(p["id"] == "web_search" for p in installed)
|
||||
|
||||
r = client.post("/api/marketplace/uninstall", json={"plugin_id": "web_search"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "uninstalled"
|
||||
|
||||
def test_install_unknown_plugin(self, client):
|
||||
r = client.post("/api/marketplace/install", json={"plugin_id": "does_not_exist"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Memory (from original tests, kept for completeness)
|
||||
@@ -747,6 +775,14 @@ class TestPagesAPI:
|
||||
assert r.status_code == 200
|
||||
assert "servers" in r.json()
|
||||
|
||||
def test_marketplace_page_registered(self, client):
|
||||
r = client.get("/api/pages")
|
||||
pages = r.json().get("pages", r.json())
|
||||
mkt = next((p for p in pages if p["id"] == "marketplace"), None)
|
||||
assert mkt is not None, "Marketplace page should be registered"
|
||||
assert mkt["group"] == "Agent"
|
||||
assert mkt["api_endpoint"] == "/api/marketplace/plugins"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Persistence across restart
|
||||
|
||||
@@ -22,7 +22,7 @@ EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
|
||||
|
||||
|
||||
def _view() -> str:
|
||||
return CHANNELS_VIEW.read_text()
|
||||
return CHANNELS_VIEW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestAddChannelControls:
|
||||
@@ -91,6 +91,6 @@ class TestEmptyState:
|
||||
|
||||
class TestExplorerPreset:
|
||||
def test_explorer_has_add_channel_post(self):
|
||||
src = EXPLORER_VIEW.read_text()
|
||||
src = EXPLORER_VIEW.read_text(encoding="utf-8")
|
||||
assert "Add Channel" in src
|
||||
assert "env:TELEGRAM_BOT_TOKEN" in src
|
||||
@@ -25,13 +25,13 @@ CHAT_JS = FRONTEND / "views" / "chat.js"
|
||||
|
||||
class TestSelectPageShellError:
|
||||
def test_shell_error_helper_defined(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
assert "function showDashboardInitError(" in src
|
||||
assert "'alert'" in src
|
||||
assert "db-error" in src
|
||||
|
||||
def test_select_page_calls_shell_error_on_missing_main(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
idx = src.find("const main = document.getElementById('db-main-content')")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 400]
|
||||
@@ -41,16 +41,16 @@ class TestSelectPageShellError:
|
||||
|
||||
class TestChatOfflineMessaging:
|
||||
def test_toast_imported(self):
|
||||
src = CHAT_JS.read_text()
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "import { showToast } from '../toast.js';" in src
|
||||
|
||||
def test_offline_hint_in_fallback(self):
|
||||
src = CHAT_JS.read_text()
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "navigator.onLine === false" in src
|
||||
assert "You are offline. " in src
|
||||
|
||||
def test_fallback_catch_shows_toast(self):
|
||||
src = CHAT_JS.read_text()
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
idx = src.find("fetch('/api/chat/send'")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 900]
|
||||
@@ -62,7 +62,7 @@ class TestChatOfflineMessaging:
|
||||
|
||||
class TestSystemErrorRoleAlert:
|
||||
def test_append_message_sets_role_alert(self):
|
||||
src = CHAT_JS.read_text()
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
idx = src.find("function appendMessage(")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 700]
|
||||
@@ -72,7 +72,7 @@ class TestSystemErrorRoleAlert:
|
||||
|
||||
class TestWebSocketSendGuard:
|
||||
def test_ws_send_wrapped_in_try_catch(self):
|
||||
src = CHAT_JS.read_text()
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
idx = src.find("ws.send(JSON.stringify(payload))")
|
||||
assert idx != -1
|
||||
before = src[max(0, idx - 120):idx]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the Code Studio view (STITCH-006)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
VIEWS = Path("src/praisonaiui/templates/frontend/plugins/views")
|
||||
PLUGINS = Path("src/praisonaiui/templates/frontend/plugins")
|
||||
|
||||
|
||||
class TestCodeStudioPage:
|
||||
def test_page_registered(self):
|
||||
client = TestClient(create_app())
|
||||
r = client.get("/api/pages")
|
||||
assert r.status_code == 200
|
||||
ids = {p["id"] for p in r.json()["pages"]}
|
||||
assert "code-studio" in ids
|
||||
|
||||
def test_page_metadata(self):
|
||||
client = TestClient(create_app())
|
||||
pages = {p["id"]: p for p in client.get("/api/pages").json()["pages"]}
|
||||
page = pages["code-studio"]
|
||||
assert page["title"] == "Code Studio"
|
||||
assert page["api_endpoint"] == "/api/code/languages"
|
||||
|
||||
|
||||
class TestCodeExecutionApi:
|
||||
def test_languages(self):
|
||||
client = TestClient(create_app())
|
||||
r = client.get("/api/code/languages")
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "languages" in d and "count" in d
|
||||
|
||||
def test_execute(self):
|
||||
client = TestClient(create_app())
|
||||
r = client.post(
|
||||
"/api/code/execute",
|
||||
json={"code": "print('hi')", "language": "python", "timeout": 30},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "status" in d and "language" in d
|
||||
|
||||
|
||||
class TestCodeStudioFrontend:
|
||||
def test_view_file_exists(self):
|
||||
assert (VIEWS / "code-studio.js").is_file()
|
||||
|
||||
def test_view_exports_render(self):
|
||||
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
|
||||
assert "export async function render" in src
|
||||
assert "export function cleanup" in src
|
||||
|
||||
def test_view_calls_execute_api(self):
|
||||
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
|
||||
assert "/api/code/execute" in src
|
||||
assert "/api/code/languages" in src
|
||||
|
||||
def test_no_browser_eval(self):
|
||||
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
|
||||
assert "eval(" not in src
|
||||
|
||||
def test_registered_in_dashboard(self):
|
||||
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "'code-studio'" in src
|
||||
assert "code-studio.js" in src
|
||||
|
||||
def test_send_to_agent_event(self):
|
||||
src = (VIEWS / "code-studio.js").read_text(encoding="utf-8")
|
||||
assert "aiui:prefill-composer" in src
|
||||
|
||||
def test_chat_has_prefill_listener(self):
|
||||
src = (VIEWS / "chat.js").read_text(encoding="utf-8")
|
||||
assert "aiui:prefill-composer" in src
|
||||
@@ -22,7 +22,7 @@ DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
|
||||
class TestCommandPalette:
|
||||
def _src(self) -> str:
|
||||
return DASHBOARD_JS.read_text()
|
||||
return DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
|
||||
def test_open_command_palette_exposed_globally(self):
|
||||
src = self._src()
|
||||
|
||||
+19
-19
@@ -86,7 +86,7 @@ class TestCompilerUiConfig:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert result.success is True
|
||||
|
||||
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["site"]["title"] == "Test Site"
|
||||
assert "templates" in ui_config
|
||||
assert "docs" in ui_config["templates"]
|
||||
@@ -96,7 +96,7 @@ class TestCompilerUiConfig:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert result.success is True
|
||||
|
||||
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert "header_main" in ui_config["components"]
|
||||
assert ui_config["components"]["header_main"]["props"]["logoText"] == "MySite"
|
||||
assert "footer_main" in ui_config["components"]
|
||||
@@ -107,7 +107,7 @@ class TestCompilerUiConfig:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert result.success is True
|
||||
|
||||
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
|
||||
docs_template = ui_config["templates"]["docs"]
|
||||
assert docs_template["slots"]["header"]["ref"] == "header_main"
|
||||
assert docs_template["slots"]["footer"]["ref"] == "footer_main"
|
||||
@@ -123,7 +123,7 @@ class TestCompilerRouteManifest:
|
||||
assert "route-manifest.json" in result.files
|
||||
|
||||
manifest = json.loads(
|
||||
(tmp_path / "output" / "route-manifest.json").read_text()
|
||||
(tmp_path / "output" / "route-manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert len(manifest["routes"]) == 1
|
||||
assert manifest["routes"][0]["pattern"] == "/docs/**"
|
||||
@@ -147,7 +147,7 @@ class TestCompilerRouteManifest:
|
||||
assert result.success is True
|
||||
|
||||
manifest = json.loads(
|
||||
(tmp_path / "output" / "route-manifest.json").read_text()
|
||||
(tmp_path / "output" / "route-manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
# First route in list gets highest priority
|
||||
assert manifest["routes"][0]["priority"] > manifest["routes"][1]["priority"]
|
||||
@@ -200,7 +200,7 @@ class TestCompilerOutputFiles:
|
||||
compiler = Compiler(minimal_config, base_path=tmp_path)
|
||||
compiler.compile(tmp_path / "output", minify=True)
|
||||
|
||||
content = (tmp_path / "output" / "ui-config.json").read_text()
|
||||
content = (tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
|
||||
# Minified JSON should not have newlines
|
||||
assert "\n" not in content
|
||||
|
||||
@@ -208,7 +208,7 @@ class TestCompilerOutputFiles:
|
||||
compiler = Compiler(minimal_config, base_path=tmp_path)
|
||||
compiler.compile(tmp_path / "output", minify=False)
|
||||
|
||||
content = (tmp_path / "output" / "ui-config.json").read_text()
|
||||
content = (tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
|
||||
# Non-minified JSON should have newlines
|
||||
assert "\n" in content
|
||||
|
||||
@@ -228,7 +228,7 @@ class TestCompilerDocsContent:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert "docs-nav.json" in result.files
|
||||
|
||||
nav = json.loads((tmp_path / "output" / "docs-nav.json").read_text())
|
||||
nav = json.loads((tmp_path / "output" / "docs-nav.json").read_text(encoding="utf-8"))
|
||||
assert "items" in nav
|
||||
|
||||
def test_no_docs_nav_without_content(self, minimal_config: Config, tmp_path: Path):
|
||||
@@ -291,7 +291,7 @@ class TestCompilerThemeCss:
|
||||
assert result.success is True
|
||||
theme_css = tmp_path / "output" / "assets" / "theme.css"
|
||||
assert theme_css.exists(), "assets/theme.css must be generated"
|
||||
content = theme_css.read_text()
|
||||
content = theme_css.read_text(encoding="utf-8")
|
||||
assert ":root {" in content
|
||||
assert "--radius:" in content
|
||||
|
||||
@@ -340,7 +340,7 @@ class TestInjectThemeCss:
|
||||
inject_theme_css(tmp_path, preset="zinc", dark_mode=True, radius="0.5rem")
|
||||
theme_file = tmp_path / "assets" / "theme.css"
|
||||
assert theme_file.exists()
|
||||
content = theme_file.read_text()
|
||||
content = theme_file.read_text(encoding="utf-8")
|
||||
assert ":root {" in content
|
||||
|
||||
def test_creates_assets_dir(self, tmp_path: Path):
|
||||
@@ -367,7 +367,7 @@ class TestThemePipelineIntegration:
|
||||
assert result.success is True
|
||||
assert "assets/theme.css" in result.files
|
||||
|
||||
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text()
|
||||
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text(encoding="utf-8")
|
||||
assert ":root {" in theme_css
|
||||
assert ".dark {" in theme_css
|
||||
assert "--radius: 1rem" in theme_css # xl = 1rem
|
||||
@@ -386,7 +386,7 @@ class TestThemePipelineIntegration:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert result.success is True
|
||||
|
||||
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text()
|
||||
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text(encoding="utf-8")
|
||||
assert ":root {" in theme_css
|
||||
assert ".dark" not in theme_css
|
||||
|
||||
@@ -405,7 +405,7 @@ class TestThemePipelineIntegration:
|
||||
assert result.success is True
|
||||
|
||||
ui_config = json.loads(
|
||||
(tmp_path / "output" / "ui-config.json").read_text()
|
||||
(tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
theme = ui_config["site"]["theme"]
|
||||
assert theme["preset"] == "blue"
|
||||
@@ -426,7 +426,7 @@ class TestThemePipelineIntegration:
|
||||
|
||||
theme_css_path = tmp_path / "output" / "assets" / "theme.css"
|
||||
assert theme_css_path.exists(), "theme.css must always be generated"
|
||||
content = theme_css_path.read_text()
|
||||
content = theme_css_path.read_text(encoding="utf-8")
|
||||
assert ":root {" in content
|
||||
assert "--radius:" in content
|
||||
|
||||
@@ -444,7 +444,7 @@ class TestThemePipelineIntegration:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert result.success is True
|
||||
|
||||
index_html = (tmp_path / "output" / "index.html").read_text()
|
||||
index_html = (tmp_path / "output" / "index.html").read_text(encoding="utf-8")
|
||||
assert "classList.add('dark')" not in index_html
|
||||
|
||||
def test_dark_mode_antiflicker_has_dark_class(self, tmp_path: Path):
|
||||
@@ -461,7 +461,7 @@ class TestThemePipelineIntegration:
|
||||
result = compiler.compile(tmp_path / "output")
|
||||
assert result.success is True
|
||||
|
||||
index_html = (tmp_path / "output" / "index.html").read_text()
|
||||
index_html = (tmp_path / "output" / "index.html").read_text(encoding="utf-8")
|
||||
assert "classList.add('dark')" in index_html
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ class TestCompositionResolver:
|
||||
|
||||
# Load ui-config.json and verify auto-bridging
|
||||
ui_config_path = tmp_path / "output" / "ui-config.json"
|
||||
ui_config = json.loads(ui_config_path.read_text())
|
||||
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
|
||||
|
||||
docs_template = ui_config["templates"]["docs"]
|
||||
assert "zones" in docs_template
|
||||
@@ -556,7 +556,7 @@ class TestCompositionResolver:
|
||||
|
||||
# Load ui-config.json and verify no auto-bridging occurred
|
||||
ui_config_path = tmp_path / "output" / "ui-config.json"
|
||||
ui_config = json.loads(ui_config_path.read_text())
|
||||
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
|
||||
|
||||
docs_template = ui_config["templates"]["docs"]
|
||||
# Should not have zones for non-FlexibleLayout
|
||||
@@ -589,7 +589,7 @@ class TestCompositionResolver:
|
||||
|
||||
# Load ui-config.json and verify no duplicate components
|
||||
ui_config_path = tmp_path / "output" / "ui-config.json"
|
||||
ui_config = json.loads(ui_config_path.read_text())
|
||||
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
|
||||
|
||||
docs_template = ui_config["templates"]["docs"]
|
||||
zones = docs_template["zones"]
|
||||
|
||||
@@ -30,7 +30,7 @@ class TestCompilerChatOutput:
|
||||
result = compiler.compile(tmp_path)
|
||||
|
||||
assert result.success
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["style"] == "chat"
|
||||
|
||||
def test_layout_in_ui_config(self, tmp_path: Path):
|
||||
@@ -43,7 +43,7 @@ class TestCompilerChatOutput:
|
||||
result = compiler.compile(tmp_path)
|
||||
|
||||
assert result.success
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["layout"]["mode"] == "bottom-right"
|
||||
assert ui_config["layout"]["width"] == "400px"
|
||||
assert ui_config["layout"]["height"] == "600px"
|
||||
@@ -73,7 +73,7 @@ class TestCompilerChatOutput:
|
||||
result = compiler.compile(tmp_path)
|
||||
|
||||
assert result.success
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["chat"]["enabled"] is True
|
||||
assert ui_config["chat"]["name"] == "AI Assistant"
|
||||
assert len(ui_config["chat"]["starters"]) == 1
|
||||
@@ -96,7 +96,7 @@ class TestCompilerChatOutput:
|
||||
result = compiler.compile(tmp_path)
|
||||
|
||||
assert result.success
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert ui_config["auth"]["enabled"] is True
|
||||
assert "password" in ui_config["auth"]["providers"]
|
||||
assert "google" in ui_config["auth"]["providers"]
|
||||
@@ -128,7 +128,7 @@ class TestCompilerChatOutput:
|
||||
result = compiler.compile(tmp_path)
|
||||
|
||||
assert result.success
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
|
||||
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
|
||||
assert len(ui_config["widgets"]) == 2
|
||||
assert ui_config["widgets"][0]["type"] == "slider"
|
||||
assert ui_config["widgets"][0]["name"] == "temperature"
|
||||
|
||||
@@ -24,7 +24,7 @@ DASHBOARD_JS = (
|
||||
@pytest.fixture
|
||||
def dashboard_source():
|
||||
"""Read dashboard.js source."""
|
||||
return DASHBOARD_JS.read_text()
|
||||
return DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestComponentRegistry:
|
||||
@@ -68,7 +68,7 @@ class TestPythonJSComponentParity:
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "ui.py"
|
||||
)
|
||||
source = ui_py.read_text()
|
||||
source = ui_py.read_text(encoding="utf-8")
|
||||
|
||||
# Extract all "type": "xxx" patterns from ui.py
|
||||
py_types = set(re.findall(r'"type":\s*"(\w+)"', source))
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestBuiltinSchemaCoverage:
|
||||
ui_src = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "ui.py"
|
||||
).read_text()
|
||||
).read_text(encoding="utf-8")
|
||||
py_types = set(re.findall(r'"type":\s*"(\w+)"', ui_src))
|
||||
schemas = srv.get_component_schemas()
|
||||
missing = py_types - set(schemas)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Unit tests for the composer context token ring (STITCH-002, #196).
|
||||
|
||||
Covers:
|
||||
* Backend char-based context estimator + /api/chat/context-stats route.
|
||||
* Deterministic, wire-safe serialisation.
|
||||
* Frontend ring threshold state machine + component presence in chat.js.
|
||||
* i18n keys for ring/banner/chip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiui.features.chat import (
|
||||
DEFAULT_CONTEXT_LIMIT,
|
||||
_chat_context_stats,
|
||||
_estimate_context_stats,
|
||||
)
|
||||
from praisonaiui.features.i18n import JSONLocaleManager
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
CHAT_JS = FRONTEND / "views" / "chat.js"
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
def __init__(self, params: dict[str, str]):
|
||||
self.query_params = params
|
||||
|
||||
|
||||
class TestEstimator:
|
||||
def test_empty_session_baseline(self):
|
||||
stats = _estimate_context_stats([], session_id="s1")
|
||||
assert stats["session_id"] == "s1"
|
||||
assert stats["limit"] == DEFAULT_CONTEXT_LIMIT
|
||||
assert stats["estimate"] is True
|
||||
# System + tools baselines only
|
||||
assert stats["breakdown"]["messages"] == 0
|
||||
assert stats["breakdown"]["memory"] == 0
|
||||
assert stats["used"] > 0
|
||||
|
||||
def test_messages_increase_usage(self):
|
||||
small = _estimate_context_stats(
|
||||
[{"role": "user", "content": "hi"}], session_id="s"
|
||||
)
|
||||
big = _estimate_context_stats(
|
||||
[{"role": "user", "content": "x" * 4000}], session_id="s"
|
||||
)
|
||||
assert big["used"] > small["used"]
|
||||
assert big["breakdown"]["messages"] > small["breakdown"]["messages"]
|
||||
|
||||
def test_tool_calls_count_towards_tools(self):
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"toolCalls": [{"args": "q" * 400, "result": "r" * 400}],
|
||||
}
|
||||
]
|
||||
stats = _estimate_context_stats(msgs, session_id="s")
|
||||
baseline = _estimate_context_stats([], session_id="s")
|
||||
assert stats["breakdown"]["tools"] > baseline["breakdown"]["tools"]
|
||||
|
||||
def test_usage_pct_matches_used_over_limit(self):
|
||||
stats = _estimate_context_stats(
|
||||
[{"role": "user", "content": "x" * 8000}], session_id="s", limit=100000
|
||||
)
|
||||
expected = round((stats["used"] / 100000) * 100, 1)
|
||||
assert stats["usage_pct"] == expected
|
||||
|
||||
def test_zero_limit_falls_back_to_default_for_pct(self):
|
||||
stats = _estimate_context_stats([], session_id="s", limit=0)
|
||||
# limit echoed as-is, but pct computed against a safe default (no div/0)
|
||||
assert stats["limit"] == 0
|
||||
assert stats["usage_pct"] >= 0
|
||||
|
||||
def test_breakdown_keys_sorted_deterministic(self):
|
||||
stats = _estimate_context_stats([], session_id="s")
|
||||
keys = list(stats["breakdown"].keys())
|
||||
assert keys == sorted(keys)
|
||||
assert set(keys) == {"memory", "messages", "system", "tools"}
|
||||
|
||||
def test_top_level_keys_sorted(self):
|
||||
stats = _estimate_context_stats([], session_id="s")
|
||||
keys = list(stats.keys())
|
||||
assert keys == sorted(keys)
|
||||
|
||||
|
||||
class TestContextStatsRoute:
|
||||
def test_unknown_session_returns_estimate_not_404(self):
|
||||
resp = asyncio.run(_chat_context_stats(_FakeRequest({"session_id": "nope"})))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_session_id_ok(self):
|
||||
resp = asyncio.run(_chat_context_stats(_FakeRequest({})))
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_limit_falls_back(self):
|
||||
resp = asyncio.run(
|
||||
_chat_context_stats(_FakeRequest({"session_id": "s", "limit": "abc"}))
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestRingThresholds:
|
||||
"""Mirror the JS ringStateFor thresholds against the spec table."""
|
||||
|
||||
def _state(self, pct: float) -> str:
|
||||
if pct >= 95:
|
||||
return "over"
|
||||
if pct >= 85:
|
||||
return "critical"
|
||||
if pct >= 60:
|
||||
return "warning"
|
||||
return "healthy"
|
||||
|
||||
def test_healthy_below_60(self):
|
||||
assert self._state(33) == "healthy"
|
||||
assert self._state(59.9) == "healthy"
|
||||
|
||||
def test_warning_60_to_85(self):
|
||||
assert self._state(60) == "warning"
|
||||
assert self._state(84.9) == "warning"
|
||||
|
||||
def test_critical_85_to_95(self):
|
||||
assert self._state(85) == "critical"
|
||||
assert self._state(94.9) == "critical"
|
||||
|
||||
def test_over_at_95(self):
|
||||
assert self._state(95) == "over"
|
||||
assert self._state(120) == "over"
|
||||
|
||||
|
||||
class TestChatJsComponents:
|
||||
def test_ring_dom_present(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'id="chat-ring-wrap"' in src
|
||||
assert 'id="chat-ring-arc"' in src
|
||||
assert 'id="chat-ring-tooltip"' in src
|
||||
|
||||
def test_send_button_inside_ring_preserved_id(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'class="chat-ring-send" id="chat-send-btn"' in src
|
||||
assert 'aria-valuenow' in src
|
||||
assert 'aria-valuemax' in src
|
||||
|
||||
def test_compaction_banner_present(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'id="chat-compaction-banner"' in src
|
||||
assert "function updateCompactionBanner(" in src
|
||||
assert "function dismissCompactionBanner(" in src
|
||||
|
||||
def test_memory_chip_present(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'id="chat-memory-chip"' in src
|
||||
assert "function refreshMemoryChip(" in src
|
||||
|
||||
def test_ring_state_machine_thresholds(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "function ringStateFor(" in src
|
||||
assert "pct >= 95" in src
|
||||
assert "pct >= 85" in src
|
||||
assert "pct >= 60" in src
|
||||
|
||||
def test_ws_context_events_handled(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "case 'context_update':" in src
|
||||
assert "case 'context_compacted':" in src
|
||||
|
||||
def test_poll_fallback_and_debounce(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "function startContextPoll(" in src
|
||||
assert "function scheduleContextRefresh(" in src
|
||||
|
||||
def test_existing_memory_panel_and_metrics_preserved(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
# Regression: do not remove existing shipped elements
|
||||
assert 'id="chat-memory-panel"' in src
|
||||
assert 'id="chat-memory-btn"' in src
|
||||
assert "function showStreamMetrics(" in src
|
||||
|
||||
|
||||
class TestI18nKeys:
|
||||
KEYS = [
|
||||
"chat.context.healthy",
|
||||
"chat.context.warning",
|
||||
"chat.context.critical",
|
||||
"chat.context.estimate",
|
||||
"chat.compaction.banner",
|
||||
"chat.compaction.compacted",
|
||||
"chat.memory.chip",
|
||||
"chat.memory.empty_turn",
|
||||
]
|
||||
|
||||
def test_all_keys_present_en(self):
|
||||
mgr = JSONLocaleManager()
|
||||
strings = mgr.get_strings("en")
|
||||
for k in self.KEYS:
|
||||
assert k in strings, f"missing en key: {k}"
|
||||
|
||||
def test_keys_present_all_locales(self):
|
||||
mgr = JSONLocaleManager()
|
||||
for locale in ("en", "es", "fr"):
|
||||
strings = mgr.get_strings(locale)
|
||||
for k in self.KEYS:
|
||||
assert k in strings, f"missing {locale} key: {k}"
|
||||
|
||||
def test_memory_chip_interpolates_count(self):
|
||||
mgr = JSONLocaleManager()
|
||||
out = mgr.t("chat.memory.chip", locale="en", count=3)
|
||||
assert "3" in out
|
||||
@@ -27,7 +27,7 @@ DASHBOARD_JS = (
|
||||
|
||||
|
||||
def _style() -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
|
||||
assert m, "DASHBOARD_STYLE template literal not found"
|
||||
return m.group(1)
|
||||
|
||||
@@ -21,7 +21,7 @@ DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
|
||||
class TestSelectPageGuard:
|
||||
def _select_page_body(self) -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
@@ -56,7 +56,7 @@ class TestSelectPageGuard:
|
||||
assert "console.error" in m.group(1)
|
||||
|
||||
def test_show_dashboard_init_error_helper_present(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
|
||||
@@ -23,7 +23,7 @@ GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
|
||||
|
||||
class TestDashboardShellOrder:
|
||||
def _build_body(self) -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"function buildDashboard\([^)]*\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
@@ -51,7 +51,7 @@ class TestDashboardShellOrder:
|
||||
assert m, "shellHeader insert must be guarded by children.length"
|
||||
|
||||
def test_generator_emits_main_before_insert_before(self):
|
||||
src = GENERATOR.read_text()
|
||||
src = GENERATOR.read_text(encoding="utf-8")
|
||||
append_idx = src.find("root.appendChild(main);\n const shellHeader")
|
||||
insert_idx = src.find("root.insertBefore(shellHeader, main)")
|
||||
assert append_idx != -1, (
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Eval Quality Cockpit (STITCH-009) view + helper wiring tests.
|
||||
|
||||
Validates the client-side refactor of the Evaluation view into a regression
|
||||
dashboard. No backend changes are expected — all trend/regression logic is
|
||||
derived in JS from existing endpoints, so these tests assert the shipped view
|
||||
source and that the eval APIs still expose the shapes the cockpit consumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
VIEWS = FRONTEND / "views"
|
||||
|
||||
|
||||
def _eval_source() -> str:
|
||||
return (VIEWS / "eval.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _helpers_source() -> str:
|
||||
return (VIEWS / "_helpers.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_eval_view_file_exists():
|
||||
assert (VIEWS / "eval.js").exists()
|
||||
|
||||
|
||||
def test_helpers_export_cockpit_functions():
|
||||
src = _helpers_source()
|
||||
for fn in ("bucketByDay", "detectRegression", "trendArrow", "regressionBadge"):
|
||||
assert f"export function {fn}" in src, fn
|
||||
|
||||
|
||||
def test_cockpit_title_and_zones():
|
||||
src = _eval_source()
|
||||
assert "Eval Quality Cockpit" in src
|
||||
assert "Suite Health" in src
|
||||
assert "Baseline Compare" in src
|
||||
assert "Run Eval" in src
|
||||
assert "Run History" in src
|
||||
|
||||
|
||||
def test_cockpit_uses_helpers():
|
||||
src = _eval_source()
|
||||
assert "bucketByDay" in src
|
||||
assert "detectRegression" in src
|
||||
assert "sparklineSVG" in src
|
||||
assert "regressionBadge" in src
|
||||
|
||||
|
||||
def test_cockpit_fault_tolerant_fetch():
|
||||
src = _eval_source()
|
||||
assert "Promise.allSettled" in src
|
||||
assert "/api/eval/status" in src
|
||||
assert "/api/eval/scores" in src
|
||||
assert "/api/eval?limit=" in src
|
||||
assert "/api/eval/judges" in src
|
||||
|
||||
|
||||
def test_cockpit_run_posts_to_run_endpoint():
|
||||
src = _eval_source()
|
||||
assert "/api/eval/run" in src
|
||||
assert "method: 'POST'" in src
|
||||
|
||||
|
||||
def test_cockpit_baseline_uses_localstorage():
|
||||
src = _eval_source()
|
||||
assert "aiui_eval_baseline_id" in src
|
||||
assert "aiui_eval_baseline_snapshot" in src
|
||||
assert "localStorage" in src
|
||||
|
||||
|
||||
def test_cockpit_debug_link_falls_back_to_traces():
|
||||
src = _eval_source()
|
||||
assert "session_id" in src
|
||||
assert "/runs?session=" in src
|
||||
assert "'traces'" in src
|
||||
|
||||
|
||||
def test_cockpit_escapes_case_content():
|
||||
src = _eval_source()
|
||||
assert "esc(" in src
|
||||
# eval content must never be raw-interpolated as innerHTML
|
||||
assert "esc((e.input" in src
|
||||
assert "esc((e.output" in src
|
||||
|
||||
|
||||
def test_eval_still_registered_in_dashboard():
|
||||
src = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "/plugins/views/eval.js" in src
|
||||
|
||||
|
||||
def test_eval_api_shapes_still_present():
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui as aiui
|
||||
import praisonaiui.server as server
|
||||
|
||||
server.reset_state()
|
||||
aiui.set_style("dashboard")
|
||||
app = server.create_app()
|
||||
client = TestClient(app)
|
||||
|
||||
status = client.get("/api/eval/status")
|
||||
assert status.status_code == 200
|
||||
assert "sdk_available" in status.json()
|
||||
|
||||
scores = client.get("/api/eval/scores")
|
||||
assert scores.status_code == 200
|
||||
assert "scores" in scores.json()
|
||||
|
||||
evals = client.get("/api/eval?limit=200")
|
||||
assert evals.status_code == 200
|
||||
assert "evaluations" in evals.json()
|
||||
|
||||
judges = client.get("/api/eval/judges")
|
||||
assert judges.status_code == 200
|
||||
assert "judges" in judges.json()
|
||||
|
||||
run = client.post(
|
||||
"/api/eval/run",
|
||||
json={
|
||||
"agent_id": "researcher",
|
||||
"input": "2+2",
|
||||
"output": "4",
|
||||
"expected": "4",
|
||||
"score": 1.0,
|
||||
"passed": True,
|
||||
},
|
||||
)
|
||||
assert run.status_code == 200
|
||||
result = run.json().get("result", {})
|
||||
assert result.get("agent_id") == "researcher"
|
||||
assert result.get("score") == 1.0
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the shared examples console helper.
|
||||
|
||||
These verify that examples degrade emoji to ASCII on legacy Windows code pages
|
||||
(cp1252) instead of raising ``UnicodeEncodeError`` while preserving emoji on
|
||||
UTF-aware terminals, mirroring the CLI ``_icon``/``_supports_unicode`` pattern.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "examples", "python", "_shared"))
|
||||
from console import icon, safe_print, supports_unicode
|
||||
|
||||
|
||||
class TestSupportsUnicode:
|
||||
def test_utf8_encoding_is_supported(self):
|
||||
mock_stdout = mock.MagicMock()
|
||||
mock_stdout.encoding = "utf-8"
|
||||
with mock.patch("sys.stdout", mock_stdout):
|
||||
assert supports_unicode() is True
|
||||
|
||||
def test_cp1252_encoding_is_not_supported(self):
|
||||
mock_stdout = mock.MagicMock()
|
||||
mock_stdout.encoding = "cp1252"
|
||||
with mock.patch("sys.stdout", mock_stdout):
|
||||
assert supports_unicode() is False
|
||||
|
||||
def test_missing_encoding_falls_back_to_ascii(self):
|
||||
mock_stdout = mock.MagicMock()
|
||||
mock_stdout.encoding = None
|
||||
with mock.patch("sys.stdout", mock_stdout):
|
||||
assert supports_unicode() is False
|
||||
|
||||
|
||||
class TestIcon:
|
||||
def test_returns_symbol_on_utf8(self):
|
||||
mock_stdout = mock.MagicMock()
|
||||
mock_stdout.encoding = "utf-8"
|
||||
with mock.patch("sys.stdout", mock_stdout):
|
||||
assert icon("🚀", "[START]") == "🚀"
|
||||
|
||||
def test_returns_fallback_on_cp1252(self):
|
||||
mock_stdout = mock.MagicMock()
|
||||
mock_stdout.encoding = "cp1252"
|
||||
with mock.patch("sys.stdout", mock_stdout):
|
||||
assert icon("🚀", "[START]") == "[START]"
|
||||
|
||||
|
||||
class TestSafePrint:
|
||||
def test_never_raises_on_cp1252(self):
|
||||
buffer = io.BytesIO()
|
||||
stream = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
|
||||
with mock.patch("sys.stdout", stream):
|
||||
safe_print("🚀 Starting")
|
||||
stream.flush()
|
||||
assert buffer.getvalue()
|
||||
|
||||
def test_preserves_unicode_on_utf8(self):
|
||||
buffer = io.StringIO()
|
||||
with mock.patch("sys.stdout", buffer):
|
||||
safe_print("🚀 Starting")
|
||||
assert "🚀" in buffer.getvalue()
|
||||
|
||||
def test_respects_sep(self):
|
||||
buffer = io.StringIO()
|
||||
with mock.patch("sys.stdout", buffer):
|
||||
safe_print("a", "b", sep="-")
|
||||
assert buffer.getvalue().strip() == "a-b"
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Unit tests for the Cost FinOps Strip (STITCH-013, #223).
|
||||
|
||||
Covers:
|
||||
* Budget threshold + today-sum logic (mirrors the JS helpers).
|
||||
* FinOps config schema section + cross-field validation.
|
||||
* Frontend component presence in overview.js / chat.js / _helpers.js.
|
||||
* i18n keys for strip/banner/chip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiui.features.config_runtime import CONFIG_SCHEMA, validate_config
|
||||
from praisonaiui.features.i18n import JSONLocaleManager
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
HELPERS_JS = FRONTEND / "views" / "_helpers.js"
|
||||
OVERVIEW_JS = FRONTEND / "views" / "overview.js"
|
||||
CHAT_JS = FRONTEND / "views" / "chat.js"
|
||||
|
||||
|
||||
def _compute_budget_pct(today: float, budget) -> float | None:
|
||||
"""Python mirror of computeBudgetPct."""
|
||||
if not budget or budget <= 0:
|
||||
return None
|
||||
pct = today / budget * 100
|
||||
if pct < 0:
|
||||
return 0
|
||||
return min(pct, 100)
|
||||
|
||||
|
||||
def _budget_level(pct, warn_pct: int = 80, critical_pct: int = 95) -> str:
|
||||
"""Python mirror of budgetLevelFor."""
|
||||
if pct is None:
|
||||
return "none"
|
||||
if pct >= critical_pct:
|
||||
return "critical"
|
||||
if pct >= warn_pct:
|
||||
return "warn"
|
||||
return "none"
|
||||
|
||||
|
||||
def _sum_today_tokens(timeseries: list[dict]) -> int:
|
||||
"""Python mirror of sumTodayTokens (uses fixed today key for determinism)."""
|
||||
today_key = "2026-07-15"
|
||||
dated = [p for p in timeseries if isinstance(p.get("hour_key"), str)]
|
||||
scope = [p for p in dated if p["hour_key"].startswith(today_key)] if dated else timeseries
|
||||
return sum(int(p.get("tokens", 0) or 0) for p in scope)
|
||||
|
||||
|
||||
def _short_model(model: str) -> str:
|
||||
idx = model.rfind("/")
|
||||
return model[idx + 1:] if idx >= 0 else model
|
||||
|
||||
|
||||
class TestBudgetPct:
|
||||
def test_80_percent(self):
|
||||
assert _compute_budget_pct(1_600_000, 2_000_000) == 80
|
||||
|
||||
def test_null_budget(self):
|
||||
assert _compute_budget_pct(1_000_000, None) is None
|
||||
|
||||
def test_zero_budget_treated_as_unset(self):
|
||||
assert _compute_budget_pct(1_000_000, 0) is None
|
||||
|
||||
def test_over_budget_capped_at_100(self):
|
||||
assert _compute_budget_pct(3_000_000, 2_000_000) == 100
|
||||
|
||||
|
||||
class TestBudgetLevel:
|
||||
def test_below_warn_is_none(self):
|
||||
assert _budget_level(79) == "none"
|
||||
|
||||
def test_at_warn_is_warn(self):
|
||||
assert _budget_level(80) == "warn"
|
||||
assert _budget_level(82) == "warn"
|
||||
|
||||
def test_at_critical_is_critical(self):
|
||||
assert _budget_level(95) == "critical"
|
||||
assert _budget_level(96) == "critical"
|
||||
|
||||
def test_null_pct_is_none(self):
|
||||
assert _budget_level(None) == "none"
|
||||
|
||||
|
||||
class TestSumTodayTokens:
|
||||
def test_only_today_buckets_summed(self):
|
||||
ts = [
|
||||
{"hour_key": "2026-07-15-08", "tokens": 100},
|
||||
{"hour_key": "2026-07-15-09", "tokens": 200},
|
||||
{"hour_key": "2026-07-14-23", "tokens": 999},
|
||||
]
|
||||
assert _sum_today_tokens(ts) == 300
|
||||
|
||||
def test_falls_back_to_all_when_undated(self):
|
||||
ts = [{"tokens": 10}, {"tokens": 20}]
|
||||
assert _sum_today_tokens(ts) == 30
|
||||
|
||||
|
||||
class TestShortModel:
|
||||
def test_strips_provider_prefix(self):
|
||||
assert _short_model("anthropic/claude-3-5-sonnet") == "claude-3-5-sonnet"
|
||||
|
||||
def test_no_prefix_unchanged(self):
|
||||
assert _short_model("gpt-4o-mini") == "gpt-4o-mini"
|
||||
|
||||
|
||||
class TestFinOpsConfigSchema:
|
||||
def test_finops_section_present(self):
|
||||
assert "finops" in CONFIG_SCHEMA["properties"]
|
||||
|
||||
def test_finops_fields_present(self):
|
||||
props = CONFIG_SCHEMA["properties"]["finops"]["properties"]
|
||||
for field in (
|
||||
"enabled",
|
||||
"daily_token_budget",
|
||||
"daily_cost_budget_usd",
|
||||
"warn_pct",
|
||||
"critical_pct",
|
||||
"show_session_chip",
|
||||
):
|
||||
assert field in props, f"missing finops field: {field}"
|
||||
|
||||
def test_threshold_defaults(self):
|
||||
props = CONFIG_SCHEMA["properties"]["finops"]["properties"]
|
||||
assert props["warn_pct"]["default"] == 80
|
||||
assert props["critical_pct"]["default"] == 95
|
||||
|
||||
|
||||
class TestFinOpsValidation:
|
||||
def test_critical_must_exceed_warn(self):
|
||||
errors = validate_config({"finops": {"warn_pct": 90, "critical_pct": 80}})
|
||||
assert any("critical_pct" in e for e in errors)
|
||||
|
||||
def test_equal_thresholds_rejected(self):
|
||||
errors = validate_config({"finops": {"warn_pct": 80, "critical_pct": 80}})
|
||||
assert any("critical_pct" in e for e in errors)
|
||||
|
||||
def test_valid_thresholds_pass(self):
|
||||
errors = validate_config({"finops": {"warn_pct": 80, "critical_pct": 95}})
|
||||
assert not any("critical_pct" in e for e in errors)
|
||||
|
||||
def test_negative_budget_rejected(self):
|
||||
errors = validate_config({"finops": {"daily_token_budget": -5}})
|
||||
assert any("daily_token_budget" in e for e in errors)
|
||||
|
||||
def test_no_finops_section_ok(self):
|
||||
assert validate_config({"model": {"name": "gpt-4o-mini"}}) == []
|
||||
|
||||
|
||||
class TestHelpersJs:
|
||||
def test_donut_and_strip_helpers_present(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert "export function MiniDonutSVG(" in src
|
||||
assert "export function costStripHTML(" in src
|
||||
assert "export function budgetBannerHTML(" in src
|
||||
|
||||
def test_math_helpers_present(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert "export function computeBudgetPct(" in src
|
||||
assert "export function budgetLevelFor(" in src
|
||||
assert "export function sumTodayTokens(" in src
|
||||
assert "export function formatTokens(" in src
|
||||
assert "export function formatCost(" in src
|
||||
|
||||
def test_donut_empty_state_accessible(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert 'aria-label="No model data"' in src
|
||||
|
||||
def test_strip_navigates_and_is_region(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert 'data-nav="usage"' in src
|
||||
assert 'role="region"' in src
|
||||
|
||||
|
||||
class TestOverviewJs:
|
||||
def test_finops_imports_and_render(self):
|
||||
src = OVERVIEW_JS.read_text(encoding="utf-8")
|
||||
assert "costStripHTML" in src
|
||||
assert "budgetBannerHTML" in src
|
||||
assert "function renderFinOps(" in src
|
||||
assert 'id="ov-finops"' in src
|
||||
|
||||
def test_fetches_models_and_config(self):
|
||||
src = OVERVIEW_JS.read_text(encoding="utf-8")
|
||||
assert "/api/usage/models" in src
|
||||
assert "/api/config/runtime" in src
|
||||
|
||||
def test_graceful_degrade_state_machine(self):
|
||||
src = OVERVIEW_JS.read_text(encoding="utf-8")
|
||||
assert "function finopsState(" in src
|
||||
assert "finops.enabled === false" in src
|
||||
|
||||
|
||||
class TestChatJs:
|
||||
def test_session_cost_chip_present(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'id="chat-session-cost-chip"' in src
|
||||
assert "function updateSessionCostChip(" in src
|
||||
|
||||
def test_finops_banner_present(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'id="chat-finops-banner"' in src
|
||||
assert "function updateFinopsBanner(" in src
|
||||
assert "function loadFinopsConfig(" in src
|
||||
|
||||
def test_chip_updates_on_run_completed(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "updateFinopsBanner()" in src
|
||||
assert "updateSessionCostChip(stats)" in src
|
||||
|
||||
def test_context_ring_regression_preserved(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert 'id="chat-ring-wrap"' in src
|
||||
assert "function ringStateFor(" in src
|
||||
assert 'class="chat-ring-send" id="chat-send-btn"' in src
|
||||
|
||||
|
||||
class TestI18nKeys:
|
||||
KEYS = [
|
||||
"finops.strip.title",
|
||||
"finops.strip.today",
|
||||
"finops.strip.view_usage",
|
||||
"finops.banner.warn",
|
||||
"finops.banner.critical",
|
||||
"finops.chip.label",
|
||||
]
|
||||
|
||||
def test_all_keys_present_all_locales(self):
|
||||
mgr = JSONLocaleManager()
|
||||
for locale in ("en", "es", "fr"):
|
||||
strings = mgr.get_strings(locale)
|
||||
for k in self.KEYS:
|
||||
assert k in strings, f"missing {locale} key: {k}"
|
||||
|
||||
def test_banner_interpolates_pct(self):
|
||||
mgr = JSONLocaleManager()
|
||||
out = mgr.t("finops.banner.warn", locale="en", pct=82)
|
||||
assert "82" in out
|
||||
|
||||
def test_chip_interpolates_tokens_and_model(self):
|
||||
mgr = JSONLocaleManager()
|
||||
out = mgr.t("finops.chip.label", locale="en", tokens="12.4k", model="gpt-4o-mini")
|
||||
assert "12.4k" in out
|
||||
assert "gpt-4o-mini" in out
|
||||
@@ -108,7 +108,7 @@ class TestFormInputDataLabel:
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend"
|
||||
/ "plugins" / "dashboard.js"
|
||||
)
|
||||
return p.read_text()
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"renderTextInput",
|
||||
|
||||
@@ -84,7 +84,7 @@ class TestRenderersEmitName:
|
||||
via `el.name` (which has priority over `el.dataset.label`)."""
|
||||
|
||||
def _src(self):
|
||||
return DASHBOARD_JS.read_text()
|
||||
return DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
|
||||
@pytest.mark.parametrize("renderer", [
|
||||
"renderTextInput",
|
||||
@@ -117,7 +117,7 @@ class TestSubmitCollectorPriority:
|
||||
"""renderFormAction submit handler must prefer `name` over `data-label`."""
|
||||
|
||||
def test_name_has_priority_over_label(self):
|
||||
source = DASHBOARD_JS.read_text()
|
||||
source = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"function renderFormAction\(comp\)\s*\{(.*?)^}",
|
||||
source, re.DOTALL | re.MULTILINE,
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestFrontendNavigation:
|
||||
index_path = Path("src/praisonaiui/templates/frontend/index.html")
|
||||
assert index_path.exists(), "index.html should exist"
|
||||
|
||||
content = index_path.read_text()
|
||||
content = index_path.read_text(encoding="utf-8")
|
||||
assert "/assets/index.js" in content, "Should reference index.js"
|
||||
assert "/assets/index.css" in content, "Should reference index.css"
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestFrontendNavigation:
|
||||
if bundle_path.exists():
|
||||
# Check if the bundle contains popstate handling code
|
||||
# Note: The bundle is minified, so we check for the event name
|
||||
content = bundle_path.read_text()
|
||||
content = bundle_path.read_text(encoding="utf-8")
|
||||
assert "popstate" in content, "Bundle should contain popstate event handler"
|
||||
|
||||
def test_title_template_handling(self):
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for issue #220 — Channel Inbox unified multi-channel message hub.
|
||||
|
||||
Asserts the committed frontend view source
|
||||
``src/praisonaiui/templates/frontend/plugins/views/inbox.js`` provides the
|
||||
three-pane triage shell (queue, conversation, context rail), thread-building
|
||||
helpers, assign-agent + status persistence via session state, inline reply via
|
||||
the chat transport, and deep links to /chat and /work. Also verifies the view
|
||||
is registered in ``dashboard.js`` and the ``inbox`` page in ``server.py``, and
|
||||
that ``channels.js`` links to the inbox.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
_FRONTEND = _ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
INBOX_VIEW = _FRONTEND / "views" / "inbox.js"
|
||||
CHANNELS_VIEW = _FRONTEND / "views" / "channels.js"
|
||||
DASHBOARD = _FRONTEND / "dashboard.js"
|
||||
SESSIONS_EXT = _ROOT / "src" / "praisonaiui" / "features" / "sessions_ext.py"
|
||||
SERVER = _ROOT / "src" / "praisonaiui" / "server.py"
|
||||
|
||||
|
||||
def _inbox() -> str:
|
||||
return INBOX_VIEW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestThreePaneShell:
|
||||
def test_view_exists(self):
|
||||
assert INBOX_VIEW.exists()
|
||||
|
||||
def test_three_panes_present(self):
|
||||
src = _inbox()
|
||||
assert "inbox-queue" in src
|
||||
assert "inbox-conv" in src
|
||||
assert "inbox-rail" in src
|
||||
|
||||
def test_exports_render(self):
|
||||
assert "export async function render(container)" in _inbox()
|
||||
|
||||
def test_marks_page_container(self):
|
||||
assert "data-page', 'inbox'" in _inbox()
|
||||
|
||||
|
||||
class TestThreadBuilder:
|
||||
def test_grouping_helpers_exported(self):
|
||||
src = _inbox()
|
||||
assert "export function groupMessagesBySender" in src
|
||||
assert "export function buildThreadRow" in src
|
||||
assert "export function sortThreads" in src
|
||||
|
||||
def test_sort_uses_status_priority(self):
|
||||
src = _inbox()
|
||||
assert "priority" in src
|
||||
assert "waiting_on_agent" in src
|
||||
|
||||
|
||||
class TestFilters:
|
||||
def test_platform_and_status_filters(self):
|
||||
src = _inbox()
|
||||
assert "inbox-platform-chips" in src
|
||||
assert "inbox-status-chips" in src
|
||||
assert "Search conversations" in src
|
||||
|
||||
|
||||
class TestAssignAndStatus:
|
||||
def test_assign_persists_via_session_state(self):
|
||||
src = _inbox()
|
||||
assert "/state" in src
|
||||
assert "inbox_threads" in src
|
||||
assert "assigned_agent" in src
|
||||
|
||||
def test_status_pills_present(self):
|
||||
src = _inbox()
|
||||
for status in ("open", "waiting_on_agent", "waiting_on_customer", "resolved"):
|
||||
assert status in src
|
||||
|
||||
def test_agent_sources(self):
|
||||
src = _inbox()
|
||||
assert "/agents" in src
|
||||
assert "/api/agents/definitions" in src
|
||||
|
||||
|
||||
class TestReplyTransport:
|
||||
def test_reply_uses_chat_send(self):
|
||||
src = _inbox()
|
||||
assert "/api/chat/send" in src
|
||||
assert "session_id" in src
|
||||
|
||||
def test_history_fetch(self):
|
||||
assert "/api/chat/history/" in _inbox()
|
||||
|
||||
|
||||
class TestEscalationLinks:
|
||||
def test_open_in_chat_link(self):
|
||||
assert "/chat?session=" in _inbox()
|
||||
|
||||
def test_escalate_to_work_hub_link(self):
|
||||
assert "/work?card=" in _inbox()
|
||||
|
||||
|
||||
class TestEmptyState:
|
||||
def test_no_channels_links_to_config(self):
|
||||
src = _inbox()
|
||||
assert "No channels configured" in src
|
||||
assert '/channels' in src
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_route_registered_in_dashboard(self):
|
||||
src = DASHBOARD.read_text(encoding="utf-8")
|
||||
assert "inbox:" in src
|
||||
assert "/plugins/views/inbox.js" in src
|
||||
|
||||
def test_page_registered_in_server(self):
|
||||
src = SERVER.read_text(encoding="utf-8")
|
||||
assert '"id": "inbox"' in src
|
||||
assert "Channel Inbox" in src
|
||||
|
||||
def test_channels_links_to_inbox(self):
|
||||
assert 'href="/inbox"' in CHANNELS_VIEW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestSessionsPlatformField:
|
||||
def test_sessions_list_surfaces_platform(self):
|
||||
src = SESSIONS_EXT.read_text(encoding="utf-8")
|
||||
assert '"platform"' in src
|
||||
assert '"channel_id"' in src
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Unit tests for knowledge embedding fallback + degraded-mode reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.features import _features, register_feature
|
||||
from praisonaiui.features import knowledge as knowledge_mod
|
||||
from praisonaiui.features.knowledge import (
|
||||
KnowledgeFeature,
|
||||
SDKKnowledgeManager,
|
||||
SimpleKnowledgeManager,
|
||||
_is_embedding_unavailable,
|
||||
)
|
||||
from praisonaiui.features.openai_api import (
|
||||
OpenAIAPIFeature,
|
||||
_default_embedding_model,
|
||||
_embedding_fallback_models,
|
||||
_is_model_not_found,
|
||||
)
|
||||
from praisonaiui.server import create_app
|
||||
|
||||
|
||||
class _FakeSDK:
|
||||
def __init__(self, search_error: BaseException | None = None) -> None:
|
||||
self._search_error = search_error
|
||||
|
||||
def search(self, query, limit=1, user_id=None):
|
||||
if query == "__probe__":
|
||||
return []
|
||||
if self._search_error is not None:
|
||||
raise self._search_error
|
||||
return []
|
||||
|
||||
|
||||
def test_is_model_not_found_detects_403():
|
||||
assert _is_model_not_found(Exception("Project does not have access to model x"))
|
||||
assert _is_model_not_found(Exception("litellm.NotFoundError: nope"))
|
||||
assert not _is_model_not_found(Exception("timeout"))
|
||||
|
||||
|
||||
def test_is_embedding_unavailable_detects_403():
|
||||
assert _is_embedding_unavailable(Exception("model_not_found"))
|
||||
assert not _is_embedding_unavailable(Exception("connection reset"))
|
||||
|
||||
|
||||
def test_default_embedding_model_env(monkeypatch):
|
||||
monkeypatch.delenv("AIUI_EMBEDDING_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False)
|
||||
assert _default_embedding_model() == "text-embedding-3-small"
|
||||
monkeypatch.setenv("AIUI_EMBEDDING_MODEL", "text-embedding-ada-002")
|
||||
assert _default_embedding_model() == "text-embedding-ada-002"
|
||||
|
||||
|
||||
def test_fallback_models_env(monkeypatch):
|
||||
monkeypatch.setenv("AIUI_EMBEDDING_FALLBACK_MODELS", "a, b ,,c")
|
||||
assert _embedding_fallback_models() == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_simple_manager_reports_local_mode():
|
||||
mgr = SimpleKnowledgeManager()
|
||||
mgr.store("hello world")
|
||||
mgr.search("hello")
|
||||
assert mgr.last_search_meta()["mode"] == "local"
|
||||
assert mgr.health()["embedding"]["available"] is False
|
||||
|
||||
|
||||
def test_sdk_search_reports_fallback_on_embedding_error():
|
||||
mgr = SDKKnowledgeManager()
|
||||
err = Exception("Project does not have access to model text-embedding-3-small")
|
||||
mgr._sdk_knowledge = _FakeSDK(search_error=err)
|
||||
mgr._sdk_probed = True
|
||||
|
||||
mgr.store("PraisonAIUI is a YAML-driven UI generator.")
|
||||
mgr.search("documentation generator")
|
||||
|
||||
meta = mgr.last_search_meta()
|
||||
assert meta["mode"] == "fallback"
|
||||
assert "text-embedding" in meta["error"].lower()
|
||||
|
||||
health = mgr.health()
|
||||
assert health["embedding"]["available"] is False
|
||||
assert health["status"] == "degraded"
|
||||
assert health["warnings"]
|
||||
|
||||
|
||||
def test_sdk_search_vector_mode_when_healthy():
|
||||
class _OkSDK:
|
||||
def search(self, query, limit=1, user_id=None):
|
||||
if query == "__probe__":
|
||||
return []
|
||||
return [{"id": "1", "memory": "hit", "score": 0.9}]
|
||||
|
||||
mgr = SDKKnowledgeManager()
|
||||
mgr._sdk_knowledge = _OkSDK()
|
||||
mgr._sdk_probed = True
|
||||
results = mgr.search("anything")
|
||||
assert results
|
||||
assert mgr.last_search_meta()["mode"] == "vector"
|
||||
|
||||
|
||||
def test_probe_flags_embedding_unavailable():
|
||||
mgr = SDKKnowledgeManager()
|
||||
|
||||
class _Kn:
|
||||
def search(self, query, limit=1):
|
||||
raise Exception("model_not_found: no access")
|
||||
|
||||
with patch("praisonaiagents.knowledge.Knowledge", return_value=_Kn(), create=True):
|
||||
mgr._get_sdk_knowledge()
|
||||
assert mgr._embedding_unavailable is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
saved = dict(_features)
|
||||
saved_mgr = knowledge_mod._knowledge_manager
|
||||
_features.clear()
|
||||
register_feature(KnowledgeFeature())
|
||||
register_feature(OpenAIAPIFeature())
|
||||
yield TestClient(create_app())
|
||||
_features.clear()
|
||||
_features.update(saved)
|
||||
knowledge_mod._knowledge_manager = saved_mgr
|
||||
|
||||
|
||||
def test_knowledge_search_reports_fallback(client):
|
||||
mgr = SDKKnowledgeManager()
|
||||
err = Exception("Project does not have access to model text-embedding-3-small")
|
||||
mgr._sdk_knowledge = _FakeSDK(search_error=err)
|
||||
mgr._sdk_probed = True
|
||||
mgr.store("PraisonAIUI is a YAML-driven documentation site generator.")
|
||||
knowledge_mod._knowledge_manager = mgr
|
||||
|
||||
r = client.post("/api/knowledge/search", json={"query": "documentation generator YAML"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["search_mode"] == "fallback"
|
||||
assert body["warning"]
|
||||
assert "embedding" in body["warning"].lower()
|
||||
|
||||
|
||||
def test_knowledge_status_reports_embedding(client):
|
||||
mgr = SDKKnowledgeManager()
|
||||
mgr._sdk_knowledge = _FakeSDK()
|
||||
mgr._sdk_probed = True
|
||||
mgr._embedding_unavailable = True
|
||||
mgr._embedding_error = "no access"
|
||||
knowledge_mod._knowledge_manager = mgr
|
||||
|
||||
r = client.get("/api/knowledge/status")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["embedding"]["available"] is False
|
||||
assert body["warnings"]
|
||||
|
||||
|
||||
def test_embeddings_endpoint_fallback_models(client):
|
||||
call_models: list[str] = []
|
||||
|
||||
async def _aembed(input, model):
|
||||
call_models.append(model)
|
||||
if model == "text-embedding-3-small":
|
||||
raise Exception("Project does not have access to model text-embedding-3-small")
|
||||
return {"embedding": [0.1, 0.2], "usage": {"prompt_tokens": 1, "total_tokens": 1}}
|
||||
|
||||
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
|
||||
with patch(
|
||||
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
|
||||
):
|
||||
r = client.post(
|
||||
"/v1/embeddings",
|
||||
json={"input": "test", "model": "text-embedding-3-small"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["model"] == "text-embedding-ada-002"
|
||||
assert call_models[0] == "text-embedding-3-small"
|
||||
|
||||
|
||||
def test_embeddings_endpoint_all_fail_returns_503(client):
|
||||
async def _aembed(input, model):
|
||||
raise Exception("model_not_found: no access to any embedding model")
|
||||
|
||||
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
|
||||
with patch(
|
||||
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
|
||||
):
|
||||
r = client.post("/v1/embeddings", json={"input": "test"})
|
||||
assert r.status_code == 503
|
||||
assert r.json()["error"]["type"] == "model_not_found"
|
||||
@@ -90,9 +90,10 @@ class TestSessionLifecycle:
|
||||
assert "openai package not installed" in session_info["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lifecycle_with_mocked_openai(self):
|
||||
async def test_session_lifecycle_with_mocked_openai(self, monkeypatch):
|
||||
"""Test full session lifecycle with mocked OpenAI."""
|
||||
import sys
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
manager = OpenAIRealtimeManager()
|
||||
|
||||
# Mock OpenAI client and response
|
||||
@@ -140,6 +141,27 @@ class TestSessionLifecycle:
|
||||
# Verify session is removed from internal tracking
|
||||
assert session_id not in manager._sessions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_audio_yields_session_ready_with_api_key(self, monkeypatch):
|
||||
"""With OPENAI_API_KEY set, receive_audio yields session.ready first."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
manager = OpenAIRealtimeManager()
|
||||
session_id = "session_ready_test"
|
||||
manager._sessions[session_id] = {
|
||||
"status": "created",
|
||||
"client_secret": "secret_abc",
|
||||
}
|
||||
|
||||
events = []
|
||||
async for event in manager.receive_audio(session_id):
|
||||
events.append(event)
|
||||
break
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "session.ready"
|
||||
assert events[0]["provider"] == "openai"
|
||||
assert events[0]["client_secret"] == "secret_abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_audio_invalid_session(self):
|
||||
"""Test receive_audio with invalid session ID."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for `aiui run` propagating --port/--host to gateway main()."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonaiui.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _write_gateway_app(tmp_path):
|
||||
app_file = tmp_path / "app.py"
|
||||
app_file.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
|
||||
|
||||
async def main():
|
||||
pass
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return app_file
|
||||
|
||||
|
||||
def _invoke(tmp_path):
|
||||
app_file = _write_gateway_app(tmp_path)
|
||||
captured = {}
|
||||
|
||||
def fake_run(coro):
|
||||
coro.close()
|
||||
captured["PORT"] = os.environ.get("PORT")
|
||||
captured["HOST"] = os.environ.get("HOST")
|
||||
|
||||
with patch("socket.socket") as mock_socket:
|
||||
mock_socket.return_value.__enter__.return_value.bind.return_value = None
|
||||
with patch(
|
||||
"praisonaiui.integration.AIUIGateway", return_value=MagicMock()
|
||||
), patch(
|
||||
"praisonaiui.integration.check_praisonai_available", return_value=True
|
||||
), patch("asyncio.run", side_effect=fake_run):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["run", str(app_file), "--backend", "praisonai", "--port", "8101"],
|
||||
)
|
||||
return result, captured
|
||||
|
||||
|
||||
def test_run_sets_port_env_before_gateway_main(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("PORT", raising=False)
|
||||
monkeypatch.delenv("HOST", raising=False)
|
||||
|
||||
result, captured = _invoke(tmp_path)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["PORT"] == "8101"
|
||||
assert captured["HOST"] == "127.0.0.1"
|
||||
|
||||
|
||||
def test_explicit_port_env_overrides_cli_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("PORT", "9000")
|
||||
monkeypatch.delenv("HOST", raising=False)
|
||||
|
||||
result, captured = _invoke(tmp_path)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["PORT"] == "9000"
|
||||
assert os.environ["PORT"] == "9000"
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Unit tests for the session fork endpoint (POST /api/sessions/{id}/fork)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from praisonaiui.datastore import MemoryDataStore
|
||||
from praisonaiui.features.sessions_ext import _sanitize_message
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return MemoryDataStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(store):
|
||||
import praisonaiui.server as server
|
||||
|
||||
original = server._datastore
|
||||
server._datastore = store
|
||||
try:
|
||||
yield TestClient(server.create_app())
|
||||
finally:
|
||||
server._datastore = original
|
||||
|
||||
|
||||
def _seed(store, session_id, count):
|
||||
async def _run():
|
||||
await store.create_session(session_id)
|
||||
for i in range(count):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
await store.add_message(session_id, {"role": role, "content": f"msg {i}"})
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def _messages(store, session_id):
|
||||
return asyncio.run(store.get_messages(session_id))
|
||||
|
||||
|
||||
def _session(store, session_id):
|
||||
return asyncio.run(store.get_session(session_id))
|
||||
|
||||
|
||||
def test_sanitize_message_strips_ephemeral_ids():
|
||||
msg = {
|
||||
"id": "ephemeral-1",
|
||||
"role": "user",
|
||||
"content": "hi",
|
||||
"tool_calls": [{"name": "x"}],
|
||||
"timestamp": 123,
|
||||
}
|
||||
out = _sanitize_message(msg)
|
||||
assert "id" not in out
|
||||
assert "timestamp" not in out
|
||||
assert out["role"] == "user"
|
||||
assert out["content"] == "hi"
|
||||
assert out["tool_calls"] == [{"name": "x"}]
|
||||
|
||||
|
||||
def test_fork_copies_all_messages(store, client):
|
||||
_seed(store, "parent", 4)
|
||||
|
||||
resp = client.post("/api/sessions/parent/fork", json={})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert body["parent_id"] == "parent"
|
||||
assert body["message_count"] == 4
|
||||
child_id = body["session_id"]
|
||||
assert child_id != "parent"
|
||||
|
||||
child_messages = _messages(store, child_id)
|
||||
assert len(child_messages) == 4
|
||||
assert child_messages[0]["content"] == "msg 0"
|
||||
|
||||
|
||||
def test_fork_up_to_index_truncates(store, client):
|
||||
_seed(store, "parent", 10)
|
||||
|
||||
resp = client.post("/api/sessions/parent/fork", json={"up_to_index": 3})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert body["forked_at_index"] == 3
|
||||
assert body["message_count"] == 4
|
||||
|
||||
child_messages = _messages(store, body["session_id"])
|
||||
assert len(child_messages) == 4
|
||||
assert child_messages[-1]["content"] == "msg 3"
|
||||
|
||||
|
||||
def test_fork_records_parent_metadata(store, client):
|
||||
_seed(store, "parent", 2)
|
||||
|
||||
resp = client.post(
|
||||
"/api/sessions/parent/fork",
|
||||
json={"agent_id": "support_bot", "labels": ["playground-fork"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
child_id = resp.json()["session_id"]
|
||||
|
||||
child = _session(store, child_id)
|
||||
meta = child.get("metadata", {})
|
||||
assert meta.get("parent_session_id") == "parent"
|
||||
assert meta.get("agent_id") == "support_bot"
|
||||
assert meta.get("source") == "playground-fork"
|
||||
|
||||
|
||||
def test_fork_missing_source_returns_404(client):
|
||||
resp = client.post("/api/sessions/does-not-exist/fork", json={})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_fork_index_out_of_range_returns_400(store, client):
|
||||
_seed(store, "parent", 3)
|
||||
|
||||
resp = client.post("/api/sessions/parent/fork", json={"up_to_index": 99})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["total_messages"] == 3
|
||||
|
||||
|
||||
def test_fork_virtual_agent_session_blocked(client):
|
||||
resp = client.post("/api/sessions/agent:support/fork", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def _persistent_stores(tmp_path):
|
||||
"""Build the persistent datastores that ship as the default (SDK + JSON)."""
|
||||
stores = []
|
||||
from praisonaiui.datastore import JSONFileDataStore
|
||||
|
||||
stores.append(JSONFileDataStore(data_dir=str(tmp_path / "json")))
|
||||
try:
|
||||
from praisonaiui.datastore_sdk import SDKFileDataStore
|
||||
|
||||
stores.append(SDKFileDataStore(session_dir=str(tmp_path / "sdk")))
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
return stores
|
||||
|
||||
|
||||
def test_fork_metadata_persists_on_default_stores(tmp_path):
|
||||
"""Fork lineage must survive on the persistent stores, not just MemoryDataStore.
|
||||
|
||||
Regression: SDKFileDataStore/JSONFileDataStore (the shipped defaults) previously
|
||||
dropped session-level ``metadata`` in ``update_session``, so parent lineage was
|
||||
silently lost outside of tests running on MemoryDataStore.
|
||||
"""
|
||||
import praisonaiui.server as server
|
||||
|
||||
for ds in _persistent_stores(tmp_path):
|
||||
original = server._datastore
|
||||
server._datastore = ds
|
||||
try:
|
||||
client = TestClient(server.create_app())
|
||||
_seed(ds, "parent", 2)
|
||||
resp = client.post(
|
||||
"/api/sessions/parent/fork",
|
||||
json={"agent_id": "support_bot"},
|
||||
)
|
||||
assert resp.status_code == 200, type(ds).__name__
|
||||
child_id = resp.json()["session_id"]
|
||||
|
||||
child = asyncio.run(ds.get_session(child_id))
|
||||
meta = child.get("metadata", {})
|
||||
assert meta.get("parent_session_id") == "parent", type(ds).__name__
|
||||
assert meta.get("agent_id") == "support_bot", type(ds).__name__
|
||||
assert meta.get("source") == "playground-fork", type(ds).__name__
|
||||
finally:
|
||||
server._datastore = original
|
||||
@@ -22,7 +22,7 @@ CHAT_JS = FRONTEND / "views" / "chat.js"
|
||||
|
||||
class TestDashboardSessionSearch:
|
||||
def _src(self) -> str:
|
||||
return DASHBOARD_JS.read_text()
|
||||
return DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
|
||||
def test_ctrl_k_keydown_handler_present(self):
|
||||
# The Ctrl+K binding is owned by the command palette, which exposes a
|
||||
@@ -107,7 +107,7 @@ class TestDashboardSessionSearch:
|
||||
|
||||
class TestChatViewSessionSelectListener:
|
||||
def _src(self) -> str:
|
||||
return CHAT_JS.read_text()
|
||||
return CHAT_JS.read_text(encoding="utf-8")
|
||||
|
||||
def test_chat_listens_for_session_select(self):
|
||||
src = self._src()
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Unit tests for Skills Studio (STITCH-008, #218).
|
||||
|
||||
Covers:
|
||||
* skills.* i18n keys across locales.
|
||||
* Frontend view content assertions (tabs, diff overlay, deep-link, APIs).
|
||||
* XSS-safety of the proposed-content diff render.
|
||||
* approvals.js cross-link for skill-type rows.
|
||||
* Python-mirrored isSkillApproval (R1-R5) predicate and LCS line diff.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiui.features.i18n import JSONLocaleManager
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
|
||||
)
|
||||
SKILLS_JS = FRONTEND / "skills.js"
|
||||
APPROVALS_JS = FRONTEND / "approvals.js"
|
||||
HELPERS_JS = FRONTEND / "_helpers.js"
|
||||
|
||||
|
||||
class TestI18nKeys:
|
||||
KEYS = [
|
||||
"skills.title",
|
||||
"skills.subtitle",
|
||||
"skills.tab.pending",
|
||||
"skills.tab.installed",
|
||||
"skills.tab.history",
|
||||
"skills.diff.current",
|
||||
"skills.diff.proposed",
|
||||
"skills.diff.no_existing",
|
||||
"skills.action.approve",
|
||||
"skills.action.reject",
|
||||
"skills.pending.empty",
|
||||
]
|
||||
|
||||
def test_keys_present_all_locales(self):
|
||||
mgr = JSONLocaleManager()
|
||||
for locale in ("en", "es", "fr"):
|
||||
strings = mgr.get_strings(locale)
|
||||
for k in self.KEYS:
|
||||
assert k in strings, f"missing {locale} key: {k}"
|
||||
|
||||
|
||||
class TestHelpersJs:
|
||||
def test_helpers_export_functions(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert "export function diffLines(" in src
|
||||
assert "export function renderMarkdownPreview(" in src
|
||||
assert "export function isSkillApproval(" in src
|
||||
|
||||
def test_diff_normalises_crlf(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert "replace(/\\r\\n?/g, '\\n')" in src
|
||||
|
||||
def test_preview_escapes_first(self):
|
||||
src = HELPERS_JS.read_text(encoding="utf-8")
|
||||
assert "esc(s)" in src
|
||||
|
||||
|
||||
class TestSkillsJs:
|
||||
def test_exports_render_and_cleanup(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "export async function render" in src
|
||||
assert "export function cleanup" in src
|
||||
|
||||
def test_tab_shell_present(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "class=\"ss-tab\"" in src
|
||||
assert "tabBtn('pending'" in src
|
||||
assert "tabBtn('installed'" in src
|
||||
assert "tabBtn('history'" in src
|
||||
|
||||
def test_url_state_and_deep_link(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "approval_id" in src
|
||||
assert "currentApprovalId(" in src
|
||||
assert "openDiff(" in src
|
||||
|
||||
def test_uses_existing_apis(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "/api/approvals/pending" in src
|
||||
assert "/api/approvals/history" in src
|
||||
assert "/api/skills" in src
|
||||
assert "/api/skills/${cb.dataset.id}/toggle" in src
|
||||
assert "/api/approvals/${id}/${verb}" in src
|
||||
|
||||
def test_imports_skill_helpers(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "isSkillApproval" in src
|
||||
assert "diffLines" in src
|
||||
assert "renderMarkdownPreview" in src
|
||||
|
||||
def test_no_browser_eval(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "eval(" not in src
|
||||
|
||||
def test_proposed_content_escaped_in_diff(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
# Diff rows must escape line text; no raw interpolation of r.text.
|
||||
assert "esc(r.text)" in src
|
||||
assert "${r.text}" not in src
|
||||
|
||||
def test_empty_state_and_no_existing_labels(self):
|
||||
src = SKILLS_JS.read_text(encoding="utf-8")
|
||||
assert "No pending skill writes" in src
|
||||
assert "No existing skill" in src
|
||||
|
||||
|
||||
class TestApprovalsCrossLink:
|
||||
def test_skill_row_links_to_studio(self):
|
||||
src = APPROVALS_JS.read_text(encoding="utf-8")
|
||||
assert "isSkillApproval" in src
|
||||
assert "/skills?tab=pending&approval_id=" in src
|
||||
|
||||
|
||||
# ── Python mirrors of the JS predicate + diff for logic verification ──
|
||||
|
||||
|
||||
def is_skill_approval(a: dict) -> bool:
|
||||
names = {"write_skill", "skill_write", "save_skill", "create_skill"}
|
||||
if str(a.get("tool_name", "")).lower() in names:
|
||||
return True
|
||||
if str(a.get("action", "")).lower() in names:
|
||||
return True
|
||||
if str(a.get("type", "")).lower() == "skill_write":
|
||||
return True
|
||||
meta = a.get("metadata") or {}
|
||||
if str(meta.get("kind", "")).lower() == "skill":
|
||||
return True
|
||||
path = str((a.get("arguments") or {}).get("path", ""))
|
||||
if path.endswith("SKILL.md") or ".praisonai/skills/" in path:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def diff_lines(before: str, after: str) -> list[tuple[str, str]]:
|
||||
a = before.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
b = after.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
n, m = len(a), len(b)
|
||||
lcs = [[0] * (m + 1) for _ in range(n + 1)]
|
||||
for i in range(n - 1, -1, -1):
|
||||
for j in range(m - 1, -1, -1):
|
||||
lcs[i][j] = (
|
||||
lcs[i + 1][j + 1] + 1
|
||||
if a[i] == b[j]
|
||||
else max(lcs[i + 1][j], lcs[i][j + 1])
|
||||
)
|
||||
out: list[tuple[str, str]] = []
|
||||
i = j = 0
|
||||
while i < n and j < m:
|
||||
if a[i] == b[j]:
|
||||
out.append(("ctx", a[i]))
|
||||
i += 1
|
||||
j += 1
|
||||
elif lcs[i + 1][j] >= lcs[i][j + 1]:
|
||||
out.append(("del", a[i]))
|
||||
i += 1
|
||||
else:
|
||||
out.append(("add", b[j]))
|
||||
j += 1
|
||||
while i < n:
|
||||
out.append(("del", a[i]))
|
||||
i += 1
|
||||
while j < m:
|
||||
out.append(("add", b[j]))
|
||||
j += 1
|
||||
return out
|
||||
|
||||
|
||||
class TestSkillPredicate:
|
||||
def test_r1_tool_name(self):
|
||||
assert is_skill_approval({"tool_name": "write_skill"})
|
||||
assert is_skill_approval({"tool_name": "Create_Skill"})
|
||||
|
||||
def test_r2_action(self):
|
||||
assert is_skill_approval({"action": "save_skill"})
|
||||
|
||||
def test_r3_type(self):
|
||||
assert is_skill_approval({"type": "skill_write"})
|
||||
|
||||
def test_r4_metadata_kind(self):
|
||||
assert is_skill_approval({"metadata": {"kind": "skill"}})
|
||||
|
||||
def test_r5_path(self):
|
||||
assert is_skill_approval({"arguments": {"path": "x/y/SKILL.md"}})
|
||||
assert is_skill_approval({"arguments": {"path": ".praisonai/skills/z/x"}})
|
||||
|
||||
def test_non_skill_rejected(self):
|
||||
assert not is_skill_approval({"tool_name": "run_shell"})
|
||||
assert not is_skill_approval({"arguments": {"path": "notes.txt"}})
|
||||
assert not is_skill_approval({})
|
||||
|
||||
|
||||
class TestDiffLines:
|
||||
def test_add_and_delete_counts(self):
|
||||
rows = diff_lines("a\nb\nc", "a\nx\nc")
|
||||
adds = [r for r in rows if r[0] == "add"]
|
||||
dels = [r for r in rows if r[0] == "del"]
|
||||
ctx = [r for r in rows if r[0] == "ctx"]
|
||||
assert len(adds) == 1 and adds[0][1] == "x"
|
||||
assert len(dels) == 1 and dels[0][1] == "b"
|
||||
assert len(ctx) == 2
|
||||
|
||||
def test_new_skill_additions(self):
|
||||
rows = diff_lines("", "line1\nline2")
|
||||
assert any(r[0] == "add" and r[1] == "line1" for r in rows)
|
||||
assert any(r[0] == "add" and r[1] == "line2" for r in rows)
|
||||
|
||||
def test_crlf_normalised(self):
|
||||
rows = diff_lines("a\r\nb", "a\nb")
|
||||
assert all(r[0] == "ctx" for r in rows)
|
||||
|
||||
def test_hundred_line_diff(self):
|
||||
before = "\n".join(f"l{i}" for i in range(100))
|
||||
after = "\n".join(("CHANGED" if i == 50 else f"l{i}") for i in range(100))
|
||||
rows = diff_lines(before, after)
|
||||
assert any(r[0] == "add" and r[1] == "CHANGED" for r in rows)
|
||||
assert any(r[0] == "del" and r[1] == "l50" for r in rows)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Work Hub (STITCH-003) registration and wiring tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
VIEWS = FRONTEND / "views"
|
||||
|
||||
|
||||
def test_work_hub_view_file_exists():
|
||||
assert (VIEWS / "work-hub.js").exists()
|
||||
|
||||
|
||||
def test_work_registered_in_builtin_views():
|
||||
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "work:" in source
|
||||
assert "/plugins/views/work-hub.js" in source
|
||||
|
||||
|
||||
def test_kanban_banner_links_to_work_hub():
|
||||
source = (VIEWS / "kanban.js").read_text(encoding="utf-8")
|
||||
assert 'href="/work"' in source
|
||||
assert "Work Hub" in source
|
||||
|
||||
|
||||
def test_board_supports_on_open_override():
|
||||
source = (FRONTEND / "board.js").read_text(encoding="utf-8")
|
||||
assert "typeof opts.onOpen === 'function'" in source
|
||||
|
||||
|
||||
def test_work_page_registered_in_server():
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui as aiui
|
||||
import praisonaiui.server as server
|
||||
|
||||
server.reset_state()
|
||||
aiui.set_style("dashboard")
|
||||
app = server.create_app()
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/pages")
|
||||
assert resp.status_code == 200
|
||||
pages = resp.json().get("pages") or []
|
||||
work = next((p for p in pages if p.get("id") == "work"), None)
|
||||
assert work is not None
|
||||
assert work["group"] == "Work"
|
||||
Reference in new issue
Block a user