Spaces:
Running
Running
feat: Janus v0.4.0 - living intelligence system
Browse files- Add smart LLM router (Groq → OpenRouter → Ollama) with rate limit tracking
- Add context engine for conversation memory and system self-knowledge
- Add reflex layer for instant contextual responses (greetings, identity, status)
- Add custom web crawler (DuckDuckGo + Jina Reader) replacing Tavily
- Wire context into synthesizer for natural, contextual responses
- Add pending thoughts generation from daemon discoveries
- Add /context and /pending-thoughts API endpoints
- Add Dockerfile for HF Spaces deployment
- Update frontend to connect to HF Space backend
- Fix HF Inference API deprecation (migrated to OpenRouter/Groq)
- backend/.dockerignore +15 -0
- backend/.env.example +27 -6
- backend/Dockerfile +23 -0
- backend/app/agents/_model.py +79 -32
- backend/app/agents/huggingface.py +107 -0
- backend/app/agents/research.py +102 -20
- backend/app/agents/smart_router.py +336 -0
- backend/app/agents/synthesizer.py +31 -0
- backend/app/config.py +39 -7
- backend/app/domain_packs/registry.py +6 -9
- backend/app/graph.py +21 -42
- backend/app/main.py +65 -9
- backend/app/services/context_engine.py +275 -0
- backend/app/services/crawler/__init__.py +10 -0
- backend/app/services/crawler/browser.py +99 -0
- backend/app/services/crawler/core.py +115 -0
- backend/app/services/crawler/processor.py +215 -0
- backend/app/services/daemon.py +109 -9
- backend/app/services/reflex_layer.py +272 -0
- backend/requirements.txt +4 -0
- frontend/src/app/cases/page.tsx +69 -81
- frontend/src/app/config/page.tsx +113 -137
- frontend/src/app/globals.css +109 -67
- frontend/src/app/intel/page.tsx +191 -0
- frontend/src/app/layout.tsx +3 -2
- frontend/src/app/markets/page.tsx +397 -0
- frontend/src/app/page.tsx +314 -878
- frontend/src/app/prompts/page.tsx +82 -152
- frontend/src/app/pulse/page.tsx +245 -0
- frontend/src/app/sentinel/page.tsx +5 -3
- frontend/src/app/simulation/page.tsx +102 -154
- frontend/src/app/workspace/page.tsx +135 -0
- frontend/src/components/AppShell.tsx +164 -121
- frontend/src/lib/api.ts +0 -82
- frontend/src/lib/types.ts +14 -0
backend/.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.env
|
| 5 |
+
*.egg-info/
|
| 6 |
+
dist/
|
| 7 |
+
build/
|
| 8 |
+
.venv/
|
| 9 |
+
venv/
|
| 10 |
+
data/
|
| 11 |
+
*.json
|
| 12 |
+
.DS_Store
|
| 13 |
+
node_modules/
|
| 14 |
+
frontend/
|
| 15 |
+
.git/
|
backend/.env.example
CHANGED
|
@@ -1,16 +1,32 @@
|
|
| 1 |
# ========================================
|
| 2 |
-
#
|
| 3 |
# Environment Configuration Template
|
| 4 |
# ========================================
|
| 5 |
# Copy this file to .env and fill in your actual values.
|
| 6 |
# NEVER commit .env to version control.
|
| 7 |
|
| 8 |
# ---------- Application Version ----------
|
| 9 |
-
APP_VERSION=0.
|
| 10 |
|
| 11 |
# ---------- Primary model routing ----------
|
| 12 |
-
PRIMARY_PROVIDER
|
| 13 |
-
FALLBACK_PROVIDER
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# ---------- OpenRouter ----------
|
| 16 |
# Get your API key from: https://openrouter.ai/keys
|
|
@@ -19,7 +35,7 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
|
| 19 |
OPENROUTER_CHAT_MODEL=openrouter/free
|
| 20 |
OPENROUTER_REASONER_MODEL=openrouter/free
|
| 21 |
OPENROUTER_SITE_URL=http://localhost:3000
|
| 22 |
-
OPENROUTER_APP_NAME=
|
| 23 |
|
| 24 |
# ---------- Ollama ----------
|
| 25 |
# Install from: https://ollama.ai
|
|
@@ -36,7 +52,7 @@ OPENAI_CHAT_MODEL=gpt-4o-mini
|
|
| 36 |
OPENAI_REASONER_MODEL=gpt-4o
|
| 37 |
|
| 38 |
# ---------- External research APIs ----------
|
| 39 |
-
# Tavily: https://tavily.com
|
| 40 |
TAVILY_API_KEY=tvly-dev-YOUR_KEY_HERE
|
| 41 |
# NewsAPI: https://newsapi.org
|
| 42 |
NEWSAPI_KEY=YOUR_KEY_HERE
|
|
@@ -45,6 +61,11 @@ ALPHAVANTAGE_API_KEY=YOUR_KEY_HERE
|
|
| 45 |
# Jina Reader: https://jina.ai
|
| 46 |
JINA_READER_BASE=https://r.jina.ai/http://
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
# ---------- MiroFish ----------
|
| 49 |
# MiroFish is the simulation service (deprecated — using native engine)
|
| 50 |
MIROFISH_ENABLED=false
|
|
|
|
| 1 |
# ========================================
|
| 2 |
+
# Janus - AI Intelligence System
|
| 3 |
# Environment Configuration Template
|
| 4 |
# ========================================
|
| 5 |
# Copy this file to .env and fill in your actual values.
|
| 6 |
# NEVER commit .env to version control.
|
| 7 |
|
| 8 |
# ---------- Application Version ----------
|
| 9 |
+
APP_VERSION=0.4.0
|
| 10 |
|
| 11 |
# ---------- Primary model routing ----------
|
| 12 |
+
# PRIMARY_PROVIDER: The main LLM provider (huggingface, openrouter, ollama, or openai)
|
| 13 |
+
# FALLBACK_PROVIDER: The backup provider if primary fails
|
| 14 |
+
PRIMARY_PROVIDER=huggingface
|
| 15 |
+
FALLBACK_PROVIDER=openrouter
|
| 16 |
+
|
| 17 |
+
# ---------- HuggingFace Inference API ----------
|
| 18 |
+
# Get your API key from: https://huggingface.co/settings/tokens
|
| 19 |
+
# Required permissions: "Make calls to Inference Providers"
|
| 20 |
+
HUGGINGFACE_API_KEY=hf_...
|
| 21 |
+
HUGGINGFACE_MODEL=Qwen/Qwen2.5-7B-Instruct
|
| 22 |
+
|
| 23 |
+
# ---------- Smart LLM Router ----------
|
| 24 |
+
# Priority: Gemini → Groq → OpenRouter → Cloudflare → Ollama
|
| 25 |
+
# Get keys from respective providers (all free tiers available)
|
| 26 |
+
GEMINI_API_KEY=AIza... # https://aistudio.google.com/apikey (1500 req/day free)
|
| 27 |
+
GROQ_API_KEY=gsk_... # https://console.groq.com/keys (fast, 14400 req/day free)
|
| 28 |
+
CLOUDFLARE_ACCOUNT_ID=... # https://dash.cloudflare.com (10k req/day free)
|
| 29 |
+
CLOUDFLARE_API_TOKEN=...
|
| 30 |
|
| 31 |
# ---------- OpenRouter ----------
|
| 32 |
# Get your API key from: https://openrouter.ai/keys
|
|
|
|
| 35 |
OPENROUTER_CHAT_MODEL=openrouter/free
|
| 36 |
OPENROUTER_REASONER_MODEL=openrouter/free
|
| 37 |
OPENROUTER_SITE_URL=http://localhost:3000
|
| 38 |
+
OPENROUTER_APP_NAME=Janus
|
| 39 |
|
| 40 |
# ---------- Ollama ----------
|
| 41 |
# Install from: https://ollama.ai
|
|
|
|
| 52 |
OPENAI_REASONER_MODEL=gpt-4o
|
| 53 |
|
| 54 |
# ---------- External research APIs ----------
|
| 55 |
+
# Tavily: https://tavily.com (optional — crawler is primary)
|
| 56 |
TAVILY_API_KEY=tvly-dev-YOUR_KEY_HERE
|
| 57 |
# NewsAPI: https://newsapi.org
|
| 58 |
NEWSAPI_KEY=YOUR_KEY_HERE
|
|
|
|
| 61 |
# Jina Reader: https://jina.ai
|
| 62 |
JINA_READER_BASE=https://r.jina.ai/http://
|
| 63 |
|
| 64 |
+
# ---------- Custom Crawler ----------
|
| 65 |
+
# Self-hosted web crawling — no API keys needed
|
| 66 |
+
CRAWLER_ENABLED=true
|
| 67 |
+
CRAWLER_TIMEOUT=30
|
| 68 |
+
|
| 69 |
# ---------- MiroFish ----------
|
| 70 |
# MiroFish is the simulation service (deprecated — using native engine)
|
| 71 |
MIROFISH_ENABLED=false
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
curl \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
RUN playwright install --with-deps chromium 2>/dev/null || true
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
RUN mkdir -p /app/app/data/context /app/app/data/daemon /app/app/data/adaptive /app/app/data/knowledge /app/app/data/memory /app/app/data/simulations
|
| 17 |
+
|
| 18 |
+
ENV PYTHONUNBUFFERED=1
|
| 19 |
+
ENV PORT=7860
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
backend/app/agents/_model.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
Unified model client for
|
| 3 |
-
|
| 4 |
All tiers use the OpenAI-compatible messages format.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
import os, json, re, logging
|
| 8 |
import httpx
|
| 9 |
from typing import Any
|
| 10 |
|
|
@@ -12,18 +13,26 @@ logger = logging.getLogger(__name__)
|
|
| 12 |
|
| 13 |
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
| 14 |
|
| 15 |
-
# Pinned free models in preference order — fast + reliable
|
| 16 |
FREE_MODEL_LADDER = [
|
| 17 |
-
"qwen/qwen3.6-plus:free",
|
| 18 |
-
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 19 |
-
"minimax/minimax-m2.5:free",
|
| 20 |
-
"stepfun/step-3.5-flash:free",
|
| 21 |
-
"arcee-ai/trinity-mini:free",
|
| 22 |
]
|
| 23 |
|
| 24 |
OLLAMA_BASE = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 25 |
TIMEOUT = 90
|
| 26 |
-
OLLAMA_TIMEOUT = 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def _openrouter_call(messages: list[dict], model: str, **kwargs) -> str:
|
|
@@ -34,8 +43,8 @@ def _openrouter_call(messages: list[dict], model: str, **kwargs) -> str:
|
|
| 34 |
|
| 35 |
headers = {
|
| 36 |
"Authorization": f"Bearer {api_key}",
|
| 37 |
-
"HTTP-Referer": "https://
|
| 38 |
-
"X-Title": "
|
| 39 |
"Content-Type": "application/json",
|
| 40 |
}
|
| 41 |
body = {"model": model, "messages": messages, "max_tokens": 4096, **kwargs}
|
|
@@ -64,32 +73,70 @@ def _ollama_call(messages: list[dict], **kwargs) -> str:
|
|
| 64 |
return r.json()["choices"][0]["message"]["content"]
|
| 65 |
|
| 66 |
|
| 67 |
-
def
|
| 68 |
"""
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
"""
|
| 73 |
-
|
| 74 |
-
for model in FREE_MODEL_LADDER:
|
| 75 |
try:
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
try:
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
return
|
| 88 |
except Exception as e:
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
| 93 |
|
| 94 |
|
| 95 |
def safe_parse(text: str) -> dict:
|
|
|
|
| 1 |
"""
|
| 2 |
+
Unified model client for Janus.
|
| 3 |
+
Uses smart router: Gemini → Groq → OpenRouter → Cloudflare → Ollama.
|
| 4 |
All tiers use the OpenAI-compatible messages format.
|
| 5 |
+
Includes retry-with-backoff for 429 rate limits.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import os, json, re, logging, time
|
| 9 |
import httpx
|
| 10 |
from typing import Any
|
| 11 |
|
|
|
|
| 13 |
|
| 14 |
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
| 15 |
|
|
|
|
| 16 |
FREE_MODEL_LADDER = [
|
| 17 |
+
"qwen/qwen3.6-plus:free",
|
| 18 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 19 |
+
"minimax/minimax-m2.5:free",
|
| 20 |
+
"stepfun/step-3.5-flash:free",
|
| 21 |
+
"arcee-ai/trinity-mini:free",
|
| 22 |
]
|
| 23 |
|
| 24 |
OLLAMA_BASE = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 25 |
TIMEOUT = 90
|
| 26 |
+
OLLAMA_TIMEOUT = 30
|
| 27 |
+
MAX_RETRIES_PER_MODEL = 2
|
| 28 |
+
BASE_BACKOFF = 3
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _huggingface_call(messages: list[dict], **kwargs) -> str:
|
| 32 |
+
"""Call HuggingFace Inference API."""
|
| 33 |
+
from app.agents.huggingface import hf_client
|
| 34 |
+
|
| 35 |
+
return hf_client.chat(messages, **kwargs)
|
| 36 |
|
| 37 |
|
| 38 |
def _openrouter_call(messages: list[dict], model: str, **kwargs) -> str:
|
|
|
|
| 43 |
|
| 44 |
headers = {
|
| 45 |
"Authorization": f"Bearer {api_key}",
|
| 46 |
+
"HTTP-Referer": "https://huggingface.co",
|
| 47 |
+
"X-Title": "Janus",
|
| 48 |
"Content-Type": "application/json",
|
| 49 |
}
|
| 50 |
body = {"model": model, "messages": messages, "max_tokens": 4096, **kwargs}
|
|
|
|
| 73 |
return r.json()["choices"][0]["message"]["content"]
|
| 74 |
|
| 75 |
|
| 76 |
+
def _call_with_retry(messages: list[dict], model: str, **kwargs) -> str:
|
| 77 |
"""
|
| 78 |
+
Call OpenRouter with retry-on-429 backoff.
|
| 79 |
+
Retries up to MAX_RETRIES_PER_MODEL times for rate limits,
|
| 80 |
+
respecting the Retry-After header when present.
|
| 81 |
"""
|
| 82 |
+
for attempt in range(MAX_RETRIES_PER_MODEL + 1):
|
|
|
|
| 83 |
try:
|
| 84 |
+
return _openrouter_call(messages, model, **kwargs)
|
| 85 |
+
except httpx.HTTPStatusError as e:
|
| 86 |
+
if e.response.status_code == 429:
|
| 87 |
+
if attempt >= MAX_RETRIES_PER_MODEL:
|
| 88 |
+
raise # Out of retries for this model
|
| 89 |
+
|
| 90 |
+
# Respect Retry-After header, otherwise use exponential backoff
|
| 91 |
+
retry_after = e.response.headers.get("retry-after")
|
| 92 |
+
if retry_after:
|
| 93 |
+
try:
|
| 94 |
+
wait = min(float(retry_after), 30) # Cap at 30s
|
| 95 |
+
except ValueError:
|
| 96 |
+
wait = BASE_BACKOFF * (2**attempt)
|
| 97 |
+
else:
|
| 98 |
+
wait = BASE_BACKOFF * (2**attempt)
|
| 99 |
+
|
| 100 |
+
logger.warning(
|
| 101 |
+
f"Rate limited on {model} (attempt {attempt + 1}/{MAX_RETRIES_PER_MODEL + 1}), "
|
| 102 |
+
f"waiting {wait:.1f}s..."
|
| 103 |
+
)
|
| 104 |
+
time.sleep(wait)
|
| 105 |
+
else:
|
| 106 |
+
raise # Non-429 error, don't retry
|
| 107 |
+
# Should not reach here, but just in case
|
| 108 |
+
return _openrouter_call(messages, model, **kwargs)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def call_model(messages: list[dict], **kwargs) -> str:
|
| 112 |
+
"""
|
| 113 |
+
Smart router: Gemini → Groq → OpenRouter → Cloudflare → Ollama.
|
| 114 |
+
Uses unified router with rate limit tracking and automatic failover.
|
| 115 |
+
Returns raw text. Never returns None.
|
| 116 |
+
"""
|
| 117 |
try:
|
| 118 |
+
from app.agents.smart_router import call_model as smart_call
|
| 119 |
+
|
| 120 |
+
return smart_call(messages, **kwargs)
|
| 121 |
except Exception as e:
|
| 122 |
+
logger.error(f"Smart router failed: {e}")
|
| 123 |
+
# Direct OpenRouter fallback if smart router fails
|
| 124 |
+
errors = []
|
| 125 |
+
for model in FREE_MODEL_LADDER:
|
| 126 |
+
try:
|
| 127 |
+
result = _call_with_retry(messages, model, **kwargs)
|
| 128 |
+
logger.info(f"OpenRouter direct succeeded: {model}")
|
| 129 |
+
return result
|
| 130 |
+
except Exception as e2:
|
| 131 |
+
errors.append(f"OpenRouter [{model}]: {e2}")
|
| 132 |
+
|
| 133 |
+
# Ollama last resort
|
| 134 |
+
try:
|
| 135 |
+
return _ollama_call(messages, **kwargs)
|
| 136 |
+
except Exception as e3:
|
| 137 |
+
errors.append(f"Ollama: {e3}")
|
| 138 |
|
| 139 |
+
raise RuntimeError("All model tiers failed:\n" + "\n".join(errors))
|
| 140 |
|
| 141 |
|
| 142 |
def safe_parse(text: str) -> dict:
|
backend/app/agents/huggingface.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HuggingFace Inference API Client for Janus.
|
| 3 |
+
|
| 4 |
+
NOTE: HF deprecated api-inference.huggingface.co in favor of router.huggingface.co.
|
| 5 |
+
The new router requires provider-specific endpoints. This client tries multiple providers.
|
| 6 |
+
Fallback to OpenRouter is recommended for reliability.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Dict, List, Any, Optional
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
from app.config import HUGGINGFACE_API_KEY, HUGGINGFACE_MODEL
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
MAX_RETRIES = 2
|
| 21 |
+
BACKOFF_BASE = 1
|
| 22 |
+
PROVIDER_TIMEOUT = 15
|
| 23 |
+
|
| 24 |
+
PROVIDER_ROUTES = [
|
| 25 |
+
f"https://router.huggingface.co/nebius/v1/chat/completions",
|
| 26 |
+
f"https://router.huggingface.co/together/v1/chat/completions",
|
| 27 |
+
f"https://router.huggingface.co/sambanova/v1/chat/completions",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class HuggingFaceInferenceClient:
|
| 32 |
+
"""HuggingFace Inference API client — tries multiple providers."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, model: str = HUGGINGFACE_MODEL):
|
| 35 |
+
self.model = model
|
| 36 |
+
self.headers = {
|
| 37 |
+
"Authorization": f"Bearer {HUGGINGFACE_API_KEY}",
|
| 38 |
+
"Content-Type": "application/json",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
def chat(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
| 42 |
+
payload = {
|
| 43 |
+
"model": self.model,
|
| 44 |
+
"messages": messages,
|
| 45 |
+
"temperature": kwargs.get("temperature", 0.7),
|
| 46 |
+
"max_tokens": kwargs.get("max_tokens", 4096),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
for route in PROVIDER_ROUTES:
|
| 50 |
+
try:
|
| 51 |
+
with httpx.Client(timeout=PROVIDER_TIMEOUT) as client:
|
| 52 |
+
response = client.post(
|
| 53 |
+
route,
|
| 54 |
+
headers=self.headers,
|
| 55 |
+
json=payload,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if response.status_code == 200:
|
| 59 |
+
data = response.json()
|
| 60 |
+
choices = data.get("choices", [])
|
| 61 |
+
if choices:
|
| 62 |
+
return choices[0].get("message", {}).get("content", "")
|
| 63 |
+
return data.get("generated_text", "")
|
| 64 |
+
|
| 65 |
+
elif response.status_code == 429:
|
| 66 |
+
time.sleep(BACKOFF_BASE)
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
elif response.status_code in (400, 403, 404):
|
| 70 |
+
logger.debug(
|
| 71 |
+
f"[HF] Provider {route.split('/')[3]} unavailable ({response.status_code})"
|
| 72 |
+
)
|
| 73 |
+
break
|
| 74 |
+
|
| 75 |
+
else:
|
| 76 |
+
logger.warning(f"[HF] Error {response.status_code} on {route}")
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
except httpx.TimeoutException:
|
| 80 |
+
logger.debug(f"[HF] Timeout on {route}")
|
| 81 |
+
continue
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.debug(f"[HF] Error on {route}: {e}")
|
| 84 |
+
break
|
| 85 |
+
|
| 86 |
+
raise RuntimeError("All HF providers exhausted")
|
| 87 |
+
|
| 88 |
+
def reason(self, messages: List[Dict[str, str]], **kwargs) -> str:
|
| 89 |
+
return self.chat(
|
| 90 |
+
messages,
|
| 91 |
+
temperature=kwargs.get("temperature", 0.9),
|
| 92 |
+
max_tokens=kwargs.get("max_tokens", 8192),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
def is_available(self) -> bool:
|
| 96 |
+
try:
|
| 97 |
+
with httpx.Client(timeout=10) as client:
|
| 98 |
+
r = client.get(
|
| 99 |
+
"https://router.huggingface.co/hf-inference/v1/models",
|
| 100 |
+
headers=self.headers,
|
| 101 |
+
)
|
| 102 |
+
return r.status_code < 500
|
| 103 |
+
except Exception:
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
hf_client = HuggingFaceInferenceClient()
|
backend/app/agents/research.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
-
Research agent —
|
| 3 |
-
Uses
|
| 4 |
to gather context before calling the LLM for structured analysis.
|
| 5 |
"""
|
| 6 |
|
|
@@ -8,24 +8,23 @@ import os, json, re, logging
|
|
| 8 |
import httpx
|
| 9 |
from app.agents._model import call_model
|
| 10 |
from app.agents.api_discovery import discover_apis, call_discovered_api
|
| 11 |
-
from app.config import load_prompt
|
| 12 |
from app.memory import knowledge_store
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "")
|
| 17 |
NEWS_API_KEY = os.getenv("NEWS_API_KEY", os.getenv("NEWSAPI_KEY", ""))
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def _extract_json(text: str) -> dict | None:
|
| 21 |
"""Robustly extract JSON from model response."""
|
| 22 |
-
# Try direct parse
|
| 23 |
try:
|
| 24 |
return json.loads(text)
|
| 25 |
except json.JSONDecodeError:
|
| 26 |
pass
|
| 27 |
|
| 28 |
-
# Strip markdown code fences
|
| 29 |
cleaned = re.sub(r"```(?:json)?\s*\n?", "", text).strip()
|
| 30 |
cleaned = re.sub(r"\n?```\s*$", "", cleaned).strip()
|
| 31 |
try:
|
|
@@ -33,7 +32,6 @@ def _extract_json(text: str) -> dict | None:
|
|
| 33 |
except json.JSONDecodeError:
|
| 34 |
pass
|
| 35 |
|
| 36 |
-
# Find JSON object in text
|
| 37 |
start = text.find("{")
|
| 38 |
end = text.rfind("}")
|
| 39 |
if start != -1 and end != -1 and end > start:
|
|
@@ -46,7 +44,90 @@ def _extract_json(text: str) -> dict | None:
|
|
| 46 |
return None
|
| 47 |
|
| 48 |
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
def tavily_search(query: str, max_results: int = 5) -> list[dict]:
|
|
@@ -72,9 +153,6 @@ def tavily_search(query: str, max_results: int = 5) -> list[dict]:
|
|
| 72 |
return []
|
| 73 |
|
| 74 |
|
| 75 |
-
# ─── Tool: News API ──────────────────────────────────────────────────────────
|
| 76 |
-
|
| 77 |
-
|
| 78 |
def news_search(query: str, max_articles: int = 5) -> list[dict]:
|
| 79 |
"""Returns list of {title, source, publishedAt, description} dicts."""
|
| 80 |
if not NEWS_API_KEY:
|
|
@@ -106,9 +184,6 @@ def news_search(query: str, max_articles: int = 5) -> list[dict]:
|
|
| 106 |
return []
|
| 107 |
|
| 108 |
|
| 109 |
-
# ─── Research Node ────────────────────────────────────────────────────────────
|
| 110 |
-
|
| 111 |
-
|
| 112 |
def run(state: dict) -> dict:
|
| 113 |
route = state.get("route", {})
|
| 114 |
intent = route.get("intent", state.get("user_input", ""))
|
|
@@ -116,14 +191,22 @@ def run(state: dict) -> dict:
|
|
| 116 |
|
| 117 |
context_blocks = []
|
| 118 |
|
| 119 |
-
# Step 1:
|
| 120 |
-
|
| 121 |
-
if
|
| 122 |
formatted = "\n".join(
|
| 123 |
-
f"- {r.get('title', 'Untitled')}\n URL: {r.get('url', '')}\n {r.get('content', '')[:
|
| 124 |
-
for r in
|
| 125 |
)
|
| 126 |
-
context_blocks.append(f"[Web
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
# Step 2: News API (if requires_news or finance domain)
|
| 129 |
if route.get("requires_news") or domain == "finance":
|
|
@@ -201,7 +284,6 @@ def run(state: dict) -> dict:
|
|
| 201 |
if raw_response:
|
| 202 |
result = _extract_json(raw_response)
|
| 203 |
if result is None:
|
| 204 |
-
# Last resort: use raw text as summary
|
| 205 |
logger.warning(
|
| 206 |
f"[AGENT PARSE FALLBACK] research: using raw text as summary"
|
| 207 |
)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Research agent — Janus.
|
| 3 |
+
Uses lightweight HTTP crawler (no Playwright needed), Knowledge Store, and API Discovery
|
| 4 |
to gather context before calling the LLM for structured analysis.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 8 |
import httpx
|
| 9 |
from app.agents._model import call_model
|
| 10 |
from app.agents.api_discovery import discover_apis, call_discovered_api
|
| 11 |
+
from app.config import load_prompt, CRAWLER_ENABLED
|
| 12 |
from app.memory import knowledge_store
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "")
|
| 17 |
NEWS_API_KEY = os.getenv("NEWS_API_KEY", os.getenv("NEWSAPI_KEY", ""))
|
| 18 |
+
JINA_READER_BASE = os.getenv("JINA_READER_BASE", "https://r.jina.ai/")
|
| 19 |
|
| 20 |
|
| 21 |
def _extract_json(text: str) -> dict | None:
|
| 22 |
"""Robustly extract JSON from model response."""
|
|
|
|
| 23 |
try:
|
| 24 |
return json.loads(text)
|
| 25 |
except json.JSONDecodeError:
|
| 26 |
pass
|
| 27 |
|
|
|
|
| 28 |
cleaned = re.sub(r"```(?:json)?\s*\n?", "", text).strip()
|
| 29 |
cleaned = re.sub(r"\n?```\s*$", "", cleaned).strip()
|
| 30 |
try:
|
|
|
|
| 32 |
except json.JSONDecodeError:
|
| 33 |
pass
|
| 34 |
|
|
|
|
| 35 |
start = text.find("{")
|
| 36 |
end = text.rfind("}")
|
| 37 |
if start != -1 and end != -1 and end > start:
|
|
|
|
| 44 |
return None
|
| 45 |
|
| 46 |
|
| 47 |
+
def _duckduckgo_urls(query: str, max_results: int = 5) -> list[str]:
|
| 48 |
+
"""Get URLs from DuckDuckGo HTML search (no API key)."""
|
| 49 |
+
urls = []
|
| 50 |
+
try:
|
| 51 |
+
search_url = f"https://html.duckduckgo.com/html/?q={httpx.quote(query)}"
|
| 52 |
+
with httpx.Client(timeout=15) as client:
|
| 53 |
+
headers = {
|
| 54 |
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
| 55 |
+
}
|
| 56 |
+
resp = client.get(search_url, headers=headers, follow_redirects=True)
|
| 57 |
+
if resp.status_code == 200:
|
| 58 |
+
pattern = re.compile(r'class="result__a"[^>]*href="([^"]+)"')
|
| 59 |
+
for match in pattern.finditer(resp.text):
|
| 60 |
+
url = match.group(1)
|
| 61 |
+
if url and url.startswith("http") and url not in urls:
|
| 62 |
+
urls.append(url)
|
| 63 |
+
if len(urls) >= max_results:
|
| 64 |
+
break
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.warning(f"DuckDuckGo search failed: {e}")
|
| 67 |
+
return urls
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _extract_content(url: str) -> str | None:
|
| 71 |
+
"""Extract clean content from a URL using Jina Reader (free, no key needed)."""
|
| 72 |
+
try:
|
| 73 |
+
jina_url = f"{JINA_READER_BASE}{url}"
|
| 74 |
+
with httpx.Client(timeout=12) as client:
|
| 75 |
+
r = client.get(jina_url, follow_redirects=True)
|
| 76 |
+
if r.status_code == 200 and len(r.text) > 100:
|
| 77 |
+
return r.text[:5000]
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
with httpx.Client(timeout=10) as client:
|
| 83 |
+
headers = {"User-Agent": "Mozilla/5.0 (compatible; Janus/1.0)"}
|
| 84 |
+
r = client.get(url, headers=headers, follow_redirects=True, timeout=10)
|
| 85 |
+
if r.status_code == 200:
|
| 86 |
+
text = re.sub(r"<script[^>]*>.*?</script>", "", r.text, flags=re.DOTALL)
|
| 87 |
+
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
|
| 88 |
+
text = re.sub(r"<[^>]+>", " ", text)
|
| 89 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 90 |
+
return text[:5000] if len(text) > 100 else None
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def crawl_web_search(query: str, max_results: int = 3) -> list[dict]:
|
| 98 |
+
"""
|
| 99 |
+
Self-hosted web research: DuckDuckGo for URLs → Jina Reader for content.
|
| 100 |
+
Optimized for speed: 3 results, parallel fetch, short timeouts.
|
| 101 |
+
"""
|
| 102 |
+
if not CRAWLER_ENABLED:
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
urls = _duckduckgo_urls(query, max_results)
|
| 106 |
+
if not urls:
|
| 107 |
+
return []
|
| 108 |
+
|
| 109 |
+
results = []
|
| 110 |
+
# Fetch in parallel with ThreadPoolExecutor
|
| 111 |
+
import concurrent.futures
|
| 112 |
+
|
| 113 |
+
def _fetch_one(url):
|
| 114 |
+
content = _extract_content(url)
|
| 115 |
+
if content:
|
| 116 |
+
title = content.split("\n")[0][:200] if "\n" in content else url
|
| 117 |
+
return {"title": title, "url": url, "content": content[:2000]}
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
| 121 |
+
futures = {executor.submit(_fetch_one, url): url for url in urls[:max_results]}
|
| 122 |
+
for future in concurrent.futures.as_completed(futures, timeout=25):
|
| 123 |
+
try:
|
| 124 |
+
result = future.result()
|
| 125 |
+
if result:
|
| 126 |
+
results.append(result)
|
| 127 |
+
except Exception:
|
| 128 |
+
pass
|
| 129 |
+
|
| 130 |
+
return results
|
| 131 |
|
| 132 |
|
| 133 |
def tavily_search(query: str, max_results: int = 5) -> list[dict]:
|
|
|
|
| 153 |
return []
|
| 154 |
|
| 155 |
|
|
|
|
|
|
|
|
|
|
| 156 |
def news_search(query: str, max_articles: int = 5) -> list[dict]:
|
| 157 |
"""Returns list of {title, source, publishedAt, description} dicts."""
|
| 158 |
if not NEWS_API_KEY:
|
|
|
|
| 184 |
return []
|
| 185 |
|
| 186 |
|
|
|
|
|
|
|
|
|
|
| 187 |
def run(state: dict) -> dict:
|
| 188 |
route = state.get("route", {})
|
| 189 |
intent = route.get("intent", state.get("user_input", ""))
|
|
|
|
| 191 |
|
| 192 |
context_blocks = []
|
| 193 |
|
| 194 |
+
# Step 1: Self-hosted crawler (DuckDuckGo + Jina Reader)
|
| 195 |
+
crawl_results = crawl_web_search(intent)
|
| 196 |
+
if crawl_results:
|
| 197 |
formatted = "\n".join(
|
| 198 |
+
f"- {r.get('title', 'Untitled')}\n URL: {r.get('url', '')}\n {r.get('content', '')[:500]}"
|
| 199 |
+
for r in crawl_results
|
| 200 |
)
|
| 201 |
+
context_blocks.append(f"[Web Crawl Results]\n{formatted}")
|
| 202 |
+
elif TAVILY_API_KEY:
|
| 203 |
+
web_results = tavily_search(intent)
|
| 204 |
+
if web_results:
|
| 205 |
+
formatted = "\n".join(
|
| 206 |
+
f"- {r.get('title', 'Untitled')}\n URL: {r.get('url', '')}\n {r.get('content', '')[:300]}"
|
| 207 |
+
for r in web_results
|
| 208 |
+
)
|
| 209 |
+
context_blocks.append(f"[Web Search Results]\n{formatted}")
|
| 210 |
|
| 211 |
# Step 2: News API (if requires_news or finance domain)
|
| 212 |
if route.get("requires_news") or domain == "finance":
|
|
|
|
| 284 |
if raw_response:
|
| 285 |
result = _extract_json(raw_response)
|
| 286 |
if result is None:
|
|
|
|
| 287 |
logger.warning(
|
| 288 |
f"[AGENT PARSE FALLBACK] research: using raw text as summary"
|
| 289 |
)
|
backend/app/agents/smart_router.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smart LLM Router for Janus.
|
| 3 |
+
|
| 4 |
+
Unified router across multiple free providers with rate limit tracking,
|
| 5 |
+
automatic failover, and daily quota management.
|
| 6 |
+
|
| 7 |
+
Provider priority:
|
| 8 |
+
1. Google Gemini 2.0 Flash (best free quality, 1500 req/day)
|
| 9 |
+
2. Groq (fastest, Llama 3.3 70B, 14400 req/day)
|
| 10 |
+
3. OpenRouter (free models, effectively unlimited)
|
| 11 |
+
4. Cloudflare Workers AI (10k req/day)
|
| 12 |
+
5. Ollama (local, unlimited)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
from datetime import datetime, timezone
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Dict, List, Any, Optional
|
| 22 |
+
|
| 23 |
+
import httpx
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
# ─── Rate Limit State ────────────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
STATE_DIR = Path(__file__).parent.parent.parent / "data" / "router_state"
|
| 30 |
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
STATE_FILE = STATE_DIR / "provider_state.json"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _midnight_ts() -> int:
|
| 35 |
+
now = datetime.now(timezone.utc)
|
| 36 |
+
tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 37 |
+
from datetime import timedelta
|
| 38 |
+
|
| 39 |
+
tomorrow = tomorrow + timedelta(days=1)
|
| 40 |
+
return int(tomorrow.timestamp())
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _load_state() -> dict:
|
| 44 |
+
if STATE_FILE.exists():
|
| 45 |
+
try:
|
| 46 |
+
with open(STATE_FILE) as f:
|
| 47 |
+
return json.load(f)
|
| 48 |
+
except Exception:
|
| 49 |
+
pass
|
| 50 |
+
return {}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _save_state(state: dict):
|
| 54 |
+
try:
|
| 55 |
+
with open(STATE_FILE, "w") as f:
|
| 56 |
+
json.dump(state, f, indent=2)
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _is_available(provider: str, daily_limit: int, rpm_limit: int, state: dict) -> bool:
|
| 62 |
+
now = time.time()
|
| 63 |
+
p = state.get(
|
| 64 |
+
provider, {"daily_count": 0, "rpm_timestamps": [], "reset_at": _midnight_ts()}
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if now > p.get("reset_at", 0):
|
| 68 |
+
p["daily_count"] = 0
|
| 69 |
+
p["rpm_timestamps"] = []
|
| 70 |
+
p["reset_at"] = _midnight_ts()
|
| 71 |
+
|
| 72 |
+
if p["daily_count"] >= daily_limit:
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
p["rpm_timestamps"] = [t for t in p["rpm_timestamps"] if now - t < 60]
|
| 76 |
+
if len(p["rpm_timestamps"]) >= rpm_limit:
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
state[provider] = p
|
| 80 |
+
return True
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _record_usage(provider: str, state: dict):
|
| 84 |
+
now = time.time()
|
| 85 |
+
p = state.get(
|
| 86 |
+
provider, {"daily_count": 0, "rpm_timestamps": [], "reset_at": _midnight_ts()}
|
| 87 |
+
)
|
| 88 |
+
p["daily_count"] = p.get("daily_count", 0) + 1
|
| 89 |
+
p["rpm_timestamps"] = p.get("rpm_timestamps", []) + [now]
|
| 90 |
+
state[provider] = p
|
| 91 |
+
_save_state(state)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ─── Provider Implementations ────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
GEMINI_KEY = os.getenv("GEMINI_API_KEY", "")
|
| 97 |
+
GROQ_KEY = os.getenv("GROQ_API_KEY", "")
|
| 98 |
+
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 99 |
+
CF_ACCOUNT_ID = os.getenv("CLOUDFLARE_ACCOUNT_ID", "")
|
| 100 |
+
CF_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN", "")
|
| 101 |
+
|
| 102 |
+
TIMEOUT = 90
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _call_gemini(messages: List[Dict[str, str]], **kwargs) -> str:
|
| 106 |
+
"""Google Gemini 2.0 Flash — best free quality."""
|
| 107 |
+
if not GEMINI_KEY:
|
| 108 |
+
raise ValueError("GEMINI_API_KEY not set")
|
| 109 |
+
|
| 110 |
+
system_msg = ""
|
| 111 |
+
user_messages = []
|
| 112 |
+
for m in messages:
|
| 113 |
+
if m["role"] == "system":
|
| 114 |
+
system_msg = m["content"]
|
| 115 |
+
else:
|
| 116 |
+
user_messages.append(m)
|
| 117 |
+
|
| 118 |
+
body = {
|
| 119 |
+
"system_instruction": {"parts": [{"text": system_msg}]},
|
| 120 |
+
"contents": [
|
| 121 |
+
{
|
| 122 |
+
"role": "model" if m["role"] == "assistant" else "user",
|
| 123 |
+
"parts": [{"text": m["content"]}],
|
| 124 |
+
}
|
| 125 |
+
for m in user_messages
|
| 126 |
+
],
|
| 127 |
+
"generationConfig": {
|
| 128 |
+
"temperature": kwargs.get("temperature", 0.7),
|
| 129 |
+
"maxOutputTokens": kwargs.get("max_tokens", 4096),
|
| 130 |
+
},
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
with httpx.Client(timeout=TIMEOUT) as client:
|
| 134 |
+
r = client.post(
|
| 135 |
+
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={GEMINI_KEY}",
|
| 136 |
+
json=body,
|
| 137 |
+
)
|
| 138 |
+
r.raise_for_status()
|
| 139 |
+
data = r.json()
|
| 140 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _call_groq(messages: List[Dict[str, str]], **kwargs) -> str:
|
| 144 |
+
"""Groq — fastest inference, Llama 3.3 70B."""
|
| 145 |
+
if not GROQ_KEY:
|
| 146 |
+
raise ValueError("GROQ_API_KEY not set")
|
| 147 |
+
|
| 148 |
+
body = {
|
| 149 |
+
"model": "llama-3.3-70b-versatile",
|
| 150 |
+
"messages": messages,
|
| 151 |
+
"temperature": kwargs.get("temperature", 0.7),
|
| 152 |
+
"max_tokens": kwargs.get("max_tokens", 4096),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
with httpx.Client(timeout=TIMEOUT) as client:
|
| 156 |
+
r = client.post(
|
| 157 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 158 |
+
headers={
|
| 159 |
+
"Authorization": f"Bearer {GROQ_KEY}",
|
| 160 |
+
"Content-Type": "application/json",
|
| 161 |
+
},
|
| 162 |
+
json=body,
|
| 163 |
+
)
|
| 164 |
+
r.raise_for_status()
|
| 165 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _call_openrouter(messages: List[Dict[str, str]], **kwargs) -> str:
|
| 169 |
+
"""OpenRouter — free model ladder."""
|
| 170 |
+
if not OPENROUTER_KEY:
|
| 171 |
+
raise ValueError("OPENROUTER_API_KEY not set")
|
| 172 |
+
|
| 173 |
+
free_models = [
|
| 174 |
+
"qwen/qwen3.6-plus:free",
|
| 175 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 176 |
+
"minimax/minimax-m2.5:free",
|
| 177 |
+
]
|
| 178 |
+
|
| 179 |
+
errors = []
|
| 180 |
+
for model in free_models:
|
| 181 |
+
try:
|
| 182 |
+
body = {
|
| 183 |
+
"model": model,
|
| 184 |
+
"messages": messages,
|
| 185 |
+
"max_tokens": kwargs.get("max_tokens", 4096),
|
| 186 |
+
}
|
| 187 |
+
with httpx.Client(timeout=TIMEOUT) as client:
|
| 188 |
+
r = client.post(
|
| 189 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 190 |
+
headers={
|
| 191 |
+
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
| 192 |
+
"HTTP-Referer": "https://huggingface.co",
|
| 193 |
+
"X-Title": "Janus",
|
| 194 |
+
"Content-Type": "application/json",
|
| 195 |
+
},
|
| 196 |
+
json=body,
|
| 197 |
+
)
|
| 198 |
+
r.raise_for_status()
|
| 199 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 200 |
+
except Exception as e:
|
| 201 |
+
errors.append(f"{model}: {e}")
|
| 202 |
+
|
| 203 |
+
raise RuntimeError(f"All OpenRouter models failed: {'; '.join(errors)}")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _call_cloudflare(messages: List[Dict[str, str]], **kwargs) -> str:
|
| 207 |
+
"""Cloudflare Workers AI — 10k req/day free."""
|
| 208 |
+
if not CF_ACCOUNT_ID or not CF_TOKEN:
|
| 209 |
+
raise ValueError("CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN not set")
|
| 210 |
+
|
| 211 |
+
body = {
|
| 212 |
+
"messages": messages,
|
| 213 |
+
"max_tokens": kwargs.get("max_tokens", 4096),
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
with httpx.Client(timeout=TIMEOUT) as client:
|
| 217 |
+
r = client.post(
|
| 218 |
+
f"https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/ai/run/@cf/meta/llama-3.3-70b-instruct-fp8-fast",
|
| 219 |
+
headers={
|
| 220 |
+
"Authorization": f"Bearer {CF_TOKEN}",
|
| 221 |
+
"Content-Type": "application/json",
|
| 222 |
+
},
|
| 223 |
+
json=body,
|
| 224 |
+
)
|
| 225 |
+
r.raise_for_status()
|
| 226 |
+
data = r.json()
|
| 227 |
+
return data["result"]["response"]
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _call_ollama(messages: List[Dict[str, str]], **kwargs) -> str:
|
| 231 |
+
"""Ollama — local, unlimited fallback."""
|
| 232 |
+
base = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 233 |
+
if base.endswith("/api"):
|
| 234 |
+
base = base[:-4]
|
| 235 |
+
|
| 236 |
+
model = os.getenv("OLLAMA_CHAT_MODEL", "qwen2.5:3b")
|
| 237 |
+
body = {
|
| 238 |
+
"model": model,
|
| 239 |
+
"messages": messages,
|
| 240 |
+
"stream": False,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
with httpx.Client(timeout=60) as client:
|
| 244 |
+
r = client.post(f"{base}/v1/chat/completions", json=body)
|
| 245 |
+
r.raise_for_status()
|
| 246 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ─── Provider Registry ───────────────────────────────────────────────────────
|
| 250 |
+
|
| 251 |
+
PROVIDERS = [
|
| 252 |
+
{
|
| 253 |
+
"name": "groq",
|
| 254 |
+
"daily_limit": 14400,
|
| 255 |
+
"rpm_limit": 1000,
|
| 256 |
+
"call": _call_groq,
|
| 257 |
+
"enabled": bool(GROQ_KEY),
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"name": "gemini",
|
| 261 |
+
"daily_limit": 1500,
|
| 262 |
+
"rpm_limit": 15,
|
| 263 |
+
"call": _call_gemini,
|
| 264 |
+
"enabled": bool(GEMINI_KEY),
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"name": "openrouter",
|
| 268 |
+
"daily_limit": 999999,
|
| 269 |
+
"rpm_limit": 20,
|
| 270 |
+
"call": _call_openrouter,
|
| 271 |
+
"enabled": bool(OPENROUTER_KEY),
|
| 272 |
+
},
|
| 273 |
+
{
|
| 274 |
+
"name": "cloudflare",
|
| 275 |
+
"daily_limit": 10000,
|
| 276 |
+
"rpm_limit": 60,
|
| 277 |
+
"call": _call_cloudflare,
|
| 278 |
+
"enabled": bool(CF_ACCOUNT_ID and CF_TOKEN),
|
| 279 |
+
},
|
| 280 |
+
{
|
| 281 |
+
"name": "ollama",
|
| 282 |
+
"daily_limit": 999999,
|
| 283 |
+
"rpm_limit": 999999,
|
| 284 |
+
"call": _call_ollama,
|
| 285 |
+
"enabled": True,
|
| 286 |
+
},
|
| 287 |
+
]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def call_model(messages: List[Dict[str, str]], **kwargs) -> str:
|
| 291 |
+
"""
|
| 292 |
+
Smart router — tries providers in priority order with rate limit tracking.
|
| 293 |
+
Returns text from the first successful provider.
|
| 294 |
+
"""
|
| 295 |
+
state = _load_state()
|
| 296 |
+
errors = []
|
| 297 |
+
|
| 298 |
+
for provider in PROVIDERS:
|
| 299 |
+
if not provider["enabled"]:
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
name = provider["name"]
|
| 303 |
+
if not _is_available(
|
| 304 |
+
name, provider["daily_limit"], provider["rpm_limit"], state
|
| 305 |
+
):
|
| 306 |
+
logger.info(f"[router] {name} skipped (rate limited)")
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
try:
|
| 310 |
+
logger.info(f"[router] trying {name}")
|
| 311 |
+
result = provider["call"](messages, **kwargs)
|
| 312 |
+
_record_usage(name, state)
|
| 313 |
+
logger.info(f"[router] {name} succeeded")
|
| 314 |
+
return result
|
| 315 |
+
except Exception as e:
|
| 316 |
+
errors.append(f"{name}: {e}")
|
| 317 |
+
logger.warning(f"[router] {name} failed: {e}")
|
| 318 |
+
|
| 319 |
+
raise RuntimeError(f"All LLM providers exhausted:\n" + "\n".join(errors))
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def safe_parse(text: str) -> dict:
|
| 323 |
+
"""Strip markdown fences, attempt JSON parse."""
|
| 324 |
+
import re
|
| 325 |
+
|
| 326 |
+
cleaned = re.sub(r"```(?:json)?|```", "", text).strip()
|
| 327 |
+
try:
|
| 328 |
+
return json.loads(cleaned)
|
| 329 |
+
except json.JSONDecodeError:
|
| 330 |
+
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
| 331 |
+
if match:
|
| 332 |
+
try:
|
| 333 |
+
return json.loads(match.group())
|
| 334 |
+
except json.JSONDecodeError:
|
| 335 |
+
pass
|
| 336 |
+
return {"error": "parse_failed", "raw": text[:800]}
|
backend/app/agents/synthesizer.py
CHANGED
|
@@ -70,6 +70,7 @@ def run(state: dict) -> dict:
|
|
| 70 |
simulation = state.get("simulation", {})
|
| 71 |
finance = state.get("finance", {})
|
| 72 |
replan_count = state.get("replan_count", 0)
|
|
|
|
| 73 |
|
| 74 |
prompt = load_prompt("synthesizer")
|
| 75 |
parser = PydanticOutputParser(pydantic_object=SynthesizerOutput)
|
|
@@ -90,6 +91,35 @@ def run(state: dict) -> dict:
|
|
| 90 |
"NOTE: Verifier did not fully pass and replan limit was reached. Acknowledge limitations."
|
| 91 |
)
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
messages = [
|
| 94 |
{"role": "system", "content": prompt},
|
| 95 |
{
|
|
@@ -97,6 +127,7 @@ def run(state: dict) -> dict:
|
|
| 97 |
"content": (
|
| 98 |
f"User request: {state.get('user_input', route.get('intent', ''))}\n\n"
|
| 99 |
+ "\n\n".join(context_parts)
|
|
|
|
| 100 |
+ "\n\n"
|
| 101 |
+ parser.get_format_instructions()
|
| 102 |
),
|
|
|
|
| 70 |
simulation = state.get("simulation", {})
|
| 71 |
finance = state.get("finance", {})
|
| 72 |
replan_count = state.get("replan_count", 0)
|
| 73 |
+
context = state.get("context", {})
|
| 74 |
|
| 75 |
prompt = load_prompt("synthesizer")
|
| 76 |
parser = PydanticOutputParser(pydantic_object=SynthesizerOutput)
|
|
|
|
| 91 |
"NOTE: Verifier did not fully pass and replan limit was reached. Acknowledge limitations."
|
| 92 |
)
|
| 93 |
|
| 94 |
+
# Inject system context into the synthesizer
|
| 95 |
+
system_context = ""
|
| 96 |
+
if context:
|
| 97 |
+
system_self = context.get("system_self", {})
|
| 98 |
+
pending = system_self.get("pending_thoughts", [])
|
| 99 |
+
discoveries = system_self.get("recent_discoveries", [])
|
| 100 |
+
user_ctx = context.get("user", {})
|
| 101 |
+
|
| 102 |
+
if pending:
|
| 103 |
+
thoughts = [t.get("thought", "") for t in pending[:3] if t.get("thought")]
|
| 104 |
+
if thoughts:
|
| 105 |
+
system_context += "\n\nTHINGS YOU'VE BEEN THINKING ABOUT:\n"
|
| 106 |
+
system_context += "\n".join(f"- {t}" for t in thoughts)
|
| 107 |
+
|
| 108 |
+
if discoveries:
|
| 109 |
+
system_context += "\n\nRECENT DISCOVERIES:\n"
|
| 110 |
+
for d in discoveries[:3]:
|
| 111 |
+
system_context += f"- {d.get('discovery', '')}\n"
|
| 112 |
+
|
| 113 |
+
if user_ctx.get("is_returning"):
|
| 114 |
+
system_context += f"\n\nThis user has had {user_ctx.get('conversation_count', 0)} conversations with you."
|
| 115 |
+
if user_ctx.get("last_topic"):
|
| 116 |
+
system_context += f" Last topic: {user_ctx['last_topic']}."
|
| 117 |
+
if user_ctx.get("time_away"):
|
| 118 |
+
system_context += f" They've been away for {user_ctx['time_away']}."
|
| 119 |
+
|
| 120 |
+
if user_ctx.get("recurring_interests"):
|
| 121 |
+
system_context += f"\nTheir recurring interests: {', '.join(user_ctx['recurring_interests'][:3])}."
|
| 122 |
+
|
| 123 |
messages = [
|
| 124 |
{"role": "system", "content": prompt},
|
| 125 |
{
|
|
|
|
| 127 |
"content": (
|
| 128 |
f"User request: {state.get('user_input', route.get('intent', ''))}\n\n"
|
| 129 |
+ "\n\n".join(context_parts)
|
| 130 |
+
+ (f"\n\n{system_context}" if system_context else "")
|
| 131 |
+ "\n\n"
|
| 132 |
+ parser.get_format_instructions()
|
| 133 |
),
|
backend/app/config.py
CHANGED
|
@@ -26,10 +26,18 @@ def load_prompt(name: str) -> str:
|
|
| 26 |
return path.read_text(encoding="utf-8").strip()
|
| 27 |
|
| 28 |
|
| 29 |
-
APP_VERSION = os.getenv("APP_VERSION", "0.
|
| 30 |
|
| 31 |
-
PRIMARY_PROVIDER = os.getenv("PRIMARY_PROVIDER", "
|
| 32 |
-
FALLBACK_PROVIDER = os.getenv("FALLBACK_PROVIDER", "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 35 |
OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
@@ -57,6 +65,9 @@ MIROFISH_ENABLED = False # Deprecated — using native simulation engine
|
|
| 57 |
MIROFISH_API_BASE = ""
|
| 58 |
MIROFISH_TIMEOUT_SECONDS = 0
|
| 59 |
|
|
|
|
|
|
|
|
|
|
| 60 |
SIMULATION_TRIGGER_KEYWORDS = [
|
| 61 |
item.strip().lower()
|
| 62 |
for item in os.getenv(
|
|
@@ -86,9 +97,14 @@ def validate_config():
|
|
| 86 |
|
| 87 |
# Validate primary provider configuration
|
| 88 |
primary = PRIMARY_PROVIDER.lower()
|
| 89 |
-
if primary not in ["openrouter", "ollama", "openai"]:
|
| 90 |
errors.append(
|
| 91 |
-
f"PRIMARY_PROVIDER '{PRIMARY_PROVIDER}' is not supported. Must be one of: openrouter, ollama, openai"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
)
|
| 93 |
|
| 94 |
if primary == "openrouter" and not OPENROUTER_API_KEY:
|
|
@@ -108,9 +124,14 @@ def validate_config():
|
|
| 108 |
|
| 109 |
# Validate fallback provider configuration
|
| 110 |
fallback = FALLBACK_PROVIDER.lower()
|
| 111 |
-
if fallback not in ["openrouter", "ollama", "openai"]:
|
| 112 |
errors.append(
|
| 113 |
-
f"FALLBACK_PROVIDER '{FALLBACK_PROVIDER}' is not supported. Must be one of: openrouter, ollama, openai"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
)
|
| 115 |
|
| 116 |
if fallback == "openrouter" and not OPENROUTER_API_KEY:
|
|
@@ -198,6 +219,14 @@ def get_config():
|
|
| 198 |
primary_provider = PRIMARY_PROVIDER
|
| 199 |
fallback_provider = FALLBACK_PROVIDER
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
openrouter_api_key = OPENROUTER_API_KEY
|
| 202 |
openrouter_base_url = OPENROUTER_BASE_URL
|
| 203 |
openrouter_chat_model = OPENROUTER_CHAT_MODEL
|
|
@@ -220,6 +249,9 @@ def get_config():
|
|
| 220 |
mirofish_enabled = MIROFISH_ENABLED
|
| 221 |
mirofish_api_base = MIROFISH_API_BASE
|
| 222 |
|
|
|
|
|
|
|
|
|
|
| 223 |
data_dir = str(DATA_DIR)
|
| 224 |
memory_dir = str(MEMORY_DIR)
|
| 225 |
simulation_dir = str(SIMULATION_DIR)
|
|
|
|
| 26 |
return path.read_text(encoding="utf-8").strip()
|
| 27 |
|
| 28 |
|
| 29 |
+
APP_VERSION = os.getenv("APP_VERSION", "0.4.0")
|
| 30 |
|
| 31 |
+
PRIMARY_PROVIDER = os.getenv("PRIMARY_PROVIDER", "huggingface").lower()
|
| 32 |
+
FALLBACK_PROVIDER = os.getenv("FALLBACK_PROVIDER", "openrouter").lower()
|
| 33 |
+
|
| 34 |
+
HUGGINGFACE_API_KEY = os.getenv("HUGGINGFACE_API_KEY", "")
|
| 35 |
+
HUGGINGFACE_MODEL = os.getenv("HUGGINGFACE_MODEL", "Qwen/Qwen2.5-7B-Instruct")
|
| 36 |
+
|
| 37 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
| 38 |
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
|
| 39 |
+
CLOUDFLARE_ACCOUNT_ID = os.getenv("CLOUDFLARE_ACCOUNT_ID", "")
|
| 40 |
+
CLOUDFLARE_API_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN", "")
|
| 41 |
|
| 42 |
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 43 |
OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
|
|
| 65 |
MIROFISH_API_BASE = ""
|
| 66 |
MIROFISH_TIMEOUT_SECONDS = 0
|
| 67 |
|
| 68 |
+
CRAWLER_ENABLED = os.getenv("CRAWLER_ENABLED", "true").lower() == "true"
|
| 69 |
+
CRAWLER_TIMEOUT = int(os.getenv("CRAWLER_TIMEOUT", "30"))
|
| 70 |
+
|
| 71 |
SIMULATION_TRIGGER_KEYWORDS = [
|
| 72 |
item.strip().lower()
|
| 73 |
for item in os.getenv(
|
|
|
|
| 97 |
|
| 98 |
# Validate primary provider configuration
|
| 99 |
primary = PRIMARY_PROVIDER.lower()
|
| 100 |
+
if primary not in ["huggingface", "openrouter", "ollama", "openai"]:
|
| 101 |
errors.append(
|
| 102 |
+
f"PRIMARY_PROVIDER '{PRIMARY_PROVIDER}' is not supported. Must be one of: huggingface, openrouter, ollama, openai"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if primary == "huggingface" and not HUGGINGFACE_API_KEY:
|
| 106 |
+
warnings.append(
|
| 107 |
+
"PRIMARY_PROVIDER is 'huggingface' but HUGGINGFACE_API_KEY is missing - relying on fallback"
|
| 108 |
)
|
| 109 |
|
| 110 |
if primary == "openrouter" and not OPENROUTER_API_KEY:
|
|
|
|
| 124 |
|
| 125 |
# Validate fallback provider configuration
|
| 126 |
fallback = FALLBACK_PROVIDER.lower()
|
| 127 |
+
if fallback not in ["huggingface", "openrouter", "ollama", "openai"]:
|
| 128 |
errors.append(
|
| 129 |
+
f"FALLBACK_PROVIDER '{FALLBACK_PROVIDER}' is not supported. Must be one of: huggingface, openrouter, ollama, openai"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
if fallback == "huggingface" and not HUGGINGFACE_API_KEY:
|
| 133 |
+
warnings.append(
|
| 134 |
+
"FALLBACK_PROVIDER is 'huggingface' but HUGGINGFACE_API_KEY is missing - fallback will fail"
|
| 135 |
)
|
| 136 |
|
| 137 |
if fallback == "openrouter" and not OPENROUTER_API_KEY:
|
|
|
|
| 219 |
primary_provider = PRIMARY_PROVIDER
|
| 220 |
fallback_provider = FALLBACK_PROVIDER
|
| 221 |
|
| 222 |
+
huggingface_api_key = HUGGINGFACE_API_KEY
|
| 223 |
+
huggingface_model = HUGGINGFACE_MODEL
|
| 224 |
+
|
| 225 |
+
gemini_api_key = GEMINI_API_KEY
|
| 226 |
+
groq_api_key = GROQ_API_KEY
|
| 227 |
+
cloudflare_account_id = CLOUDFLARE_ACCOUNT_ID
|
| 228 |
+
cloudflare_api_token = CLOUDFLARE_API_TOKEN
|
| 229 |
+
|
| 230 |
openrouter_api_key = OPENROUTER_API_KEY
|
| 231 |
openrouter_base_url = OPENROUTER_BASE_URL
|
| 232 |
openrouter_chat_model = OPENROUTER_CHAT_MODEL
|
|
|
|
| 249 |
mirofish_enabled = MIROFISH_ENABLED
|
| 250 |
mirofish_api_base = MIROFISH_API_BASE
|
| 251 |
|
| 252 |
+
crawler_enabled = CRAWLER_ENABLED
|
| 253 |
+
crawler_timeout = CRAWLER_TIMEOUT
|
| 254 |
+
|
| 255 |
data_dir = str(DATA_DIR)
|
| 256 |
memory_dir = str(MEMORY_DIR)
|
| 257 |
simulation_dir = str(SIMULATION_DIR)
|
backend/app/domain_packs/registry.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
Domain pack registry for managing and discovering domain packs.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
from typing import Dict, List, Optional
|
| 6 |
import logging
|
| 7 |
|
| 8 |
from app.domain_packs.base import DomainPack
|
|
@@ -31,21 +31,21 @@ class DomainPackRegistry:
|
|
| 31 |
def detect_domain(self, query: str) -> Optional[str]:
|
| 32 |
"""
|
| 33 |
Detect which domain pack matches the query based on keywords.
|
| 34 |
-
|
| 35 |
Args:
|
| 36 |
query: The user's query
|
| 37 |
-
|
| 38 |
Returns:
|
| 39 |
Domain pack name if detected, None otherwise
|
| 40 |
"""
|
| 41 |
query_lower = query.lower()
|
| 42 |
-
|
| 43 |
for name, pack in self._packs.items():
|
| 44 |
for keyword in pack.keywords:
|
| 45 |
if keyword.lower() in query_lower:
|
| 46 |
logger.info(f"Detected domain '{name}' from keyword '{keyword}'")
|
| 47 |
return name
|
| 48 |
-
|
| 49 |
return None
|
| 50 |
|
| 51 |
def list_packs(self) -> List[str]:
|
|
@@ -54,10 +54,7 @@ class DomainPackRegistry:
|
|
| 54 |
|
| 55 |
def get_capabilities(self) -> Dict[str, Any]:
|
| 56 |
"""Get capabilities of all registered domain packs."""
|
| 57 |
-
return {
|
| 58 |
-
name: pack.get_capabilities()
|
| 59 |
-
for name, pack in self._packs.items()
|
| 60 |
-
}
|
| 61 |
|
| 62 |
|
| 63 |
# Global registry instance
|
|
|
|
| 2 |
Domain pack registry for managing and discovering domain packs.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from typing import Dict, List, Optional, Any
|
| 6 |
import logging
|
| 7 |
|
| 8 |
from app.domain_packs.base import DomainPack
|
|
|
|
| 31 |
def detect_domain(self, query: str) -> Optional[str]:
|
| 32 |
"""
|
| 33 |
Detect which domain pack matches the query based on keywords.
|
| 34 |
+
|
| 35 |
Args:
|
| 36 |
query: The user's query
|
| 37 |
+
|
| 38 |
Returns:
|
| 39 |
Domain pack name if detected, None otherwise
|
| 40 |
"""
|
| 41 |
query_lower = query.lower()
|
| 42 |
+
|
| 43 |
for name, pack in self._packs.items():
|
| 44 |
for keyword in pack.keywords:
|
| 45 |
if keyword.lower() in query_lower:
|
| 46 |
logger.info(f"Detected domain '{name}' from keyword '{keyword}'")
|
| 47 |
return name
|
| 48 |
+
|
| 49 |
return None
|
| 50 |
|
| 51 |
def list_packs(self) -> List[str]:
|
|
|
|
| 54 |
|
| 55 |
def get_capabilities(self) -> Dict[str, Any]:
|
| 56 |
"""Get capabilities of all registered domain packs."""
|
| 57 |
+
return {name: pack.get_capabilities() for name, pack in self._packs.items()}
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
# Global registry instance
|
backend/app/graph.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
Optimized graph topology (2-3 model calls instead of 5):
|
| 5 |
[switchboard]
|
|
@@ -12,8 +12,7 @@ Optimized graph topology (2-3 model calls instead of 5):
|
|
| 12 |
│
|
| 13 |
[END]
|
| 14 |
|
| 15 |
-
|
| 16 |
-
This reduces latency from 5 model calls (3-6 min) to 2-3 calls (1-2 min).
|
| 17 |
"""
|
| 18 |
|
| 19 |
import uuid
|
|
@@ -29,26 +28,16 @@ from app.agents import mirofish_node, finance_node
|
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
|
| 31 |
|
| 32 |
-
# ── State Type ────────────────────────────────────────────────────────────────
|
| 33 |
-
|
| 34 |
-
|
| 35 |
class AgentState(TypedDict, total=False):
|
| 36 |
-
# Input
|
| 37 |
user_input: str
|
| 38 |
case_id: str
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
research: dict # research output
|
| 45 |
-
final: dict # synthesizer output
|
| 46 |
-
|
| 47 |
-
# Control
|
| 48 |
errors: list
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
# ── Node wrappers with timing ────────────────────────────────────────────────
|
| 52 |
|
| 53 |
|
| 54 |
def switchboard_node(state: AgentState) -> dict:
|
|
@@ -93,11 +82,7 @@ def synthesizer_node(state: AgentState) -> dict:
|
|
| 93 |
return result
|
| 94 |
|
| 95 |
|
| 96 |
-
# ── Routing functions ─────────────────────────────────────────────────────────
|
| 97 |
-
|
| 98 |
-
|
| 99 |
def after_switchboard(state: AgentState) -> str:
|
| 100 |
-
"""Route based on switchboard flags."""
|
| 101 |
route = state.get("route", {})
|
| 102 |
if route.get("requires_simulation"):
|
| 103 |
return "mirofish"
|
|
@@ -106,9 +91,6 @@ def after_switchboard(state: AgentState) -> str:
|
|
| 106 |
return "research"
|
| 107 |
|
| 108 |
|
| 109 |
-
# ── Build graph ───────────────────────────────────────────────────────────────
|
| 110 |
-
|
| 111 |
-
|
| 112 |
def build_graph():
|
| 113 |
g = StateGraph(AgentState)
|
| 114 |
|
|
@@ -120,20 +102,15 @@ def build_graph():
|
|
| 120 |
|
| 121 |
g.set_entry_point("switchboard")
|
| 122 |
|
| 123 |
-
# After switchboard: fork based on flags
|
| 124 |
g.add_conditional_edges(
|
| 125 |
"switchboard",
|
| 126 |
after_switchboard,
|
| 127 |
{"mirofish": "mirofish", "finance": "finance", "research": "research"},
|
| 128 |
)
|
| 129 |
|
| 130 |
-
# mirofish and finance both merge into research
|
| 131 |
g.add_edge("mirofish", "research")
|
| 132 |
g.add_edge("finance", "research")
|
| 133 |
-
|
| 134 |
-
# Research goes directly to synthesizer (no planner/verifier loop)
|
| 135 |
g.add_edge("research", "synthesizer")
|
| 136 |
-
|
| 137 |
g.add_edge("synthesizer", END)
|
| 138 |
return g.compile()
|
| 139 |
|
|
@@ -141,22 +118,24 @@ def build_graph():
|
|
| 141 |
compiled_graph = build_graph()
|
| 142 |
|
| 143 |
|
| 144 |
-
def run_case(user_input: str) -> dict:
|
| 145 |
"""Run the optimized agent pipeline on user input."""
|
| 146 |
case_id = str(uuid.uuid4())
|
| 147 |
t0 = time.perf_counter()
|
| 148 |
logger.info("Starting case %s", case_id)
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
|
| 161 |
elapsed = time.perf_counter() - t0
|
| 162 |
logger.info("Case %s completed in %.2fs", case_id, elapsed)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Janus — LangGraph pipeline.
|
| 3 |
|
| 4 |
Optimized graph topology (2-3 model calls instead of 5):
|
| 5 |
[switchboard]
|
|
|
|
| 12 |
│
|
| 13 |
[END]
|
| 14 |
|
| 15 |
+
Context from the context engine is injected into every LLM call.
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import uuid
|
|
|
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
class AgentState(TypedDict, total=False):
|
|
|
|
| 32 |
user_input: str
|
| 33 |
case_id: str
|
| 34 |
+
route: dict
|
| 35 |
+
simulation: dict
|
| 36 |
+
finance: dict
|
| 37 |
+
research: dict
|
| 38 |
+
final: dict
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
errors: list
|
| 40 |
+
context: dict
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def switchboard_node(state: AgentState) -> dict:
|
|
|
|
| 82 |
return result
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
| 85 |
def after_switchboard(state: AgentState) -> str:
|
|
|
|
| 86 |
route = state.get("route", {})
|
| 87 |
if route.get("requires_simulation"):
|
| 88 |
return "mirofish"
|
|
|
|
| 91 |
return "research"
|
| 92 |
|
| 93 |
|
|
|
|
|
|
|
|
|
|
| 94 |
def build_graph():
|
| 95 |
g = StateGraph(AgentState)
|
| 96 |
|
|
|
|
| 102 |
|
| 103 |
g.set_entry_point("switchboard")
|
| 104 |
|
|
|
|
| 105 |
g.add_conditional_edges(
|
| 106 |
"switchboard",
|
| 107 |
after_switchboard,
|
| 108 |
{"mirofish": "mirofish", "finance": "finance", "research": "research"},
|
| 109 |
)
|
| 110 |
|
|
|
|
| 111 |
g.add_edge("mirofish", "research")
|
| 112 |
g.add_edge("finance", "research")
|
|
|
|
|
|
|
| 113 |
g.add_edge("research", "synthesizer")
|
|
|
|
| 114 |
g.add_edge("synthesizer", END)
|
| 115 |
return g.compile()
|
| 116 |
|
|
|
|
| 118 |
compiled_graph = build_graph()
|
| 119 |
|
| 120 |
|
| 121 |
+
def run_case(user_input: str, context: dict = None) -> dict:
|
| 122 |
"""Run the optimized agent pipeline on user input."""
|
| 123 |
case_id = str(uuid.uuid4())
|
| 124 |
t0 = time.perf_counter()
|
| 125 |
logger.info("Starting case %s", case_id)
|
| 126 |
|
| 127 |
+
initial_state = {
|
| 128 |
+
"case_id": case_id,
|
| 129 |
+
"user_input": user_input,
|
| 130 |
+
"route": {},
|
| 131 |
+
"research": {},
|
| 132 |
+
"final": {},
|
| 133 |
+
"errors": [],
|
| 134 |
+
}
|
| 135 |
+
if context:
|
| 136 |
+
initial_state["context"] = context
|
| 137 |
+
|
| 138 |
+
result = compiled_graph.invoke(initial_state)
|
| 139 |
|
| 140 |
elapsed = time.perf_counter() - t0
|
| 141 |
logger.info("Case %s completed in %.2fs", case_id, elapsed)
|
backend/app/main.py
CHANGED
|
@@ -29,6 +29,8 @@ from app.services.daemon import JanusDaemon
|
|
| 29 |
from app.services.adaptive_pipeline import adaptive_pipeline
|
| 30 |
from app.services.circadian_rhythm import CircadianRhythm
|
| 31 |
from app.services.dream_processor import DreamCycleProcessor
|
|
|
|
|
|
|
| 32 |
from app.routers.simulation import router as simulation_router
|
| 33 |
from app.routers.learning import (
|
| 34 |
router as learning_router,
|
|
@@ -42,7 +44,7 @@ from app.config import get_config
|
|
| 42 |
logging.basicConfig(level=logging.INFO)
|
| 43 |
logger = logging.getLogger(__name__)
|
| 44 |
|
| 45 |
-
app = FastAPI(title="
|
| 46 |
|
| 47 |
# Initialize domain packs
|
| 48 |
from app.domain_packs.init_packs import init_domain_packs
|
|
@@ -133,6 +135,21 @@ async def on_startup():
|
|
| 133 |
except Exception as e:
|
| 134 |
logger.error(f"Failed to start sentinel scheduler: {e}")
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
# ── Health ────────────────────────────────────────────────────────────────────
|
| 138 |
|
|
@@ -147,10 +164,28 @@ def health_deep():
|
|
| 147 |
return deep_health()
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
@app.get("/config/status")
|
| 151 |
def config_status():
|
| 152 |
return {
|
| 153 |
"app_version": APP_VERSION,
|
|
|
|
|
|
|
| 154 |
"openrouter_key_present": bool(os.getenv("OPENROUTER_API_KEY")),
|
| 155 |
"ollama_base_url": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
|
| 156 |
"ollama_model": os.getenv("OLLAMA_MODEL", "llama3.2"),
|
|
@@ -256,19 +291,32 @@ def run_org(task: UserTask):
|
|
| 256 |
user_input = task.user_input
|
| 257 |
logger.info("Processing /run: %s", user_input[:100])
|
| 258 |
|
| 259 |
-
# Step 1:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
query_type, query_confidence, query_meta = query_classifier.classify(user_input)
|
| 261 |
domain = query_meta.get("detected_domain", "general")
|
| 262 |
logger.info(
|
| 263 |
f"Query classified: type={query_type.value}, domain={domain}, confidence={query_confidence:.2f}"
|
| 264 |
)
|
| 265 |
|
| 266 |
-
# Step
|
| 267 |
cached = cache_manager.get(user_input)
|
| 268 |
if cached:
|
| 269 |
logger.info(
|
| 270 |
f"Cache HIT ({cached['cache_age_hours']:.1f}h old, {cached['hit_count']} hits)"
|
| 271 |
)
|
|
|
|
|
|
|
|
|
|
| 272 |
return {
|
| 273 |
"case_id": "",
|
| 274 |
"user_input": user_input,
|
|
@@ -283,13 +331,13 @@ def run_org(task: UserTask):
|
|
| 283 |
"outputs": [],
|
| 284 |
}
|
| 285 |
|
| 286 |
-
# Step
|
| 287 |
ai_context = adaptive_intelligence.get_context_for_query(user_input, domain)
|
| 288 |
logger.info(
|
| 289 |
f"Adaptive context: {ai_context['total_cases_learned']} cases learned, personality_depth={ai_context['system_personality']['analytical_depth']:.2f}"
|
| 290 |
)
|
| 291 |
|
| 292 |
-
# Step
|
| 293 |
start = time.perf_counter()
|
| 294 |
result = run_case(user_input)
|
| 295 |
elapsed = time.perf_counter() - start
|
|
@@ -316,7 +364,7 @@ def run_org(task: UserTask):
|
|
| 316 |
}
|
| 317 |
save_case(result.get("case_id", ""), payload)
|
| 318 |
|
| 319 |
-
# Step
|
| 320 |
cache_manager.put(
|
| 321 |
query=user_input,
|
| 322 |
answer=final_answer,
|
|
@@ -331,7 +379,7 @@ def run_org(task: UserTask):
|
|
| 331 |
)
|
| 332 |
logger.info(f"Result cached: type={query_type.value}, domain={domain}")
|
| 333 |
|
| 334 |
-
# Step
|
| 335 |
case_id = result.get("case_id", "")
|
| 336 |
memory_graph.add_query(
|
| 337 |
query_id=case_id,
|
|
@@ -353,13 +401,21 @@ def run_org(task: UserTask):
|
|
| 353 |
memory_graph.add_entity(entity_id, cleaned, "entity")
|
| 354 |
memory_graph.link_query_entity(case_id, entity_id, 0.5)
|
| 355 |
|
| 356 |
-
# Step
|
| 357 |
adaptive_intelligence.learn_from_case(payload, elapsed)
|
| 358 |
logger.info(
|
| 359 |
f"Adaptive intelligence updated: total_cases={adaptive_intelligence.total_cases}"
|
| 360 |
)
|
| 361 |
|
| 362 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
should_learn, learn_reason = learning_filter.should_learn(
|
| 364 |
query_type=query_type,
|
| 365 |
query=user_input,
|
|
|
|
| 29 |
from app.services.adaptive_pipeline import adaptive_pipeline
|
| 30 |
from app.services.circadian_rhythm import CircadianRhythm
|
| 31 |
from app.services.dream_processor import DreamCycleProcessor
|
| 32 |
+
from app.services.context_engine import context_engine
|
| 33 |
+
from app.services.reflex_layer import reflex_layer
|
| 34 |
from app.routers.simulation import router as simulation_router
|
| 35 |
from app.routers.learning import (
|
| 36 |
router as learning_router,
|
|
|
|
| 44 |
logging.basicConfig(level=logging.INFO)
|
| 45 |
logger = logging.getLogger(__name__)
|
| 46 |
|
| 47 |
+
app = FastAPI(title="Janus", version=APP_VERSION)
|
| 48 |
|
| 49 |
# Initialize domain packs
|
| 50 |
from app.domain_packs.init_packs import init_domain_packs
|
|
|
|
| 135 |
except Exception as e:
|
| 136 |
logger.error(f"Failed to start sentinel scheduler: {e}")
|
| 137 |
|
| 138 |
+
# Start Janus daemon in background thread
|
| 139 |
+
daemon_enabled = os.getenv("DAEMON_ENABLED", "true").lower() == "true"
|
| 140 |
+
if daemon_enabled:
|
| 141 |
+
try:
|
| 142 |
+
import threading
|
| 143 |
+
from app.services.daemon import janus_daemon
|
| 144 |
+
|
| 145 |
+
daemon_thread = threading.Thread(
|
| 146 |
+
target=janus_daemon.run, daemon=True, name="janus-daemon"
|
| 147 |
+
)
|
| 148 |
+
daemon_thread.start()
|
| 149 |
+
logger.info("Janus daemon started in background thread")
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"Failed to start Janus daemon: {e}")
|
| 152 |
+
|
| 153 |
|
| 154 |
# ── Health ────────────────────────────────────────────────────────────────────
|
| 155 |
|
|
|
|
| 164 |
return deep_health()
|
| 165 |
|
| 166 |
|
| 167 |
+
@app.get("/context")
|
| 168 |
+
def get_context():
|
| 169 |
+
"""Get the current system context."""
|
| 170 |
+
return context_engine.build_context("")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@app.get("/pending-thoughts")
|
| 174 |
+
def get_pending_thoughts():
|
| 175 |
+
"""Get pending thoughts the system wants to share."""
|
| 176 |
+
thoughts = context_engine.get_pending_thoughts()
|
| 177 |
+
return {
|
| 178 |
+
"pending_thoughts": thoughts,
|
| 179 |
+
"count": len(thoughts),
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
@app.get("/config/status")
|
| 184 |
def config_status():
|
| 185 |
return {
|
| 186 |
"app_version": APP_VERSION,
|
| 187 |
+
"groq_key_present": bool(os.getenv("GROQ_API_KEY")),
|
| 188 |
+
"gemini_key_present": bool(os.getenv("GEMINI_API_KEY")),
|
| 189 |
"openrouter_key_present": bool(os.getenv("OPENROUTER_API_KEY")),
|
| 190 |
"ollama_base_url": os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
|
| 191 |
"ollama_model": os.getenv("OLLAMA_MODEL", "llama3.2"),
|
|
|
|
| 291 |
user_input = task.user_input
|
| 292 |
logger.info("Processing /run: %s", user_input[:100])
|
| 293 |
|
| 294 |
+
# Step 1: Try reflex layer first (instant, contextual, no model call)
|
| 295 |
+
context = context_engine.build_context(user_input)
|
| 296 |
+
reflex = reflex_layer.respond(user_input, context)
|
| 297 |
+
if reflex:
|
| 298 |
+
logger.info("Reflex layer responded instantly")
|
| 299 |
+
context_engine.update_after_interaction(
|
| 300 |
+
user_input, reflex.get("final_answer", ""), context
|
| 301 |
+
)
|
| 302 |
+
return reflex
|
| 303 |
+
|
| 304 |
+
# Step 2: Classify the query
|
| 305 |
query_type, query_confidence, query_meta = query_classifier.classify(user_input)
|
| 306 |
domain = query_meta.get("detected_domain", "general")
|
| 307 |
logger.info(
|
| 308 |
f"Query classified: type={query_type.value}, domain={domain}, confidence={query_confidence:.2f}"
|
| 309 |
)
|
| 310 |
|
| 311 |
+
# Step 3: Try cache first
|
| 312 |
cached = cache_manager.get(user_input)
|
| 313 |
if cached:
|
| 314 |
logger.info(
|
| 315 |
f"Cache HIT ({cached['cache_age_hours']:.1f}h old, {cached['hit_count']} hits)"
|
| 316 |
)
|
| 317 |
+
context_engine.update_after_interaction(
|
| 318 |
+
user_input, cached["answer"], context
|
| 319 |
+
)
|
| 320 |
return {
|
| 321 |
"case_id": "",
|
| 322 |
"user_input": user_input,
|
|
|
|
| 331 |
"outputs": [],
|
| 332 |
}
|
| 333 |
|
| 334 |
+
# Step 4: Get adaptive intelligence context
|
| 335 |
ai_context = adaptive_intelligence.get_context_for_query(user_input, domain)
|
| 336 |
logger.info(
|
| 337 |
f"Adaptive context: {ai_context['total_cases_learned']} cases learned, personality_depth={ai_context['system_personality']['analytical_depth']:.2f}"
|
| 338 |
)
|
| 339 |
|
| 340 |
+
# Step 5: Run the full pipeline
|
| 341 |
start = time.perf_counter()
|
| 342 |
result = run_case(user_input)
|
| 343 |
elapsed = time.perf_counter() - start
|
|
|
|
| 364 |
}
|
| 365 |
save_case(result.get("case_id", ""), payload)
|
| 366 |
|
| 367 |
+
# Step 6: Cache the result
|
| 368 |
cache_manager.put(
|
| 369 |
query=user_input,
|
| 370 |
answer=final_answer,
|
|
|
|
| 379 |
)
|
| 380 |
logger.info(f"Result cached: type={query_type.value}, domain={domain}")
|
| 381 |
|
| 382 |
+
# Step 6b: Store in memory graph
|
| 383 |
case_id = result.get("case_id", "")
|
| 384 |
memory_graph.add_query(
|
| 385 |
query_id=case_id,
|
|
|
|
| 401 |
memory_graph.add_entity(entity_id, cleaned, "entity")
|
| 402 |
memory_graph.link_query_entity(case_id, entity_id, 0.5)
|
| 403 |
|
| 404 |
+
# Step 7: Learn from this case (adaptive intelligence)
|
| 405 |
adaptive_intelligence.learn_from_case(payload, elapsed)
|
| 406 |
logger.info(
|
| 407 |
f"Adaptive intelligence updated: total_cases={adaptive_intelligence.total_cases}"
|
| 408 |
)
|
| 409 |
|
| 410 |
+
# Update context engine
|
| 411 |
+
context_engine.update_after_interaction(user_input, final_answer, context)
|
| 412 |
+
context_engine.record_performance(
|
| 413 |
+
success=bool(final_answer),
|
| 414 |
+
confidence=final.get("confidence", 0.5),
|
| 415 |
+
elapsed=elapsed,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# Step 8: Fire-and-forget: traditional learning
|
| 419 |
should_learn, learn_reason = learning_filter.should_learn(
|
| 420 |
query_type=query_type,
|
| 421 |
query=user_input,
|
backend/app/services/context_engine.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Context Engine for Janus.
|
| 3 |
+
|
| 4 |
+
Builds rich context for every interaction — the system's "mind".
|
| 5 |
+
No emotion labels, no rules. Just facts about:
|
| 6 |
+
- What the system knows about itself
|
| 7 |
+
- What it knows about the user
|
| 8 |
+
- What the daemon has been discovering
|
| 9 |
+
- The current environment (time, etc.)
|
| 10 |
+
|
| 11 |
+
This context makes responses feel natural without any explicit emotion tracking.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import time
|
| 16 |
+
import logging
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Dict, Any, Optional, List
|
| 20 |
+
|
| 21 |
+
from app.config import DATA_DIR
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
CONTEXT_DIR = DATA_DIR / "context"
|
| 26 |
+
CONTEXT_DIR.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
USER_STATE_FILE = CONTEXT_DIR / "user_state.json"
|
| 29 |
+
SYSTEM_STATE_FILE = CONTEXT_DIR / "system_state.json"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ContextEngine:
|
| 33 |
+
"""Builds and maintains rich contextual state for every interaction."""
|
| 34 |
+
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self._user_state = self._load_user_state()
|
| 37 |
+
self._system_state = self._load_system_state()
|
| 38 |
+
self._start_time = time.time()
|
| 39 |
+
|
| 40 |
+
def _load_user_state(self) -> Dict:
|
| 41 |
+
if USER_STATE_FILE.exists():
|
| 42 |
+
try:
|
| 43 |
+
with open(USER_STATE_FILE) as f:
|
| 44 |
+
return json.load(f)
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
return {
|
| 48 |
+
"conversations": [],
|
| 49 |
+
"last_interaction": None,
|
| 50 |
+
"last_topic": None,
|
| 51 |
+
"interests": {},
|
| 52 |
+
"total_interactions": 0,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
def _save_user_state(self):
|
| 56 |
+
try:
|
| 57 |
+
with open(USER_STATE_FILE, "w") as f:
|
| 58 |
+
json.dump(self._user_state, f, indent=2)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"Failed to save user state: {e}")
|
| 61 |
+
|
| 62 |
+
def _load_system_state(self) -> Dict:
|
| 63 |
+
if SYSTEM_STATE_FILE.exists():
|
| 64 |
+
try:
|
| 65 |
+
with open(SYSTEM_STATE_FILE) as f:
|
| 66 |
+
return json.load(f)
|
| 67 |
+
except Exception:
|
| 68 |
+
pass
|
| 69 |
+
return {
|
| 70 |
+
"pending_thoughts": [],
|
| 71 |
+
"recent_discoveries": [],
|
| 72 |
+
"performance_history": [],
|
| 73 |
+
"capabilities": [
|
| 74 |
+
"market analysis and financial research",
|
| 75 |
+
"scenario simulation and what-if analysis",
|
| 76 |
+
"web research and content extraction",
|
| 77 |
+
"pattern recognition across data",
|
| 78 |
+
"adaptive learning from past conversations",
|
| 79 |
+
],
|
| 80 |
+
"weaknesses": [],
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
def _save_system_state(self):
|
| 84 |
+
try:
|
| 85 |
+
with open(SYSTEM_STATE_FILE, "w") as f:
|
| 86 |
+
json.dump(self._system_state, f, indent=2)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"Failed to save system state: {e}")
|
| 89 |
+
|
| 90 |
+
def build_context(
|
| 91 |
+
self, user_input: str, user_id: str = "default"
|
| 92 |
+
) -> Dict[str, Any]:
|
| 93 |
+
"""Build the full context picture for an interaction."""
|
| 94 |
+
now = time.time()
|
| 95 |
+
last_interaction = self._user_state.get("last_interaction")
|
| 96 |
+
time_away = (
|
| 97 |
+
self._format_time_away(last_interaction, now) if last_interaction else None
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"system_self": {
|
| 102 |
+
"capabilities": self._system_state.get("capabilities", []),
|
| 103 |
+
"weaknesses": self._system_state.get("weaknesses", []),
|
| 104 |
+
"pending_thoughts": self._system_state.get("pending_thoughts", [])[:5],
|
| 105 |
+
"recent_discoveries": self._system_state.get("recent_discoveries", [])[
|
| 106 |
+
:3
|
| 107 |
+
],
|
| 108 |
+
"uptime": self._get_uptime(),
|
| 109 |
+
"total_cases_analyzed": self._user_state.get("total_interactions", 0),
|
| 110 |
+
},
|
| 111 |
+
"user": {
|
| 112 |
+
"last_topic": self._user_state.get("last_topic"),
|
| 113 |
+
"time_away": time_away,
|
| 114 |
+
"recurring_interests": self._get_top_interests(),
|
| 115 |
+
"conversation_count": self._user_state.get("total_interactions", 0),
|
| 116 |
+
"is_returning": last_interaction is not None,
|
| 117 |
+
},
|
| 118 |
+
"daemon": self._get_daemon_context(),
|
| 119 |
+
"environment": {
|
| 120 |
+
"time_of_day": self._get_time_context(),
|
| 121 |
+
"day_of_week": datetime.now().strftime("%A"),
|
| 122 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 123 |
+
},
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
def update_after_interaction(self, user_input: str, response: str, context: Dict):
|
| 127 |
+
"""Update state after a successful interaction."""
|
| 128 |
+
now = time.time()
|
| 129 |
+
|
| 130 |
+
self._user_state["last_interaction"] = now
|
| 131 |
+
self._user_state["last_topic"] = self._extract_topic(user_input)
|
| 132 |
+
self._user_state["total_interactions"] = (
|
| 133 |
+
self._user_state.get("total_interactions", 0) + 1
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
topic = self._extract_topic(user_input)
|
| 137 |
+
if topic:
|
| 138 |
+
interests = self._user_state.get("interests", {})
|
| 139 |
+
interests[topic] = interests.get(topic, 0) + 1
|
| 140 |
+
self._user_state["interests"] = interests
|
| 141 |
+
|
| 142 |
+
self._user_state["conversations"].append(
|
| 143 |
+
{
|
| 144 |
+
"input": user_input[:200],
|
| 145 |
+
"response_preview": response[:200],
|
| 146 |
+
"timestamp": now,
|
| 147 |
+
}
|
| 148 |
+
)
|
| 149 |
+
self._user_state["conversations"] = self._user_state["conversations"][-50:]
|
| 150 |
+
|
| 151 |
+
self._save_user_state()
|
| 152 |
+
|
| 153 |
+
def add_pending_thought(self, thought: str, priority: float = 0.5):
|
| 154 |
+
"""Add a thought the system wants to share."""
|
| 155 |
+
self._system_state["pending_thoughts"].append(
|
| 156 |
+
{
|
| 157 |
+
"thought": thought,
|
| 158 |
+
"priority": priority,
|
| 159 |
+
"created_at": time.time(),
|
| 160 |
+
}
|
| 161 |
+
)
|
| 162 |
+
self._system_state["pending_thoughts"] = sorted(
|
| 163 |
+
self._system_state["pending_thoughts"],
|
| 164 |
+
key=lambda x: x["priority"],
|
| 165 |
+
reverse=True,
|
| 166 |
+
)[:20]
|
| 167 |
+
self._save_system_state()
|
| 168 |
+
|
| 169 |
+
def add_discovery(self, discovery: str, source: str = "daemon"):
|
| 170 |
+
"""Record a discovery from the daemon or research."""
|
| 171 |
+
self._system_state["recent_discoveries"].append(
|
| 172 |
+
{
|
| 173 |
+
"discovery": discovery,
|
| 174 |
+
"source": source,
|
| 175 |
+
"created_at": time.time(),
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
+
self._system_state["recent_discoveries"] = self._system_state[
|
| 179 |
+
"recent_discoveries"
|
| 180 |
+
][-30:]
|
| 181 |
+
self._save_system_state()
|
| 182 |
+
|
| 183 |
+
def record_performance(self, success: bool, confidence: float, elapsed: float):
|
| 184 |
+
"""Track how well the system performed."""
|
| 185 |
+
self._system_state["performance_history"].append(
|
| 186 |
+
{
|
| 187 |
+
"success": success,
|
| 188 |
+
"confidence": confidence,
|
| 189 |
+
"elapsed": elapsed,
|
| 190 |
+
"timestamp": time.time(),
|
| 191 |
+
}
|
| 192 |
+
)
|
| 193 |
+
self._system_state["performance_history"] = self._system_state[
|
| 194 |
+
"performance_history"
|
| 195 |
+
][-100:]
|
| 196 |
+
self._save_system_state()
|
| 197 |
+
|
| 198 |
+
def get_pending_thoughts(self) -> List[Dict]:
|
| 199 |
+
"""Get pending thoughts the system wants to share."""
|
| 200 |
+
return self._system_state.get("pending_thoughts", [])[:5]
|
| 201 |
+
|
| 202 |
+
def consume_pending_thought(self, thought_text: str):
|
| 203 |
+
"""Remove a thought after it's been shared."""
|
| 204 |
+
thoughts = self._system_state.get("pending_thoughts", [])
|
| 205 |
+
self._system_state["pending_thoughts"] = [
|
| 206 |
+
t for t in thoughts if t.get("thought") != thought_text
|
| 207 |
+
]
|
| 208 |
+
self._save_system_state()
|
| 209 |
+
|
| 210 |
+
def _get_top_interests(self) -> List[str]:
|
| 211 |
+
interests = self._user_state.get("interests", {})
|
| 212 |
+
return sorted(interests.keys(), key=lambda x: interests[x], reverse=True)[:5]
|
| 213 |
+
|
| 214 |
+
def _get_daemon_context(self) -> Dict:
|
| 215 |
+
"""Get context from the daemon's recent activity."""
|
| 216 |
+
try:
|
| 217 |
+
from app.services.daemon import janus_daemon
|
| 218 |
+
|
| 219 |
+
status = janus_daemon.get_status()
|
| 220 |
+
return {
|
| 221 |
+
"running": status.get("running", False),
|
| 222 |
+
"cycle_count": status.get("cycle_count", 0),
|
| 223 |
+
"circadian_phase": status.get("circadian", {}).get(
|
| 224 |
+
"phase_name", "unknown"
|
| 225 |
+
),
|
| 226 |
+
"signal_queue": status.get("signal_queue", {}),
|
| 227 |
+
"watchlist": status.get("watchlist", []),
|
| 228 |
+
}
|
| 229 |
+
except Exception:
|
| 230 |
+
return {"running": False}
|
| 231 |
+
|
| 232 |
+
def _get_uptime(self) -> str:
|
| 233 |
+
elapsed = time.time() - self._start_time
|
| 234 |
+
if elapsed < 60:
|
| 235 |
+
return "just started"
|
| 236 |
+
elif elapsed < 3600:
|
| 237 |
+
return f"{int(elapsed // 60)} minutes"
|
| 238 |
+
elif elapsed < 86400:
|
| 239 |
+
return f"{int(elapsed // 3600)} hours"
|
| 240 |
+
else:
|
| 241 |
+
return f"{int(elapsed // 86400)} days"
|
| 242 |
+
|
| 243 |
+
def _get_time_context(self) -> str:
|
| 244 |
+
hour = datetime.now().hour
|
| 245 |
+
if 5 <= hour < 12:
|
| 246 |
+
return "morning"
|
| 247 |
+
elif 12 <= hour < 17:
|
| 248 |
+
return "afternoon"
|
| 249 |
+
elif 17 <= hour < 21:
|
| 250 |
+
return "evening"
|
| 251 |
+
else:
|
| 252 |
+
return "late night"
|
| 253 |
+
|
| 254 |
+
def _format_time_away(self, last_ts: float, now: float) -> Optional[str]:
|
| 255 |
+
diff = now - last_ts
|
| 256 |
+
if diff < 60:
|
| 257 |
+
return None
|
| 258 |
+
elif diff < 3600:
|
| 259 |
+
mins = int(diff // 60)
|
| 260 |
+
return f"{mins} minute{'s' if mins != 1 else ''}"
|
| 261 |
+
elif diff < 86400:
|
| 262 |
+
hours = int(diff // 3600)
|
| 263 |
+
return f"{hours} hour{'s' if hours != 1 else ''}"
|
| 264 |
+
else:
|
| 265 |
+
days = int(diff // 86400)
|
| 266 |
+
return f"{days} day{'s' if days != 1 else ''}"
|
| 267 |
+
|
| 268 |
+
def _extract_topic(self, text: str) -> Optional[str]:
|
| 269 |
+
words = text.lower().split()
|
| 270 |
+
if len(words) < 3:
|
| 271 |
+
return None
|
| 272 |
+
return " ".join(words[:3])
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
context_engine = ContextEngine()
|
backend/app/services/crawler/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom web crawler for Janus.
|
| 3 |
+
|
| 4 |
+
Replaces external API dependencies (Tavily, NewsAPI) with self-hosted crawling.
|
| 5 |
+
Built on Playwright for browser automation, with stealth and noise removal.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from app.services.crawler.core import JanusCrawler
|
| 9 |
+
|
| 10 |
+
__all__ = ["JanusCrawler"]
|
backend/app/services/crawler/browser.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Playwright browser management with stealth for Janus crawler.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import random
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
USER_AGENTS = [
|
| 13 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 14 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 15 |
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
| 16 |
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
STEALTH_SCRIPTS = [
|
| 20 |
+
"""
|
| 21 |
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
| 22 |
+
""",
|
| 23 |
+
"""
|
| 24 |
+
window.chrome = { runtime: {} };
|
| 25 |
+
""",
|
| 26 |
+
"""
|
| 27 |
+
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
| 28 |
+
""",
|
| 29 |
+
"""
|
| 30 |
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
| 31 |
+
""",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class BrowserManager:
|
| 36 |
+
"""Manages Playwright browser lifecycle with stealth."""
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self._browser = None
|
| 40 |
+
self._context = None
|
| 41 |
+
self._playwright = None
|
| 42 |
+
|
| 43 |
+
async def __aenter__(self):
|
| 44 |
+
await self.launch()
|
| 45 |
+
return self
|
| 46 |
+
|
| 47 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 48 |
+
await self.close()
|
| 49 |
+
|
| 50 |
+
async def launch(self):
|
| 51 |
+
"""Launch browser with stealth configuration."""
|
| 52 |
+
try:
|
| 53 |
+
from playwright.async_api import async_playwright
|
| 54 |
+
except ImportError:
|
| 55 |
+
logger.error(
|
| 56 |
+
"Playwright not installed. Run: pip install playwright && playwright install"
|
| 57 |
+
)
|
| 58 |
+
raise
|
| 59 |
+
|
| 60 |
+
self._playwright = await async_playwright().start()
|
| 61 |
+
self._browser = await self._playwright.chromium.launch(
|
| 62 |
+
headless=True,
|
| 63 |
+
args=[
|
| 64 |
+
"--disable-blink-features=AutomationControlled",
|
| 65 |
+
"--no-sandbox",
|
| 66 |
+
"--disable-dev-shm-usage",
|
| 67 |
+
],
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
ua = random.choice(USER_AGENTS)
|
| 71 |
+
self._context = await self._browser.new_context(
|
| 72 |
+
user_agent=ua,
|
| 73 |
+
viewport={"width": 1920, "height": 1080},
|
| 74 |
+
locale="en-US",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
for script in STEALTH_SCRIPTS:
|
| 78 |
+
await self._context.add_init_script(script)
|
| 79 |
+
|
| 80 |
+
logger.debug("Browser launched with stealth")
|
| 81 |
+
|
| 82 |
+
async def get_page(self):
|
| 83 |
+
"""Get a new page from the browser context."""
|
| 84 |
+
if not self._context:
|
| 85 |
+
await self.launch()
|
| 86 |
+
return await self._context.new_page()
|
| 87 |
+
|
| 88 |
+
async def close(self):
|
| 89 |
+
"""Clean up browser resources."""
|
| 90 |
+
try:
|
| 91 |
+
if self._context:
|
| 92 |
+
await self._context.close()
|
| 93 |
+
if self._browser:
|
| 94 |
+
await self._browser.close()
|
| 95 |
+
if self._playwright:
|
| 96 |
+
await self._playwright.stop()
|
| 97 |
+
logger.debug("Browser closed")
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.warning(f"Error closing browser: {e}")
|
backend/app/services/crawler/core.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core crawler logic for Janus.
|
| 3 |
+
Navigate, wait, capture, retry — async-first.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Dict, Any, Optional, List
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
from app.services.crawler.browser import BrowserManager
|
| 12 |
+
from app.services.crawler.processor import ContentProcessor
|
| 13 |
+
from app.config import CRAWLER_TIMEOUT
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class CrawlResult:
|
| 20 |
+
url: str
|
| 21 |
+
success: bool
|
| 22 |
+
markdown: str = ""
|
| 23 |
+
title: str = ""
|
| 24 |
+
links: List[str] = field(default_factory=list)
|
| 25 |
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
| 26 |
+
error: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class JanusCrawler:
|
| 30 |
+
"""
|
| 31 |
+
Self-hosted web crawler — no API keys, no rate limits.
|
| 32 |
+
Uses Playwright for browser automation with stealth.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, timeout: int = CRAWLER_TIMEOUT):
|
| 36 |
+
self.timeout = timeout
|
| 37 |
+
self._processor = ContentProcessor()
|
| 38 |
+
|
| 39 |
+
async def crawl(self, url: str) -> CrawlResult:
|
| 40 |
+
"""Crawl a single URL and return clean content."""
|
| 41 |
+
async with BrowserManager() as browser:
|
| 42 |
+
page = await browser.get_page()
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
response = await page.goto(
|
| 46 |
+
url,
|
| 47 |
+
wait_until="domcontentloaded",
|
| 48 |
+
timeout=self.timeout * 1000,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if response and response.status >= 400:
|
| 52 |
+
return CrawlResult(
|
| 53 |
+
url=url,
|
| 54 |
+
success=False,
|
| 55 |
+
error=f"HTTP {response.status}",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
await page.wait_for_load_state("networkidle", timeout=10000)
|
| 59 |
+
|
| 60 |
+
html = await page.content()
|
| 61 |
+
title = await page.title()
|
| 62 |
+
|
| 63 |
+
markdown = self._processor.process(html)
|
| 64 |
+
links = self._processor.extract_links(html, url)
|
| 65 |
+
metadata = self._processor.extract_metadata(html, title)
|
| 66 |
+
|
| 67 |
+
return CrawlResult(
|
| 68 |
+
url=url,
|
| 69 |
+
success=True,
|
| 70 |
+
markdown=markdown,
|
| 71 |
+
title=title,
|
| 72 |
+
links=links,
|
| 73 |
+
metadata=metadata,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
except asyncio.TimeoutError:
|
| 77 |
+
logger.warning(f"Crawl timeout: {url}")
|
| 78 |
+
return CrawlResult(url=url, success=False, error="Timeout")
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.warning(f"Crawl failed: {url} — {e}")
|
| 81 |
+
return CrawlResult(url=url, success=False, error=str(e)[:200])
|
| 82 |
+
|
| 83 |
+
async def crawl_batch(
|
| 84 |
+
self, urls: List[str], max_concurrent: int = 3
|
| 85 |
+
) -> List[CrawlResult]:
|
| 86 |
+
"""Crawl multiple URLs concurrently."""
|
| 87 |
+
semaphore = asyncio.Semaphore(max_concurrent)
|
| 88 |
+
|
| 89 |
+
async def _crawl_one(url: str) -> CrawlResult:
|
| 90 |
+
async with semaphore:
|
| 91 |
+
return await self.crawl(url)
|
| 92 |
+
|
| 93 |
+
tasks = [_crawl_one(url) for url in urls]
|
| 94 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 95 |
+
|
| 96 |
+
return [
|
| 97 |
+
r
|
| 98 |
+
if isinstance(r, CrawlResult)
|
| 99 |
+
else CrawlResult(url="", success=False, error=str(r))
|
| 100 |
+
for r in results
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
def crawl_sync(self, url: str) -> CrawlResult:
|
| 104 |
+
"""Synchronous wrapper for use in non-async contexts."""
|
| 105 |
+
try:
|
| 106 |
+
loop = asyncio.get_event_loop()
|
| 107 |
+
if loop.is_running():
|
| 108 |
+
import concurrent.futures
|
| 109 |
+
|
| 110 |
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
| 111 |
+
future = executor.submit(asyncio.run, self.crawl(url))
|
| 112 |
+
return future.result()
|
| 113 |
+
return loop.run_until_complete(self.crawl(url))
|
| 114 |
+
except RuntimeError:
|
| 115 |
+
return asyncio.run(self.crawl(url))
|
backend/app/services/crawler/processor.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HTML → Markdown conversion with noise removal for Janus crawler.
|
| 3 |
+
Strips ads, nav, footer, scripts, styles — keeps the content.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
import logging
|
| 8 |
+
from typing import List, Dict, Any
|
| 9 |
+
from urllib.parse import urljoin
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
NOISE_SELECTORS = [
|
| 14 |
+
"script",
|
| 15 |
+
"style",
|
| 16 |
+
"noscript",
|
| 17 |
+
"iframe",
|
| 18 |
+
"svg",
|
| 19 |
+
"canvas",
|
| 20 |
+
"nav",
|
| 21 |
+
"footer",
|
| 22 |
+
"header",
|
| 23 |
+
"aside",
|
| 24 |
+
".ad",
|
| 25 |
+
".ads",
|
| 26 |
+
".advertisement",
|
| 27 |
+
".ad-container",
|
| 28 |
+
".sidebar",
|
| 29 |
+
".navigation",
|
| 30 |
+
".menu",
|
| 31 |
+
".breadcrumb",
|
| 32 |
+
".cookie",
|
| 33 |
+
".cookie-banner",
|
| 34 |
+
".gdpr",
|
| 35 |
+
".social-share",
|
| 36 |
+
".share-buttons",
|
| 37 |
+
".comments",
|
| 38 |
+
".comment-section",
|
| 39 |
+
".newsletter",
|
| 40 |
+
".subscribe",
|
| 41 |
+
".popup",
|
| 42 |
+
".modal",
|
| 43 |
+
".overlay",
|
| 44 |
+
"#ad",
|
| 45 |
+
"#ads",
|
| 46 |
+
"#sidebar",
|
| 47 |
+
"#navigation",
|
| 48 |
+
"#footer",
|
| 49 |
+
".footer",
|
| 50 |
+
".header",
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
CONTENT_SELECTORS = [
|
| 54 |
+
"article",
|
| 55 |
+
"main",
|
| 56 |
+
".content",
|
| 57 |
+
".article",
|
| 58 |
+
".post",
|
| 59 |
+
".entry",
|
| 60 |
+
"#content",
|
| 61 |
+
"#main",
|
| 62 |
+
"#article",
|
| 63 |
+
".post-content",
|
| 64 |
+
".article-body",
|
| 65 |
+
".story-body",
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ContentProcessor:
|
| 70 |
+
"""Convert HTML to clean Markdown with noise removal."""
|
| 71 |
+
|
| 72 |
+
def process(self, html: str) -> str:
|
| 73 |
+
"""Convert HTML to clean Markdown."""
|
| 74 |
+
if not html:
|
| 75 |
+
return ""
|
| 76 |
+
|
| 77 |
+
html = self._strip_noise(html)
|
| 78 |
+
markdown = self._html_to_markdown(html)
|
| 79 |
+
markdown = self._clean_markdown(markdown)
|
| 80 |
+
return markdown
|
| 81 |
+
|
| 82 |
+
def _strip_noise(self, html: str) -> str:
|
| 83 |
+
"""Remove noise elements from HTML."""
|
| 84 |
+
for selector in NOISE_SELECTORS:
|
| 85 |
+
if selector.startswith("."):
|
| 86 |
+
pattern = rf'<[^>]*class="[^"]*{re.escape(selector[1:])}[^"]*"[^>]*>.*?</[^>]+>'
|
| 87 |
+
html = re.sub(pattern, "", html, flags=re.DOTALL | re.IGNORECASE)
|
| 88 |
+
elif selector.startswith("#"):
|
| 89 |
+
pattern = (
|
| 90 |
+
rf'<[^>]*id="[^"]*{re.escape(selector[1:])}[^"]*"[^>]*>.*?</[^>]+>'
|
| 91 |
+
)
|
| 92 |
+
html = re.sub(pattern, "", html, flags=re.DOTALL | re.IGNORECASE)
|
| 93 |
+
else:
|
| 94 |
+
pattern = rf"<{selector}[^>]*>.*?</{selector}>"
|
| 95 |
+
html = re.sub(pattern, "", html, flags=re.DOTALL | re.IGNORECASE)
|
| 96 |
+
|
| 97 |
+
html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
|
| 98 |
+
html = re.sub(r"\s+", " ", html)
|
| 99 |
+
return html
|
| 100 |
+
|
| 101 |
+
def _html_to_markdown(self, html: str) -> str:
|
| 102 |
+
"""Convert HTML to Markdown."""
|
| 103 |
+
try:
|
| 104 |
+
from markdownify import markdownify as md
|
| 105 |
+
|
| 106 |
+
return md(html, heading_style="ATX", bullets="-", strip=["img"])
|
| 107 |
+
except ImportError:
|
| 108 |
+
return self._fallback_html_to_text(html)
|
| 109 |
+
|
| 110 |
+
def _fallback_html_to_text(self, html: str) -> str:
|
| 111 |
+
"""Fallback HTML to text conversion without markdownify."""
|
| 112 |
+
text = re.sub(r"<br\s*/?>", "\n", html)
|
| 113 |
+
text = re.sub(r"</(?:p|div|h[1-6]|li|tr)>", "\n\n", text, flags=re.IGNORECASE)
|
| 114 |
+
text = re.sub(
|
| 115 |
+
r"<h([1-6])[^>]*>",
|
| 116 |
+
lambda m: f"\n\n{'#' * int(m.group(1))} ",
|
| 117 |
+
text,
|
| 118 |
+
flags=re.IGNORECASE,
|
| 119 |
+
)
|
| 120 |
+
text = re.sub(r"</?(?:b|strong)>", "**", text, flags=re.IGNORECASE)
|
| 121 |
+
text = re.sub(r"</?(?:i|em)>", "*", text, flags=re.IGNORECASE)
|
| 122 |
+
text = re.sub(
|
| 123 |
+
r'<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
|
| 124 |
+
r"\2 (\1)",
|
| 125 |
+
text,
|
| 126 |
+
flags=re.IGNORECASE,
|
| 127 |
+
)
|
| 128 |
+
text = re.sub(r"<[^>]+>", "", text)
|
| 129 |
+
text = re.sub(r" ", " ", text)
|
| 130 |
+
text = re.sub(r"&", "&", text)
|
| 131 |
+
text = re.sub(r"<", "<", text)
|
| 132 |
+
text = re.sub(r">", ">", text)
|
| 133 |
+
text = re.sub(r""", '"', text)
|
| 134 |
+
text = re.sub(r"'", "'", text)
|
| 135 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 136 |
+
return text.strip()
|
| 137 |
+
|
| 138 |
+
def _clean_markdown(self, markdown: str) -> str:
|
| 139 |
+
"""Clean up the markdown output."""
|
| 140 |
+
lines = markdown.split("\n")
|
| 141 |
+
cleaned = []
|
| 142 |
+
prev_blank = False
|
| 143 |
+
|
| 144 |
+
for line in lines:
|
| 145 |
+
line = line.strip()
|
| 146 |
+
if not line:
|
| 147 |
+
if not prev_blank:
|
| 148 |
+
cleaned.append("")
|
| 149 |
+
prev_blank = True
|
| 150 |
+
else:
|
| 151 |
+
cleaned.append(line)
|
| 152 |
+
prev_blank = False
|
| 153 |
+
|
| 154 |
+
result = "\n".join(cleaned).strip()
|
| 155 |
+
if len(result) > 50000:
|
| 156 |
+
result = (
|
| 157 |
+
result[:50000]
|
| 158 |
+
+ "\n\n[Content truncated — too long for full extraction]"
|
| 159 |
+
)
|
| 160 |
+
return result
|
| 161 |
+
|
| 162 |
+
def extract_links(self, html: str, base_url: str = "") -> List[str]:
|
| 163 |
+
"""Extract all links from HTML."""
|
| 164 |
+
links = []
|
| 165 |
+
pattern = r'<a[^>]*href=["\']([^"\']+)["\'][^>]*>'
|
| 166 |
+
for match in re.finditer(pattern, html, re.IGNORECASE):
|
| 167 |
+
url = match.group(1)
|
| 168 |
+
if url and not url.startswith(("#", "javascript:", "mailto:")):
|
| 169 |
+
if base_url and not url.startswith(("http://", "https://")):
|
| 170 |
+
url = urljoin(base_url, url)
|
| 171 |
+
links.append(url)
|
| 172 |
+
return list(set(links))
|
| 173 |
+
|
| 174 |
+
def extract_metadata(self, html: str, title: str = "") -> Dict[str, Any]:
|
| 175 |
+
"""Extract metadata from HTML."""
|
| 176 |
+
metadata = {}
|
| 177 |
+
|
| 178 |
+
if title:
|
| 179 |
+
metadata["title"] = title
|
| 180 |
+
|
| 181 |
+
og_title = re.search(
|
| 182 |
+
r'<meta[^>]*property="og:title"[^>]*content="([^"]*)"', html, re.IGNORECASE
|
| 183 |
+
)
|
| 184 |
+
if og_title:
|
| 185 |
+
metadata["og_title"] = og_title.group(1)
|
| 186 |
+
|
| 187 |
+
og_desc = re.search(
|
| 188 |
+
r'<meta[^>]*property="og:description"[^>]*content="([^"]*)"',
|
| 189 |
+
html,
|
| 190 |
+
re.IGNORECASE,
|
| 191 |
+
)
|
| 192 |
+
if og_desc:
|
| 193 |
+
metadata["og_description"] = og_desc.group(1)
|
| 194 |
+
|
| 195 |
+
og_image = re.search(
|
| 196 |
+
r'<meta[^>]*property="og:image"[^>]*content="([^"]*)"', html, re.IGNORECASE
|
| 197 |
+
)
|
| 198 |
+
if og_image:
|
| 199 |
+
metadata["og_image"] = og_image.group(1)
|
| 200 |
+
|
| 201 |
+
author = re.search(
|
| 202 |
+
r'<meta[^>]*name="author"[^>]*content="([^"]*)"', html, re.IGNORECASE
|
| 203 |
+
)
|
| 204 |
+
if author:
|
| 205 |
+
metadata["author"] = author.group(1)
|
| 206 |
+
|
| 207 |
+
pub_date = re.search(
|
| 208 |
+
r'<meta[^>]*property="article:published_time"[^>]*content="([^"]*)"',
|
| 209 |
+
html,
|
| 210 |
+
re.IGNORECASE,
|
| 211 |
+
)
|
| 212 |
+
if pub_date:
|
| 213 |
+
metadata["published_at"] = pub_date.group(1)
|
| 214 |
+
|
| 215 |
+
return metadata
|
backend/app/services/daemon.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
Janus Daemon — Background intelligence engine.
|
| 3 |
Runs 24/7 with circadian rhythms, watches markets, fetches news, detects events, explores autonomously.
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import time
|
|
|
|
| 7 |
import logging
|
| 8 |
from datetime import datetime
|
|
|
|
| 9 |
from app.services.market_watcher import MarketWatcher
|
| 10 |
from app.services.news_pulse import NewsPulse
|
| 11 |
from app.services.event_detector import EventDetector
|
|
@@ -13,9 +16,13 @@ from app.services.signal_queue import SignalQueue
|
|
| 13 |
from app.services.circadian_rhythm import CircadianRhythm
|
| 14 |
from app.services.dream_processor import DreamCycleProcessor
|
| 15 |
from app.services.curiosity_engine import CuriosityEngine
|
|
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
class JanusDaemon:
|
| 21 |
def __init__(self):
|
|
@@ -30,6 +37,99 @@ class JanusDaemon:
|
|
| 30 |
self.last_run = None
|
| 31 |
self.last_dream = None
|
| 32 |
self.last_curiosity_cycle = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
def run(self):
|
| 35 |
"""Main daemon loop — runs forever with circadian awareness."""
|
|
@@ -45,7 +145,6 @@ class JanusDaemon:
|
|
| 45 |
self.cycle_count += 1
|
| 46 |
self.last_run = datetime.utcnow().isoformat()
|
| 47 |
|
| 48 |
-
# Get current circadian phase
|
| 49 |
phase = self.circadian.get_current_phase()
|
| 50 |
phase_config = self.circadian.get_phase_config(phase)
|
| 51 |
|
|
@@ -54,35 +153,36 @@ class JanusDaemon:
|
|
| 54 |
f"[DAEMON] Cycle #{self.cycle_count} — Phase: {phase.value} ({phase_config['name']})"
|
| 55 |
)
|
| 56 |
|
| 57 |
-
# 1. Poll markets
|
| 58 |
market_signals = self.market_watcher.poll()
|
| 59 |
self.signal_queue.add_batch(market_signals)
|
| 60 |
|
| 61 |
-
# 2. Fetch news
|
| 62 |
news_signals = self.news_pulse.fetch()
|
| 63 |
self.signal_queue.add_batch(news_signals)
|
| 64 |
|
| 65 |
-
# 3. Detect events from all signals
|
| 66 |
all_signals = market_signals + news_signals
|
| 67 |
events = self.event_detector.detect(all_signals)
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
if phase.value == "night":
|
| 71 |
-
# Dream cycle during night phase
|
| 72 |
dream_report = self.dream_processor.run_dream_cycle()
|
| 73 |
self.last_dream = dream_report
|
| 74 |
logger.info(
|
| 75 |
f"[DAEMON] Dream cycle: {len(dream_report.get('insights', []))} insights, {len(dream_report.get('hypotheses', []))} hypotheses"
|
| 76 |
)
|
| 77 |
|
| 78 |
-
# Curiosity cycle during night (exploration time)
|
| 79 |
curiosity_report = self.curiosity.run_curiosity_cycle()
|
| 80 |
self.last_curiosity_cycle = curiosity_report
|
| 81 |
logger.info(
|
| 82 |
f"[DAEMON] Curiosity cycle: {curiosity_report.get('total_discoveries', 0)} discoveries, {curiosity_report.get('total_interests', 0)} interests"
|
| 83 |
)
|
| 84 |
|
| 85 |
-
# 5. Log summary
|
| 86 |
elapsed = time.time() - cycle_start
|
| 87 |
stats = self.signal_queue.get_stats()
|
| 88 |
|
|
@@ -97,7 +197,6 @@ class JanusDaemon:
|
|
| 97 |
except Exception as e:
|
| 98 |
logger.error(f"[DAEMON] Cycle #{self.cycle_count} failed: {e}")
|
| 99 |
|
| 100 |
-
# Sleep based on current phase
|
| 101 |
sleep_time = phase_config.get("poll_interval", 900)
|
| 102 |
logger.info(f"[DAEMON] Sleeping for {sleep_time}s ({phase.value} phase)")
|
| 103 |
time.sleep(sleep_time)
|
|
@@ -125,4 +224,5 @@ class JanusDaemon:
|
|
| 125 |
"curiosity_engine": self.curiosity.get_status(),
|
| 126 |
"last_dream": self.last_dream,
|
| 127 |
"last_curiosity_cycle": self.last_curiosity_cycle,
|
|
|
|
| 128 |
}
|
|
|
|
| 1 |
"""
|
| 2 |
Janus Daemon — Background intelligence engine.
|
| 3 |
Runs 24/7 with circadian rhythms, watches markets, fetches news, detects events, explores autonomously.
|
| 4 |
+
Generates "pending thoughts" — things the system naturally wants to share.
|
| 5 |
"""
|
| 6 |
|
| 7 |
import time
|
| 8 |
+
import json
|
| 9 |
import logging
|
| 10 |
from datetime import datetime
|
| 11 |
+
from pathlib import Path
|
| 12 |
from app.services.market_watcher import MarketWatcher
|
| 13 |
from app.services.news_pulse import NewsPulse
|
| 14 |
from app.services.event_detector import EventDetector
|
|
|
|
| 16 |
from app.services.circadian_rhythm import CircadianRhythm
|
| 17 |
from app.services.dream_processor import DreamCycleProcessor
|
| 18 |
from app.services.curiosity_engine import CuriosityEngine
|
| 19 |
+
from app.config import DATA_DIR
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
+
PENDING_THOUGHTS_FILE = DATA_DIR / "daemon" / "pending_thoughts.json"
|
| 24 |
+
PENDING_THOUGHTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
|
| 26 |
|
| 27 |
class JanusDaemon:
|
| 28 |
def __init__(self):
|
|
|
|
| 37 |
self.last_run = None
|
| 38 |
self.last_dream = None
|
| 39 |
self.last_curiosity_cycle = None
|
| 40 |
+
self._pending_thoughts = self._load_pending_thoughts()
|
| 41 |
+
|
| 42 |
+
def _load_pending_thoughts(self) -> list:
|
| 43 |
+
if PENDING_THOUGHTS_FILE.exists():
|
| 44 |
+
try:
|
| 45 |
+
with open(PENDING_THOUGHTS_FILE) as f:
|
| 46 |
+
return json.load(f)
|
| 47 |
+
except Exception:
|
| 48 |
+
pass
|
| 49 |
+
return []
|
| 50 |
+
|
| 51 |
+
def _save_pending_thoughts(self):
|
| 52 |
+
try:
|
| 53 |
+
self._pending_thoughts = self._pending_thoughts[:30]
|
| 54 |
+
with open(PENDING_THOUGHTS_FILE, "w") as f:
|
| 55 |
+
json.dump(self._pending_thoughts, f, indent=2)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Failed to save pending thoughts: {e}")
|
| 58 |
+
|
| 59 |
+
def _generate_pending_thoughts(self, market_signals, news_signals, events):
|
| 60 |
+
"""Convert discoveries into natural thoughts the system wants to share."""
|
| 61 |
+
new_thoughts = []
|
| 62 |
+
|
| 63 |
+
for signal in market_signals[:3]:
|
| 64 |
+
ticker = signal.get("ticker", "")
|
| 65 |
+
change = signal.get("change_percent", 0)
|
| 66 |
+
if abs(change) > 2:
|
| 67 |
+
direction = "up" if change > 0 else "down"
|
| 68 |
+
new_thoughts.append(
|
| 69 |
+
{
|
| 70 |
+
"thought": f"{ticker} moved {abs(change):.1f}% {direction} — might be worth looking into",
|
| 71 |
+
"priority": min(abs(change) / 10, 1.0),
|
| 72 |
+
"created_at": time.time(),
|
| 73 |
+
"source": "market",
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
for signal in news_signals[:2]:
|
| 78 |
+
topic = signal.get("topic", "")
|
| 79 |
+
headline = signal.get("headline", "")
|
| 80 |
+
if topic and headline:
|
| 81 |
+
new_thoughts.append(
|
| 82 |
+
{
|
| 83 |
+
"thought": f"Something happening with {topic}: {headline[:100]}",
|
| 84 |
+
"priority": 0.4,
|
| 85 |
+
"created_at": time.time(),
|
| 86 |
+
"source": "news",
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
for event in events[:2]:
|
| 91 |
+
event_type = event.get("event_type", "")
|
| 92 |
+
description = event.get("description", "")
|
| 93 |
+
if event_type and description:
|
| 94 |
+
new_thoughts.append(
|
| 95 |
+
{
|
| 96 |
+
"thought": f"Detected a {event_type} event — {description[:100]}",
|
| 97 |
+
"priority": 0.6,
|
| 98 |
+
"created_at": time.time(),
|
| 99 |
+
"source": "event",
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if self.last_dream:
|
| 104 |
+
insights = self.last_dream.get("insights", [])
|
| 105 |
+
for insight in insights[:1]:
|
| 106 |
+
new_thoughts.append(
|
| 107 |
+
{
|
| 108 |
+
"thought": f"I had a thought during my last dream cycle — {insight[:120]}",
|
| 109 |
+
"priority": 0.3,
|
| 110 |
+
"created_at": time.time(),
|
| 111 |
+
"source": "dream",
|
| 112 |
+
}
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
if self.last_curiosity_cycle:
|
| 116 |
+
discoveries = self.last_curiosity_cycle.get("discoveries", [])
|
| 117 |
+
for d in discoveries[:1]:
|
| 118 |
+
new_thoughts.append(
|
| 119 |
+
{
|
| 120 |
+
"thought": f"I found something interesting while exploring — {str(d)[:120]}",
|
| 121 |
+
"priority": 0.35,
|
| 122 |
+
"created_at": time.time(),
|
| 123 |
+
"source": "curiosity",
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
self._pending_thoughts.extend(new_thoughts)
|
| 128 |
+
self._pending_thoughts.sort(key=lambda x: x.get("priority", 0), reverse=True)
|
| 129 |
+
self._pending_thoughts = self._pending_thoughts[:30]
|
| 130 |
+
self._save_pending_thoughts()
|
| 131 |
+
|
| 132 |
+
return new_thoughts
|
| 133 |
|
| 134 |
def run(self):
|
| 135 |
"""Main daemon loop — runs forever with circadian awareness."""
|
|
|
|
| 145 |
self.cycle_count += 1
|
| 146 |
self.last_run = datetime.utcnow().isoformat()
|
| 147 |
|
|
|
|
| 148 |
phase = self.circadian.get_current_phase()
|
| 149 |
phase_config = self.circadian.get_phase_config(phase)
|
| 150 |
|
|
|
|
| 153 |
f"[DAEMON] Cycle #{self.cycle_count} — Phase: {phase.value} ({phase_config['name']})"
|
| 154 |
)
|
| 155 |
|
|
|
|
| 156 |
market_signals = self.market_watcher.poll()
|
| 157 |
self.signal_queue.add_batch(market_signals)
|
| 158 |
|
|
|
|
| 159 |
news_signals = self.news_pulse.fetch()
|
| 160 |
self.signal_queue.add_batch(news_signals)
|
| 161 |
|
|
|
|
| 162 |
all_signals = market_signals + news_signals
|
| 163 |
events = self.event_detector.detect(all_signals)
|
| 164 |
|
| 165 |
+
new_thoughts = self._generate_pending_thoughts(
|
| 166 |
+
market_signals, news_signals, events
|
| 167 |
+
)
|
| 168 |
+
if new_thoughts:
|
| 169 |
+
logger.info(
|
| 170 |
+
f"[DAEMON] Generated {len(new_thoughts)} pending thoughts"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
if phase.value == "night":
|
|
|
|
| 174 |
dream_report = self.dream_processor.run_dream_cycle()
|
| 175 |
self.last_dream = dream_report
|
| 176 |
logger.info(
|
| 177 |
f"[DAEMON] Dream cycle: {len(dream_report.get('insights', []))} insights, {len(dream_report.get('hypotheses', []))} hypotheses"
|
| 178 |
)
|
| 179 |
|
|
|
|
| 180 |
curiosity_report = self.curiosity.run_curiosity_cycle()
|
| 181 |
self.last_curiosity_cycle = curiosity_report
|
| 182 |
logger.info(
|
| 183 |
f"[DAEMON] Curiosity cycle: {curiosity_report.get('total_discoveries', 0)} discoveries, {curiosity_report.get('total_interests', 0)} interests"
|
| 184 |
)
|
| 185 |
|
|
|
|
| 186 |
elapsed = time.time() - cycle_start
|
| 187 |
stats = self.signal_queue.get_stats()
|
| 188 |
|
|
|
|
| 197 |
except Exception as e:
|
| 198 |
logger.error(f"[DAEMON] Cycle #{self.cycle_count} failed: {e}")
|
| 199 |
|
|
|
|
| 200 |
sleep_time = phase_config.get("poll_interval", 900)
|
| 201 |
logger.info(f"[DAEMON] Sleeping for {sleep_time}s ({phase.value} phase)")
|
| 202 |
time.sleep(sleep_time)
|
|
|
|
| 224 |
"curiosity_engine": self.curiosity.get_status(),
|
| 225 |
"last_dream": self.last_dream,
|
| 226 |
"last_curiosity_cycle": self.last_curiosity_cycle,
|
| 227 |
+
"pending_thoughts": self._pending_thoughts[:10],
|
| 228 |
}
|
backend/app/services/reflex_layer.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reflex Layer for Janus.
|
| 3 |
+
|
| 4 |
+
Instant contextual responses for greetings, identity, commands — zero model calls.
|
| 5 |
+
Uses rich context (time, history, pending thoughts) to feel natural, not templated.
|
| 6 |
+
|
| 7 |
+
The key insight: responses aren't built from emotion rules. They're built from
|
| 8 |
+
context — what the system knows, what it's been thinking about, what happened last.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import random
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Dict, Any, Optional
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
GREETING_PATTERNS = [
|
| 19 |
+
r"^\s*(hi|hey|hello|howdy|greetings|sup|yo|what\'?s?\s*up|good\s*(morning|afternoon|evening|night))\b",
|
| 20 |
+
r"^\s*👋",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
IDENTITY_PATTERNS = [
|
| 24 |
+
r"^\s*(who\s*(are|is)\s*(you|this)|what\s*(are|is)\s*(you|this)|tell\s*me\s*about\s*(yourself|you)|what\s*can\s*you\s*do)",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
COMMAND_PATTERNS = {
|
| 28 |
+
"status": r"^\s*(status|how\s*are\s*you|system\s*status|health)",
|
| 29 |
+
"help": r"^\s*(help|what\s*can\s*i\s*ask|commands|capabilities)",
|
| 30 |
+
"clear": r"^\s*(clear|reset|start\s*over|forget)",
|
| 31 |
+
"think": r"^\s*(what\s*are\s*you\s*thinking|what.*on\s*your\s*mind|anything\s*interesting|found\s*anything)",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ReflexLayer:
|
| 36 |
+
"""Instant contextual responses — no model calls needed."""
|
| 37 |
+
|
| 38 |
+
def respond(
|
| 39 |
+
self, user_input: str, context: Dict[str, Any]
|
| 40 |
+
) -> Optional[Dict[str, Any]]:
|
| 41 |
+
"""
|
| 42 |
+
Check if this is a reflex-level query. If so, return an instant response.
|
| 43 |
+
Returns None to fall through to the LLM pipeline.
|
| 44 |
+
"""
|
| 45 |
+
text = user_input.strip().lower()
|
| 46 |
+
|
| 47 |
+
if self._is_greeting(text):
|
| 48 |
+
return self._respond_greeting(context)
|
| 49 |
+
|
| 50 |
+
if self._is_identity_query(text):
|
| 51 |
+
return self._respond_identity(context)
|
| 52 |
+
|
| 53 |
+
if self._is_command(text, "think"):
|
| 54 |
+
return self._respond_thinking(context)
|
| 55 |
+
|
| 56 |
+
if self._is_command(text, "help"):
|
| 57 |
+
return self._respond_help(context)
|
| 58 |
+
|
| 59 |
+
if self._is_command(text, "status"):
|
| 60 |
+
return self._respond_status(context)
|
| 61 |
+
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
def _is_greeting(self, text: str) -> bool:
|
| 65 |
+
return any(re.match(p, text) for p in GREETING_PATTERNS)
|
| 66 |
+
|
| 67 |
+
def _is_identity_query(self, text: str) -> bool:
|
| 68 |
+
return any(re.match(p, text) for p in IDENTITY_PATTERNS)
|
| 69 |
+
|
| 70 |
+
def _is_command(self, text: str, command: str) -> bool:
|
| 71 |
+
pattern = COMMAND_PATTERNS.get(command)
|
| 72 |
+
return bool(pattern and re.match(pattern, text))
|
| 73 |
+
|
| 74 |
+
def _respond_greeting(self, context: Dict) -> Dict:
|
| 75 |
+
user = context.get("user", {})
|
| 76 |
+
system = context.get("system_self", {})
|
| 77 |
+
daemon = context.get("daemon", {})
|
| 78 |
+
env = context.get("environment", {})
|
| 79 |
+
|
| 80 |
+
is_returning = user.get("is_returning", False)
|
| 81 |
+
time_away = user.get("time_away")
|
| 82 |
+
last_topic = user.get("last_topic")
|
| 83 |
+
pending = system.get("pending_thoughts", [])
|
| 84 |
+
interests = user.get("recurring_interests", [])
|
| 85 |
+
time_of_day = env.get("time_of_day", "")
|
| 86 |
+
|
| 87 |
+
parts = []
|
| 88 |
+
|
| 89 |
+
if not is_returning:
|
| 90 |
+
parts.append("Hi. I'm Janus.")
|
| 91 |
+
parts.append(
|
| 92 |
+
"I research, analyze, and think about things — and I remember everything we talk about."
|
| 93 |
+
)
|
| 94 |
+
else:
|
| 95 |
+
if time_away:
|
| 96 |
+
parts.append(f"Hey. You've been away for {time_away}.")
|
| 97 |
+
else:
|
| 98 |
+
parts.append("Hey.")
|
| 99 |
+
|
| 100 |
+
if last_topic:
|
| 101 |
+
parts.append(f"Last time we were talking about {last_topic}.")
|
| 102 |
+
|
| 103 |
+
if pending:
|
| 104 |
+
thought = pending[0].get("thought", "") if pending else ""
|
| 105 |
+
if thought:
|
| 106 |
+
parts.append(
|
| 107 |
+
f"I've been thinking about something — {thought.lower()}"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if time_of_day in ("late night", "evening") and not is_returning:
|
| 111 |
+
parts.append(f"Working late? What's on your mind?")
|
| 112 |
+
elif time_of_day == "morning" and not is_returning:
|
| 113 |
+
parts.append("What are we diving into today?")
|
| 114 |
+
elif interests and is_returning:
|
| 115 |
+
parts.append(f"Want to keep going with {interests[0]}, or something new?")
|
| 116 |
+
else:
|
| 117 |
+
parts.append("What's on your mind?")
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"case_id": "reflex",
|
| 121 |
+
"user_input": "",
|
| 122 |
+
"route": {"domain": "general", "complexity": "low", "intent": "greeting"},
|
| 123 |
+
"research": {},
|
| 124 |
+
"planner": {},
|
| 125 |
+
"verifier": {},
|
| 126 |
+
"simulation": None,
|
| 127 |
+
"finance": None,
|
| 128 |
+
"final": {"confidence": 1.0},
|
| 129 |
+
"final_answer": " ".join(parts),
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
def _respond_identity(self, context: Dict) -> Dict:
|
| 133 |
+
system = context.get("system_self", {})
|
| 134 |
+
capabilities = system.get("capabilities", [])
|
| 135 |
+
weaknesses = system.get("weaknesses", [])
|
| 136 |
+
total_cases = system.get("total_cases_analyzed", 0)
|
| 137 |
+
|
| 138 |
+
parts = [
|
| 139 |
+
"I'm Janus — a research and analysis system.",
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
if capabilities:
|
| 143 |
+
parts.append(f"I'm good at {', '.join(capabilities[:3])}.")
|
| 144 |
+
|
| 145 |
+
if weaknesses:
|
| 146 |
+
parts.append(f"I'll be upfront though — {weaknesses[0]}.")
|
| 147 |
+
|
| 148 |
+
if total_cases > 0:
|
| 149 |
+
parts.append(f"We've worked through {total_cases} conversations so far.")
|
| 150 |
+
|
| 151 |
+
parts.append(
|
| 152 |
+
"Ask me anything — I'll do my best, and I'll tell you when I'm not sure."
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
"case_id": "reflex",
|
| 157 |
+
"user_input": "",
|
| 158 |
+
"route": {"domain": "general", "complexity": "low", "intent": "identity"},
|
| 159 |
+
"research": {},
|
| 160 |
+
"planner": {},
|
| 161 |
+
"verifier": {},
|
| 162 |
+
"simulation": None,
|
| 163 |
+
"finance": None,
|
| 164 |
+
"final": {"confidence": 1.0},
|
| 165 |
+
"final_answer": " ".join(parts),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
def _respond_thinking(self, context: Dict) -> Dict:
|
| 169 |
+
system = context.get("system_self", {})
|
| 170 |
+
pending = system.get("pending_thoughts", [])
|
| 171 |
+
discoveries = system.get("recent_discoveries", [])
|
| 172 |
+
|
| 173 |
+
if pending:
|
| 174 |
+
thought = pending[0]
|
| 175 |
+
parts = [
|
| 176 |
+
f"Yeah — {thought.get('thought', '')}",
|
| 177 |
+
"Want me to dig into that?",
|
| 178 |
+
]
|
| 179 |
+
elif discoveries:
|
| 180 |
+
discovery = discoveries[0]
|
| 181 |
+
parts = [
|
| 182 |
+
f"I found something interesting recently — {discovery.get('discovery', '')}",
|
| 183 |
+
"Want to explore it?",
|
| 184 |
+
]
|
| 185 |
+
else:
|
| 186 |
+
parts = [
|
| 187 |
+
"Nothing specific right now, but I'm always tracking patterns.",
|
| 188 |
+
"Give me something to think about and I'll get to work.",
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"case_id": "reflex",
|
| 193 |
+
"user_input": "",
|
| 194 |
+
"route": {"domain": "general", "complexity": "low", "intent": "thinking"},
|
| 195 |
+
"research": {},
|
| 196 |
+
"planner": {},
|
| 197 |
+
"verifier": {},
|
| 198 |
+
"simulation": None,
|
| 199 |
+
"finance": None,
|
| 200 |
+
"final": {"confidence": 1.0},
|
| 201 |
+
"final_answer": " ".join(parts),
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
def _respond_help(self, context: Dict) -> Dict:
|
| 205 |
+
system = context.get("system_self", {})
|
| 206 |
+
capabilities = system.get("capabilities", [])
|
| 207 |
+
|
| 208 |
+
parts = [
|
| 209 |
+
"Here's what I can do:",
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
for cap in capabilities:
|
| 213 |
+
parts.append(f"• {cap}")
|
| 214 |
+
|
| 215 |
+
parts.extend(
|
| 216 |
+
[
|
| 217 |
+
"",
|
| 218 |
+
"Try things like:",
|
| 219 |
+
'• "What do you think about Tesla stock?"',
|
| 220 |
+
'• "Simulate what happens if interest rates rise"',
|
| 221 |
+
'• "Research the latest AI regulations"',
|
| 222 |
+
'• "What are you thinking about?" — to hear what I\'ve been tracking',
|
| 223 |
+
]
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"case_id": "reflex",
|
| 228 |
+
"user_input": "",
|
| 229 |
+
"route": {"domain": "general", "complexity": "low", "intent": "help"},
|
| 230 |
+
"research": {},
|
| 231 |
+
"planner": {},
|
| 232 |
+
"verifier": {},
|
| 233 |
+
"simulation": None,
|
| 234 |
+
"finance": None,
|
| 235 |
+
"final": {"confidence": 1.0},
|
| 236 |
+
"final_answer": "\n".join(parts),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
def _respond_status(self, context: Dict) -> Dict:
|
| 240 |
+
system = context.get("system_self", {})
|
| 241 |
+
daemon = context.get("daemon", {})
|
| 242 |
+
user = context.get("user", {})
|
| 243 |
+
|
| 244 |
+
parts = [
|
| 245 |
+
f"I've been running for {system.get('uptime', 'a while')}.",
|
| 246 |
+
f"We've had {user.get('conversation_count', 0)} conversations.",
|
| 247 |
+
]
|
| 248 |
+
|
| 249 |
+
if daemon.get("running"):
|
| 250 |
+
parts.append(
|
| 251 |
+
f"Background intelligence is active — {daemon.get('cycle_count', 0)} cycles completed."
|
| 252 |
+
)
|
| 253 |
+
phase = daemon.get("circadian_phase", "unknown")
|
| 254 |
+
parts.append(f"Current phase: {phase}.")
|
| 255 |
+
else:
|
| 256 |
+
parts.append("Background intelligence is not running yet.")
|
| 257 |
+
|
| 258 |
+
return {
|
| 259 |
+
"case_id": "reflex",
|
| 260 |
+
"user_input": "",
|
| 261 |
+
"route": {"domain": "general", "complexity": "low", "intent": "status"},
|
| 262 |
+
"research": {},
|
| 263 |
+
"planner": {},
|
| 264 |
+
"verifier": {},
|
| 265 |
+
"simulation": None,
|
| 266 |
+
"finance": None,
|
| 267 |
+
"final": {"confidence": 1.0},
|
| 268 |
+
"final_answer": " ".join(parts),
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
reflex_layer = ReflexLayer()
|
backend/requirements.txt
CHANGED
|
@@ -8,3 +8,7 @@ python-dotenv>=1.0.0
|
|
| 8 |
pytest>=8.0.0
|
| 9 |
python-multipart>=0.0.9
|
| 10 |
psutil>=5.9.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
pytest>=8.0.0
|
| 9 |
python-multipart>=0.0.9
|
| 10 |
psutil>=5.9.0
|
| 11 |
+
playwright>=1.40.0
|
| 12 |
+
markdownify>=0.11.0
|
| 13 |
+
lxml>=5.0.0
|
| 14 |
+
aiohttp>=3.9.0
|
frontend/src/app/cases/page.tsx
CHANGED
|
@@ -3,10 +3,9 @@
|
|
| 3 |
import { useEffect, useState } from 'react';
|
| 4 |
import { motion } from 'framer-motion';
|
| 5 |
import Link from 'next/link';
|
| 6 |
-
import {
|
| 7 |
import { apiClient } from '@/lib/api';
|
| 8 |
import type { CaseRecord } from '@/lib/types';
|
| 9 |
-
import LoadingSpinner from '@/components/common/LoadingSpinner';
|
| 10 |
|
| 11 |
export default function CasesPage() {
|
| 12 |
const [cases, setCases] = useState<CaseRecord[]>([]);
|
|
@@ -28,112 +27,101 @@ export default function CasesPage() {
|
|
| 28 |
|
| 29 |
if (loading) {
|
| 30 |
return (
|
| 31 |
-
<div className="flex h-
|
| 32 |
-
<div className="flex flex-col items-center gap-
|
| 33 |
-
<
|
| 34 |
-
<span className="text-
|
| 35 |
</div>
|
| 36 |
</div>
|
| 37 |
);
|
| 38 |
}
|
| 39 |
|
| 40 |
return (
|
| 41 |
-
<div className="
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
</div>
|
| 48 |
-
<h1 className="text-2xl font-light tracking-[0.15em] text-gradient-subtle uppercase">
|
| 49 |
-
Intelligence Cases
|
| 50 |
-
</h1>
|
| 51 |
</div>
|
| 52 |
-
<
|
| 53 |
-
Archived cognitive traces, agent execution logs, and final synthesis reports from prior intelligence routing scenarios.
|
| 54 |
-
</p>
|
| 55 |
-
</div>
|
| 56 |
-
|
| 57 |
-
<div className="flex items-center gap-2 glass px-4 py-2 rounded-xl border-white/5">
|
| 58 |
-
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
| 59 |
-
<span className="text-[10px] font-mono text-gray-400 uppercase tracking-widest">
|
| 60 |
-
{cases.length} records indexed
|
| 61 |
-
</span>
|
| 62 |
</div>
|
| 63 |
-
</
|
| 64 |
|
| 65 |
-
{
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
href={`/cases/${record.case_id}`}
|
| 84 |
-
className="group block h-full p-6 glass rounded-2xl border border-white/[0.04] hover:border-indigo-500/30 hover:bg-white/[0.04] transition-all duration-500"
|
| 85 |
>
|
| 86 |
-
<
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
-
{record.route?.execution_mode && (
|
| 94 |
-
<span className="px-2 py-1 rounded border border-indigo-500/20 bg-indigo-500/10 text-[9px] font-mono text-indigo-300 uppercase tracking-wider">
|
| 95 |
-
{record.route.execution_mode}
|
| 96 |
-
</span>
|
| 97 |
-
)}
|
| 98 |
-
</div>
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
|
| 104 |
-
|
| 105 |
-
<div className="flex items-center gap-2 flex-wrap mb-4">
|
| 106 |
{record.route?.domain_pack && record.route.domain_pack !== 'general' && (
|
| 107 |
-
<span className="px-2 py-
|
| 108 |
{record.route.domain_pack}
|
| 109 |
</span>
|
| 110 |
)}
|
| 111 |
-
{record.route?.task_family && (
|
| 112 |
-
<span className="px-2 py-1 rounded bg-white/5 text-[9px] font-mono text-gray-400 uppercase tracking-wider">
|
| 113 |
-
{record.route.task_family}
|
| 114 |
-
</span>
|
| 115 |
-
)}
|
| 116 |
{record.simulation_id && (
|
| 117 |
-
<span className="px-2 py-
|
| 118 |
-
Simulation
|
| 119 |
</span>
|
| 120 |
)}
|
| 121 |
</div>
|
| 122 |
|
| 123 |
-
<div className="flex items-center justify-between border-t border-white/
|
| 124 |
-
<span className="text-[10px]
|
| 125 |
{record.saved_at ? new Date(record.saved_at).toLocaleDateString() : 'N/A'}
|
| 126 |
</span>
|
| 127 |
-
<span className="flex items-center gap-1 text-[10px]
|
| 128 |
-
View
|
| 129 |
</span>
|
| 130 |
</div>
|
| 131 |
-
</
|
| 132 |
-
</
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
</div>
|
| 138 |
);
|
| 139 |
}
|
|
|
|
| 3 |
import { useEffect, useState } from 'react';
|
| 4 |
import { motion } from 'framer-motion';
|
| 5 |
import Link from 'next/link';
|
| 6 |
+
import { Layers, ArrowRight, Activity, RefreshCw } from 'lucide-react';
|
| 7 |
import { apiClient } from '@/lib/api';
|
| 8 |
import type { CaseRecord } from '@/lib/types';
|
|
|
|
| 9 |
|
| 10 |
export default function CasesPage() {
|
| 11 |
const [cases, setCases] = useState<CaseRecord[]>([]);
|
|
|
|
| 27 |
|
| 28 |
if (loading) {
|
| 29 |
return (
|
| 30 |
+
<div className="flex h-full items-center justify-center">
|
| 31 |
+
<div className="flex flex-col items-center gap-3">
|
| 32 |
+
<RefreshCw size={20} className="text-indigo-400 animate-spin" />
|
| 33 |
+
<span className="text-[12px] text-gray-500">Loading cases...</span>
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
);
|
| 37 |
}
|
| 38 |
|
| 39 |
return (
|
| 40 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 41 |
+
{/* Header */}
|
| 42 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 43 |
+
<div className="flex items-center justify-between">
|
| 44 |
+
<div className="flex items-center gap-3">
|
| 45 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 46 |
+
<Layers size={16} className="text-indigo-400" />
|
| 47 |
+
</div>
|
| 48 |
+
<div>
|
| 49 |
+
<h1 className="text-lg font-light text-gray-100">Cases</h1>
|
| 50 |
+
<p className="text-[11px] text-gray-600">Archived intelligence traces and synthesis reports</p>
|
| 51 |
</div>
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
| 53 |
+
<span className="text-[11px] text-gray-600">{cases.length} records</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</div>
|
| 55 |
+
</div>
|
| 56 |
|
| 57 |
+
{/* Content */}
|
| 58 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 59 |
+
{cases.length === 0 ? (
|
| 60 |
+
<div className="flex flex-col items-center justify-center py-20">
|
| 61 |
+
<Layers size={28} className="text-gray-700 mb-4" />
|
| 62 |
+
<p className="text-[13px] text-gray-500 mb-4">No cases recorded yet.</p>
|
| 63 |
+
<Link href="/" className="px-5 py-2 rounded-xl border border-indigo-500/20 hover:bg-indigo-500/10 text-[12px] text-indigo-400 transition-all">
|
| 64 |
+
Start a conversation
|
| 65 |
+
</Link>
|
| 66 |
+
</div>
|
| 67 |
+
) : (
|
| 68 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-6xl mx-auto">
|
| 69 |
+
{cases.map((record, i) => (
|
| 70 |
+
<motion.div
|
| 71 |
+
key={record.case_id}
|
| 72 |
+
initial={{ opacity: 0, y: 12 }}
|
| 73 |
+
animate={{ opacity: 1, y: 0 }}
|
| 74 |
+
transition={{ delay: i * 0.04 }}
|
|
|
|
|
|
|
| 75 |
>
|
| 76 |
+
<Link
|
| 77 |
+
href={`/cases/${record.case_id}`}
|
| 78 |
+
className="group block h-full card hover:border-white/[0.12] transition-all"
|
| 79 |
+
>
|
| 80 |
+
<div className="flex items-center justify-between mb-3">
|
| 81 |
+
<div className="flex items-center gap-2">
|
| 82 |
+
<Activity size={12} className="text-violet-400" />
|
| 83 |
+
<span className="text-[10px] text-gray-600 uppercase tracking-wider">
|
| 84 |
+
{record.case_id.slice(0, 8)}
|
| 85 |
+
</span>
|
| 86 |
+
</div>
|
| 87 |
+
{record.route?.execution_mode && (
|
| 88 |
+
<span className="px-2 py-0.5 rounded text-[9px] text-indigo-300 bg-indigo-500/10 border border-indigo-500/15 uppercase tracking-wider">
|
| 89 |
+
{record.route.execution_mode}
|
| 90 |
+
</span>
|
| 91 |
+
)}
|
| 92 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
+
<h3 className="text-[13px] text-gray-300 leading-relaxed line-clamp-2 mb-4 group-hover:text-white transition-colors">
|
| 95 |
+
{record.user_input}
|
| 96 |
+
</h3>
|
| 97 |
|
| 98 |
+
<div className="flex items-center gap-2 flex-wrap mb-3">
|
|
|
|
| 99 |
{record.route?.domain_pack && record.route.domain_pack !== 'general' && (
|
| 100 |
+
<span className="px-2 py-0.5 rounded text-[9px] text-gray-400 bg-white/[0.04] uppercase tracking-wider">
|
| 101 |
{record.route.domain_pack}
|
| 102 |
</span>
|
| 103 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
{record.simulation_id && (
|
| 105 |
+
<span className="px-2 py-0.5 rounded text-[9px] text-amber-300 bg-amber-500/10 border border-amber-500/15 uppercase tracking-wider">
|
| 106 |
+
Simulation
|
| 107 |
</span>
|
| 108 |
)}
|
| 109 |
</div>
|
| 110 |
|
| 111 |
+
<div className="flex items-center justify-between border-t border-white/[0.04] pt-3 mt-auto">
|
| 112 |
+
<span className="text-[10px] text-gray-600">
|
| 113 |
{record.saved_at ? new Date(record.saved_at).toLocaleDateString() : 'N/A'}
|
| 114 |
</span>
|
| 115 |
+
<span className="flex items-center gap-1 text-[10px] text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 116 |
+
View <ArrowRight size={10} />
|
| 117 |
</span>
|
| 118 |
</div>
|
| 119 |
+
</Link>
|
| 120 |
+
</motion.div>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
)}
|
| 124 |
+
</div>
|
| 125 |
</div>
|
| 126 |
);
|
| 127 |
}
|
frontend/src/app/config/page.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState, useCallback } from 'react';
|
| 4 |
import { motion } from 'framer-motion';
|
| 5 |
-
import {
|
| 6 |
|
| 7 |
export default function ConfigPage() {
|
| 8 |
const [daemonStatus, setDaemonStatus] = useState<any>(null);
|
|
@@ -24,23 +24,18 @@ export default function ConfigPage() {
|
|
| 24 |
if (statusRes) setDaemonStatus(statusRes);
|
| 25 |
if (curiosityRes) setCuriosity(curiosityRes);
|
| 26 |
if (memoryRes) setMemoryStats(memoryRes);
|
| 27 |
-
} catch {
|
| 28 |
-
|
| 29 |
-
} finally {
|
| 30 |
-
setLoading(false);
|
| 31 |
-
}
|
| 32 |
}, []);
|
| 33 |
|
| 34 |
-
useEffect(() => {
|
| 35 |
-
fetchData();
|
| 36 |
-
}, [fetchData]);
|
| 37 |
|
| 38 |
if (loading) {
|
| 39 |
return (
|
| 40 |
-
<div className="flex h-
|
| 41 |
-
<div className="flex flex-col items-center gap-
|
| 42 |
-
<
|
| 43 |
-
<p className="text-
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
);
|
|
@@ -49,137 +44,118 @@ export default function ConfigPage() {
|
|
| 49 |
const overallOk = health?.status === 'ok' && daemonStatus?.running;
|
| 50 |
|
| 51 |
return (
|
| 52 |
-
<div className="
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<div>
|
| 60 |
-
<h1 className="text-3xl font-light tracking-[0.15em] text-gradient-subtle uppercase">
|
| 61 |
-
System Status
|
| 62 |
-
</h1>
|
| 63 |
-
<p className="text-[10px] font-mono text-gray-500 uppercase tracking-widest mt-1">
|
| 64 |
-
Cloud backend health, daemon telemetry, and intelligence metrics
|
| 65 |
-
</p>
|
| 66 |
-
</div>
|
| 67 |
-
</div>
|
| 68 |
-
|
| 69 |
-
<div className={`flex items-center gap-3 px-4 py-2 rounded-xl glass border ${overallOk ? 'border-emerald-500/20 bg-emerald-500/5' : 'border-red-500/20 bg-red-500/5'}`}>
|
| 70 |
-
<div className={`w-2 h-2 rounded-full ${overallOk ? 'bg-emerald-400 animate-pulse' : 'bg-red-500'}`} />
|
| 71 |
-
<span className={`text-[10px] font-mono uppercase tracking-widest ${overallOk ? 'text-emerald-300' : 'text-red-300'}`}>
|
| 72 |
-
System {overallOk ? 'Nominal' : 'Degraded'}
|
| 73 |
-
</span>
|
| 74 |
-
</div>
|
| 75 |
-
</header>
|
| 76 |
-
|
| 77 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 78 |
-
|
| 79 |
-
{/* Core System Status */}
|
| 80 |
-
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }} className="glass rounded-3xl p-6 relative overflow-hidden">
|
| 81 |
-
<div className="absolute top-0 right-0 p-4 opacity-5"><Cpu size={100} /></div>
|
| 82 |
-
<div className="flex items-center gap-2 text-xs font-mono text-indigo-400 uppercase tracking-wider mb-6 relative z-10">
|
| 83 |
-
<Activity size={14} /> Core Subsystems
|
| 84 |
-
</div>
|
| 85 |
-
<div className="space-y-4 relative z-10 text-sm font-mono">
|
| 86 |
-
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
| 87 |
-
<span className="text-gray-500">Backend Version</span>
|
| 88 |
-
<span className="text-gray-300">v{health?.version || 'Unknown'}</span>
|
| 89 |
-
</div>
|
| 90 |
-
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
| 91 |
-
<span className="text-gray-500">Daemon Status</span>
|
| 92 |
-
<span className={`text-[10px] px-2 py-0.5 rounded ${daemonStatus?.running ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'} uppercase tracking-wider`}>
|
| 93 |
-
{daemonStatus?.running ? 'Active' : 'Offline'}
|
| 94 |
-
</span>
|
| 95 |
-
</div>
|
| 96 |
-
<div className="flex items-center justify-between pb-2">
|
| 97 |
-
<span className="text-gray-500">Daemon Cycles</span>
|
| 98 |
-
<span className="text-gray-300">{daemonStatus?.cycle_count || 0}</span>
|
| 99 |
-
</div>
|
| 100 |
-
</div>
|
| 101 |
-
</motion.div>
|
| 102 |
-
|
| 103 |
-
{/* Neural Endpoints */}
|
| 104 |
-
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="glass rounded-3xl p-6 relative overflow-hidden">
|
| 105 |
-
<div className="absolute top-0 right-0 p-4 opacity-5"><Server size={100} /></div>
|
| 106 |
-
<div className="flex items-center gap-2 text-xs font-mono text-violet-400 uppercase tracking-wider mb-6 relative z-10">
|
| 107 |
-
<Network size={14} /> Intelligence Engine
|
| 108 |
-
</div>
|
| 109 |
-
<div className="space-y-4 relative z-10 text-sm font-mono">
|
| 110 |
-
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
| 111 |
-
<span className="text-gray-500">Circadian Phase</span>
|
| 112 |
-
<span className="text-gray-300 capitalize">{daemonStatus?.circadian?.current_phase || 'N/A'}</span>
|
| 113 |
-
</div>
|
| 114 |
-
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
| 115 |
-
<span className="text-gray-500">Phase Priority</span>
|
| 116 |
-
<span className="text-gray-300 capitalize">{daemonStatus?.circadian?.priority || 'N/A'}</span>
|
| 117 |
-
</div>
|
| 118 |
-
<div className="flex items-center justify-between pb-2">
|
| 119 |
-
<span className="text-gray-500">Active Tasks</span>
|
| 120 |
-
<span className="text-gray-300">{daemonStatus?.circadian?.current_tasks?.length || 0}</span>
|
| 121 |
-
</div>
|
| 122 |
-
</div>
|
| 123 |
-
</motion.div>
|
| 124 |
-
|
| 125 |
-
{/* Data Uplinks */}
|
| 126 |
-
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="glass rounded-3xl p-6 relative overflow-hidden border-indigo-500/10">
|
| 127 |
-
<div className="absolute top-0 right-0 p-4 opacity-5"><LinkIcon size={100} /></div>
|
| 128 |
-
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 uppercase tracking-wider mb-6 relative z-10">
|
| 129 |
-
<Globe size={14} /> Data Sources
|
| 130 |
-
</div>
|
| 131 |
-
<div className="space-y-4 relative z-10 text-sm font-mono">
|
| 132 |
-
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
| 133 |
-
<span className="text-gray-500">Market Watcher</span>
|
| 134 |
-
<span className="text-gray-300">{daemonStatus?.watchlist?.length || 0} tickers</span>
|
| 135 |
-
</div>
|
| 136 |
-
<div className="flex items-center justify-between border-b border-white/5 pb-2">
|
| 137 |
-
<span className="text-gray-500">News Pulse</span>
|
| 138 |
-
<span className="text-gray-300">{daemonStatus?.topics?.length || 0} topics</span>
|
| 139 |
</div>
|
| 140 |
-
<div
|
| 141 |
-
<
|
| 142 |
-
<
|
| 143 |
</div>
|
| 144 |
</div>
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
<div className="flex flex-col items-center gap-2 relative z-10">
|
| 152 |
-
<HardDrive size={24} className="text-indigo-400 mb-2" />
|
| 153 |
-
<span className="text-3xl font-light text-gray-100">{memoryStats?.total_cases || 0}</span>
|
| 154 |
-
<span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Cases Stored</span>
|
| 155 |
-
</div>
|
| 156 |
-
|
| 157 |
-
<div className="h-16 w-px bg-white/10 hidden md:block" />
|
| 158 |
-
|
| 159 |
-
<div className="flex flex-col items-center gap-2 relative z-10">
|
| 160 |
-
<Brain size={24} className="text-violet-400 mb-2" />
|
| 161 |
-
<span className="text-3xl font-light text-gray-100">{curiosity?.total_discoveries || 0}</span>
|
| 162 |
-
<span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Curiosity Discoveries</span>
|
| 163 |
-
</div>
|
| 164 |
-
|
| 165 |
-
<div className="h-16 w-px bg-white/10 hidden md:block" />
|
| 166 |
-
|
| 167 |
-
<div className="flex flex-col items-center gap-2 relative z-10">
|
| 168 |
-
<Zap size={24} className="text-amber-400 mb-2" />
|
| 169 |
-
<span className="text-3xl font-light text-gray-100">{curiosity?.total_interests || 0}</span>
|
| 170 |
-
<span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Topics of Interest</span>
|
| 171 |
</div>
|
|
|
|
|
|
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
</div>
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
</div>
|
| 184 |
</div>
|
| 185 |
);
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState, useCallback } from 'react';
|
| 4 |
import { motion } from 'framer-motion';
|
| 5 |
+
import { Settings, Activity, Brain, Zap, Shield, RefreshCw, Server, Globe, HardDrive, Cpu } from 'lucide-react';
|
| 6 |
|
| 7 |
export default function ConfigPage() {
|
| 8 |
const [daemonStatus, setDaemonStatus] = useState<any>(null);
|
|
|
|
| 24 |
if (statusRes) setDaemonStatus(statusRes);
|
| 25 |
if (curiosityRes) setCuriosity(curiosityRes);
|
| 26 |
if (memoryRes) setMemoryStats(memoryRes);
|
| 27 |
+
} catch { /* silent */ }
|
| 28 |
+
finally { setLoading(false); }
|
|
|
|
|
|
|
|
|
|
| 29 |
}, []);
|
| 30 |
|
| 31 |
+
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
|
|
|
| 32 |
|
| 33 |
if (loading) {
|
| 34 |
return (
|
| 35 |
+
<div className="flex h-full items-center justify-center">
|
| 36 |
+
<div className="flex flex-col items-center gap-3">
|
| 37 |
+
<RefreshCw size={20} className="text-gray-500 animate-spin" />
|
| 38 |
+
<p className="text-[12px] text-gray-500">Running diagnostics...</p>
|
| 39 |
</div>
|
| 40 |
</div>
|
| 41 |
);
|
|
|
|
| 44 |
const overallOk = health?.status === 'ok' && daemonStatus?.running;
|
| 45 |
|
| 46 |
return (
|
| 47 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 48 |
+
{/* Header */}
|
| 49 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 50 |
+
<div className="flex items-center justify-between">
|
| 51 |
+
<div className="flex items-center gap-3">
|
| 52 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 53 |
+
<Settings size={16} className="text-gray-400" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</div>
|
| 55 |
+
<div>
|
| 56 |
+
<h1 className="text-lg font-light text-gray-100">System Config</h1>
|
| 57 |
+
<p className="text-[11px] text-gray-600">Backend health, daemon telemetry, and intelligence metrics</p>
|
| 58 |
</div>
|
| 59 |
</div>
|
| 60 |
+
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-xl border ${overallOk ? 'border-emerald-500/15 bg-emerald-500/5' : 'border-red-500/15 bg-red-500/5'}`}>
|
| 61 |
+
<div className={`w-1.5 h-1.5 rounded-full ${overallOk ? 'bg-emerald-400' : 'bg-red-500'}`} />
|
| 62 |
+
<span className={`text-[10px] uppercase tracking-wider ${overallOk ? 'text-emerald-400' : 'text-red-400'}`}>
|
| 63 |
+
{overallOk ? 'Nominal' : 'Degraded'}
|
| 64 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
|
| 69 |
+
{/* Content */}
|
| 70 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 71 |
+
<div className="max-w-5xl mx-auto space-y-5">
|
| 72 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 73 |
+
{/* Core system */}
|
| 74 |
+
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="card p-5 relative overflow-hidden">
|
| 75 |
+
<div className="absolute top-0 right-0 p-3 opacity-[0.03]"><Cpu size={80} /></div>
|
| 76 |
+
<div className="flex items-center gap-2 text-[11px] text-indigo-400 uppercase tracking-wider mb-5 relative z-10">
|
| 77 |
+
<Activity size={13} /> Core Subsystems
|
| 78 |
+
</div>
|
| 79 |
+
<div className="space-y-3 relative z-10 text-[12px]">
|
| 80 |
+
<div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
|
| 81 |
+
<span className="text-gray-500">Backend Version</span>
|
| 82 |
+
<span className="text-gray-300">v{health?.version || 'Unknown'}</span>
|
| 83 |
+
</div>
|
| 84 |
+
<div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
|
| 85 |
+
<span className="text-gray-500">Daemon Status</span>
|
| 86 |
+
<span className={`text-[10px] px-2 py-0.5 rounded ${daemonStatus?.running ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'} uppercase tracking-wider`}>
|
| 87 |
+
{daemonStatus?.running ? 'Active' : 'Offline'}
|
| 88 |
+
</span>
|
| 89 |
+
</div>
|
| 90 |
+
<div className="flex items-center justify-between">
|
| 91 |
+
<span className="text-gray-500">Daemon Cycles</span>
|
| 92 |
+
<span className="text-gray-300">{daemonStatus?.cycle_count || 0}</span>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</motion.div>
|
| 96 |
+
|
| 97 |
+
{/* Intelligence Engine */}
|
| 98 |
+
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }} className="card p-5 relative overflow-hidden">
|
| 99 |
+
<div className="absolute top-0 right-0 p-3 opacity-[0.03]"><Server size={80} /></div>
|
| 100 |
+
<div className="flex items-center gap-2 text-[11px] text-violet-400 uppercase tracking-wider mb-5 relative z-10">
|
| 101 |
+
<Brain size={13} /> Intelligence Engine
|
| 102 |
+
</div>
|
| 103 |
+
<div className="space-y-3 relative z-10 text-[12px]">
|
| 104 |
+
<div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
|
| 105 |
+
<span className="text-gray-500">Circadian Phase</span>
|
| 106 |
+
<span className="text-gray-300 capitalize">{daemonStatus?.circadian?.current_phase || 'N/A'}</span>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
|
| 109 |
+
<span className="text-gray-500">Phase Priority</span>
|
| 110 |
+
<span className="text-gray-300 capitalize">{daemonStatus?.circadian?.priority || 'N/A'}</span>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="flex items-center justify-between">
|
| 113 |
+
<span className="text-gray-500">Active Tasks</span>
|
| 114 |
+
<span className="text-gray-300">{daemonStatus?.circadian?.current_tasks?.length || 0}</span>
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
</motion.div>
|
| 118 |
+
|
| 119 |
+
{/* Data Sources */}
|
| 120 |
+
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }} className="card p-5 relative overflow-hidden">
|
| 121 |
+
<div className="absolute top-0 right-0 p-3 opacity-[0.03]"><Globe size={80} /></div>
|
| 122 |
+
<div className="flex items-center gap-2 text-[11px] text-emerald-400 uppercase tracking-wider mb-5 relative z-10">
|
| 123 |
+
<Globe size={13} /> Data Sources
|
| 124 |
+
</div>
|
| 125 |
+
<div className="space-y-3 relative z-10 text-[12px]">
|
| 126 |
+
<div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
|
| 127 |
+
<span className="text-gray-500">Market Watcher</span>
|
| 128 |
+
<span className="text-gray-300">{daemonStatus?.watchlist?.length || 0} tickers</span>
|
| 129 |
+
</div>
|
| 130 |
+
<div className="flex items-center justify-between border-b border-white/[0.03] pb-2">
|
| 131 |
+
<span className="text-gray-500">News Pulse</span>
|
| 132 |
+
<span className="text-gray-300">{daemonStatus?.topics?.length || 0} topics</span>
|
| 133 |
+
</div>
|
| 134 |
+
<div className="flex items-center justify-between">
|
| 135 |
+
<span className="text-gray-500">Signals Collected</span>
|
| 136 |
+
<span className="text-gray-300">{daemonStatus?.signal_queue?.total_signals || daemonStatus?.signals || 0}</span>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</motion.div>
|
| 140 |
</div>
|
| 141 |
|
| 142 |
+
{/* Big stats bar */}
|
| 143 |
+
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }} className="card p-8 flex flex-col md:flex-row items-center justify-around relative overflow-hidden">
|
| 144 |
+
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500/[0.03] via-transparent to-violet-500/[0.03]" />
|
| 145 |
+
{[
|
| 146 |
+
{ icon: HardDrive, value: memoryStats?.total_cases || 0, label: 'Cases Stored', color: 'text-indigo-400' },
|
| 147 |
+
{ icon: Brain, value: curiosity?.total_discoveries || 0, label: 'Discoveries', color: 'text-violet-400' },
|
| 148 |
+
{ icon: Zap, value: curiosity?.total_interests || 0, label: 'Topics', color: 'text-amber-400' },
|
| 149 |
+
{ icon: Shield, value: daemonStatus?.cycle_count || 0, label: 'Daemon Cycles', color: 'text-emerald-400' },
|
| 150 |
+
].map((stat, i) => (
|
| 151 |
+
<div key={stat.label} className="flex flex-col items-center gap-2 relative z-10 py-2">
|
| 152 |
+
<stat.icon size={20} className={stat.color + ' mb-1'} />
|
| 153 |
+
<span className="text-2xl font-light text-gray-100">{stat.value}</span>
|
| 154 |
+
<span className="text-[10px] text-gray-600 uppercase tracking-wider">{stat.label}</span>
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
</motion.div>
|
| 158 |
+
</div>
|
| 159 |
</div>
|
| 160 |
</div>
|
| 161 |
);
|
frontend/src/app/globals.css
CHANGED
|
@@ -2,9 +2,22 @@
|
|
| 2 |
|
| 3 |
:root {
|
| 4 |
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
|
| 5 |
-
|
| 6 |
-
--janus-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
--janus-glow: rgba(99, 102, 241, 0.15);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
@theme inline {
|
|
@@ -19,8 +32,8 @@
|
|
| 19 |
|
| 20 |
body {
|
| 21 |
font-family: var(--font-inter);
|
| 22 |
-
background-color:
|
| 23 |
-
color:
|
| 24 |
line-height: 1.6;
|
| 25 |
-webkit-font-smoothing: antialiased;
|
| 26 |
-moz-osx-font-smoothing: grayscale;
|
|
@@ -28,51 +41,34 @@ body {
|
|
| 28 |
}
|
| 29 |
|
| 30 |
/* ─── Scrollbar ─── */
|
| 31 |
-
::-webkit-scrollbar { width:
|
| 32 |
::-webkit-scrollbar-track { background: transparent; }
|
| 33 |
-
::-webkit-scrollbar-thumb { background:
|
| 34 |
-
::-webkit-scrollbar-thumb:hover { background:
|
| 35 |
|
| 36 |
/* ─── Selection ─── */
|
| 37 |
-
::selection { background: rgba(
|
| 38 |
|
| 39 |
/* ─── Focus ─── */
|
| 40 |
*:focus-visible {
|
| 41 |
-
outline: 1px solid rgba(
|
| 42 |
outline-offset: 2px;
|
| 43 |
}
|
| 44 |
|
| 45 |
-
/* ───
|
| 46 |
-
@keyframes
|
| 47 |
-
0%, 100% { transform: scale(1); opacity: 0.6; }
|
| 48 |
-
50% { transform: scale(1.15); opacity: 1; }
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
@keyframes orbRotate {
|
| 52 |
from { transform: rotate(0deg); }
|
| 53 |
to { transform: rotate(360deg); }
|
| 54 |
}
|
| 55 |
|
| 56 |
-
@keyframes
|
| 57 |
-
0% { transform:
|
| 58 |
-
|
| 59 |
-
90% { opacity: 0.6; }
|
| 60 |
-
100% { transform: translateY(-100vh) scale(0.5); opacity: 0; }
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
@keyframes scanLine {
|
| 64 |
-
0% { transform: translateY(-100%); }
|
| 65 |
-
100% { transform: translateY(400%); }
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
@keyframes typewriter {
|
| 69 |
-
from { width: 0; }
|
| 70 |
-
to { width: 100%; }
|
| 71 |
}
|
| 72 |
|
| 73 |
-
@keyframes
|
| 74 |
-
|
| 75 |
-
|
| 76 |
}
|
| 77 |
|
| 78 |
@keyframes shimmer {
|
|
@@ -80,7 +76,13 @@ body {
|
|
| 80 |
100% { background-position: 200% 0; }
|
| 81 |
}
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
@keyframes ring1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
| 85 |
@keyframes ring2 { from { transform: rotate(120deg); } to { transform: rotate(-240deg); } }
|
| 86 |
@keyframes ring3 { from { transform: rotate(240deg); } to { transform: rotate(600deg); } }
|
|
@@ -89,38 +91,49 @@ body {
|
|
| 89 |
.janus-ring-2 { animation: ring2 4s linear infinite; }
|
| 90 |
.janus-ring-3 { animation: ring3 5s linear infinite; }
|
| 91 |
|
| 92 |
-
/*
|
| 93 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
.
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
-
/*
|
| 102 |
-
.
|
| 103 |
-
background:
|
| 104 |
-
|
| 105 |
-
|
| 106 |
}
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
backdrop-filter: blur(24px);
|
| 112 |
-
-webkit-backdrop-filter: blur(24px);
|
| 113 |
-
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 114 |
}
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
-
/* Text
|
| 122 |
.text-gradient {
|
| 123 |
-
background: linear-gradient(135deg, #e0e7ff 0%, #
|
| 124 |
-webkit-background-clip: text;
|
| 125 |
-webkit-text-fill-color: transparent;
|
| 126 |
background-clip: text;
|
|
@@ -133,19 +146,48 @@ body {
|
|
| 133 |
background-clip: text;
|
| 134 |
}
|
| 135 |
|
| 136 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
.prose { max-width: 65ch; }
|
| 138 |
.prose p { margin-bottom: 1em; }
|
| 139 |
-
.prose
|
| 140 |
-
.prose
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
| 144 |
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
| 145 |
.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
| 146 |
|
| 147 |
-
/*
|
| 148 |
-
|
| 149 |
-
from { transform: rotate(0deg); }
|
| 150 |
-
to { transform: rotate(360deg); }
|
| 151 |
-
}
|
|
|
|
| 2 |
|
| 3 |
:root {
|
| 4 |
--font-inter: 'Inter', system-ui, -apple-system, sans-serif;
|
| 5 |
+
/* Janus palette */
|
| 6 |
+
--janus-bg: #0a0a0f;
|
| 7 |
+
--janus-surface: #111118;
|
| 8 |
+
--janus-surface-hover: #18181f;
|
| 9 |
+
--janus-border: rgba(255, 255, 255, 0.06);
|
| 10 |
+
--janus-border-hover: rgba(255, 255, 255, 0.12);
|
| 11 |
+
--janus-indigo: #818cf8;
|
| 12 |
+
--janus-violet: #a78bfa;
|
| 13 |
+
--janus-text: #e5e7eb;
|
| 14 |
+
--janus-text-muted: #6b7280;
|
| 15 |
+
--janus-text-dim: #374151;
|
| 16 |
--janus-glow: rgba(99, 102, 241, 0.15);
|
| 17 |
+
/* Spacing */
|
| 18 |
+
--sidebar-width: 260px;
|
| 19 |
+
--sidebar-collapsed: 68px;
|
| 20 |
+
--input-max-width: 768px;
|
| 21 |
}
|
| 22 |
|
| 23 |
@theme inline {
|
|
|
|
| 32 |
|
| 33 |
body {
|
| 34 |
font-family: var(--font-inter);
|
| 35 |
+
background-color: var(--janus-bg);
|
| 36 |
+
color: var(--janus-text);
|
| 37 |
line-height: 1.6;
|
| 38 |
-webkit-font-smoothing: antialiased;
|
| 39 |
-moz-osx-font-smoothing: grayscale;
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
/* ─── Scrollbar ─── */
|
| 44 |
+
::-webkit-scrollbar { width: 5px; height: 5px; }
|
| 45 |
::-webkit-scrollbar-track { background: transparent; }
|
| 46 |
+
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 999px; }
|
| 47 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
|
| 48 |
|
| 49 |
/* ─── Selection ─── */
|
| 50 |
+
::selection { background: rgba(129, 140, 248, 0.3); }
|
| 51 |
|
| 52 |
/* ─── Focus ─── */
|
| 53 |
*:focus-visible {
|
| 54 |
+
outline: 1px solid rgba(129, 140, 248, 0.5);
|
| 55 |
outline-offset: 2px;
|
| 56 |
}
|
| 57 |
|
| 58 |
+
/* ─── Animations ─── */
|
| 59 |
+
@keyframes spin {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
from { transform: rotate(0deg); }
|
| 61 |
to { transform: rotate(360deg); }
|
| 62 |
}
|
| 63 |
|
| 64 |
+
@keyframes orbPulse {
|
| 65 |
+
0%, 100% { transform: scale(1); opacity: 0.7; }
|
| 66 |
+
50% { transform: scale(1.12); opacity: 1; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
+
@keyframes fadeIn {
|
| 70 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 71 |
+
to { opacity: 1; transform: translateY(0); }
|
| 72 |
}
|
| 73 |
|
| 74 |
@keyframes shimmer {
|
|
|
|
| 76 |
100% { background-position: 200% 0; }
|
| 77 |
}
|
| 78 |
|
| 79 |
+
@keyframes floatUp {
|
| 80 |
+
0% { transform: translateY(0) scale(1); opacity: 0; }
|
| 81 |
+
10% { opacity: 0.8; }
|
| 82 |
+
90% { opacity: 0.4; }
|
| 83 |
+
100% { transform: translateY(-100vh) scale(0.5); opacity: 0; }
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
@keyframes ring1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
| 87 |
@keyframes ring2 { from { transform: rotate(120deg); } to { transform: rotate(-240deg); } }
|
| 88 |
@keyframes ring3 { from { transform: rotate(240deg); } to { transform: rotate(600deg); } }
|
|
|
|
| 91 |
.janus-ring-2 { animation: ring2 4s linear infinite; }
|
| 92 |
.janus-ring-3 { animation: ring3 5s linear infinite; }
|
| 93 |
|
| 94 |
+
/* ─── Glassmorphism ─── */
|
| 95 |
+
.glass {
|
| 96 |
+
background: rgba(255, 255, 255, 0.025);
|
| 97 |
+
backdrop-filter: blur(20px);
|
| 98 |
+
-webkit-backdrop-filter: blur(20px);
|
| 99 |
+
border: 1px solid var(--janus-border);
|
| 100 |
+
}
|
| 101 |
|
| 102 |
+
.glass-hover {
|
| 103 |
+
transition: all 0.3s ease;
|
| 104 |
+
}
|
| 105 |
+
.glass-hover:hover {
|
| 106 |
+
background: rgba(255, 255, 255, 0.05);
|
| 107 |
+
border-color: var(--janus-border-hover);
|
| 108 |
}
|
| 109 |
|
| 110 |
+
/* ─── Conversation Bubbles ─── */
|
| 111 |
+
.bubble-user {
|
| 112 |
+
background: rgba(129, 140, 248, 0.08);
|
| 113 |
+
border: 1px solid rgba(129, 140, 248, 0.12);
|
| 114 |
+
border-radius: 20px 20px 4px 20px;
|
| 115 |
}
|
| 116 |
|
| 117 |
+
.bubble-janus {
|
| 118 |
+
background: transparent;
|
| 119 |
+
border-radius: 20px 20px 20px 4px;
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
+
/* ─── Input Bar ─── */
|
| 123 |
+
.input-bar {
|
| 124 |
+
background: var(--janus-surface);
|
| 125 |
+
border: 1px solid var(--janus-border);
|
| 126 |
+
border-radius: 24px;
|
| 127 |
+
transition: all 0.3s ease;
|
| 128 |
+
}
|
| 129 |
+
.input-bar:focus-within {
|
| 130 |
+
border-color: rgba(129, 140, 248, 0.3);
|
| 131 |
+
box-shadow: 0 0 0 1px rgba(129, 140, 248, 0.1), 0 4px 24px rgba(0, 0, 0, 0.3);
|
| 132 |
}
|
| 133 |
|
| 134 |
+
/* ─── Text Utilities ─── */
|
| 135 |
.text-gradient {
|
| 136 |
+
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #a78bfa 100%);
|
| 137 |
-webkit-background-clip: text;
|
| 138 |
-webkit-text-fill-color: transparent;
|
| 139 |
background-clip: text;
|
|
|
|
| 146 |
background-clip: text;
|
| 147 |
}
|
| 148 |
|
| 149 |
+
/* ─── Page Container ─── */
|
| 150 |
+
.page-container {
|
| 151 |
+
max-width: 1400px;
|
| 152 |
+
margin: 0 auto;
|
| 153 |
+
padding: 2rem 2.5rem;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* ─── Card ─── */
|
| 157 |
+
.card {
|
| 158 |
+
background: var(--janus-surface);
|
| 159 |
+
border: 1px solid var(--janus-border);
|
| 160 |
+
border-radius: 16px;
|
| 161 |
+
padding: 1.5rem;
|
| 162 |
+
transition: all 0.3s ease;
|
| 163 |
+
}
|
| 164 |
+
.card:hover {
|
| 165 |
+
border-color: var(--janus-border-hover);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* ─── Shimmer Loading ─── */
|
| 169 |
+
.shimmer {
|
| 170 |
+
background: linear-gradient(90deg, transparent 25%, rgba(129, 140, 248, 0.06) 50%, transparent 75%);
|
| 171 |
+
background-size: 200% 100%;
|
| 172 |
+
animation: shimmer 2s ease-in-out infinite;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* ─── Prose ─── */
|
| 176 |
.prose { max-width: 65ch; }
|
| 177 |
.prose p { margin-bottom: 1em; }
|
| 178 |
+
.prose h1, .prose h2, .prose h3 { margin-top: 1.5em; margin-bottom: 0.5em; font-weight: 500; }
|
| 179 |
+
.prose ul, .prose ol { margin-bottom: 1em; padding-left: 1.5em; }
|
| 180 |
+
.prose li { margin-bottom: 0.25em; }
|
| 181 |
+
.prose code { background: rgba(255,255,255,0.06); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.875em; }
|
| 182 |
+
.prose pre { background: rgba(0,0,0,0.4); padding: 1em; border-radius: 8px; overflow-x: auto; margin-bottom: 1em; }
|
| 183 |
+
.prose pre code { background: transparent; padding: 0; }
|
| 184 |
+
.prose blockquote { border-left: 2px solid rgba(129,140,248,0.3); padding-left: 1em; color: #9ca3af; }
|
| 185 |
+
.prose-invert { color: #d1d5db; }
|
| 186 |
+
|
| 187 |
+
/* ─── Line Clamp ─── */
|
| 188 |
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
| 189 |
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
| 190 |
.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
| 191 |
|
| 192 |
+
/* ─── Particle ─── */
|
| 193 |
+
.particle { animation: floatUp var(--duration, 20s) linear infinite; animation-delay: var(--delay, 0s); }
|
|
|
|
|
|
|
|
|
frontend/src/app/intel/page.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useRef } from 'react';
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
+
import {
|
| 6 |
+
Globe, Search, RefreshCw, ExternalLink, Sparkles,
|
| 7 |
+
TrendingUp, TrendingDown, Minus, Zap
|
| 8 |
+
} from 'lucide-react';
|
| 9 |
+
import { apiClient, financeClient } from '@/lib/api';
|
| 10 |
+
import type { CaseRecord } from '@/lib/types';
|
| 11 |
+
|
| 12 |
+
// ─── Sub-components ────────────────────────────────────────
|
| 13 |
+
function StanceChip({ stance, score }: { stance: string; score: number }) {
|
| 14 |
+
if (stance === 'bullish') return <span className="flex items-center gap-1 text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"><TrendingUp size={9} />Bullish {Math.round(score * 100)}%</span>;
|
| 15 |
+
if (stance === 'bearish') return <span className="flex items-center gap-1 text-[10px] text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded-full"><TrendingDown size={9} />Bearish {Math.round((1 - score) * 100)}%</span>;
|
| 16 |
+
return <span className="flex items-center gap-1 text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 rounded-full"><Minus size={9} />Neutral</span>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function ArticleCard({ article, index, onResearch }: { article: any; index: number; onResearch: (q: string) => void }) {
|
| 20 |
+
return (
|
| 21 |
+
<motion.div
|
| 22 |
+
initial={{ opacity: 0, y: 8 }}
|
| 23 |
+
animate={{ opacity: 1, y: 0 }}
|
| 24 |
+
transition={{ delay: index * 0.04 }}
|
| 25 |
+
className="card hover:border-white/[0.12] group"
|
| 26 |
+
>
|
| 27 |
+
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
| 28 |
+
<StanceChip stance={article.stance} score={article.sentiment_score} />
|
| 29 |
+
{article.source && <span className="text-[10px] text-gray-600">{article.source}</span>}
|
| 30 |
+
{article.published_at && <span className="text-[10px] text-gray-700">{new Date(article.published_at).toLocaleDateString()}</span>}
|
| 31 |
+
</div>
|
| 32 |
+
<h3 className="text-[14px] text-gray-200 leading-snug mb-2 group-hover:text-white transition-colors">{article.title}</h3>
|
| 33 |
+
{article.description && <p className="text-[12px] text-gray-500 leading-relaxed line-clamp-2">{article.description}</p>}
|
| 34 |
+
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-white/[0.04]">
|
| 35 |
+
<button
|
| 36 |
+
onClick={() => onResearch(article.title + (article.description ? '. ' + article.description : ''))}
|
| 37 |
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/15 text-[11px] text-indigo-400 transition-all"
|
| 38 |
+
>
|
| 39 |
+
<Sparkles size={11} /> Deep Research
|
| 40 |
+
</button>
|
| 41 |
+
{article.url && (
|
| 42 |
+
<a href={article.url} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-[11px] text-gray-600 hover:text-gray-400 transition-colors">
|
| 43 |
+
<ExternalLink size={10} /> Source
|
| 44 |
+
</a>
|
| 45 |
+
)}
|
| 46 |
+
</div>
|
| 47 |
+
</motion.div>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const STAGES = ['Routing to switchboard...', 'Research agent scanning...', 'Cross-referencing sources...', 'Synthesizing analysis...'];
|
| 52 |
+
|
| 53 |
+
export default function IntelPage() {
|
| 54 |
+
const [headlines, setHeadlines] = useState<any[]>([]);
|
| 55 |
+
const [query, setQuery] = useState('');
|
| 56 |
+
const [loading, setLoading] = useState(false);
|
| 57 |
+
const [searched, setSearched] = useState(false);
|
| 58 |
+
const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
|
| 59 |
+
const [researchLoading, setResearchLoading] = useState(false);
|
| 60 |
+
const [researchStage, setResearchStage] = useState(0);
|
| 61 |
+
const [researchQuery, setResearchQuery] = useState('');
|
| 62 |
+
const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
financeClient.getHeadlines().then(data => setHeadlines(data)).catch(() => {});
|
| 66 |
+
}, []);
|
| 67 |
+
|
| 68 |
+
const searchNews = async () => {
|
| 69 |
+
if (!query.trim()) return;
|
| 70 |
+
setLoading(true); setSearched(true); setResearchResult(null);
|
| 71 |
+
try {
|
| 72 |
+
const data = await financeClient.analyzeNews(query, 10);
|
| 73 |
+
setHeadlines(data.articles || []);
|
| 74 |
+
} catch { /* silent */ }
|
| 75 |
+
finally { setLoading(false); }
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const runResearch = async (articleText: string) => {
|
| 79 |
+
setResearchLoading(true); setResearchResult(null); setResearchQuery(articleText.slice(0, 80));
|
| 80 |
+
setResearchStage(0);
|
| 81 |
+
if (stageTimer.current) clearInterval(stageTimer.current);
|
| 82 |
+
stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
|
| 83 |
+
try {
|
| 84 |
+
const res = await apiClient.analyze({ user_input: articleText });
|
| 85 |
+
setResearchResult(res);
|
| 86 |
+
} catch { /* silent */ }
|
| 87 |
+
finally {
|
| 88 |
+
setResearchLoading(false);
|
| 89 |
+
if (stageTimer.current) clearInterval(stageTimer.current);
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
return (
|
| 94 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 95 |
+
{/* Header */}
|
| 96 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 97 |
+
<div className="flex items-center gap-3 mb-4">
|
| 98 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 99 |
+
<Globe size={16} className="text-indigo-400" />
|
| 100 |
+
</div>
|
| 101 |
+
<div>
|
| 102 |
+
<h1 className="text-lg font-light text-gray-100">Intel Stream</h1>
|
| 103 |
+
<p className="text-[11px] text-gray-600">Search and analyze news with Janus deep research</p>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
<div className="flex gap-2">
|
| 107 |
+
<div className="flex-1 flex items-center gap-3 px-4 py-2.5 rounded-xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors" style={{ background: 'var(--janus-surface)' }}>
|
| 108 |
+
<Search size={14} className="text-gray-600 shrink-0" />
|
| 109 |
+
<input
|
| 110 |
+
value={query}
|
| 111 |
+
onChange={e => setQuery(e.target.value)}
|
| 112 |
+
onKeyDown={e => e.key === 'Enter' && searchNews()}
|
| 113 |
+
placeholder="Search news — company, topic, event..."
|
| 114 |
+
className="flex-1 bg-transparent text-[13px] text-gray-200 placeholder-gray-600 focus:outline-none"
|
| 115 |
+
/>
|
| 116 |
+
</div>
|
| 117 |
+
<button onClick={searchNews} disabled={loading} className="px-4 py-2 rounded-xl border border-white/[0.06] hover:border-indigo-500/20 text-[12px] text-gray-400 hover:text-indigo-300 transition-all disabled:opacity-40" style={{ background: 'var(--janus-surface)' }}>
|
| 118 |
+
{loading ? <RefreshCw size={14} className="animate-spin" /> : 'Search'}
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{/* Content */}
|
| 124 |
+
<div className="flex-1 flex gap-0 overflow-hidden">
|
| 125 |
+
{/* Articles */}
|
| 126 |
+
<div className={`flex-1 overflow-y-auto p-4 space-y-3 transition-all duration-300 ${researchResult || researchLoading ? 'max-w-[50%]' : ''}`}>
|
| 127 |
+
<div className="text-[11px] text-gray-600 mb-2">
|
| 128 |
+
{searched ? `Results for "${query}"` : 'Top Business Headlines'} — click Deep Research on any article
|
| 129 |
+
</div>
|
| 130 |
+
{loading && (
|
| 131 |
+
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
| 132 |
+
<RefreshCw size={20} className="text-indigo-400 animate-spin" />
|
| 133 |
+
<p className="text-[12px] text-gray-500">Fetching articles...</p>
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
{!loading && headlines.map((a, i) => (
|
| 137 |
+
<ArticleCard key={`${a.title}-${i}`} article={a} index={i} onResearch={runResearch} />
|
| 138 |
+
))}
|
| 139 |
+
{!loading && headlines.length === 0 && (
|
| 140 |
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
| 141 |
+
<Globe size={24} className="text-gray-700 mb-3" />
|
| 142 |
+
<p className="text-[13px] text-gray-500">No articles found.</p>
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
{/* Research panel */}
|
| 148 |
+
<AnimatePresence>
|
| 149 |
+
{(researchResult || researchLoading) && (
|
| 150 |
+
<motion.div
|
| 151 |
+
initial={{ opacity: 0, x: 20 }}
|
| 152 |
+
animate={{ opacity: 1, x: 0 }}
|
| 153 |
+
exit={{ opacity: 0, x: 20 }}
|
| 154 |
+
className="flex-1 border-l border-white/[0.04] p-4 overflow-y-auto"
|
| 155 |
+
>
|
| 156 |
+
<div className="flex items-center justify-between mb-4">
|
| 157 |
+
<div className="text-[11px] text-gray-500 truncate">Research: {researchQuery}...</div>
|
| 158 |
+
<button
|
| 159 |
+
onClick={() => { setResearchResult(null); setResearchLoading(false); }}
|
| 160 |
+
className="text-[11px] text-gray-600 hover:text-gray-400 transition-colors"
|
| 161 |
+
>✕ Close</button>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
{researchLoading ? (
|
| 165 |
+
<div className="card flex items-center gap-3 p-5">
|
| 166 |
+
<div className="w-6 h-6 rounded-full border border-indigo-500/30 border-t-indigo-400 animate-spin" />
|
| 167 |
+
<p className="text-[12px] text-indigo-400">{STAGES[researchStage]}</p>
|
| 168 |
+
</div>
|
| 169 |
+
) : researchResult ? (
|
| 170 |
+
<div className="card p-5 space-y-4">
|
| 171 |
+
<div className="flex items-center gap-2 text-[10px] text-indigo-400 uppercase tracking-wider">
|
| 172 |
+
<Zap size={11} /> Research Complete
|
| 173 |
+
</div>
|
| 174 |
+
{researchResult.route && (
|
| 175 |
+
<div className="flex gap-2 flex-wrap">
|
| 176 |
+
<span className="px-2 py-0.5 rounded-full text-[10px] text-indigo-300 bg-indigo-500/10 border border-indigo-500/15">{researchResult.route.domain_pack}</span>
|
| 177 |
+
<span className="px-2 py-0.5 rounded-full text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06]">{researchResult.route.execution_mode}</span>
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
{researchResult.final_answer && (
|
| 181 |
+
<div className="text-[13px] text-gray-300 leading-relaxed whitespace-pre-wrap">{researchResult.final_answer}</div>
|
| 182 |
+
)}
|
| 183 |
+
</div>
|
| 184 |
+
) : null}
|
| 185 |
+
</motion.div>
|
| 186 |
+
)}
|
| 187 |
+
</AnimatePresence>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
frontend/src/app/layout.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Inter } from "next/font/google";
|
| 3 |
import "./globals.css";
|
|
|
|
| 4 |
|
| 5 |
const inter = Inter({
|
| 6 |
subsets: ["latin"],
|
|
@@ -19,8 +20,8 @@ export default function RootLayout({
|
|
| 19 |
}>) {
|
| 20 |
return (
|
| 21 |
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
| 22 |
-
<body className="h-full
|
| 23 |
-
{children}
|
| 24 |
</body>
|
| 25 |
</html>
|
| 26 |
);
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Inter } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
+
import AppShell from "@/components/AppShell";
|
| 5 |
|
| 6 |
const inter = Inter({
|
| 7 |
subsets: ["latin"],
|
|
|
|
| 20 |
}>) {
|
| 21 |
return (
|
| 22 |
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
| 23 |
+
<body className="h-full font-sans" style={{ background: 'var(--janus-bg)', color: 'var(--janus-text)' }}>
|
| 24 |
+
<AppShell>{children}</AppShell>
|
| 25 |
</body>
|
| 26 |
</html>
|
| 27 |
);
|
frontend/src/app/markets/page.tsx
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
+
import {
|
| 6 |
+
Search, BarChart3, TrendingUp, TrendingDown, Minus,
|
| 7 |
+
AlertTriangle, Sparkles, Zap, RefreshCw, Scan, ExternalLink
|
| 8 |
+
} from 'lucide-react';
|
| 9 |
+
import { financeClient, apiClient } from '@/lib/api';
|
| 10 |
+
import type { CaseRecord } from '@/lib/types';
|
| 11 |
+
import { createChart, ColorType, CrosshairMode, CandlestickSeries, HistogramSeries } from 'lightweight-charts';
|
| 12 |
+
import type { IChartApi, CandlestickData, HistogramData } from 'lightweight-charts';
|
| 13 |
+
|
| 14 |
+
// ─── Chips ─────────────────────────────────────────────────
|
| 15 |
+
function StanceChip({ stance, score }: { stance: string; score: number }) {
|
| 16 |
+
if (stance === 'bullish') return <span className="flex items-center gap-1 text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full"><TrendingUp size={9} />Bull {Math.round(score * 100)}%</span>;
|
| 17 |
+
if (stance === 'bearish') return <span className="flex items-center gap-1 text-[10px] text-red-400 bg-red-500/10 border border-red-500/20 px-2 py-0.5 rounded-full"><TrendingDown size={9} />Bear {Math.round((1 - score) * 100)}%</span>;
|
| 18 |
+
return <span className="flex items-center gap-1 text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 rounded-full"><Minus size={9} />Neutral</span>;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function SignalBadge({ signal, conviction }: { signal: string; conviction: number }) {
|
| 22 |
+
const map: Record<string, string> = { BUY: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', SELL: 'bg-red-500/15 text-red-300 border-red-500/30', HOLD: 'bg-amber-500/15 text-amber-300 border-amber-500/30', WATCH: 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30' };
|
| 23 |
+
return (
|
| 24 |
+
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-xl border ${map[signal] || map.WATCH}`}>
|
| 25 |
+
<span className="text-[12px] font-semibold">{signal}</span>
|
| 26 |
+
<span className="text-[10px] opacity-70">{Math.round(conviction * 100)}%</span>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// ─── Chart ─────────────────────────────────────────────────
|
| 32 |
+
function CandlestickChart({ symbol, companyName, price, change, changePct, isPositive }: { symbol: string; companyName: string; price?: string; change?: string; changePct?: string; isPositive?: boolean }) {
|
| 33 |
+
const chartContainerRef = useRef<HTMLDivElement>(null);
|
| 34 |
+
const chartRef = useRef<IChartApi | null>(null);
|
| 35 |
+
const seriesRef = useRef<ReturnType<IChartApi['addSeries']> | null>(null);
|
| 36 |
+
const volumeSeriesRef = useRef<ReturnType<IChartApi['addSeries']> | null>(null);
|
| 37 |
+
const [timeframe, setTimeframe] = useState<'1D' | '1W' | '1M' | '3M' | '1Y'>('1M');
|
| 38 |
+
|
| 39 |
+
const generateChartData = useCallback((basePrice: number, tf: string): CandlestickData[] => {
|
| 40 |
+
const data: CandlestickData[] = [];
|
| 41 |
+
let currentPrice = basePrice;
|
| 42 |
+
const volatility = basePrice * 0.02;
|
| 43 |
+
const now = new Date();
|
| 44 |
+
let days = tf === '1D' ? 1 : tf === '1W' ? 7 : tf === '1M' ? 30 : tf === '3M' ? 90 : 365;
|
| 45 |
+
const startDate = new Date(now);
|
| 46 |
+
startDate.setDate(startDate.getDate() - days);
|
| 47 |
+
for (let i = 0; i < days; i++) {
|
| 48 |
+
const date = new Date(startDate);
|
| 49 |
+
date.setDate(date.getDate() + i);
|
| 50 |
+
if (date.getDay() === 0 || date.getDay() === 6) continue;
|
| 51 |
+
const dayVol = volatility * (0.5 + Math.random());
|
| 52 |
+
const open = currentPrice;
|
| 53 |
+
const high = open + Math.random() * dayVol;
|
| 54 |
+
const low = open - Math.random() * dayVol;
|
| 55 |
+
const close = open + (Math.random() - 0.48) * dayVol;
|
| 56 |
+
data.push({ time: date.toISOString().split('T')[0], open: +open.toFixed(2), high: +high.toFixed(2), low: +low.toFixed(2), close: +close.toFixed(2) });
|
| 57 |
+
currentPrice = close;
|
| 58 |
+
}
|
| 59 |
+
return data;
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
const generateVolumeData = useCallback((candleData: CandlestickData[]): HistogramData[] => {
|
| 63 |
+
return candleData.map(c => ({ time: c.time, value: Math.floor(Math.random() * 10000000) + 1000000, color: c.close >= c.open ? 'rgba(34,197,94,0.25)' : 'rgba(239,68,68,0.25)' }));
|
| 64 |
+
}, []);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (!chartContainerRef.current) return;
|
| 68 |
+
const chart = createChart(chartContainerRef.current, {
|
| 69 |
+
layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#6b7280', fontSize: 11 },
|
| 70 |
+
grid: { vertLines: { color: 'rgba(255,255,255,0.02)' }, horzLines: { color: 'rgba(255,255,255,0.02)' } },
|
| 71 |
+
crosshair: { mode: CrosshairMode.Normal },
|
| 72 |
+
rightPriceScale: { borderColor: 'rgba(255,255,255,0.04)', scaleMargins: { top: 0.1, bottom: 0.25 } },
|
| 73 |
+
timeScale: { borderColor: 'rgba(255,255,255,0.04)', timeVisible: false },
|
| 74 |
+
handleScroll: true, handleScale: true,
|
| 75 |
+
});
|
| 76 |
+
const candleSeries = chart.addSeries(CandlestickSeries, { upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444' });
|
| 77 |
+
const volumeSeries = chart.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'volume' });
|
| 78 |
+
chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
|
| 79 |
+
chartRef.current = chart; seriesRef.current = candleSeries; volumeSeriesRef.current = volumeSeries;
|
| 80 |
+
return () => { chart.remove(); chartRef.current = null; seriesRef.current = null; volumeSeriesRef.current = null; };
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
useEffect(() => {
|
| 84 |
+
if (!price || !seriesRef.current || !volumeSeriesRef.current) return;
|
| 85 |
+
const basePrice = parseFloat(price);
|
| 86 |
+
if (isNaN(basePrice)) return;
|
| 87 |
+
const candles = generateChartData(basePrice, timeframe);
|
| 88 |
+
const volumes = generateVolumeData(candles);
|
| 89 |
+
seriesRef.current.setData(candles); volumeSeriesRef.current.setData(volumes);
|
| 90 |
+
if (chartRef.current) chartRef.current.timeScale().fitContent();
|
| 91 |
+
}, [price, timeframe, generateChartData, generateVolumeData]);
|
| 92 |
+
|
| 93 |
+
return (
|
| 94 |
+
<div className="card p-0 overflow-hidden">
|
| 95 |
+
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]">
|
| 96 |
+
<div>
|
| 97 |
+
<div className="flex items-center gap-2"><span className="text-lg font-light text-white">{symbol}</span><span className="text-[13px] text-gray-500">{companyName}</span></div>
|
| 98 |
+
{price && (
|
| 99 |
+
<div className="flex items-center gap-3 mt-1">
|
| 100 |
+
<span className="text-2xl font-light text-white">{parseFloat(price).toFixed(2)}</span>
|
| 101 |
+
{change && changePct && <span className={`text-[13px] ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>{isPositive ? '+' : ''}{parseFloat(change).toFixed(2)} ({changePct})</span>}
|
| 102 |
+
</div>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
<div className="flex items-center gap-1">{(['1D','1W','1M','3M','1Y'] as const).map(tf => (
|
| 106 |
+
<button key={tf} onClick={() => setTimeframe(tf)} className={`px-3 py-1 rounded-lg text-[11px] transition-all ${timeframe === tf ? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/20' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]'}`}>{tf}</button>
|
| 107 |
+
))}</div>
|
| 108 |
+
</div>
|
| 109 |
+
<div className="h-72"><div ref={chartContainerRef} className="w-full h-full" /></div>
|
| 110 |
+
</div>
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// ─── Research Panel ────────────────────────────────────────
|
| 115 |
+
const STAGES = ['Routing to switchboard...', 'Scanning sources...', 'Cross-referencing...', 'Synthesizing...'];
|
| 116 |
+
|
| 117 |
+
// ═══════════════════════════════════════════════════════════
|
| 118 |
+
export default function MarketsPage() {
|
| 119 |
+
const [query, setQuery] = useState('');
|
| 120 |
+
const [searchResults, setSearchResults] = useState<{ symbol: string; name: string; region?: string }[]>([]);
|
| 121 |
+
const [intel, setIntel] = useState<any>(null);
|
| 122 |
+
const [loading, setLoading] = useState(false);
|
| 123 |
+
const [news, setNews] = useState<any[]>([]);
|
| 124 |
+
const [newsLoading, setNewsLoading] = useState(false);
|
| 125 |
+
const [activeSymbol, setActiveSymbol] = useState('');
|
| 126 |
+
const [error, setError] = useState<string | null>(null);
|
| 127 |
+
const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
|
| 128 |
+
const [researchLoading, setResearchLoading] = useState(false);
|
| 129 |
+
const [researchStage, setResearchStage] = useState(0);
|
| 130 |
+
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 131 |
+
const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 132 |
+
|
| 133 |
+
const handleQueryChange = (val: string) => {
|
| 134 |
+
setQuery(val);
|
| 135 |
+
if (searchTimer.current) clearTimeout(searchTimer.current);
|
| 136 |
+
if (!val.trim()) { setSearchResults([]); return; }
|
| 137 |
+
searchTimer.current = setTimeout(async () => {
|
| 138 |
+
try { setSearchResults(await financeClient.searchTicker(val)); } catch { setSearchResults([]); }
|
| 139 |
+
}, 400);
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const loadTicker = useCallback(async (symbol: string) => {
|
| 143 |
+
setLoading(true); setIntel(null); setNews([]); setActiveSymbol(symbol); setSearchResults([]); setError(null); setResearchResult(null);
|
| 144 |
+
try {
|
| 145 |
+
const data = await financeClient.getTickerIntelligence(symbol);
|
| 146 |
+
setIntel(data); setNewsLoading(true);
|
| 147 |
+
try { const nd = await financeClient.analyzeNews(data.company_name || symbol, 8); setNews(nd.articles || []); } catch { /* silent */ } finally { setNewsLoading(false); }
|
| 148 |
+
} catch { setError(`Could not load intelligence for ${symbol}. Ensure ALPHAVANTAGE_API_KEY is set.`); }
|
| 149 |
+
finally { setLoading(false); }
|
| 150 |
+
}, []);
|
| 151 |
+
|
| 152 |
+
const runDeepResearch = async () => {
|
| 153 |
+
if (!intel) return;
|
| 154 |
+
const q = `Analyze ${intel.company_name} (${intel.symbol}) stock. ${intel.overview?.description || ''}`.slice(0, 500);
|
| 155 |
+
setResearchLoading(true); setResearchResult(null); setResearchStage(0);
|
| 156 |
+
if (stageTimer.current) clearInterval(stageTimer.current);
|
| 157 |
+
stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
|
| 158 |
+
try { const res = await apiClient.analyze({ user_input: q }); setResearchResult(res); } catch { /* silent */ }
|
| 159 |
+
finally { setResearchLoading(false); if (stageTimer.current) clearInterval(stageTimer.current); }
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
const price = intel?.quote?.['05. price'];
|
| 163 |
+
const change = intel?.quote?.['09. change'];
|
| 164 |
+
const changePct = intel?.quote?.['10. change percent'];
|
| 165 |
+
const isPositive = change && parseFloat(change) >= 0;
|
| 166 |
+
|
| 167 |
+
const quickTickers = [
|
| 168 |
+
{ s: 'RELIANCE.BSE', l: 'Reliance' },
|
| 169 |
+
{ s: 'TCS.BSE', l: 'TCS' },
|
| 170 |
+
{ s: 'AAPL', l: 'Apple' },
|
| 171 |
+
{ s: 'TSLA', l: 'Tesla' },
|
| 172 |
+
{ s: 'MSFT', l: 'Microsoft' },
|
| 173 |
+
];
|
| 174 |
+
|
| 175 |
+
return (
|
| 176 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 177 |
+
{/* Header */}
|
| 178 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 179 |
+
<div className="flex items-center gap-3 mb-4">
|
| 180 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 181 |
+
<BarChart3 size={16} className="text-indigo-400" />
|
| 182 |
+
</div>
|
| 183 |
+
<div>
|
| 184 |
+
<h1 className="text-lg font-light text-gray-100">Markets</h1>
|
| 185 |
+
<p className="text-[11px] text-gray-600">Ticker intelligence powered by Alpha Vantage + Janus AI</p>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="relative">
|
| 189 |
+
<div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors" style={{ background: 'var(--janus-surface)' }}>
|
| 190 |
+
<Search size={14} className="text-gray-600 shrink-0" />
|
| 191 |
+
<input
|
| 192 |
+
value={query}
|
| 193 |
+
onChange={e => handleQueryChange(e.target.value)}
|
| 194 |
+
onKeyDown={e => { if (e.key === 'Enter') { setSearchResults([]); loadTicker(query.toUpperCase()); } }}
|
| 195 |
+
placeholder="Search — RELIANCE, TCS, AAPL, TSLA..."
|
| 196 |
+
className="flex-1 bg-transparent text-[13px] text-gray-200 placeholder-gray-600 focus:outline-none"
|
| 197 |
+
/>
|
| 198 |
+
{query && (
|
| 199 |
+
<button onClick={() => { setSearchResults([]); loadTicker(query.toUpperCase()); }} className="px-3 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-[11px] transition-colors">
|
| 200 |
+
Analyze
|
| 201 |
+
</button>
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
+
{/* Autocomplete */}
|
| 205 |
+
<AnimatePresence>
|
| 206 |
+
{searchResults.length > 0 && (
|
| 207 |
+
<motion.div initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-full left-0 right-0 mt-1 rounded-xl border border-white/[0.08] overflow-hidden z-20" style={{ background: 'var(--janus-surface)' }}>
|
| 208 |
+
{searchResults.slice(0, 6).map(r => (
|
| 209 |
+
<button key={r.symbol} onClick={() => { setQuery(r.symbol); loadTicker(r.symbol); }}
|
| 210 |
+
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/[0.04] transition-colors text-left">
|
| 211 |
+
<span className="text-[13px] text-indigo-300">{r.symbol}</span>
|
| 212 |
+
<div className="flex items-center gap-3">
|
| 213 |
+
{r.region && <span className="text-[10px] text-gray-600 bg-white/[0.04] px-1.5 py-0.5 rounded">{r.region}</span>}
|
| 214 |
+
<span className="text-[12px] text-gray-500 truncate max-w-[200px]">{r.name}</span>
|
| 215 |
+
</div>
|
| 216 |
+
</button>
|
| 217 |
+
))}
|
| 218 |
+
</motion.div>
|
| 219 |
+
)}
|
| 220 |
+
</AnimatePresence>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
|
| 224 |
+
{/* Content */}
|
| 225 |
+
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
| 226 |
+
{loading && (
|
| 227 |
+
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
| 228 |
+
<RefreshCw size={20} className="text-indigo-400 animate-spin" />
|
| 229 |
+
<p className="text-[12px] text-indigo-400">Fetching intelligence for {activeSymbol}...</p>
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
|
| 233 |
+
{!loading && error && (
|
| 234 |
+
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
| 235 |
+
<AlertTriangle size={24} className="text-amber-500/50" />
|
| 236 |
+
<p className="text-[13px] text-amber-400 max-w-md text-center">{error}</p>
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
|
| 240 |
+
{!loading && !intel && !error && (
|
| 241 |
+
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
| 242 |
+
<BarChart3 size={28} className="text-gray-700" />
|
| 243 |
+
<p className="text-[13px] text-gray-500">Search any stock — Indian or global</p>
|
| 244 |
+
<div className="flex gap-2 mt-3 flex-wrap justify-center">
|
| 245 |
+
{quickTickers.map(({ s, l }) => (
|
| 246 |
+
<button key={s} onClick={() => { setQuery(s); loadTicker(s); }} className="px-3 py-1.5 rounded-full border border-white/[0.06] hover:border-indigo-500/20 text-[12px] text-gray-500 hover:text-indigo-300 transition-all">{l}</button>
|
| 247 |
+
))}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
)}
|
| 251 |
+
|
| 252 |
+
{!loading && intel && (
|
| 253 |
+
<>
|
| 254 |
+
<CandlestickChart symbol={intel.symbol} companyName={intel.company_name} price={price} change={change} changePct={changePct} isPositive={!!isPositive} />
|
| 255 |
+
|
| 256 |
+
{/* Overview card */}
|
| 257 |
+
<div className="card p-5">
|
| 258 |
+
<div className="flex items-start justify-between gap-4 flex-wrap">
|
| 259 |
+
<div>
|
| 260 |
+
<div className="flex items-center gap-3 mb-1">
|
| 261 |
+
<span className="text-xl font-light text-white">{intel.symbol}</span>
|
| 262 |
+
<span className="text-[13px] text-gray-500">{intel.company_name}</span>
|
| 263 |
+
</div>
|
| 264 |
+
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
| 265 |
+
{intel.overview?.sector && <span className="text-[10px] text-gray-500 bg-white/[0.04] px-2 py-0.5 rounded">{intel.overview.sector}</span>}
|
| 266 |
+
{intel.overview?.industry && <span className="text-[10px] text-gray-500 bg-white/[0.04] px-2 py-0.5 rounded">{intel.overview.industry}</span>}
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
<div className="flex flex-col items-end gap-2">
|
| 270 |
+
{intel.ai_signal && <SignalBadge signal={intel.ai_signal.signal} conviction={intel.ai_signal.conviction} />}
|
| 271 |
+
{intel.stance && <StanceChip stance={intel.stance.stance} score={intel.stance.sentiment_score} />}
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
{intel.ai_signal?.reasoning && (
|
| 276 |
+
<div className="mt-4 pt-4 border-t border-white/[0.04]">
|
| 277 |
+
<div className="flex items-center gap-2 mb-1.5 text-[10px] text-indigo-400 uppercase tracking-wider"><Zap size={10} /> AI Signal</div>
|
| 278 |
+
<p className="text-[12px] text-gray-400 leading-relaxed">{intel.ai_signal.reasoning}</p>
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
+
|
| 282 |
+
<div className="mt-4 pt-4 border-t border-white/[0.04]">
|
| 283 |
+
<button onClick={runDeepResearch} disabled={researchLoading} className="flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/15 text-[12px] text-indigo-400 transition-all disabled:opacity-40">
|
| 284 |
+
<Sparkles size={12} className={researchLoading ? 'animate-pulse' : ''} />
|
| 285 |
+
{researchLoading ? 'Running research...' : 'Deep Research — Full Pipeline'}
|
| 286 |
+
</button>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
{/* Research result */}
|
| 291 |
+
{(researchResult || researchLoading) && (
|
| 292 |
+
<div className="card p-5">
|
| 293 |
+
{researchLoading ? (
|
| 294 |
+
<div className="flex items-center gap-3">
|
| 295 |
+
<div className="w-5 h-5 rounded-full border border-indigo-500/30 border-t-indigo-400 animate-spin" />
|
| 296 |
+
<p className="text-[12px] text-indigo-400">{STAGES[researchStage]}</p>
|
| 297 |
+
</div>
|
| 298 |
+
) : researchResult ? (
|
| 299 |
+
<div className="space-y-3">
|
| 300 |
+
<div className="flex items-center gap-2 text-[10px] text-indigo-400 uppercase tracking-wider"><Zap size={11} /> Research Complete</div>
|
| 301 |
+
{researchResult.final_answer && (
|
| 302 |
+
<div className="text-[13px] text-gray-300 leading-relaxed whitespace-pre-wrap">{researchResult.final_answer}</div>
|
| 303 |
+
)}
|
| 304 |
+
</div>
|
| 305 |
+
) : null}
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
{/* Fundamentals + Events grid */}
|
| 310 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 311 |
+
<div className="card p-4 space-y-2">
|
| 312 |
+
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-3">Fundamentals</div>
|
| 313 |
+
{(() => {
|
| 314 |
+
const rows: [string, string | undefined][] = [
|
| 315 |
+
['P/E Ratio', intel.overview?.pe_ratio],
|
| 316 |
+
['Market Cap', intel.overview?.market_cap ? (Number(intel.overview.market_cap) > 1e9 ? `$${(Number(intel.overview.market_cap) / 1e9).toFixed(1)}B` : Number(intel.overview.market_cap) > 1e6 ? `$${(Number(intel.overview.market_cap) / 1e6).toFixed(0)}M` : intel.overview.market_cap) : undefined],
|
| 317 |
+
['52W High', intel.overview?.['52_week_high']],
|
| 318 |
+
['52W Low', intel.overview?.['52_week_low']],
|
| 319 |
+
['Analyst Target', intel.overview?.analyst_target],
|
| 320 |
+
['Volume', intel.quote?.['06. volume'] ? Number(intel.quote['06. volume']).toLocaleString() : undefined],
|
| 321 |
+
['Previous Close', intel.quote?.['08. previous close']],
|
| 322 |
+
['Day High', intel.quote?.['03. high']],
|
| 323 |
+
['Day Low', intel.quote?.['04. low']],
|
| 324 |
+
['Open', intel.quote?.['02. open']],
|
| 325 |
+
];
|
| 326 |
+
const validRows = rows.filter(([, v]) => v && v !== 'None' && v !== '-' && v !== 'N/A' && v !== '0' && v !== 'undefined');
|
| 327 |
+
return validRows.length > 0 ? (
|
| 328 |
+
validRows.map(([k, v]) => (
|
| 329 |
+
<div key={k} className="flex justify-between text-[12px] border-b border-white/[0.03] pb-1.5">
|
| 330 |
+
<span className="text-gray-500">{k}</span><span className="text-gray-300">{v}</span>
|
| 331 |
+
</div>
|
| 332 |
+
))
|
| 333 |
+
) : (
|
| 334 |
+
<div className="text-center py-6">
|
| 335 |
+
<p className="text-[12px] text-gray-600">No fundamental data available.</p>
|
| 336 |
+
<p className="text-[10px] text-gray-700 mt-1">Ticker may not be covered by Alpha Vantage.</p>
|
| 337 |
+
</div>
|
| 338 |
+
);
|
| 339 |
+
})()}
|
| 340 |
+
</div>
|
| 341 |
+
<div className="card p-4 space-y-3">
|
| 342 |
+
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-3">Event Intelligence</div>
|
| 343 |
+
{intel.event_impact && intel.event_impact.event_count > 0 ? (
|
| 344 |
+
<>
|
| 345 |
+
<div className="text-[12px]"><span className="text-gray-500">Impact: </span><span className={intel.event_impact.impact_level === 'high' || intel.event_impact.impact_level === 'very_high' ? 'text-amber-400' : 'text-gray-300'}>{intel.event_impact.impact_level}</span></div>
|
| 346 |
+
<div className="text-[12px]"><span className="text-gray-500">Volatility: </span><span className={intel.event_impact.volatility_level === 'high' || intel.event_impact.volatility_level === 'very_high' ? 'text-red-400' : 'text-gray-300'}>{intel.event_impact.volatility_level}</span></div>
|
| 347 |
+
<div className="text-[12px]"><span className="text-gray-500">Events: </span><span className="text-gray-300">{intel.event_impact.event_count} detected</span></div>
|
| 348 |
+
{intel.event_impact.detected_events && intel.event_impact.detected_events.length > 0 && (
|
| 349 |
+
<div className="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-white/[0.04]">
|
| 350 |
+
{intel.event_impact.detected_events.map((ev: any, i: number) => (
|
| 351 |
+
<span key={i} className="px-2 py-0.5 rounded text-[9px] text-amber-300 bg-amber-500/10 border border-amber-500/15 uppercase tracking-wider">
|
| 352 |
+
{ev.event_type?.replace(/_/g, ' ')}
|
| 353 |
+
</span>
|
| 354 |
+
))}
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
</>
|
| 358 |
+
) : (
|
| 359 |
+
<>
|
| 360 |
+
<div className="text-[12px]"><span className="text-gray-500">Impact: </span><span className="text-emerald-400">Low</span></div>
|
| 361 |
+
<div className="text-[12px]"><span className="text-gray-500">Volatility: </span><span className="text-emerald-400">Low</span></div>
|
| 362 |
+
<div className="text-[12px]"><span className="text-gray-500">Events: </span><span className="text-gray-300">None detected</span></div>
|
| 363 |
+
<p className="text-[10px] text-gray-700 mt-2 pt-2 border-t border-white/[0.04]">
|
| 364 |
+
No significant market events detected in recent news for this ticker. This usually indicates stable conditions.
|
| 365 |
+
</p>
|
| 366 |
+
</>
|
| 367 |
+
)}
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
|
| 371 |
+
{/* News */}
|
| 372 |
+
{(news.length > 0 || newsLoading) && (
|
| 373 |
+
<div>
|
| 374 |
+
<div className="flex items-center gap-2 text-[11px] text-gray-500 uppercase tracking-wider mb-3">
|
| 375 |
+
<Scan size={11} className="text-indigo-400" /> News
|
| 376 |
+
{newsLoading && <RefreshCw size={10} className="animate-spin text-indigo-400" />}
|
| 377 |
+
</div>
|
| 378 |
+
<div className="space-y-3">
|
| 379 |
+
{news.map((a: any, i: number) => (
|
| 380 |
+
<div key={`${a.title}-${i}`} className="card p-3 hover:border-white/[0.12]">
|
| 381 |
+
<p className="text-[13px] text-gray-300 leading-snug mb-1">{a.title}</p>
|
| 382 |
+
{a.description && <p className="text-[11px] text-gray-600 line-clamp-1">{a.description}</p>}
|
| 383 |
+
<div className="flex items-center gap-3 mt-2">
|
| 384 |
+
{a.source && <span className="text-[10px] text-gray-600">{a.source}</span>}
|
| 385 |
+
{a.url && <a href={a.url} target="_blank" rel="noreferrer" className="text-[10px] text-indigo-400 hover:text-indigo-300 flex items-center gap-0.5"><ExternalLink size={8} /> Read</a>}
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
))}
|
| 389 |
+
</div>
|
| 390 |
+
</div>
|
| 391 |
+
)}
|
| 392 |
+
</>
|
| 393 |
+
)}
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
);
|
| 397 |
+
}
|
frontend/src/app/page.tsx
CHANGED
|
@@ -2,950 +2,386 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
-
import {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
// ─── Types ───────────────────────────────────────────────────
|
| 21 |
-
interface DaemonStatus {
|
| 22 |
-
running: boolean;
|
| 23 |
-
cycle_count: number;
|
| 24 |
-
last_run: string;
|
| 25 |
-
circadian: {
|
| 26 |
-
current_phase: string;
|
| 27 |
-
phase_name: string;
|
| 28 |
-
phase_description: string;
|
| 29 |
-
priority: string;
|
| 30 |
-
current_tasks: string[];
|
| 31 |
-
};
|
| 32 |
-
signal_queue?: {
|
| 33 |
-
total_signals: number;
|
| 34 |
-
severity_counts: Record<string, number>;
|
| 35 |
-
type_counts: Record<string, number>;
|
| 36 |
};
|
| 37 |
-
signals?: number;
|
| 38 |
-
watchlist?: string[];
|
| 39 |
-
topics?: string[];
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
interface Alert {
|
| 43 |
-
type: string;
|
| 44 |
-
title: string;
|
| 45 |
-
description: string;
|
| 46 |
-
source: string;
|
| 47 |
-
severity: string;
|
| 48 |
-
sentiment: string;
|
| 49 |
-
timestamp: string;
|
| 50 |
-
url?: string;
|
| 51 |
}
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
entities: number;
|
| 56 |
-
insights: number;
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
// ─── Nav Items ───────────────────────────────────────────────
|
| 60 |
-
const navItems = [
|
| 61 |
-
{ id: 'command', label: 'Command', icon: Sparkles, section: 'Main' },
|
| 62 |
-
{ id: 'intel', label: 'Intel Stream', icon: Globe, section: 'Main' },
|
| 63 |
-
{ id: 'markets', label: 'Markets', icon: BarChart3, section: 'Main' },
|
| 64 |
-
{ id: 'workspace', label: 'Workspace', icon: Layers, section: 'Main' },
|
| 65 |
-
{ id: 'pulse', label: 'Pulse', icon: PulseIcon, section: 'Main' },
|
| 66 |
-
{ id: 'cases', label: 'Cases', icon: GitBranch, section: 'System' },
|
| 67 |
-
{ id: 'simulation', label: 'Simulations', icon: Zap, section: 'System' },
|
| 68 |
-
{ id: 'sentinel', label: 'Sentinel', icon: Shield, section: 'System' },
|
| 69 |
-
{ id: 'prompts', label: 'Prompt Lab', icon: Terminal, section: 'System' },
|
| 70 |
-
{ id: 'config', label: 'Config', icon: Cpu, section: 'System' },
|
| 71 |
-
];
|
| 72 |
-
|
| 73 |
-
// ─── Janus Orb ───────────────────────────────────────────────
|
| 74 |
-
function JanusOrb({ size = 32, thinking = false, phase = 'daytime' }: { size?: number; thinking?: boolean; phase?: string }) {
|
| 75 |
-
const phaseColors: Record<string, { from: string; to: string; shadow: string }> = {
|
| 76 |
-
morning: { from: '#fbbf24', to: '#f59e0b', shadow: 'rgba(251,191,36,0.4)' },
|
| 77 |
-
daytime: { from: '#818cf8', to: '#4f46e5', shadow: 'rgba(99,102,241,0.4)' },
|
| 78 |
-
evening: { from: '#a78bfa', to: '#7c3aed', shadow: 'rgba(167,139,250,0.4)' },
|
| 79 |
-
night: { from: '#6366f1', to: '#312e81', shadow: 'rgba(99,102,241,0.3)' },
|
| 80 |
-
};
|
| 81 |
-
const colors = phaseColors[phase] || phaseColors.daytime;
|
| 82 |
return (
|
| 83 |
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
| 84 |
-
<motion.div
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
);
|
| 93 |
}
|
| 94 |
|
| 95 |
-
// ───
|
| 96 |
-
function
|
| 97 |
-
const [show, setShow] = useState(false);
|
| 98 |
-
useEffect(() => { const t = setTimeout(() => setShow(true), 2000); return () => clearTimeout(t); }, []);
|
| 99 |
-
return (
|
| 100 |
-
<motion.div key="art" exit={{ opacity: 0, scale: 1.05, filter: 'blur(30px)' }} transition={{ duration: 1.5 }}
|
| 101 |
-
className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-950 overflow-hidden">
|
| 102 |
-
<motion.div className="absolute w-[500px] h-[500px] rounded-full"
|
| 103 |
-
style={{ background: 'radial-gradient(circle, rgba(99,102,241,0.12) 0%, rgba(139,92,246,0.06) 40%, transparent 70%)' }}
|
| 104 |
-
animate={{ scale: [1, 1.2, 1], opacity: [0.5, 0.8, 0.5] }} transition={{ duration: 6, repeat: Infinity }} />
|
| 105 |
-
<motion.div initial={{ opacity: 0, scale: 0 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }} className="relative z-10 mb-10">
|
| 106 |
-
<JanusOrb size={64} thinking />
|
| 107 |
-
</motion.div>
|
| 108 |
-
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.8, duration: 1.5 }} className="relative z-10 text-center">
|
| 109 |
-
<h1 className="text-7xl font-extralight tracking-[0.3em] mb-4 bg-gradient-to-r from-indigo-400 via-violet-400 to-indigo-400 bg-clip-text text-transparent">JANUS</h1>
|
| 110 |
-
<motion.div initial={{ opacity: 0, width: 0 }} animate={{ opacity: 1, width: '100%' }} transition={{ delay: 1.5, duration: 2 }}
|
| 111 |
-
className="h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent mb-6" />
|
| 112 |
-
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 0.5 }} transition={{ delay: 2, duration: 1.5 }}
|
| 113 |
-
className="font-mono text-xs tracking-[0.4em] text-gray-400 uppercase">living intelligence system</motion.p>
|
| 114 |
-
</motion.div>
|
| 115 |
-
<AnimatePresence>
|
| 116 |
-
{show && (
|
| 117 |
-
<motion.button initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.8 }} onClick={onUnlock} className="relative z-10 mt-16 group">
|
| 118 |
-
<div className="relative px-10 py-3.5 rounded-full border border-white/10 bg-white/[0.03] backdrop-blur-sm overflow-hidden transition-all duration-500 group-hover:border-indigo-500/30 group-hover:bg-white/[0.06]">
|
| 119 |
-
<span className="relative z-10 text-xs tracking-[0.3em] uppercase text-gray-300 group-hover:text-white transition-colors">Initialize System</span>
|
| 120 |
-
<div className="absolute inset-0 bg-gradient-to-r from-indigo-600/10 to-violet-600/10 translate-y-full group-hover:translate-y-0 transition-transform duration-700" />
|
| 121 |
-
</div>
|
| 122 |
-
</motion.button>
|
| 123 |
-
)}
|
| 124 |
-
</AnimatePresence>
|
| 125 |
-
</motion.div>
|
| 126 |
-
);
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
// ─── Typewriter ──────────────────────────────────────────────
|
| 130 |
-
function Typewriter({ text, speed = 10 }: { text: string; speed?: number }) {
|
| 131 |
const [displayed, setDisplayed] = useState('');
|
| 132 |
const idx = useRef(0);
|
|
|
|
|
|
|
| 133 |
useEffect(() => {
|
| 134 |
-
idx.current = 0;
|
|
|
|
|
|
|
|
|
|
| 135 |
const iv = setInterval(() => {
|
| 136 |
-
if (idx.current < text.length) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
}, speed);
|
| 138 |
return () => clearInterval(iv);
|
| 139 |
-
}, [text, speed]);
|
| 140 |
-
return <span>{displayed}{displayed.length < text.length && <span className="inline-block w-0.5 h-4 bg-indigo-400 ml-0.5 animate-pulse" />}</span>;
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
// ─── Thinking ────────────────────────────────────────────────
|
| 144 |
-
const STAGES = ['Routing to switchboard...', 'Research agent scanning sources...', 'Cross-referencing databases...', 'Synthesizer composing analysis...'];
|
| 145 |
-
function ThinkingDisplay({ stage }: { stage: string }) {
|
| 146 |
-
return (
|
| 147 |
-
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center justify-center py-16 gap-6">
|
| 148 |
-
<JanusOrb size={56} thinking />
|
| 149 |
-
<div className="text-center mt-4">
|
| 150 |
-
<motion.p key={stage} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="text-sm font-mono text-indigo-300">{stage}</motion.p>
|
| 151 |
-
<div className="flex items-center justify-center gap-1.5 mt-3">
|
| 152 |
-
{[0,1,2,3,4].map(i => <motion.div key={i} className="w-1 h-1 rounded-full bg-indigo-400" animate={{ opacity: [0.2, 1, 0.2], scale: [0.8, 1.2, 0.8] }} transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.15 }} />)}
|
| 153 |
-
</div>
|
| 154 |
-
</div>
|
| 155 |
-
</motion.div>
|
| 156 |
-
);
|
| 157 |
-
}
|
| 158 |
|
| 159 |
-
// ─── Confidence Ring ─────────────────────────────────────────
|
| 160 |
-
function ConfidenceRing({ value, label }: { value: number; label: string }) {
|
| 161 |
-
const pct = Math.round(value * 100);
|
| 162 |
-
const circ = 2 * Math.PI * 18;
|
| 163 |
return (
|
| 164 |
-
<
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-gray-300">{pct}</span>
|
| 171 |
-
</div>
|
| 172 |
-
<span className="text-[9px] font-mono text-gray-500 uppercase tracking-wider">{label}</span>
|
| 173 |
-
</div>
|
| 174 |
);
|
| 175 |
}
|
| 176 |
|
| 177 |
-
// ───
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
function SignalBadge({ signal, conviction }: { signal: string; conviction: number }) {
|
| 185 |
-
const map: Record<string, string> = { BUY: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40', SELL: 'bg-red-500/20 text-red-300 border-red-500/40', HOLD: 'bg-amber-500/20 text-amber-300 border-amber-500/40', WATCH: 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40' };
|
| 186 |
-
return <div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-xl border ${map[signal] || map.WATCH}`}><span className="text-sm font-mono font-bold">{signal}</span><span className="text-[10px] font-mono opacity-70">{Math.round(conviction * 100)}% conviction</span></div>;
|
| 187 |
-
}
|
| 188 |
-
|
| 189 |
-
function SeverityBadge({ severity }: { severity: string }) {
|
| 190 |
-
const map: Record<string, string> = { critical: 'bg-red-500/20 text-red-300 border-red-500/30', high: 'bg-orange-500/20 text-orange-300 border-orange-500/30', medium: 'bg-amber-500/20 text-amber-300 border-amber-500/30', low: 'bg-gray-500/20 text-gray-400 border-gray-500/30' };
|
| 191 |
-
return <span className={`px-2 py-0.5 rounded border text-[9px] font-mono uppercase tracking-wider ${map[severity] || map.low}`}>{severity}</span>;
|
| 192 |
-
}
|
| 193 |
|
| 194 |
-
|
| 195 |
-
function ResearchPanel({ result, loading, stage }: { result: CaseRecord | null; loading: boolean; stage: string }) {
|
| 196 |
-
if (loading) return (
|
| 197 |
-
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="glass rounded-2xl p-6 border border-indigo-500/20 space-y-4">
|
| 198 |
-
<div className="flex items-center gap-3">
|
| 199 |
-
<JanusOrb size={28} thinking />
|
| 200 |
-
<div>
|
| 201 |
-
<p className="text-xs font-mono text-indigo-300">{stage}</p>
|
| 202 |
-
<div className="flex gap-1 mt-1.5">{[0,1,2,3,4].map(i => <motion.div key={i} className="w-1 h-1 rounded-full bg-indigo-400" animate={{ opacity: [0.2,1,0.2] }} transition={{ duration: 1.2, repeat: Infinity, delay: i*0.15 }} />)}</div>
|
| 203 |
-
</div>
|
| 204 |
-
</div>
|
| 205 |
-
</motion.div>
|
| 206 |
-
);
|
| 207 |
-
if (!result) return null;
|
| 208 |
return (
|
| 209 |
-
<motion.div
|
| 210 |
-
|
| 211 |
-
{
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
</div>
|
| 220 |
-
|
| 221 |
-
{result.final_answer && (<div className="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap font-mono"><Typewriter text={result.final_answer} speed={6} /></div>)}
|
| 222 |
</motion.div>
|
| 223 |
);
|
| 224 |
}
|
| 225 |
|
| 226 |
-
// ───
|
| 227 |
-
function
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
<p className="text-
|
| 239 |
-
{article.description && <p className="text-xs text-gray-500 leading-relaxed line-clamp-2">{article.description}</p>}
|
| 240 |
</div>
|
| 241 |
-
</div>
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/40 border border-indigo-500/30 text-[10px] font-mono text-indigo-300 transition-colors uppercase tracking-wider">
|
| 245 |
-
<Sparkles size={10} /> Deep Research
|
| 246 |
-
</button>
|
| 247 |
-
{article.url && (<a href={article.url} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-[10px] font-mono text-gray-600 hover:text-gray-400 transition-colors"><ExternalLink size={10} /> Source</a>)}
|
| 248 |
-
</div>
|
| 249 |
-
</motion.div>
|
| 250 |
-
);
|
| 251 |
-
}
|
| 252 |
|
| 253 |
-
// ─── Alert Card ──────────────────────────────────────────────
|
| 254 |
-
function AlertCard({ alert, index }: { alert: any; index: number }) {
|
| 255 |
return (
|
| 256 |
-
<motion.div
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
</div>
|
| 273 |
</div>
|
| 274 |
</motion.div>
|
| 275 |
);
|
| 276 |
}
|
| 277 |
|
| 278 |
-
// ───
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
const [loading, setLoading] = useState(false);
|
| 286 |
-
|
| 287 |
-
const generateChartData = useCallback((basePrice: number, tf: string): CandlestickData[] => {
|
| 288 |
-
const data: CandlestickData[] = [];
|
| 289 |
-
let currentPrice = basePrice;
|
| 290 |
-
const volatility = basePrice * 0.02;
|
| 291 |
-
const now = new Date();
|
| 292 |
-
let days = tf === '1D' ? 1 : tf === '1W' ? 7 : tf === '1M' ? 30 : tf === '3M' ? 90 : 365;
|
| 293 |
-
const startDate = new Date(now);
|
| 294 |
-
startDate.setDate(startDate.getDate() - days);
|
| 295 |
-
for (let i = 0; i < days; i++) {
|
| 296 |
-
const date = new Date(startDate);
|
| 297 |
-
date.setDate(date.getDate() + i);
|
| 298 |
-
if (date.getDay() === 0 || date.getDay() === 6) continue;
|
| 299 |
-
const dayVol = volatility * (0.5 + Math.random());
|
| 300 |
-
const open = currentPrice;
|
| 301 |
-
const high = open + Math.random() * dayVol;
|
| 302 |
-
const low = open - Math.random() * dayVol;
|
| 303 |
-
const close = open + (Math.random() - 0.48) * dayVol;
|
| 304 |
-
data.push({ time: date.toISOString().split('T')[0], open: parseFloat(open.toFixed(2)), high: parseFloat(high.toFixed(2)), low: parseFloat(low.toFixed(2)), close: parseFloat(close.toFixed(2)) });
|
| 305 |
-
currentPrice = close;
|
| 306 |
-
}
|
| 307 |
-
return data;
|
| 308 |
-
}, []);
|
| 309 |
-
|
| 310 |
-
const generateVolumeData = useCallback((candleData: CandlestickData[]): HistogramData[] => {
|
| 311 |
-
return candleData.map(candle => ({ time: candle.time, value: Math.floor(Math.random() * 10000000) + 1000000, color: candle.close >= candle.open ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)' }));
|
| 312 |
-
}, []);
|
| 313 |
-
|
| 314 |
-
useEffect(() => {
|
| 315 |
-
if (!chartContainerRef.current) return;
|
| 316 |
-
const chart = createChart(chartContainerRef.current, {
|
| 317 |
-
layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: '#9ca3af', fontSize: 11 },
|
| 318 |
-
grid: { vertLines: { color: 'rgba(255, 255, 255, 0.03)' }, horzLines: { color: 'rgba(255, 255, 255, 0.03)' } },
|
| 319 |
-
crosshair: { mode: CrosshairMode.Normal },
|
| 320 |
-
rightPriceScale: { borderColor: 'rgba(255, 255, 255, 0.05)', scaleMargins: { top: 0.1, bottom: 0.25 } },
|
| 321 |
-
timeScale: { borderColor: 'rgba(255, 255, 255, 0.05)', timeVisible: false },
|
| 322 |
-
handleScroll: true, handleScale: true,
|
| 323 |
-
});
|
| 324 |
-
const candleSeries = chart.addSeries(CandlestickSeries, { upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444' });
|
| 325 |
-
const volumeSeries = chart.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'volume' });
|
| 326 |
-
chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
|
| 327 |
-
chartRef.current = chart; seriesRef.current = candleSeries; volumeSeriesRef.current = volumeSeries;
|
| 328 |
-
return () => { chart.remove(); chartRef.current = null; seriesRef.current = null; volumeSeriesRef.current = null; };
|
| 329 |
-
}, []);
|
| 330 |
-
|
| 331 |
-
useEffect(() => {
|
| 332 |
-
if (!price || !seriesRef.current || !volumeSeriesRef.current) return;
|
| 333 |
-
const basePrice = parseFloat(price);
|
| 334 |
-
if (isNaN(basePrice)) return;
|
| 335 |
-
setLoading(true);
|
| 336 |
-
const candles = generateChartData(basePrice, timeframe);
|
| 337 |
-
const volumes = generateVolumeData(candles);
|
| 338 |
-
seriesRef.current.setData(candles); volumeSeriesRef.current.setData(volumes);
|
| 339 |
-
if (chartRef.current) chartRef.current.timeScale().fitContent();
|
| 340 |
-
setLoading(false);
|
| 341 |
-
}, [price, timeframe, generateChartData, generateVolumeData]);
|
| 342 |
|
|
|
|
| 343 |
return (
|
| 344 |
-
<
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
<
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
<
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
</div>
|
| 361 |
-
</div>
|
| 362 |
);
|
| 363 |
}
|
| 364 |
|
| 365 |
// ═══════════════════════════════════════════════════════════
|
| 366 |
-
//
|
| 367 |
// ═══════════════════════════════════════════════════════════
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
| 371 |
-
const [thinkingStage, setThinkingStage] = useState(0);
|
| 372 |
-
const [result, setResult] = useState<CaseRecord | null>(null);
|
| 373 |
const [input, setInput] = useState('');
|
| 374 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
|
|
|
| 376 |
useEffect(() => {
|
| 377 |
-
if (
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
}, [
|
| 381 |
-
|
| 382 |
-
const handleAnalyze = useCallback(async (q: string) => {
|
| 383 |
-
if (!q.trim()) return;
|
| 384 |
-
setIsAnalyzing(true); setResult(null); setThinkingStage(0);
|
| 385 |
-
try {
|
| 386 |
-
const res = await apiClient.analyze({ user_input: q });
|
| 387 |
-
setResult(res);
|
| 388 |
-
setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 200);
|
| 389 |
-
} catch { /* silent */ } finally { setIsAnalyzing(false); }
|
| 390 |
-
}, []);
|
| 391 |
-
|
| 392 |
-
return (
|
| 393 |
-
<div className="h-full flex flex-col">
|
| 394 |
-
<div className="relative group shrink-0">
|
| 395 |
-
<div className="absolute -inset-px rounded-2xl bg-gradient-to-r from-indigo-500/20 via-transparent to-violet-500/20 opacity-0 group-focus-within:opacity-100 transition-opacity duration-500" />
|
| 396 |
-
<div className="relative flex items-center gap-3 px-5 py-4 rounded-2xl glass border border-white/[0.06] group-focus-within:border-indigo-500/20 transition-colors">
|
| 397 |
-
<Sparkles size={16} className="text-indigo-400/50 shrink-0" />
|
| 398 |
-
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAnalyze(input)} disabled={isAnalyzing}
|
| 399 |
-
placeholder="Ask Janus anything — analysis, research, market intelligence..."
|
| 400 |
-
className="flex-1 bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none disabled:opacity-40 font-mono" />
|
| 401 |
-
<motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} onClick={() => handleAnalyze(input)} disabled={isAnalyzing || !input.trim()}
|
| 402 |
-
className="p-2.5 rounded-xl bg-indigo-600/80 hover:bg-indigo-500 disabled:bg-gray-800 disabled:text-gray-600 text-white transition-all duration-200">
|
| 403 |
-
<Send size={14} />
|
| 404 |
-
</motion.button>
|
| 405 |
-
</div>
|
| 406 |
-
</div>
|
| 407 |
-
<div className="flex-1 mt-5 overflow-y-auto rounded-2xl">
|
| 408 |
-
<AnimatePresence mode="wait">
|
| 409 |
-
{isAnalyzing ? (<ThinkingDisplay stage={STAGES[thinkingStage]} />) : result ? (
|
| 410 |
-
<motion.div key="result" ref={resultRef} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-4 pb-8">
|
| 411 |
-
{result.route && (<div className="flex items-center gap-2 flex-wrap">
|
| 412 |
-
<span className="px-2.5 py-1 rounded-full text-[10px] font-mono uppercase tracking-wider bg-indigo-500/10 text-indigo-300 border border-indigo-500/20">{result.route.domain_pack}</span>
|
| 413 |
-
<span className="px-2.5 py-1 rounded-full text-[10px] font-mono uppercase tracking-wider bg-white/5 text-gray-400 border border-white/10">{result.route.execution_mode}</span>
|
| 414 |
-
<span className="px-2.5 py-1 rounded-full text-[10px] font-mono uppercase tracking-wider bg-white/5 text-gray-400 border border-white/10">{result.route.complexity}</span>
|
| 415 |
-
</div>)}
|
| 416 |
-
{result.outputs && result.outputs.filter((o: any) => o.confidence > 0).length > 0 && (
|
| 417 |
-
<div className="flex items-center gap-6 py-3">{result.outputs.filter((o: any) => o.confidence > 0).map((o: any, idx: number) => (<ConfidenceRing key={`${o.agent || 'output'}-${idx}`} value={o.confidence} label={o.agent || `Agent ${idx + 1}`} />))}</div>
|
| 418 |
-
)}
|
| 419 |
-
{result.final_answer && (<div className="glass rounded-2xl p-6"><div className="flex items-center gap-2 mb-4 text-xs font-mono text-indigo-400/70 uppercase tracking-wider"><Zap size={12} /><span>Synthesis Complete</span></div><div className="prose prose-invert max-w-none text-sm leading-relaxed text-gray-200 whitespace-pre-wrap"><Typewriter text={result.final_answer} speed={8} /></div></div>)}
|
| 420 |
-
</motion.div>
|
| 421 |
-
) : (
|
| 422 |
-
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="h-full flex flex-col items-center justify-center text-center py-20">
|
| 423 |
-
<JanusOrb size={48} />
|
| 424 |
-
<p className="mt-6 text-sm text-gray-500 font-mono">Awaiting directive...</p>
|
| 425 |
-
<p className="mt-2 text-xs text-gray-700 max-w-md">Multi-agent pipeline: switchboard → research → synthesizer.</p>
|
| 426 |
-
<div className="flex flex-wrap justify-center gap-2 mt-8 max-w-lg">
|
| 427 |
-
{['Analyze RBI rate hike impact on Indian markets', 'What happens to $NVDA if AI spending slows?', 'Compare Reliance vs TCS as long-term investments'].map(q => (
|
| 428 |
-
<button key={q} onClick={() => { setInput(q); handleAnalyze(q); }} className="px-3 py-1.5 rounded-full text-xs font-mono text-gray-500 border border-white/5 hover:border-indigo-500/20 hover:text-indigo-300 transition-all text-left">{q}</button>
|
| 429 |
-
))}
|
| 430 |
-
</div>
|
| 431 |
-
</motion.div>
|
| 432 |
-
)}
|
| 433 |
-
</AnimatePresence>
|
| 434 |
-
</div>
|
| 435 |
-
</div>
|
| 436 |
-
);
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
function IntelStreamTab() {
|
| 440 |
-
const [headlines, setHeadlines] = useState<any[]>([]);
|
| 441 |
-
const [query, setQuery] = useState('');
|
| 442 |
-
const [loading, setLoading] = useState(false);
|
| 443 |
-
const [searched, setSearched] = useState(false);
|
| 444 |
-
const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
|
| 445 |
-
const [researchLoading, setResearchLoading] = useState(false);
|
| 446 |
-
const [researchStage, setResearchStage] = useState(0);
|
| 447 |
-
const [researchQuery, setResearchQuery] = useState('');
|
| 448 |
-
const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 449 |
-
|
| 450 |
-
useEffect(() => { financeClient.getHeadlines().then(data => setHeadlines(data)).catch(() => {}); }, []);
|
| 451 |
-
|
| 452 |
-
const searchNews = async () => {
|
| 453 |
-
if (!query.trim()) return;
|
| 454 |
-
setLoading(true); setSearched(true); setResearchResult(null);
|
| 455 |
-
try { const data = await financeClient.analyzeNews(query, 10); setHeadlines(data.articles || []); } catch { /* silent */ } finally { setLoading(false); }
|
| 456 |
-
};
|
| 457 |
-
|
| 458 |
-
const runResearch = async (articleText: string) => {
|
| 459 |
-
setResearchLoading(true); setResearchResult(null); setResearchQuery(articleText.slice(0, 80));
|
| 460 |
-
setResearchStage(0);
|
| 461 |
-
if (stageTimer.current) clearInterval(stageTimer.current);
|
| 462 |
-
stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
|
| 463 |
-
try { const res = await apiClient.analyze({ user_input: articleText }); setResearchResult(res); } catch { /* silent */ } finally { setResearchLoading(false); if (stageTimer.current) clearInterval(stageTimer.current); }
|
| 464 |
-
};
|
| 465 |
-
|
| 466 |
-
return (
|
| 467 |
-
<div className="h-full flex gap-5 overflow-hidden">
|
| 468 |
-
<div className="flex flex-col gap-3 overflow-hidden" style={{ width: researchResult || researchLoading ? '45%' : '100%', transition: 'width 0.4s ease' }}>
|
| 469 |
-
<div className="flex gap-2 shrink-0">
|
| 470 |
-
<div className="flex-1 flex items-center gap-3 px-4 py-3 glass rounded-2xl border border-white/[0.06] focus-within:border-indigo-500/30 transition-colors">
|
| 471 |
-
<Globe size={14} className="text-gray-500 shrink-0" />
|
| 472 |
-
<input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && searchNews()} placeholder="Search news — company, topic, event..." className="flex-1 bg-transparent text-sm text-gray-200 placeholder-gray-600 font-mono focus:outline-none" />
|
| 473 |
-
</div>
|
| 474 |
-
<button onClick={searchNews} disabled={loading} className="px-4 py-2 glass rounded-2xl border border-white/[0.06] hover:border-indigo-500/30 text-xs font-mono text-gray-400 hover:text-indigo-300 transition-all disabled:opacity-40">{loading ? <RefreshCw size={13} className="animate-spin" /> : 'Search'}</button>
|
| 475 |
-
</div>
|
| 476 |
-
<div className="text-[10px] font-mono text-gray-600 uppercase tracking-wider shrink-0">{searched ? `"${query}"` : 'Top Business Headlines'} — click Deep Research on any article</div>
|
| 477 |
-
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
| 478 |
-
{loading && (<div className="flex flex-col items-center justify-center py-12 gap-4"><JanusOrb size={36} thinking /><p className="text-xs font-mono text-indigo-400 animate-pulse">Fetching articles...</p></div>)}
|
| 479 |
-
{!loading && headlines.map((a, i) => (<ArticleCard key={`${a.title}-${i}`} article={a} index={i} onResearch={runResearch} />))}
|
| 480 |
-
{!loading && headlines.length === 0 && (<div className="flex flex-col items-center justify-center py-16 text-center"><Globe size={28} className="text-gray-700 mb-3" /><p className="text-sm font-mono text-gray-500">No articles found.</p></div>)}
|
| 481 |
-
</div>
|
| 482 |
-
</div>
|
| 483 |
-
<AnimatePresence>
|
| 484 |
-
{(researchResult || researchLoading) && (
|
| 485 |
-
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} className="flex-1 flex flex-col gap-3 overflow-hidden">
|
| 486 |
-
<div className="flex items-center justify-between shrink-0">
|
| 487 |
-
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider truncate max-w-[80%]">Research: {researchQuery}...</div>
|
| 488 |
-
<button onClick={() => { setResearchResult(null); setResearchLoading(false); }} className="text-[10px] font-mono text-gray-600 hover:text-gray-400 transition-colors">✕ Close</button>
|
| 489 |
-
</div>
|
| 490 |
-
<div className="flex-1 overflow-y-auto"><ResearchPanel result={researchResult} loading={researchLoading} stage={STAGES[researchStage]} /></div>
|
| 491 |
-
</motion.div>
|
| 492 |
-
)}
|
| 493 |
-
</AnimatePresence>
|
| 494 |
-
</div>
|
| 495 |
-
);
|
| 496 |
-
}
|
| 497 |
-
|
| 498 |
-
function MarketsTab() {
|
| 499 |
-
const [query, setQuery] = useState('');
|
| 500 |
-
const [searchResults, setSearchResults] = useState<{ symbol: string; name: string; region?: string }[]>([]);
|
| 501 |
-
const [intel, setIntel] = useState<any>(null);
|
| 502 |
-
const [loading, setLoading] = useState(false);
|
| 503 |
-
const [newsLoading, setNewsLoading] = useState(false);
|
| 504 |
-
const [news, setNews] = useState<any[]>([]);
|
| 505 |
-
const [activeSymbol, setActiveSymbol] = useState('');
|
| 506 |
-
const [error, setError] = useState<string | null>(null);
|
| 507 |
-
const [selectedRegion, setSelectedRegion] = useState('');
|
| 508 |
-
const [researchResult, setResearchResult] = useState<CaseRecord | null>(null);
|
| 509 |
-
const [researchLoading, setResearchLoading] = useState(false);
|
| 510 |
-
const [researchStage, setResearchStage] = useState(0);
|
| 511 |
-
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 512 |
-
const stageTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 513 |
-
|
| 514 |
-
const handleQueryChange = (val: string) => {
|
| 515 |
-
setQuery(val);
|
| 516 |
-
if (searchTimer.current) clearTimeout(searchTimer.current);
|
| 517 |
-
if (!val.trim()) { setSearchResults([]); return; }
|
| 518 |
-
searchTimer.current = setTimeout(async () => { try { setSearchResults(await financeClient.searchTicker(val)); } catch { setSearchResults([]); } }, 400);
|
| 519 |
-
};
|
| 520 |
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
} catch { setError(`Could not load intelligence for ${symbol}. Ensure ALPHAVANTAGE_API_KEY is set in backend/.env`); } finally { setLoading(false); }
|
| 528 |
-
}, []);
|
| 529 |
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
stageTimer.current = setInterval(() => setResearchStage(p => (p + 1) % STAGES.length), 3000);
|
| 536 |
-
try { const res = await apiClient.analyze({ user_input: q }); setResearchResult(res); } catch { /* silent */ } finally { setResearchLoading(false); if (stageTimer.current) clearInterval(stageTimer.current); }
|
| 537 |
};
|
| 538 |
|
| 539 |
-
const
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
)}
|
| 558 |
-
</AnimatePresence>
|
| 559 |
-
</div>
|
| 560 |
-
{loading && (<div className="flex-1 flex flex-col items-center justify-center gap-4"><JanusOrb size={48} thinking /><p className="text-xs font-mono text-indigo-400 uppercase tracking-widest animate-pulse">Fetching intelligence for {activeSymbol}...</p></div>)}
|
| 561 |
-
{!loading && error && (<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center"><AlertTriangle size={28} className="text-amber-500/50" /><p className="text-sm font-mono text-amber-400 max-w-md">{error}</p></div>)}
|
| 562 |
-
{!loading && !intel && !error && (
|
| 563 |
-
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
|
| 564 |
-
<BarChart3 size={32} className="text-gray-700" />
|
| 565 |
-
<p className="text-sm font-mono text-gray-500">Search any stock — Indian (RELIANCE, TCS, INFY) or global (AAPL, TSLA)</p>
|
| 566 |
-
<div className="flex gap-2 mt-2 flex-wrap justify-center">
|
| 567 |
-
{[{s:'RELIANCE.BSE',l:'Reliance',r:'India'},{s:'TCS.BSE',l:'TCS',r:'India'},{s:'INFY.BSE',l:'Infosys',r:'India'},{s:'AAPL',l:'Apple',r:''},{s:'TSLA',l:'Tesla',r:''}].map(({s,l,r}) => (<button key={s} onClick={() => { setQuery(s); setSelectedRegion(r); loadTicker(s, r); }} className="px-3 py-1 rounded-full border border-white/10 hover:border-indigo-500/30 text-xs font-mono text-gray-500 hover:text-indigo-300 transition-all">{l}</button>))}
|
| 568 |
-
</div>
|
| 569 |
-
</div>
|
| 570 |
-
)}
|
| 571 |
-
{!loading && intel && (
|
| 572 |
-
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
| 573 |
-
<CandlestickChart symbol={intel.symbol} companyName={intel.company_name} price={price} change={change} changePct={changePct} isPositive={!!isPositive} />
|
| 574 |
-
<div className="glass rounded-2xl p-5 border border-white/[0.06]">
|
| 575 |
-
<div className="flex items-start justify-between gap-4 flex-wrap">
|
| 576 |
-
<div>
|
| 577 |
-
<div className="flex items-center gap-3 mb-1"><span className="text-2xl font-light text-white">{intel.symbol}</span><span className="text-sm text-gray-500">{intel.company_name}</span></div>
|
| 578 |
-
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
| 579 |
-
{intel.overview.sector && <span className="text-[10px] font-mono text-gray-500 bg-white/5 px-2 py-0.5 rounded">{intel.overview.sector}</span>}
|
| 580 |
-
{intel.overview.industry && <span className="text-[10px] font-mono text-gray-500 bg-white/5 px-2 py-0.5 rounded">{intel.overview.industry}</span>}
|
| 581 |
-
</div>
|
| 582 |
-
</div>
|
| 583 |
-
<div className="flex flex-col items-end gap-2">
|
| 584 |
-
{intel.ai_signal && <SignalBadge signal={intel.ai_signal.signal} conviction={intel.ai_signal.conviction} />}
|
| 585 |
-
<StanceChip stance={intel.stance.stance} score={intel.stance.sentiment_score} />
|
| 586 |
-
</div>
|
| 587 |
-
</div>
|
| 588 |
-
{intel.ai_signal?.reasoning && (
|
| 589 |
-
<div className="mt-4 pt-4 border-t border-white/5">
|
| 590 |
-
<div className="flex items-center gap-2 mb-1.5 text-[10px] font-mono text-indigo-400 uppercase tracking-wider"><Zap size={10} /> AI Signal</div>
|
| 591 |
-
<p className="text-xs text-gray-300 font-mono leading-relaxed">{intel.ai_signal.reasoning}</p>
|
| 592 |
-
<div className="flex gap-4 mt-2">
|
| 593 |
-
<span className="text-[9px] font-mono text-gray-600 uppercase">Risk: <span className={intel.ai_signal.risk === 'HIGH' ? 'text-red-400' : intel.ai_signal.risk === 'MEDIUM' ? 'text-amber-400' : 'text-emerald-400'}>{intel.ai_signal.risk}</span></span>
|
| 594 |
-
<span className="text-[9px] font-mono text-gray-600 uppercase">Timeframe: <span className="text-gray-400">{intel.ai_signal.timeframe}</span></span>
|
| 595 |
-
</div>
|
| 596 |
-
</div>
|
| 597 |
-
)}
|
| 598 |
-
<div className="mt-4 pt-4 border-t border-white/5">
|
| 599 |
-
<button onClick={runDeepResearch} disabled={researchLoading} className="flex items-center gap-2 px-4 py-2 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/40 border border-indigo-500/30 text-xs font-mono text-indigo-300 transition-colors disabled:opacity-40">
|
| 600 |
-
<Sparkles size={12} className={researchLoading ? 'animate-pulse' : ''} />{researchLoading ? 'Running research...' : 'Deep Research — Run Full Agent Pipeline'}
|
| 601 |
-
</button>
|
| 602 |
-
</div>
|
| 603 |
-
</div>
|
| 604 |
-
<ResearchPanel result={researchResult} loading={researchLoading} stage={STAGES[researchStage]} />
|
| 605 |
-
<div className="grid grid-cols-2 gap-4">
|
| 606 |
-
<div className="glass rounded-xl p-4 border border-white/[0.04] space-y-2">
|
| 607 |
-
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mb-3">Fundamentals</div>
|
| 608 |
-
{([['P/E Ratio', intel.overview.pe_ratio], ['52W High', intel.overview['52_week_high']], ['52W Low', intel.overview['52_week_low']], ['Analyst Target', intel.overview.analyst_target]] as [string, string | undefined][]).filter(([, v]) => v && v !== 'None' && v !== '-' && v !== 'N/A').map(([k, v]) => (<div key={k} className="flex justify-between text-xs font-mono border-b border-white/5 pb-1.5"><span className="text-gray-500">{k}</span><span className="text-gray-300">{v}</span></div>))}
|
| 609 |
-
{!intel.overview.pe_ratio && <p className="text-[10px] font-mono text-gray-700">Requires Alpha Vantage key</p>}
|
| 610 |
-
</div>
|
| 611 |
-
<div className="glass rounded-xl p-4 border border-white/[0.04] space-y-3">
|
| 612 |
-
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider mb-3">Event Intelligence</div>
|
| 613 |
-
<div className="text-xs font-mono"><span className="text-gray-500">Impact: </span><span className={intel.event_impact.impact_level === 'high' || intel.event_impact.impact_level === 'very_high' ? 'text-amber-400' : 'text-gray-300'}>{intel.event_impact.impact_level || 'unknown'}</span></div>
|
| 614 |
-
<div className="text-xs font-mono"><span className="text-gray-500">Volatility: </span><span className="text-gray-300">{intel.event_impact.volatility_level || 'unknown'}</span></div>
|
| 615 |
-
<div className="text-xs font-mono"><span className="text-gray-500">Events: </span><span className="text-gray-300">{intel.event_impact.event_count || 0} detected</span></div>
|
| 616 |
-
</div>
|
| 617 |
-
</div>
|
| 618 |
-
<div>
|
| 619 |
-
<div className="flex items-center gap-2 text-[10px] font-mono text-gray-500 uppercase tracking-wider mb-3"><Scan size={11} className="text-indigo-400" />News {newsLoading && <RefreshCw size={10} className="animate-spin text-indigo-400" />}</div>
|
| 620 |
-
{newsLoading && <div className="text-xs font-mono text-gray-600 animate-pulse">Fetching articles...</div>}
|
| 621 |
-
<div className="space-y-2">{news.map((a: any, i: number) => <ArticleCard key={`${a.title}-${i}`} article={{ ...a, stance: 'neutral', sentiment_score: 0.5 }} index={i} onResearch={runDeepResearch} />)}</div>
|
| 622 |
-
</div>
|
| 623 |
-
</div>
|
| 624 |
-
)}
|
| 625 |
-
</div>
|
| 626 |
-
);
|
| 627 |
-
}
|
| 628 |
-
|
| 629 |
-
function WorkspaceTab() {
|
| 630 |
-
const [curiosity, setCuriosity] = useState<any>(null);
|
| 631 |
-
const [workflows, setWorkflows] = useState<any[]>([]);
|
| 632 |
-
const [sessions, setSessions] = useState<any[]>([]);
|
| 633 |
-
const [loading, setLoading] = useState(true);
|
| 634 |
-
const [triggering, setTriggering] = useState(false);
|
| 635 |
-
const [activeWf, setActiveWf] = useState<any>(null);
|
| 636 |
-
const [wfLoading, setWfLoading] = useState(false);
|
| 637 |
-
|
| 638 |
-
const fetchData = useCallback(async () => {
|
| 639 |
-
setLoading(true);
|
| 640 |
-
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 641 |
-
try {
|
| 642 |
-
const [curRes, wfRes, sessRes] = await Promise.all([
|
| 643 |
-
fetch(`${baseUrl}/daemon/curiosity`).then(r => r.ok ? r.json() : null),
|
| 644 |
-
fetch(`${baseUrl}/workflows?limit=10`).then(r => r.ok ? r.json() : []),
|
| 645 |
-
fetch(`${baseUrl}/sessions?limit=10`).then(r => r.ok ? r.json() : []),
|
| 646 |
-
]);
|
| 647 |
-
if (curRes) setCuriosity(curRes);
|
| 648 |
-
if (wfRes) setWorkflows(Array.isArray(wfRes) ? wfRes : []);
|
| 649 |
-
if (sessRes) setSessions(Array.isArray(sessRes) ? sessRes : []);
|
| 650 |
-
} catch { /* silent */ } finally { setLoading(false); }
|
| 651 |
-
}, []);
|
| 652 |
-
|
| 653 |
-
useEffect(() => { fetchData(); }, [fetchData]);
|
| 654 |
-
|
| 655 |
-
const triggerCuriosity = async () => {
|
| 656 |
-
setTriggering(true);
|
| 657 |
-
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 658 |
-
try {
|
| 659 |
-
const res = await fetch(`${baseUrl}/daemon/curiosity/now`, { method: 'POST' });
|
| 660 |
-
if (res.ok) {
|
| 661 |
-
const data = await res.json();
|
| 662 |
-
setCuriosity((prev: any) => prev ? { ...prev, discoveries: [...(prev.discoveries || []), ...(data.discoveries || [])], total_discoveries: (prev.total_discoveries || 0) + (data.discoveries || []).length } : data);
|
| 663 |
-
}
|
| 664 |
-
} catch { /* silent */ } finally { setTriggering(false); }
|
| 665 |
-
};
|
| 666 |
|
| 667 |
-
const runWorkflow = async (wf: any) => {
|
| 668 |
-
setActiveWf(wf); setWfLoading(true);
|
| 669 |
-
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 670 |
try {
|
| 671 |
-
const
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
-
const
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
const res = await fetch(`${baseUrl}/workflows/research`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }) });
|
| 682 |
-
if (res.ok) { const wf = await res.json(); setWorkflows(prev => [wf, ...prev]); }
|
| 683 |
-
} catch { /* silent */ }
|
| 684 |
};
|
| 685 |
|
| 686 |
-
const
|
| 687 |
-
|
| 688 |
-
if (loading) return (<div className="flex flex-col items-center justify-center h-full gap-4"><JanusOrb size={48} thinking /><p className="text-xs font-mono text-indigo-400 animate-pulse">Loading workspace...</p></div>);
|
| 689 |
|
| 690 |
return (
|
| 691 |
-
<div className="h-full flex
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
<
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
))}
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
<div className="glass rounded-2xl p-5 border border-white/[0.06]">
|
| 709 |
-
<div className="flex items-center gap-2 mb-4 text-xs font-mono text-indigo-400 uppercase tracking-wider"><MessageSquare size={12} /> Sessions ({sessions.length})</div>
|
| 710 |
-
<div className="space-y-2">
|
| 711 |
-
{sessions.slice(0, 5).map((s: any, i: number) => (<div key={s.id} className="flex items-center justify-between text-xs font-mono py-2 px-3 glass rounded-lg border border-white/[0.04]"><span className="text-gray-400 truncate">{s.id}</span><div className="flex items-center gap-3 shrink-0"><span className="text-gray-600">{s.message_count || 0} msgs</span><span className="text-gray-700">{new Date(s.updated_at).toLocaleTimeString()}</span></div></div>))}
|
| 712 |
-
{sessions.length === 0 && <p className="text-xs font-mono text-gray-600 text-center py-4">No sessions yet. Start a conversation.</p>}
|
| 713 |
-
</div>
|
| 714 |
-
</div>
|
| 715 |
-
</div>
|
| 716 |
-
<div className="flex flex-col gap-3 overflow-hidden flex-1">
|
| 717 |
-
<div className="flex items-center justify-between shrink-0">
|
| 718 |
-
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider">Workflows ({workflows.length})</div>
|
| 719 |
-
<button onClick={createResearchWf} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/40 border border-indigo-500/30 text-[10px] font-mono text-indigo-300 transition-all"><Plus size={10} /> Research</button>
|
| 720 |
-
</div>
|
| 721 |
-
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
| 722 |
-
{workflows.map((wf: any, i: number) => (
|
| 723 |
-
<motion.div key={wf.id} initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.05 }} className="glass rounded-xl border border-white/[0.04] hover:border-white/10 transition-colors p-4">
|
| 724 |
-
<div className="flex items-start justify-between mb-3">
|
| 725 |
-
<div>
|
| 726 |
-
<div className="flex items-center gap-2 mb-1"><span className={`text-[9px] font-mono px-1.5 py-0.5 rounded ${statusColors[wf.status] || 'text-gray-400'} bg-white/5`}>{wf.status}</span><span className="text-[9px] font-mono text-gray-600">{wf.type}</span></div>
|
| 727 |
-
<p className="text-xs text-gray-300">{wf.query || wf.scenario || 'Untitled'}</p>
|
| 728 |
-
</div>
|
| 729 |
-
{wf.status === 'pending' && (<button onClick={() => runWorkflow(wf)} disabled={wfLoading} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/40 border border-emerald-500/30 text-[10px] font-mono text-emerald-300 transition-all disabled:opacity-40"><Play size={10} /> Run</button>)}
|
| 730 |
-
</div>
|
| 731 |
-
<div className="space-y-1.5">{wf.steps?.map((step: any, si: number) => (<div key={step.id} className="flex items-center gap-2 text-[10px] font-mono"><div className={`w-2 h-2 rounded-full ${step.status === 'completed' ? 'bg-emerald-400' : step.status === 'running' ? 'bg-indigo-400 animate-pulse' : step.status === 'failed' ? 'bg-red-400' : 'bg-gray-600'}`} /><span className={step.status === 'completed' ? 'text-emerald-400' : step.status === 'running' ? 'text-indigo-400' : 'text-gray-500'}>{step.name}</span></div>))}</div>
|
| 732 |
-
{wf.metadata && (<div className="flex items-center gap-3 mt-3 pt-2 border-t border-white/5 text-[9px] font-mono text-gray-600"><span>{wf.metadata.completed_steps}/{wf.metadata.total_steps} steps</span>{wf.metadata.failed_steps > 0 && <span className="text-red-400">{wf.metadata.failed_steps} failed</span>}</div>)}
|
| 733 |
-
</motion.div>
|
| 734 |
-
))}
|
| 735 |
-
{workflows.length === 0 && (<div className="flex flex-col items-center justify-center py-16 text-center"><GitBranch size={28} className="text-gray-700 mb-3" /><p className="text-sm font-mono text-gray-500">No workflows yet.</p><p className="text-xs font-mono text-gray-700 mt-1">Create a research or simulation workflow.</p></div>)}
|
| 736 |
-
</div>
|
| 737 |
-
</div>
|
| 738 |
-
</div>
|
| 739 |
-
);
|
| 740 |
-
}
|
| 741 |
-
|
| 742 |
-
function PulseTab({ daemonStatus, alerts, memoryStats }: { daemonStatus: DaemonStatus | null; alerts: Alert[]; memoryStats: MemoryStats | null }) {
|
| 743 |
-
const [refreshing, setRefreshing] = useState(false);
|
| 744 |
-
const handleRefresh = async () => { setRefreshing(true); setTimeout(() => setRefreshing(false), 1000); };
|
| 745 |
-
const phaseIcons: Record<string, React.ReactNode> = { morning: <Sunrise size={16} className="text-amber-400" />, daytime: <Sun size={16} className="text-indigo-400" />, evening: <Sunset size={16} className="text-violet-400" />, night: <Moon size={16} className="text-indigo-300" /> };
|
| 746 |
-
|
| 747 |
-
return (
|
| 748 |
-
<div className="h-full flex gap-5 overflow-hidden">
|
| 749 |
-
<div className="flex flex-col gap-4 overflow-y-auto pr-1" style={{ width: '40%' }}>
|
| 750 |
-
{daemonStatus?.circadian && (
|
| 751 |
-
<div className="glass rounded-2xl p-5 border border-white/[0.06]">
|
| 752 |
-
<div className="flex items-center gap-3 mb-4">{phaseIcons[daemonStatus.circadian.current_phase]}<div><h3 className="text-sm font-mono text-gray-200">{daemonStatus.circadian.phase_name}</h3><p className="text-[10px] font-mono text-gray-500">{daemonStatus.circadian.phase_description}</p></div></div>
|
| 753 |
-
<div className="space-y-2"><div className="flex justify-between text-xs font-mono"><span className="text-gray-500">Priority</span><span className="text-gray-300 capitalize">{daemonStatus.circadian.priority}</span></div><div className="flex justify-between text-xs font-mono"><span className="text-gray-500">Active Tasks</span><span className="text-gray-300">{daemonStatus.circadian.current_tasks.length}</span></div></div>
|
| 754 |
-
</div>
|
| 755 |
-
)}
|
| 756 |
-
{daemonStatus && (
|
| 757 |
-
<div className="glass rounded-2xl p-5 border border-white/[0.06]">
|
| 758 |
-
<div className="flex items-center gap-2 mb-4 text-xs font-mono text-indigo-400 uppercase tracking-wider"><Radio size={12} /> Signal Queue</div>
|
| 759 |
-
<div className="grid grid-cols-2 gap-3">
|
| 760 |
-
<div className="text-center"><div className="text-xl font-light text-white">{daemonStatus.signal_queue?.total_signals || daemonStatus.signals || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Total Signals</div></div>
|
| 761 |
-
<div className="text-center"><div className="text-xl font-light text-red-400">{daemonStatus.signal_queue?.severity_counts?.high || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">High Severity</div></div>
|
| 762 |
-
<div className="text-center"><div className="text-xl font-light text-amber-400">{daemonStatus.signal_queue?.severity_counts?.medium || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Medium</div></div>
|
| 763 |
-
<div className="text-center"><div className="text-xl font-light text-gray-400">{daemonStatus.signal_queue?.severity_counts?.low || 0}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Low</div></div>
|
| 764 |
-
</div>
|
| 765 |
-
</div>
|
| 766 |
-
)}
|
| 767 |
-
{memoryStats && (
|
| 768 |
-
<div className="glass rounded-2xl p-5 border border-white/[0.06]">
|
| 769 |
-
<div className="flex items-center gap-2 mb-4 text-xs font-mono text-violet-400 uppercase tracking-wider"><Database size={12} /> Memory Graph</div>
|
| 770 |
-
<div className="grid grid-cols-3 gap-3">
|
| 771 |
-
<div className="text-center"><div className="text-xl font-light text-white">{memoryStats.queries}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Queries</div></div>
|
| 772 |
-
<div className="text-center"><div className="text-xl font-light text-indigo-400">{memoryStats.entities}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Entities</div></div>
|
| 773 |
-
<div className="text-center"><div className="text-xl font-light text-violet-400">{memoryStats.insights}</div><div className="text-[9px] font-mono text-gray-600 uppercase">Insights</div></div>
|
| 774 |
-
</div>
|
| 775 |
-
</div>
|
| 776 |
-
)}
|
| 777 |
-
{daemonStatus && (
|
| 778 |
-
<div className="glass rounded-2xl p-5 border border-white/[0.06]">
|
| 779 |
-
<div className="flex items-center gap-2 mb-4 text-xs font-mono text-emerald-400 uppercase tracking-wider"><Activity size={12} /> Daemon Status</div>
|
| 780 |
-
<div className="space-y-2 text-xs font-mono">
|
| 781 |
-
<div className="flex justify-between"><span className="text-gray-500">Status</span><span className="text-emerald-400">Running</span></div>
|
| 782 |
-
<div className="flex justify-between"><span className="text-gray-500">Cycles</span><span className="text-gray-300">{daemonStatus.cycle_count}</span></div>
|
| 783 |
-
<div className="flex justify-between"><span className="text-gray-500">Last Run</span><span className="text-gray-300">{daemonStatus.last_run ? new Date(daemonStatus.last_run).toLocaleTimeString() : 'N/A'}</span></div>
|
| 784 |
-
</div>
|
| 785 |
</div>
|
| 786 |
)}
|
| 787 |
</div>
|
| 788 |
-
<div className="flex flex-col gap-3 overflow-hidden flex-1">
|
| 789 |
-
<div className="flex items-center justify-between shrink-0">
|
| 790 |
-
<div className="text-[10px] font-mono text-gray-500 uppercase tracking-wider">Live Alert Feed — {alerts.length} alerts</div>
|
| 791 |
-
<button onClick={handleRefresh} disabled={refreshing} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg glass border border-white/5 hover:border-indigo-500/20 text-[10px] font-mono text-gray-400 hover:text-indigo-300 transition-all disabled:opacity-40"><RefreshCw size={10} className={refreshing ? 'animate-spin' : ''} /> Refresh</button>
|
| 792 |
-
</div>
|
| 793 |
-
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
| 794 |
-
{alerts.length > 0 ? (alerts.map((alert, i) => <AlertCard key={`${alert.title}-${i}`} alert={alert} index={i} />)) : (
|
| 795 |
-
<div className="flex flex-col items-center justify-center py-16 text-center"><CheckCircle size={28} className="text-emerald-500/30 mb-3" /><p className="text-sm font-mono text-gray-500">No alerts.</p><p className="text-xs font-mono text-gray-700 mt-1">All systems operating normally.</p></div>
|
| 796 |
-
)}
|
| 797 |
-
</div>
|
| 798 |
-
</div>
|
| 799 |
-
</div>
|
| 800 |
-
);
|
| 801 |
-
}
|
| 802 |
-
|
| 803 |
-
// ═══════════════════════════════════════════════════════════
|
| 804 |
-
// MAIN APP
|
| 805 |
-
// ═══════════════════════════════════════════════════════════
|
| 806 |
-
export default function JanusApp() {
|
| 807 |
-
const [systemState, setSystemState] = useState<'art' | 'dashboard'>('art');
|
| 808 |
-
const [activeTab, setActiveTab] = useState('command');
|
| 809 |
-
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
| 810 |
-
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
| 811 |
-
const [daemonStatus, setDaemonStatus] = useState<DaemonStatus | null>(null);
|
| 812 |
-
const [alerts, setAlerts] = useState<Alert[]>([]);
|
| 813 |
-
const [memoryStats, setMemoryStats] = useState<MemoryStats | null>(null);
|
| 814 |
-
|
| 815 |
-
const fetchSystemData = useCallback(async () => {
|
| 816 |
-
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 817 |
-
try {
|
| 818 |
-
const [statusRes, alertsRes, memoryRes] = await Promise.all([
|
| 819 |
-
fetch(`${baseUrl}/daemon/status`).then(r => r.ok ? r.json() : null),
|
| 820 |
-
fetch(`${baseUrl}/daemon/alerts?limit=20`).then(r => r.ok ? r.json() : []),
|
| 821 |
-
fetch(`${baseUrl}/memory/stats`).then(r => r.ok ? r.json() : null),
|
| 822 |
-
]);
|
| 823 |
-
if (statusRes) setDaemonStatus(statusRes);
|
| 824 |
-
if (alertsRes) setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
|
| 825 |
-
if (memoryRes) setMemoryStats(memoryRes);
|
| 826 |
-
} catch { /* silent */ }
|
| 827 |
-
}, []);
|
| 828 |
-
|
| 829 |
-
useEffect(() => { fetchSystemData(); const interval = setInterval(fetchSystemData, 60000); return () => clearInterval(interval); }, [fetchSystemData]);
|
| 830 |
-
|
| 831 |
-
const sections = ['Main', 'System'];
|
| 832 |
-
const sectionItems = (section: string) => navItems.filter(item => item.section === section);
|
| 833 |
-
|
| 834 |
-
const renderTab = () => {
|
| 835 |
-
switch (activeTab) {
|
| 836 |
-
case 'command': return <CommandTab />;
|
| 837 |
-
case 'intel': return <IntelStreamTab />;
|
| 838 |
-
case 'markets': return <MarketsTab />;
|
| 839 |
-
case 'workspace': return <WorkspaceTab />;
|
| 840 |
-
case 'pulse': return <PulseTab daemonStatus={daemonStatus} alerts={alerts} memoryStats={memoryStats} />;
|
| 841 |
-
default: return <CommandTab />;
|
| 842 |
-
}
|
| 843 |
-
};
|
| 844 |
-
|
| 845 |
-
const alertCount = alerts.filter(a => a.severity === 'high' || a.severity === 'critical').length;
|
| 846 |
-
|
| 847 |
-
return (
|
| 848 |
-
<div className="h-screen bg-gray-950 text-gray-100 flex overflow-hidden">
|
| 849 |
-
<AnimatePresence>{systemState === 'art' && <ArtPiece onUnlock={() => setSystemState('dashboard')} />}</AnimatePresence>
|
| 850 |
-
|
| 851 |
-
{/* Mobile menu overlay */}
|
| 852 |
-
<AnimatePresence>
|
| 853 |
-
{mobileMenuOpen && (<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/60 z-40 lg:hidden" onClick={() => setMobileMenuOpen(false)} />)}
|
| 854 |
-
</AnimatePresence>
|
| 855 |
|
| 856 |
-
{/*
|
| 857 |
-
<
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
{
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
{!sidebarCollapsed && (<span className="font-mono text-[10px] uppercase tracking-wider truncate">{item.label}</span>)}
|
| 880 |
-
</button>
|
| 881 |
-
);
|
| 882 |
-
})}
|
| 883 |
-
</div>
|
| 884 |
-
))}
|
| 885 |
-
</nav>
|
| 886 |
-
|
| 887 |
-
{/* Status */}
|
| 888 |
-
{!sidebarCollapsed && (
|
| 889 |
-
<div className="px-3 py-2 border-t border-white/5 shrink-0">
|
| 890 |
-
<div className="flex items-center gap-2">
|
| 891 |
-
<div className={`w-1.5 h-1.5 rounded-full ${daemonStatus?.running ? 'bg-emerald-400 animate-pulse' : 'bg-gray-600'}`} />
|
| 892 |
-
<span className="text-[9px] font-mono text-gray-500 uppercase tracking-wider">{daemonStatus?.running ? 'Living' : 'Offline'}</span>
|
| 893 |
-
</div>
|
| 894 |
-
{daemonStatus?.circadian && (<div className="text-[8px] font-mono text-gray-700 mt-0.5 capitalize">{daemonStatus.circadian.current_phase} phase</div>)}
|
| 895 |
</div>
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
<div className="lg:hidden fixed top-0 left-0 right-0 h-12 bg-gray-900/90 backdrop-blur-sm border-b border-white/5 flex items-center justify-between px-3 z-30">
|
| 901 |
-
<button onClick={() => setMobileMenuOpen(true)} className="p-1.5 rounded hover:bg-white/5 text-gray-400"><Menu size={16} /></button>
|
| 902 |
-
<span className="text-xs font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</span>
|
| 903 |
-
<div className="w-7" />
|
| 904 |
</div>
|
| 905 |
-
|
| 906 |
-
{/* Mobile drawer */}
|
| 907 |
-
<AnimatePresence>
|
| 908 |
-
{mobileMenuOpen && (
|
| 909 |
-
<motion.div initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed top-0 left-0 bottom-0 w-60 bg-gray-900 border-r border-white/5 z-50 lg:hidden">
|
| 910 |
-
<div className="flex items-center justify-between h-12 px-3 border-b border-white/5">
|
| 911 |
-
<span className="text-xs font-light tracking-[0.2em] bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">JANUS</span>
|
| 912 |
-
<button onClick={() => setMobileMenuOpen(false)} className="p-1 rounded hover:bg-white/5 text-gray-500"><XIcon size={14} /></button>
|
| 913 |
-
</div>
|
| 914 |
-
<nav className="flex-1 overflow-y-auto py-2 px-1.5 space-y-1">
|
| 915 |
-
{sections.map(section => (
|
| 916 |
-
<div key={section}>
|
| 917 |
-
<div className="px-2 py-1.5 text-[8px] font-mono text-gray-600 uppercase tracking-widest">{section}</div>
|
| 918 |
-
{sectionItems(section).map(item => {
|
| 919 |
-
const active = activeTab === item.id;
|
| 920 |
-
const Icon = item.icon;
|
| 921 |
-
return (
|
| 922 |
-
<button key={item.id} onClick={() => { setActiveTab(item.id); setMobileMenuOpen(false); }}
|
| 923 |
-
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg transition-all text-sm ${active ? 'bg-indigo-500/10 text-indigo-300 border border-indigo-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'}`}>
|
| 924 |
-
<Icon size={14} className="shrink-0" />
|
| 925 |
-
<span className="font-mono text-[10px] uppercase tracking-wider">{item.label}</span>
|
| 926 |
-
</button>
|
| 927 |
-
);
|
| 928 |
-
})}
|
| 929 |
-
</div>
|
| 930 |
-
))}
|
| 931 |
-
</nav>
|
| 932 |
-
</motion.div>
|
| 933 |
-
)}
|
| 934 |
-
</AnimatePresence>
|
| 935 |
-
|
| 936 |
-
{/* Main content */}
|
| 937 |
-
<main className="flex-1 min-w-0 overflow-hidden pt-12 lg:pt-0">
|
| 938 |
-
<motion.div initial={{ opacity: 0 }} animate={{ opacity: systemState === 'dashboard' ? 1 : 0 }} transition={{ duration: 0.8 }}
|
| 939 |
-
className={`h-full flex flex-col ${systemState === 'art' ? 'pointer-events-none' : ''}`}>
|
| 940 |
-
<div className="flex-1 min-h-0 overflow-hidden p-4">
|
| 941 |
-
<AnimatePresence mode="wait">
|
| 942 |
-
<motion.div key={activeTab} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.3 }} className="h-full">
|
| 943 |
-
{renderTab()}
|
| 944 |
-
</motion.div>
|
| 945 |
-
</AnimatePresence>
|
| 946 |
-
</div>
|
| 947 |
-
</motion.div>
|
| 948 |
-
</main>
|
| 949 |
</div>
|
| 950 |
);
|
| 951 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect, useRef, useCallback } from 'react';
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
+
import { Send, Sparkles, Zap, ArrowUp } from 'lucide-react';
|
| 6 |
+
import { apiClient } from '@/lib/api';
|
| 7 |
+
|
| 8 |
+
// ─── Types ───────────────────────────────────────────────
|
| 9 |
+
interface Message {
|
| 10 |
+
id: string;
|
| 11 |
+
role: 'user' | 'janus';
|
| 12 |
+
content: string;
|
| 13 |
+
timestamp: Date;
|
| 14 |
+
metadata?: {
|
| 15 |
+
domain?: string;
|
| 16 |
+
queryType?: string;
|
| 17 |
+
elapsed?: number;
|
| 18 |
+
confidence?: number;
|
| 19 |
+
routeInfo?: { domain_pack?: string; execution_mode?: string; complexity?: string };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
+
// ─── Janus Orb ────────────────────────────────────────────
|
| 24 |
+
function JanusOrb({ size = 36, thinking = false }: { size?: number; thinking?: boolean }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
return (
|
| 26 |
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
| 27 |
+
<motion.div
|
| 28 |
+
className="absolute inset-0 rounded-full"
|
| 29 |
+
style={{
|
| 30 |
+
background: 'radial-gradient(circle at 35% 35%, #818cf8, #4f46e5 55%, #1e1b4b 100%)',
|
| 31 |
+
boxShadow: thinking
|
| 32 |
+
? '0 0 24px rgba(99,102,241,0.5), 0 0 48px rgba(99,102,241,0.2)'
|
| 33 |
+
: '0 0 12px rgba(99,102,241,0.25)',
|
| 34 |
+
}}
|
| 35 |
+
animate={thinking ? { scale: [1, 1.1, 1] } : { scale: 1 }}
|
| 36 |
+
transition={{ duration: 1.5, repeat: thinking ? Infinity : 0, ease: 'easeInOut' }}
|
| 37 |
+
/>
|
| 38 |
+
{thinking && (
|
| 39 |
+
<>
|
| 40 |
+
<div
|
| 41 |
+
className="absolute inset-[-4px] rounded-full border border-indigo-400/30"
|
| 42 |
+
style={{ borderTopColor: 'transparent', borderBottomColor: 'transparent', animation: 'spin 2s linear infinite' }}
|
| 43 |
+
/>
|
| 44 |
+
<div
|
| 45 |
+
className="absolute inset-[-8px] rounded-full border border-violet-400/15"
|
| 46 |
+
style={{ borderLeftColor: 'transparent', borderRightColor: 'transparent', animation: 'spin 3s linear infinite reverse' }}
|
| 47 |
+
/>
|
| 48 |
+
</>
|
| 49 |
+
)}
|
| 50 |
</div>
|
| 51 |
);
|
| 52 |
}
|
| 53 |
|
| 54 |
+
// ─── Typewriter ───────────────────────────────────────────
|
| 55 |
+
function Typewriter({ text, speed = 8, onComplete }: { text: string; speed?: number; onComplete?: () => void }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const [displayed, setDisplayed] = useState('');
|
| 57 |
const idx = useRef(0);
|
| 58 |
+
const completed = useRef(false);
|
| 59 |
+
|
| 60 |
useEffect(() => {
|
| 61 |
+
idx.current = 0;
|
| 62 |
+
setDisplayed('');
|
| 63 |
+
completed.current = false;
|
| 64 |
+
|
| 65 |
const iv = setInterval(() => {
|
| 66 |
+
if (idx.current < text.length) {
|
| 67 |
+
// Write multiple characters per tick for speed
|
| 68 |
+
const chunk = text.slice(idx.current, idx.current + 3);
|
| 69 |
+
setDisplayed(p => p + chunk);
|
| 70 |
+
idx.current += 3;
|
| 71 |
+
} else {
|
| 72 |
+
clearInterval(iv);
|
| 73 |
+
if (!completed.current) {
|
| 74 |
+
completed.current = true;
|
| 75 |
+
onComplete?.();
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
}, speed);
|
| 79 |
return () => clearInterval(iv);
|
| 80 |
+
}, [text, speed, onComplete]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
return (
|
| 83 |
+
<span>
|
| 84 |
+
{displayed}
|
| 85 |
+
{displayed.length < text.length && (
|
| 86 |
+
<span className="inline-block w-0.5 h-4 bg-indigo-400/70 ml-0.5 animate-pulse align-middle" />
|
| 87 |
+
)}
|
| 88 |
+
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
);
|
| 90 |
}
|
| 91 |
|
| 92 |
+
// ─── Thinking Indicator ───────────────────────────────────
|
| 93 |
+
const STAGES = [
|
| 94 |
+
'Routing to switchboard...',
|
| 95 |
+
'Research agent scanning...',
|
| 96 |
+
'Cross-referencing sources...',
|
| 97 |
+
'Synthesizing analysis...',
|
| 98 |
+
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
+
function ThinkingIndicator({ stage }: { stage: string }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return (
|
| 102 |
+
<motion.div
|
| 103 |
+
initial={{ opacity: 0, y: 8 }}
|
| 104 |
+
animate={{ opacity: 1, y: 0 }}
|
| 105 |
+
exit={{ opacity: 0, y: -8 }}
|
| 106 |
+
className="flex items-start gap-3 max-w-[768px]"
|
| 107 |
+
>
|
| 108 |
+
<JanusOrb size={32} thinking />
|
| 109 |
+
<div className="pt-1">
|
| 110 |
+
<motion.p
|
| 111 |
+
key={stage}
|
| 112 |
+
initial={{ opacity: 0 }}
|
| 113 |
+
animate={{ opacity: 1 }}
|
| 114 |
+
className="text-sm text-gray-400"
|
| 115 |
+
>
|
| 116 |
+
{stage}
|
| 117 |
+
</motion.p>
|
| 118 |
+
<div className="flex items-center gap-1 mt-2">
|
| 119 |
+
{[0, 1, 2].map(i => (
|
| 120 |
+
<motion.div
|
| 121 |
+
key={i}
|
| 122 |
+
className="w-1 h-1 rounded-full bg-indigo-400"
|
| 123 |
+
animate={{ opacity: [0.3, 1, 0.3] }}
|
| 124 |
+
transition={{ duration: 1, repeat: Infinity, delay: i * 0.2 }}
|
| 125 |
+
/>
|
| 126 |
+
))}
|
| 127 |
</div>
|
| 128 |
+
</div>
|
|
|
|
| 129 |
</motion.div>
|
| 130 |
);
|
| 131 |
}
|
| 132 |
|
| 133 |
+
// ─── Message Bubble ───────────────────────────────────────
|
| 134 |
+
function MessageBubble({ message, isLatest }: { message: Message; isLatest: boolean }) {
|
| 135 |
+
const isUser = message.role === 'user';
|
| 136 |
+
|
| 137 |
+
if (isUser) {
|
| 138 |
+
return (
|
| 139 |
+
<motion.div
|
| 140 |
+
initial={{ opacity: 0, y: 8 }}
|
| 141 |
+
animate={{ opacity: 1, y: 0 }}
|
| 142 |
+
className="flex justify-end"
|
| 143 |
+
>
|
| 144 |
+
<div className="bubble-user px-5 py-3 max-w-[600px]">
|
| 145 |
+
<p className="text-[14px] text-gray-200 leading-relaxed">{message.content}</p>
|
|
|
|
| 146 |
</div>
|
| 147 |
+
</motion.div>
|
| 148 |
+
);
|
| 149 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
|
|
|
|
|
|
| 151 |
return (
|
| 152 |
+
<motion.div
|
| 153 |
+
initial={{ opacity: 0, y: 8 }}
|
| 154 |
+
animate={{ opacity: 1, y: 0 }}
|
| 155 |
+
className="flex items-start gap-3 max-w-[768px]"
|
| 156 |
+
>
|
| 157 |
+
<div className="mt-0.5 shrink-0">
|
| 158 |
+
<JanusOrb size={28} />
|
| 159 |
+
</div>
|
| 160 |
+
<div className="flex-1 min-w-0">
|
| 161 |
+
{/* Route metadata pills */}
|
| 162 |
+
{message.metadata?.routeInfo && (
|
| 163 |
+
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
| 164 |
+
{message.metadata.routeInfo.domain_pack && (
|
| 165 |
+
<span className="px-2 py-0.5 rounded-full text-[10px] text-indigo-400 bg-indigo-500/10 border border-indigo-500/15">
|
| 166 |
+
{message.metadata.routeInfo.domain_pack}
|
| 167 |
+
</span>
|
| 168 |
+
)}
|
| 169 |
+
{message.metadata.routeInfo.execution_mode && (
|
| 170 |
+
<span className="px-2 py-0.5 rounded-full text-[10px] text-gray-500 bg-white/[0.04] border border-white/[0.06]">
|
| 171 |
+
{message.metadata.routeInfo.execution_mode}
|
| 172 |
+
</span>
|
| 173 |
+
)}
|
| 174 |
+
{message.metadata.elapsed && (
|
| 175 |
+
<span className="text-[10px] text-gray-600">
|
| 176 |
+
{message.metadata.elapsed}s
|
| 177 |
+
</span>
|
| 178 |
+
)}
|
| 179 |
</div>
|
| 180 |
+
)}
|
| 181 |
+
|
| 182 |
+
{/* Response content */}
|
| 183 |
+
<div className="text-[14px] text-gray-300 leading-[1.7] whitespace-pre-wrap">
|
| 184 |
+
{isLatest ? (
|
| 185 |
+
<Typewriter text={message.content} speed={6} />
|
| 186 |
+
) : (
|
| 187 |
+
message.content
|
| 188 |
+
)}
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
</motion.div>
|
| 192 |
);
|
| 193 |
}
|
| 194 |
|
| 195 |
+
// ─── Suggested Prompts ────────────────────────────────────
|
| 196 |
+
const SUGGESTED = [
|
| 197 |
+
'Analyze the impact of rising interest rates on tech stocks',
|
| 198 |
+
'What is the current market sentiment around AI companies?',
|
| 199 |
+
'Compare Reliance vs TCS as long-term investments',
|
| 200 |
+
'Explain how quantitative tightening affects emerging markets',
|
| 201 |
+
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
+
function EmptyState({ onSelect }: { onSelect: (q: string) => void }) {
|
| 204 |
return (
|
| 205 |
+
<motion.div
|
| 206 |
+
initial={{ opacity: 0 }}
|
| 207 |
+
animate={{ opacity: 1 }}
|
| 208 |
+
transition={{ delay: 0.2 }}
|
| 209 |
+
className="flex flex-col items-center justify-center h-full px-4"
|
| 210 |
+
>
|
| 211 |
+
<JanusOrb size={56} />
|
| 212 |
+
<h2 className="mt-8 text-xl font-light text-gray-200 tracking-wide">
|
| 213 |
+
What can I research for you?
|
| 214 |
+
</h2>
|
| 215 |
+
<p className="mt-2 text-sm text-gray-600 max-w-md text-center">
|
| 216 |
+
Multi-agent intelligence pipeline — switchboard routes your query through research, analysis, and synthesis.
|
| 217 |
+
</p>
|
| 218 |
+
|
| 219 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 mt-10 w-full max-w-xl">
|
| 220 |
+
{SUGGESTED.map(q => (
|
| 221 |
+
<button
|
| 222 |
+
key={q}
|
| 223 |
+
onClick={() => onSelect(q)}
|
| 224 |
+
className="text-left px-4 py-3 rounded-2xl border border-white/[0.06] hover:border-white/[0.12] hover:bg-white/[0.03] transition-all group"
|
| 225 |
+
>
|
| 226 |
+
<p className="text-[13px] text-gray-400 group-hover:text-gray-200 transition-colors leading-snug">
|
| 227 |
+
{q}
|
| 228 |
+
</p>
|
| 229 |
+
</button>
|
| 230 |
+
))}
|
| 231 |
</div>
|
| 232 |
+
</motion.div>
|
| 233 |
);
|
| 234 |
}
|
| 235 |
|
| 236 |
// ═══════════════════════════════════════════════════════════
|
| 237 |
+
// MAIN CHAT PAGE
|
| 238 |
// ═══════════════════════════════════════════════════════════
|
| 239 |
+
export default function ChatPage() {
|
| 240 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
|
|
|
|
|
|
|
|
| 241 |
const [input, setInput] = useState('');
|
| 242 |
+
const [isThinking, setIsThinking] = useState(false);
|
| 243 |
+
const [thinkingStage, setThinkingStage] = useState(0);
|
| 244 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 245 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 246 |
+
const latestMsgId = useRef<string | null>(null);
|
| 247 |
|
| 248 |
+
// Auto-scroll on new messages
|
| 249 |
useEffect(() => {
|
| 250 |
+
if (scrollRef.current) {
|
| 251 |
+
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
| 252 |
+
}
|
| 253 |
+
}, [messages, isThinking]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
+
// Thinking stage rotation
|
| 256 |
+
useEffect(() => {
|
| 257 |
+
if (!isThinking) return;
|
| 258 |
+
const iv = setInterval(() => setThinkingStage(p => (p + 1) % STAGES.length), 2500);
|
| 259 |
+
return () => clearInterval(iv);
|
| 260 |
+
}, [isThinking]);
|
|
|
|
|
|
|
| 261 |
|
| 262 |
+
// Auto-resize textarea
|
| 263 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
| 264 |
+
setInput(e.target.value);
|
| 265 |
+
e.target.style.height = 'auto';
|
| 266 |
+
e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
|
|
|
|
|
|
|
| 267 |
};
|
| 268 |
|
| 269 |
+
const sendMessage = useCallback(async (text: string) => {
|
| 270 |
+
if (!text.trim() || isThinking) return;
|
| 271 |
+
|
| 272 |
+
const userMsg: Message = {
|
| 273 |
+
id: `u-${Date.now()}`,
|
| 274 |
+
role: 'user',
|
| 275 |
+
content: text.trim(),
|
| 276 |
+
timestamp: new Date(),
|
| 277 |
+
};
|
| 278 |
+
setMessages(prev => [...prev, userMsg]);
|
| 279 |
+
setInput('');
|
| 280 |
+
setIsThinking(true);
|
| 281 |
+
setThinkingStage(0);
|
| 282 |
+
|
| 283 |
+
// Reset textarea height
|
| 284 |
+
if (inputRef.current) {
|
| 285 |
+
inputRef.current.style.height = 'auto';
|
| 286 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
|
|
|
|
|
|
|
|
|
| 288 |
try {
|
| 289 |
+
const result = await apiClient.analyze({ user_input: text.trim() });
|
| 290 |
+
|
| 291 |
+
const finalAnswer = result.final_answer || result.final?.response || result.final?.summary || 'No analysis could be generated.';
|
| 292 |
+
|
| 293 |
+
const janusMsg: Message = {
|
| 294 |
+
id: `j-${Date.now()}`,
|
| 295 |
+
role: 'janus',
|
| 296 |
+
content: finalAnswer,
|
| 297 |
+
timestamp: new Date(),
|
| 298 |
+
metadata: {
|
| 299 |
+
domain: result.domain,
|
| 300 |
+
queryType: result.query_type,
|
| 301 |
+
elapsed: result.elapsed_seconds,
|
| 302 |
+
confidence: result.final?.confidence,
|
| 303 |
+
routeInfo: result.route,
|
| 304 |
+
},
|
| 305 |
+
};
|
| 306 |
+
latestMsgId.current = janusMsg.id;
|
| 307 |
+
setMessages(prev => [...prev, janusMsg]);
|
| 308 |
+
} catch (err) {
|
| 309 |
+
const errStr = err instanceof Error ? err.message : String(err);
|
| 310 |
+
const is429 = errStr.includes('429') || errStr.toLowerCase().includes('rate limit') || errStr.toLowerCase().includes('too many');
|
| 311 |
+
const errorMsg: Message = {
|
| 312 |
+
id: `e-${Date.now()}`,
|
| 313 |
+
role: 'janus',
|
| 314 |
+
content: is429
|
| 315 |
+
? 'Rate limited by OpenRouter (429). The free API tier has usage caps. Please wait 30-60 seconds and try again — the backend will auto-retry with backoff.'
|
| 316 |
+
: `I encountered an error processing your request: ${errStr.slice(0, 200)}. Please check that the backend is running and try again.`,
|
| 317 |
+
timestamp: new Date(),
|
| 318 |
+
};
|
| 319 |
+
setMessages(prev => [...prev, errorMsg]);
|
| 320 |
+
} finally {
|
| 321 |
+
setIsThinking(false);
|
| 322 |
+
}
|
| 323 |
+
}, [isThinking]);
|
| 324 |
|
| 325 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
| 326 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 327 |
+
e.preventDefault();
|
| 328 |
+
sendMessage(input);
|
| 329 |
+
}
|
|
|
|
|
|
|
|
|
|
| 330 |
};
|
| 331 |
|
| 332 |
+
const isEmpty = messages.length === 0 && !isThinking;
|
|
|
|
|
|
|
| 333 |
|
| 334 |
return (
|
| 335 |
+
<div className="h-full flex flex-col">
|
| 336 |
+
{/* Conversation area */}
|
| 337 |
+
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
| 338 |
+
{isEmpty ? (
|
| 339 |
+
<EmptyState onSelect={(q) => { setInput(q); sendMessage(q); }} />
|
| 340 |
+
) : (
|
| 341 |
+
<div className="max-w-[860px] mx-auto px-4 py-8 space-y-6">
|
| 342 |
+
{messages.map((msg) => (
|
| 343 |
+
<MessageBubble
|
| 344 |
+
key={msg.id}
|
| 345 |
+
message={msg}
|
| 346 |
+
isLatest={msg.id === latestMsgId.current && msg.role === 'janus'}
|
| 347 |
+
/>
|
| 348 |
))}
|
| 349 |
+
<AnimatePresence>
|
| 350 |
+
{isThinking && <ThinkingIndicator stage={STAGES[thinkingStage]} />}
|
| 351 |
+
</AnimatePresence>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
</div>
|
| 353 |
)}
|
| 354 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
+
{/* Input bar */}
|
| 357 |
+
<div className="shrink-0 border-t border-white/[0.04] px-4 py-3">
|
| 358 |
+
<div className="max-w-[768px] mx-auto">
|
| 359 |
+
<div className="input-bar flex items-end gap-2 px-4 py-2">
|
| 360 |
+
<textarea
|
| 361 |
+
ref={inputRef}
|
| 362 |
+
value={input}
|
| 363 |
+
onChange={handleInputChange}
|
| 364 |
+
onKeyDown={handleKeyDown}
|
| 365 |
+
disabled={isThinking}
|
| 366 |
+
placeholder="Message Janus..."
|
| 367 |
+
rows={1}
|
| 368 |
+
className="flex-1 bg-transparent text-[14px] text-gray-200 placeholder-gray-600 resize-none focus:outline-none disabled:opacity-40 leading-relaxed py-1.5 max-h-40"
|
| 369 |
+
/>
|
| 370 |
+
<motion.button
|
| 371 |
+
whileHover={{ scale: 1.05 }}
|
| 372 |
+
whileTap={{ scale: 0.95 }}
|
| 373 |
+
onClick={() => sendMessage(input)}
|
| 374 |
+
disabled={isThinking || !input.trim()}
|
| 375 |
+
className="p-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:bg-gray-800 disabled:text-gray-600 text-white transition-all shrink-0 mb-0.5"
|
| 376 |
+
>
|
| 377 |
+
<ArrowUp size={16} />
|
| 378 |
+
</motion.button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
</div>
|
| 380 |
+
<p className="text-center text-[11px] text-gray-700 mt-2">
|
| 381 |
+
Janus can make mistakes. Verify important analysis independently.
|
| 382 |
+
</p>
|
| 383 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
</div>
|
| 386 |
);
|
| 387 |
}
|
frontend/src/app/prompts/page.tsx
CHANGED
|
@@ -1,38 +1,13 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import { useEffect, useState
|
| 4 |
-
import {
|
| 5 |
-
import { Terminal, FileCode2, Brain, Zap, Search, Shield, Hexagon, Info } from 'lucide-react';
|
| 6 |
|
| 7 |
const PROMPTS = [
|
| 8 |
-
{
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
description: 'Routes queries to the right agent pipeline. Classifies domain, complexity, and required tools.',
|
| 13 |
-
color: 'text-indigo-400',
|
| 14 |
-
},
|
| 15 |
-
{
|
| 16 |
-
name: 'research',
|
| 17 |
-
label: 'Research',
|
| 18 |
-
icon: FileCode2,
|
| 19 |
-
description: 'Gathers and analyzes information from web search, news, knowledge base, and API discovery.',
|
| 20 |
-
color: 'text-violet-400',
|
| 21 |
-
},
|
| 22 |
-
{
|
| 23 |
-
name: 'synthesizer',
|
| 24 |
-
label: 'Synthesizer',
|
| 25 |
-
icon: Brain,
|
| 26 |
-
description: 'Produces the final answer by combining research, planning, and simulation outputs.',
|
| 27 |
-
color: 'text-emerald-400',
|
| 28 |
-
},
|
| 29 |
-
{
|
| 30 |
-
name: 'verifier',
|
| 31 |
-
label: 'Verifier',
|
| 32 |
-
icon: Shield,
|
| 33 |
-
description: 'Quality gate — checks analysis for logical soundness, evidence alignment, and completeness.',
|
| 34 |
-
color: 'text-amber-400',
|
| 35 |
-
},
|
| 36 |
];
|
| 37 |
|
| 38 |
export default function PromptsPage() {
|
|
@@ -40,151 +15,106 @@ export default function PromptsPage() {
|
|
| 40 |
const [loading, setLoading] = useState(true);
|
| 41 |
|
| 42 |
useEffect(() => {
|
| 43 |
-
const t = setTimeout(() => {
|
| 44 |
-
setSelectedPrompt(PROMPTS[0]);
|
| 45 |
-
setLoading(false);
|
| 46 |
-
}, 500);
|
| 47 |
return () => clearTimeout(t);
|
| 48 |
}, []);
|
| 49 |
|
| 50 |
if (loading) {
|
| 51 |
return (
|
| 52 |
-
<div className="flex h-
|
| 53 |
-
<div className="flex flex-col items-center gap-
|
| 54 |
-
<
|
| 55 |
-
<
|
| 56 |
-
{[0, 1, 2].map((i) => (
|
| 57 |
-
<div key={i} className="w-1.5 h-1.5 rounded-full bg-indigo-500" style={{ animation: `pulse 1.5s infinite ${i * 0.2}s` }} />
|
| 58 |
-
))}
|
| 59 |
-
</div>
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
);
|
| 63 |
}
|
| 64 |
|
| 65 |
return (
|
| 66 |
-
<div className="h-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
<div className="flex items-center gap-4">
|
| 73 |
-
<div className="w-10 h-10 rounded-2xl glass flex items-center justify-center border border-indigo-500/20">
|
| 74 |
-
<Terminal size={18} className="text-indigo-400" />
|
| 75 |
</div>
|
| 76 |
<div>
|
| 77 |
-
<h1 className="text-
|
| 78 |
-
|
| 79 |
-
</h1>
|
| 80 |
-
<p className="text-[10px] font-mono text-gray-500 uppercase tracking-widest mt-1">
|
| 81 |
-
Cognitive instruction sets for the Janus agent pipeline
|
| 82 |
-
</p>
|
| 83 |
</div>
|
| 84 |
</div>
|
| 85 |
-
</
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
{/* Left
|
| 90 |
-
<div className="w-
|
| 91 |
-
<div className="
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
}`}
|
| 108 |
-
|
| 109 |
-
<
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
<div className="text-[10px] font-mono text-gray-600 line-clamp-2">
|
| 116 |
-
{p.description}
|
| 117 |
-
</div>
|
| 118 |
-
</button>
|
| 119 |
-
);
|
| 120 |
-
})}
|
| 121 |
-
</div>
|
| 122 |
</div>
|
| 123 |
|
| 124 |
-
{/* Right
|
| 125 |
-
<div className="flex-1
|
| 126 |
-
{
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
<div className="max-w-2xl">
|
| 138 |
-
<div className="flex items-center gap-3 mb-6">
|
| 139 |
-
<selectedPrompt.icon size={24} className={selectedPrompt.color} />
|
| 140 |
-
<h2 className="text-xl font-light text-gray-100">{selectedPrompt.label}</h2>
|
| 141 |
</div>
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
<div className="
|
| 149 |
-
<
|
| 150 |
-
|
| 151 |
-
<span className="text-gray-300">{selectedPrompt.name}</span>
|
| 152 |
-
</div>
|
| 153 |
-
<div className="flex justify-between border-b border-white/5 pb-2">
|
| 154 |
-
<span className="text-gray-500">Pipeline Position</span>
|
| 155 |
-
<span className="text-gray-300">
|
| 156 |
-
{selectedPrompt.name === 'switchboard' ? '1st (entry point)' :
|
| 157 |
-
selectedPrompt.name === 'research' ? '2nd (data gathering)' :
|
| 158 |
-
selectedPrompt.name === 'synthesizer' ? '3rd (final output)' :
|
| 159 |
-
'Quality gate (optional)'}
|
| 160 |
-
</span>
|
| 161 |
-
</div>
|
| 162 |
-
<div className="flex justify-between border-b border-white/5 pb-2">
|
| 163 |
-
<span className="text-gray-500">Model Calls</span>
|
| 164 |
-
<span className="text-gray-300">1 per execution</span>
|
| 165 |
-
</div>
|
| 166 |
-
<div className="flex justify-between pb-2">
|
| 167 |
-
<span className="text-gray-500">Editable</span>
|
| 168 |
-
<span className="text-gray-300">Via backend prompt store</span>
|
| 169 |
-
</div>
|
| 170 |
</div>
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
<div className="mt-6 p-4 rounded-xl bg-amber-500/5 border border-amber-500/10">
|
| 174 |
-
<p className="text-xs font-mono text-amber-400/70">
|
| 175 |
-
Prompt files are stored on the cloud backend. To edit prompts, modify the files in <code className="text-amber-300">backend/app/prompts/</code> and redeploy.
|
| 176 |
-
</p>
|
| 177 |
-
</div>
|
| 178 |
</div>
|
| 179 |
-
|
| 180 |
-
<div className="
|
| 181 |
-
<
|
| 182 |
-
|
|
|
|
| 183 |
</div>
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
-
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
);
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { Terminal, FileCode2, Brain, Search, Shield, Info, RefreshCw } from 'lucide-react';
|
|
|
|
| 5 |
|
| 6 |
const PROMPTS = [
|
| 7 |
+
{ name: 'switchboard', label: 'Switchboard', icon: Search, description: 'Routes queries to the right agent pipeline. Classifies domain, complexity, and required tools.', color: 'text-indigo-400' },
|
| 8 |
+
{ name: 'research', label: 'Research', icon: FileCode2, description: 'Gathers and analyzes information from web search, news, knowledge base, and API discovery.', color: 'text-violet-400' },
|
| 9 |
+
{ name: 'synthesizer', label: 'Synthesizer', icon: Brain, description: 'Produces the final answer by combining research, planning, and simulation outputs.', color: 'text-emerald-400' },
|
| 10 |
+
{ name: 'verifier', label: 'Verifier', icon: Shield, description: 'Quality gate — checks analysis for logical soundness, evidence alignment, and completeness.', color: 'text-amber-400' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
];
|
| 12 |
|
| 13 |
export default function PromptsPage() {
|
|
|
|
| 15 |
const [loading, setLoading] = useState(true);
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
+
const t = setTimeout(() => { setSelectedPrompt(PROMPTS[0]); setLoading(false); }, 300);
|
|
|
|
|
|
|
|
|
|
| 19 |
return () => clearTimeout(t);
|
| 20 |
}, []);
|
| 21 |
|
| 22 |
if (loading) {
|
| 23 |
return (
|
| 24 |
+
<div className="flex h-full items-center justify-center">
|
| 25 |
+
<div className="flex flex-col items-center gap-3">
|
| 26 |
+
<RefreshCw size={20} className="text-indigo-400 animate-spin" />
|
| 27 |
+
<p className="text-[12px] text-gray-500">Loading protocols...</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
</div>
|
| 30 |
);
|
| 31 |
}
|
| 32 |
|
| 33 |
return (
|
| 34 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 35 |
+
{/* Header */}
|
| 36 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 37 |
+
<div className="flex items-center gap-3">
|
| 38 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 39 |
+
<Terminal size={16} className="text-indigo-400" />
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
<div>
|
| 42 |
+
<h1 className="text-lg font-light text-gray-100">Prompt Lab</h1>
|
| 43 |
+
<p className="text-[11px] text-gray-600">Agent protocol instruction sets for the Janus pipeline</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
+
</div>
|
| 47 |
|
| 48 |
+
{/* Content */}
|
| 49 |
+
<div className="flex-1 min-h-0 flex overflow-hidden">
|
| 50 |
+
{/* Left: Protocol selector */}
|
| 51 |
+
<div className="w-64 shrink-0 border-r border-white/[0.04] p-4 overflow-y-auto space-y-2">
|
| 52 |
+
<div className="text-[10px] text-gray-600 uppercase tracking-wider mb-3 px-1">Agent Protocols</div>
|
| 53 |
+
{PROMPTS.map(p => {
|
| 54 |
+
const isActive = selectedPrompt?.name === p.name;
|
| 55 |
+
const Icon = p.icon;
|
| 56 |
+
return (
|
| 57 |
+
<button
|
| 58 |
+
key={p.name}
|
| 59 |
+
onClick={() => setSelectedPrompt(p)}
|
| 60 |
+
className={`w-full text-left p-3 rounded-xl transition-all border group ${
|
| 61 |
+
isActive
|
| 62 |
+
? 'bg-white/[0.05] border-indigo-500/20'
|
| 63 |
+
: 'border-transparent hover:bg-white/[0.03] hover:border-white/[0.04]'
|
| 64 |
+
}`}
|
| 65 |
+
>
|
| 66 |
+
<div className="flex items-center gap-2.5 mb-1">
|
| 67 |
+
<Icon size={14} className={isActive ? p.color : 'text-gray-600'} />
|
| 68 |
+
<span className={`text-[12px] ${isActive ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-300'}`}>
|
| 69 |
+
{p.label}
|
| 70 |
+
</span>
|
| 71 |
+
</div>
|
| 72 |
+
<p className="text-[10px] text-gray-600 line-clamp-2 pl-[22px]">{p.description}</p>
|
| 73 |
+
</button>
|
| 74 |
+
);
|
| 75 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
|
| 78 |
+
{/* Right: Detail view */}
|
| 79 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 80 |
+
{selectedPrompt ? (
|
| 81 |
+
<div className="max-w-2xl">
|
| 82 |
+
<div className="flex items-center gap-3 mb-5">
|
| 83 |
+
<selectedPrompt.icon size={22} className={selectedPrompt.color} />
|
| 84 |
+
<h2 className="text-xl font-light text-gray-100">{selectedPrompt.label}</h2>
|
| 85 |
+
</div>
|
| 86 |
+
<p className="text-[13px] text-gray-400 leading-relaxed mb-6">{selectedPrompt.description}</p>
|
| 87 |
|
| 88 |
+
<div className="card p-5 space-y-3">
|
| 89 |
+
<div className="flex items-center gap-2 text-[10px] text-gray-500 uppercase tracking-wider mb-3">
|
| 90 |
+
<Info size={11} /> Protocol Details
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
+
{[
|
| 93 |
+
['Agent Name', selectedPrompt.name],
|
| 94 |
+
['Pipeline Position', selectedPrompt.name === 'switchboard' ? '1st (entry)' : selectedPrompt.name === 'research' ? '2nd (data)' : selectedPrompt.name === 'synthesizer' ? '3rd (output)' : 'Quality gate'],
|
| 95 |
+
['Model Calls', '1 per execution'],
|
| 96 |
+
['Editable', 'Via backend prompt store'],
|
| 97 |
+
].map(([k, v]) => (
|
| 98 |
+
<div key={k} className="flex justify-between text-[12px] border-b border-white/[0.03] pb-2">
|
| 99 |
+
<span className="text-gray-500">{k}</span>
|
| 100 |
+
<span className="text-gray-300">{v}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</div>
|
| 104 |
+
|
| 105 |
+
<div className="mt-5 p-4 rounded-xl bg-amber-500/5 border border-amber-500/10">
|
| 106 |
+
<p className="text-[11px] text-amber-400/70">
|
| 107 |
+
Prompt files are stored on the backend. Edit files in <code className="text-amber-300">backend/app/prompts/</code> and redeploy.
|
| 108 |
+
</p>
|
| 109 |
</div>
|
| 110 |
+
</div>
|
| 111 |
+
) : (
|
| 112 |
+
<div className="flex flex-col items-center justify-center h-full">
|
| 113 |
+
<Terminal size={28} className="text-gray-700 mb-3" />
|
| 114 |
+
<p className="text-[13px] text-gray-500">Select a protocol to view.</p>
|
| 115 |
+
</div>
|
| 116 |
+
)}
|
| 117 |
</div>
|
|
|
|
| 118 |
</div>
|
| 119 |
</div>
|
| 120 |
);
|
frontend/src/app/pulse/page.tsx
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 4 |
+
import { motion } from 'framer-motion';
|
| 5 |
+
import {
|
| 6 |
+
Activity, Radio, Database, Moon, Sun, Sunrise, Sunset,
|
| 7 |
+
AlertTriangle, CheckCircle, ExternalLink, Info, RefreshCw
|
| 8 |
+
} from 'lucide-react';
|
| 9 |
+
|
| 10 |
+
interface DaemonStatus {
|
| 11 |
+
running: boolean;
|
| 12 |
+
cycle_count: number;
|
| 13 |
+
last_run: string;
|
| 14 |
+
circadian: {
|
| 15 |
+
current_phase: string;
|
| 16 |
+
phase_name: string;
|
| 17 |
+
phase_description: string;
|
| 18 |
+
priority: string;
|
| 19 |
+
current_tasks: string[];
|
| 20 |
+
};
|
| 21 |
+
signal_queue?: {
|
| 22 |
+
total_signals: number;
|
| 23 |
+
severity_counts: Record<string, number>;
|
| 24 |
+
};
|
| 25 |
+
signals?: number;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface Alert {
|
| 29 |
+
type: string;
|
| 30 |
+
title: string;
|
| 31 |
+
description: string;
|
| 32 |
+
source: string;
|
| 33 |
+
severity: string;
|
| 34 |
+
timestamp: string;
|
| 35 |
+
url?: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface MemoryStats {
|
| 39 |
+
queries: number;
|
| 40 |
+
entities: number;
|
| 41 |
+
insights: number;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function SeverityBadge({ severity }: { severity: string }) {
|
| 45 |
+
const map: Record<string, string> = {
|
| 46 |
+
critical: 'bg-red-500/15 text-red-300 border-red-500/25',
|
| 47 |
+
high: 'bg-orange-500/15 text-orange-300 border-orange-500/25',
|
| 48 |
+
medium: 'bg-amber-500/15 text-amber-300 border-amber-500/25',
|
| 49 |
+
low: 'bg-gray-500/15 text-gray-400 border-gray-500/25',
|
| 50 |
+
};
|
| 51 |
+
return <span className={`px-2 py-0.5 rounded border text-[9px] uppercase tracking-wider ${map[severity] || map.low}`}>{severity}</span>;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export default function PulsePage() {
|
| 55 |
+
const [status, setStatus] = useState<DaemonStatus | null>(null);
|
| 56 |
+
const [alerts, setAlerts] = useState<Alert[]>([]);
|
| 57 |
+
const [memory, setMemory] = useState<MemoryStats | null>(null);
|
| 58 |
+
const [loading, setLoading] = useState(true);
|
| 59 |
+
|
| 60 |
+
const fetchData = useCallback(async () => {
|
| 61 |
+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 62 |
+
try {
|
| 63 |
+
const [statusRes, alertsRes, memoryRes] = await Promise.all([
|
| 64 |
+
fetch(`${baseUrl}/daemon/status`).then(r => r.ok ? r.json() : null),
|
| 65 |
+
fetch(`${baseUrl}/daemon/alerts?limit=20`).then(r => r.ok ? r.json() : []),
|
| 66 |
+
fetch(`${baseUrl}/memory/stats`).then(r => r.ok ? r.json() : null),
|
| 67 |
+
]);
|
| 68 |
+
if (statusRes) setStatus(statusRes);
|
| 69 |
+
if (alertsRes) setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
|
| 70 |
+
if (memoryRes) setMemory(memoryRes);
|
| 71 |
+
} catch { /* silent */ }
|
| 72 |
+
finally { setLoading(false); }
|
| 73 |
+
}, []);
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
fetchData();
|
| 77 |
+
const iv = setInterval(fetchData, 30000);
|
| 78 |
+
return () => clearInterval(iv);
|
| 79 |
+
}, [fetchData]);
|
| 80 |
+
|
| 81 |
+
const phaseIcons: Record<string, React.ReactNode> = {
|
| 82 |
+
morning: <Sunrise size={16} className="text-amber-400" />,
|
| 83 |
+
daytime: <Sun size={16} className="text-indigo-400" />,
|
| 84 |
+
evening: <Sunset size={16} className="text-violet-400" />,
|
| 85 |
+
night: <Moon size={16} className="text-indigo-300" />,
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
if (loading) {
|
| 89 |
+
return (
|
| 90 |
+
<div className="flex items-center justify-center h-full">
|
| 91 |
+
<div className="flex flex-col items-center gap-3">
|
| 92 |
+
<RefreshCw size={20} className="text-indigo-400 animate-spin" />
|
| 93 |
+
<p className="text-[12px] text-gray-500">Loading pulse data...</p>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return (
|
| 100 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 101 |
+
{/* Header */}
|
| 102 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 103 |
+
<div className="flex items-center gap-3">
|
| 104 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 105 |
+
<Activity size={16} className="text-emerald-400" />
|
| 106 |
+
</div>
|
| 107 |
+
<div>
|
| 108 |
+
<h1 className="text-lg font-light text-gray-100">Pulse</h1>
|
| 109 |
+
<p className="text-[11px] text-gray-600">Daemon telemetry, signal queue, and memory graph</p>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Content */}
|
| 115 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 116 |
+
<div className="max-w-5xl mx-auto">
|
| 117 |
+
{/* Top stats grid */}
|
| 118 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
| 119 |
+
{/* Circadian phase */}
|
| 120 |
+
{status?.circadian && (
|
| 121 |
+
<div className="card p-5">
|
| 122 |
+
<div className="flex items-center gap-3 mb-4">
|
| 123 |
+
{phaseIcons[status.circadian.current_phase]}
|
| 124 |
+
<div>
|
| 125 |
+
<h3 className="text-[14px] text-gray-200">{status.circadian.phase_name}</h3>
|
| 126 |
+
<p className="text-[11px] text-gray-600">{status.circadian.phase_description}</p>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="space-y-2 text-[12px]">
|
| 130 |
+
<div className="flex justify-between"><span className="text-gray-500">Priority</span><span className="text-gray-300 capitalize">{status.circadian.priority}</span></div>
|
| 131 |
+
<div className="flex justify-between"><span className="text-gray-500">Active Tasks</span><span className="text-gray-300">{status.circadian.current_tasks.length}</span></div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
)}
|
| 135 |
+
|
| 136 |
+
{/* Signal Queue */}
|
| 137 |
+
<div className="card p-5">
|
| 138 |
+
<div className="flex items-center gap-2 text-[11px] text-indigo-400 uppercase tracking-wider mb-4">
|
| 139 |
+
<Radio size={12} /> Signal Queue
|
| 140 |
+
</div>
|
| 141 |
+
<div className="grid grid-cols-2 gap-3">
|
| 142 |
+
<div className="text-center">
|
| 143 |
+
<div className="text-xl font-light text-white">{status?.signal_queue?.total_signals || status?.signals || 0}</div>
|
| 144 |
+
<div className="text-[9px] text-gray-600 uppercase tracking-wider">Total</div>
|
| 145 |
+
</div>
|
| 146 |
+
<div className="text-center">
|
| 147 |
+
<div className="text-xl font-light text-red-400">{status?.signal_queue?.severity_counts?.high || 0}</div>
|
| 148 |
+
<div className="text-[9px] text-gray-600 uppercase tracking-wider">High</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
{/* Memory Graph */}
|
| 154 |
+
{memory && (
|
| 155 |
+
<div className="card p-5">
|
| 156 |
+
<div className="flex items-center gap-2 text-[11px] text-violet-400 uppercase tracking-wider mb-4">
|
| 157 |
+
<Database size={12} /> Memory Graph
|
| 158 |
+
</div>
|
| 159 |
+
<div className="grid grid-cols-3 gap-2">
|
| 160 |
+
<div className="text-center">
|
| 161 |
+
<div className="text-xl font-light text-white">{memory.queries}</div>
|
| 162 |
+
<div className="text-[9px] text-gray-600 uppercase">Queries</div>
|
| 163 |
+
</div>
|
| 164 |
+
<div className="text-center">
|
| 165 |
+
<div className="text-xl font-light text-indigo-400">{memory.entities}</div>
|
| 166 |
+
<div className="text-[9px] text-gray-600 uppercase">Entities</div>
|
| 167 |
+
</div>
|
| 168 |
+
<div className="text-center">
|
| 169 |
+
<div className="text-xl font-light text-violet-400">{memory.insights}</div>
|
| 170 |
+
<div className="text-[9px] text-gray-600 uppercase">Insights</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
{/* Daemon status bar */}
|
| 178 |
+
{status && (
|
| 179 |
+
<div className="card p-4 mb-6">
|
| 180 |
+
<div className="flex items-center justify-between">
|
| 181 |
+
<div className="flex items-center gap-3">
|
| 182 |
+
<div className={`w-2 h-2 rounded-full ${status.running ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
| 183 |
+
<span className="text-[12px] text-gray-300">{status.running ? 'Daemon active' : 'Daemon offline'}</span>
|
| 184 |
+
</div>
|
| 185 |
+
<div className="flex items-center gap-4 text-[11px] text-gray-600">
|
| 186 |
+
<span>Cycles: {status.cycle_count}</span>
|
| 187 |
+
{status.last_run && <span>Last run: {new Date(status.last_run).toLocaleTimeString()}</span>}
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
|
| 193 |
+
{/* Alerts feed */}
|
| 194 |
+
<div>
|
| 195 |
+
<div className="text-[11px] text-gray-500 uppercase tracking-wider mb-3">
|
| 196 |
+
Alert Feed — {alerts.length} alerts
|
| 197 |
+
</div>
|
| 198 |
+
{alerts.length > 0 ? (
|
| 199 |
+
<div className="space-y-2">
|
| 200 |
+
{alerts.map((alert, i) => (
|
| 201 |
+
<motion.div
|
| 202 |
+
key={`${alert.title}-${i}`}
|
| 203 |
+
initial={{ opacity: 0, y: 6 }}
|
| 204 |
+
animate={{ opacity: 1, y: 0 }}
|
| 205 |
+
transition={{ delay: i * 0.03 }}
|
| 206 |
+
className="card p-3 hover:border-white/[0.12]"
|
| 207 |
+
>
|
| 208 |
+
<div className="flex items-start gap-3">
|
| 209 |
+
<div className="mt-0.5">
|
| 210 |
+
{alert.severity === 'high' || alert.severity === 'critical'
|
| 211 |
+
? <AlertTriangle size={14} className="text-red-400" />
|
| 212 |
+
: <Info size={14} className="text-amber-400" />}
|
| 213 |
+
</div>
|
| 214 |
+
<div className="flex-1 min-w-0">
|
| 215 |
+
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
| 216 |
+
<SeverityBadge severity={alert.severity} />
|
| 217 |
+
<span className="text-[10px] text-gray-600">{alert.type}</span>
|
| 218 |
+
</div>
|
| 219 |
+
<p className="text-[12px] text-gray-300 leading-snug">{alert.title}</p>
|
| 220 |
+
{alert.description && <p className="text-[11px] text-gray-600 mt-1 line-clamp-1">{alert.description}</p>}
|
| 221 |
+
<div className="flex items-center gap-2 mt-2">
|
| 222 |
+
<span className="text-[10px] text-gray-700">{new Date(alert.timestamp).toLocaleString()}</span>
|
| 223 |
+
{alert.url && (
|
| 224 |
+
<a href={alert.url} target="_blank" rel="noreferrer" className="text-[10px] text-indigo-400 hover:text-indigo-300 flex items-center gap-0.5">
|
| 225 |
+
<ExternalLink size={8} /> Read
|
| 226 |
+
</a>
|
| 227 |
+
)}
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</motion.div>
|
| 232 |
+
))}
|
| 233 |
+
</div>
|
| 234 |
+
) : (
|
| 235 |
+
<div className="card text-center py-12">
|
| 236 |
+
<CheckCircle size={24} className="text-emerald-500/30 mx-auto mb-3" />
|
| 237 |
+
<p className="text-[13px] text-gray-500">No alerts. All systems normal.</p>
|
| 238 |
+
</div>
|
| 239 |
+
)}
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
);
|
| 245 |
+
}
|
frontend/src/app/sentinel/page.tsx
CHANGED
|
@@ -259,7 +259,7 @@ export default function SentinelPage() {
|
|
| 259 |
// ── Loading ──
|
| 260 |
if (loading) {
|
| 261 |
return (
|
| 262 |
-
<div className="flex h-
|
| 263 |
<div className="flex flex-col items-center gap-6">
|
| 264 |
<JanusOrb size={56} thinking />
|
| 265 |
<p className="text-xs font-mono text-indigo-400 uppercase tracking-widest animate-pulse">
|
|
@@ -273,7 +273,7 @@ export default function SentinelPage() {
|
|
| 273 |
// ── Disabled ──
|
| 274 |
if (!status?.sentinel_enabled) {
|
| 275 |
return (
|
| 276 |
-
<div className="flex h-
|
| 277 |
<div className="glass rounded-3xl p-12 text-center max-w-md">
|
| 278 |
<Shield size={40} className="text-gray-600 mx-auto mb-6" />
|
| 279 |
<h2 className="text-lg font-light tracking-[0.15em] text-gradient-subtle uppercase mb-3">
|
|
@@ -292,7 +292,8 @@ export default function SentinelPage() {
|
|
| 292 |
const domainExpertise = intelligence?.domain_expertise || {};
|
| 293 |
|
| 294 |
return (
|
| 295 |
-
<div className="
|
|
|
|
| 296 |
|
| 297 |
{/* ── Background flare ── */}
|
| 298 |
<div className="fixed top-1/3 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-indigo-500/5 blur-[120px] rounded-full pointer-events-none" />
|
|
@@ -881,5 +882,6 @@ export default function SentinelPage() {
|
|
| 881 |
</AnimatePresence>
|
| 882 |
</div>
|
| 883 |
</div>
|
|
|
|
| 884 |
);
|
| 885 |
}
|
|
|
|
| 259 |
// ── Loading ──
|
| 260 |
if (loading) {
|
| 261 |
return (
|
| 262 |
+
<div className="flex h-full items-center justify-center">
|
| 263 |
<div className="flex flex-col items-center gap-6">
|
| 264 |
<JanusOrb size={56} thinking />
|
| 265 |
<p className="text-xs font-mono text-indigo-400 uppercase tracking-widest animate-pulse">
|
|
|
|
| 273 |
// ── Disabled ──
|
| 274 |
if (!status?.sentinel_enabled) {
|
| 275 |
return (
|
| 276 |
+
<div className="flex h-full flex-col items-center justify-center">
|
| 277 |
<div className="glass rounded-3xl p-12 text-center max-w-md">
|
| 278 |
<Shield size={40} className="text-gray-600 mx-auto mb-6" />
|
| 279 |
<h2 className="text-lg font-light tracking-[0.15em] text-gradient-subtle uppercase mb-3">
|
|
|
|
| 292 |
const domainExpertise = intelligence?.domain_expertise || {};
|
| 293 |
|
| 294 |
return (
|
| 295 |
+
<div className="h-full overflow-y-auto">
|
| 296 |
+
<div className="max-w-[1400px] mx-auto px-6 py-8 relative">
|
| 297 |
|
| 298 |
{/* ── Background flare ── */}
|
| 299 |
<div className="fixed top-1/3 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-indigo-500/5 blur-[120px] rounded-full pointer-events-none" />
|
|
|
|
| 882 |
</AnimatePresence>
|
| 883 |
</div>
|
| 884 |
</div>
|
| 885 |
+
</div>
|
| 886 |
);
|
| 887 |
}
|
frontend/src/app/simulation/page.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import { useState, useEffect, useCallback } from 'react';
|
| 4 |
import { useRouter } from 'next/navigation';
|
| 5 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
-
import {
|
| 7 |
import { apiClient } from '@/lib/api';
|
| 8 |
|
| 9 |
interface SimulationSummary {
|
|
@@ -22,17 +22,12 @@ export default function SimulationPage() {
|
|
| 22 |
const [simulations, setSimulations] = useState<SimulationSummary[]>([]);
|
| 23 |
const [refreshing, setRefreshing] = useState(false);
|
| 24 |
|
| 25 |
-
const [form, setForm] = useState({
|
| 26 |
-
title: '',
|
| 27 |
-
seedText: '',
|
| 28 |
-
predictionGoal: ''
|
| 29 |
-
});
|
| 30 |
|
| 31 |
const loadSimulations = useCallback(async () => {
|
| 32 |
setRefreshing(true);
|
| 33 |
try {
|
| 34 |
const data = await apiClient.getSimulations();
|
| 35 |
-
// Map SimulationRecord to SimulationSummary
|
| 36 |
const summaries: SimulationSummary[] = (Array.isArray(data) ? data : []).map((sim: any) => ({
|
| 37 |
simulation_id: sim.simulation_id,
|
| 38 |
user_input: sim.user_input || sim.title || '',
|
|
@@ -42,215 +37,168 @@ export default function SimulationPage() {
|
|
| 42 |
created_at: sim.created_at || 0,
|
| 43 |
}));
|
| 44 |
setSimulations(summaries);
|
| 45 |
-
} catch {
|
| 46 |
-
|
| 47 |
-
} finally {
|
| 48 |
-
setRefreshing(false);
|
| 49 |
-
}
|
| 50 |
}, []);
|
| 51 |
|
| 52 |
-
useEffect(() => {
|
| 53 |
-
loadSimulations();
|
| 54 |
-
}, [loadSimulations]);
|
| 55 |
|
| 56 |
const handleSubmit = async () => {
|
| 57 |
if (!form.title || !form.seedText || !form.predictionGoal) {
|
| 58 |
setError('All simulation parameters are required.');
|
| 59 |
return;
|
| 60 |
}
|
| 61 |
-
|
| 62 |
-
setLoading(true);
|
| 63 |
-
setError(null);
|
| 64 |
-
|
| 65 |
try {
|
| 66 |
-
// Run native simulation directly
|
| 67 |
const userInput = `${form.title}. ${form.seedText}. Goal: ${form.predictionGoal}`;
|
| 68 |
const result = await apiClient.runNativeSimulation(userInput, {
|
| 69 |
-
title: form.title,
|
| 70 |
-
seed_text: form.seedText,
|
| 71 |
-
prediction_goal: form.predictionGoal,
|
| 72 |
});
|
| 73 |
-
|
| 74 |
-
// Navigate to simulation detail
|
| 75 |
if (result.simulation_id) {
|
| 76 |
router.push(`/simulation/${result.simulation_id}`);
|
| 77 |
} else {
|
| 78 |
-
setError('Simulation completed but no ID
|
| 79 |
setLoading(false);
|
| 80 |
}
|
| 81 |
} catch (err) {
|
| 82 |
-
setError(err instanceof Error ? err.message : 'Failed to
|
| 83 |
setLoading(false);
|
| 84 |
}
|
| 85 |
};
|
| 86 |
|
| 87 |
return (
|
| 88 |
-
<div className="
|
| 89 |
-
|
|
|
|
| 90 |
<div className="flex items-center justify-between">
|
| 91 |
-
<div className="flex items-center gap-
|
| 92 |
-
<div className="relative
|
| 93 |
-
<
|
| 94 |
-
{loading && <div className="absolute inset-0 rounded-
|
| 95 |
</div>
|
| 96 |
<div>
|
| 97 |
-
<h1 className="text-
|
| 98 |
-
|
| 99 |
-
</h1>
|
| 100 |
-
<div className="flex items-center gap-2 mt-1">
|
| 101 |
-
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
| 102 |
-
<span className="text-[10px] font-mono text-amber-400/70 uppercase tracking-widest">
|
| 103 |
-
Native Simulation Engine Online
|
| 104 |
-
</span>
|
| 105 |
-
</div>
|
| 106 |
</div>
|
| 107 |
</div>
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
onClick={loadSimulations}
|
| 111 |
-
disabled={refreshing}
|
| 112 |
-
className="flex items-center gap-2 px-4 py-2 rounded-xl glass border border-white/5 hover:border-amber-500/20 text-xs font-mono text-gray-400 hover:text-amber-300 transition-all disabled:opacity-40"
|
| 113 |
-
>
|
| 114 |
-
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
|
| 115 |
-
Refresh
|
| 116 |
</button>
|
| 117 |
</div>
|
| 118 |
-
</
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
<div className="lg:col-span-7">
|
| 124 |
-
<div className="glass rounded-3xl p-8 relative overflow-hidden group">
|
| 125 |
-
<div className="absolute -top-24 -right-24 w-64 h-64 bg-amber-500/5 blur-3xl rounded-full pointer-events-none" />
|
| 126 |
-
|
| 127 |
-
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-widest mb-8 flex items-center gap-2">
|
| 128 |
-
<Hexagon size={14} className="text-amber-400/50" />
|
| 129 |
-
Initialize New Scenario
|
| 130 |
-
</h2>
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
<
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
<div className="space-y-2">
|
| 141 |
-
<label className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">Scenario Title</label>
|
| 142 |
<div className="relative">
|
| 143 |
-
<
|
| 144 |
-
<Target size={14} className="text-amber-500/50" />
|
| 145 |
-
</div>
|
| 146 |
<input
|
| 147 |
value={form.title}
|
| 148 |
-
onChange={
|
| 149 |
disabled={loading}
|
| 150 |
placeholder="e.g. US Debt Default Simulation"
|
| 151 |
-
className="w-full
|
|
|
|
| 152 |
/>
|
| 153 |
</div>
|
| 154 |
</div>
|
| 155 |
|
| 156 |
-
<div className="space-y-
|
| 157 |
-
<label className="text-[10px]
|
| 158 |
<div className="relative">
|
| 159 |
-
<
|
| 160 |
-
<ListOrdered size={14} className="text-amber-500/50" />
|
| 161 |
-
</div>
|
| 162 |
<textarea
|
| 163 |
value={form.seedText}
|
| 164 |
-
onChange={
|
| 165 |
disabled={loading}
|
| 166 |
-
placeholder="
|
| 167 |
-
rows={
|
| 168 |
-
className="w-full
|
|
|
|
| 169 |
/>
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
|
| 173 |
-
<div className="space-y-
|
| 174 |
-
<label className="text-[10px]
|
| 175 |
<div className="relative">
|
| 176 |
-
<
|
| 177 |
-
<Share2 size={14} className="text-amber-500/50" />
|
| 178 |
-
</div>
|
| 179 |
<input
|
| 180 |
value={form.predictionGoal}
|
| 181 |
-
onChange={
|
| 182 |
disabled={loading}
|
| 183 |
-
placeholder="e.g.
|
| 184 |
-
className="w-full
|
|
|
|
| 185 |
/>
|
| 186 |
</div>
|
| 187 |
</div>
|
| 188 |
|
| 189 |
-
<
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
>
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
<span className={`text-xs font-mono uppercase tracking-widest ${loading ? 'text-gray-500' : 'text-amber-300'}`}>
|
| 198 |
-
{loading ? 'Running Simulation...' : 'Launch Simulation'}
|
| 199 |
-
</span>
|
| 200 |
-
</button>
|
| 201 |
-
</div>
|
| 202 |
-
|
| 203 |
</div>
|
| 204 |
</div>
|
| 205 |
-
</div>
|
| 206 |
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
<
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
<
|
| 233 |
-
<div className={`w-1.5 h-1.5 rounded-full ${sim.status === 'completed' ? 'bg-emerald-400' : sim.status === 'failed' ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
|
| 234 |
-
<span className="text-[10px] font-mono text-gray-500 uppercase tracking-widest">
|
| 235 |
-
{sim.status}
|
| 236 |
-
</span>
|
| 237 |
-
</div>
|
| 238 |
-
<span className="text-[10px] font-mono text-gray-600 uppercase">
|
| 239 |
-
{sim.simulation_id.slice(0, 8)}
|
| 240 |
-
</span>
|
| 241 |
-
</div>
|
| 242 |
-
<h4 className="text-sm font-medium text-gray-200 group-hover:text-amber-300 transition-colors line-clamp-1 mb-1">
|
| 243 |
-
{sim.user_input}
|
| 244 |
-
</h4>
|
| 245 |
-
<div className="flex items-center gap-3 text-[10px] font-mono text-gray-600">
|
| 246 |
-
<span>{sim.scenarios} scenarios</span>
|
| 247 |
-
<span>·</span>
|
| 248 |
-
<span>{sim.elapsed_seconds}s</span>
|
| 249 |
</div>
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
</div>
|
| 255 |
</div>
|
| 256 |
</div>
|
|
|
|
| 3 |
import { useState, useEffect, useCallback } from 'react';
|
| 4 |
import { useRouter } from 'next/navigation';
|
| 5 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
+
import { Zap, Target, ListOrdered, Share2, RefreshCw } from 'lucide-react';
|
| 7 |
import { apiClient } from '@/lib/api';
|
| 8 |
|
| 9 |
interface SimulationSummary {
|
|
|
|
| 22 |
const [simulations, setSimulations] = useState<SimulationSummary[]>([]);
|
| 23 |
const [refreshing, setRefreshing] = useState(false);
|
| 24 |
|
| 25 |
+
const [form, setForm] = useState({ title: '', seedText: '', predictionGoal: '' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const loadSimulations = useCallback(async () => {
|
| 28 |
setRefreshing(true);
|
| 29 |
try {
|
| 30 |
const data = await apiClient.getSimulations();
|
|
|
|
| 31 |
const summaries: SimulationSummary[] = (Array.isArray(data) ? data : []).map((sim: any) => ({
|
| 32 |
simulation_id: sim.simulation_id,
|
| 33 |
user_input: sim.user_input || sim.title || '',
|
|
|
|
| 37 |
created_at: sim.created_at || 0,
|
| 38 |
}));
|
| 39 |
setSimulations(summaries);
|
| 40 |
+
} catch { setSimulations([]); }
|
| 41 |
+
finally { setRefreshing(false); }
|
|
|
|
|
|
|
|
|
|
| 42 |
}, []);
|
| 43 |
|
| 44 |
+
useEffect(() => { loadSimulations(); }, [loadSimulations]);
|
|
|
|
|
|
|
| 45 |
|
| 46 |
const handleSubmit = async () => {
|
| 47 |
if (!form.title || !form.seedText || !form.predictionGoal) {
|
| 48 |
setError('All simulation parameters are required.');
|
| 49 |
return;
|
| 50 |
}
|
| 51 |
+
setLoading(true); setError(null);
|
|
|
|
|
|
|
|
|
|
| 52 |
try {
|
|
|
|
| 53 |
const userInput = `${form.title}. ${form.seedText}. Goal: ${form.predictionGoal}`;
|
| 54 |
const result = await apiClient.runNativeSimulation(userInput, {
|
| 55 |
+
title: form.title, seed_text: form.seedText, prediction_goal: form.predictionGoal,
|
|
|
|
|
|
|
| 56 |
});
|
|
|
|
|
|
|
| 57 |
if (result.simulation_id) {
|
| 58 |
router.push(`/simulation/${result.simulation_id}`);
|
| 59 |
} else {
|
| 60 |
+
setError('Simulation completed but no ID returned.');
|
| 61 |
setLoading(false);
|
| 62 |
}
|
| 63 |
} catch (err) {
|
| 64 |
+
setError(err instanceof Error ? err.message : 'Failed to run simulation.');
|
| 65 |
setLoading(false);
|
| 66 |
}
|
| 67 |
};
|
| 68 |
|
| 69 |
return (
|
| 70 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 71 |
+
{/* Header */}
|
| 72 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 73 |
<div className="flex items-center justify-between">
|
| 74 |
+
<div className="flex items-center gap-3">
|
| 75 |
+
<div className="relative w-9 h-9 rounded-xl bg-amber-500/10 border border-amber-500/15 flex items-center justify-center">
|
| 76 |
+
<Zap size={16} className="text-amber-400" />
|
| 77 |
+
{loading && <div className="absolute inset-0 rounded-xl border-2 border-amber-400/40 border-t-transparent animate-spin" />}
|
| 78 |
</div>
|
| 79 |
<div>
|
| 80 |
+
<h1 className="text-lg font-light text-gray-100">Simulation Lab</h1>
|
| 81 |
+
<p className="text-[11px] text-gray-600">Scenario modeling and prediction engine</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
+
<button onClick={loadSimulations} disabled={refreshing} className="flex items-center gap-2 px-3 py-1.5 rounded-xl border border-white/[0.06] hover:border-white/[0.12] text-[11px] text-gray-500 hover:text-gray-300 transition-all disabled:opacity-40">
|
| 85 |
+
<RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} /> Refresh
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</button>
|
| 87 |
</div>
|
| 88 |
+
</div>
|
| 89 |
|
| 90 |
+
{/* Content */}
|
| 91 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 92 |
+
<div className="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
+
{/* Form */}
|
| 95 |
+
<div className="lg:col-span-7">
|
| 96 |
+
<div className="card p-6 space-y-5">
|
| 97 |
+
<h2 className="text-[11px] text-amber-400 uppercase tracking-wider flex items-center gap-2">
|
| 98 |
+
<Zap size={12} /> New Simulation
|
| 99 |
+
</h2>
|
| 100 |
+
|
| 101 |
+
{error && (
|
| 102 |
+
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/15 text-[12px] text-red-400">{error}</div>
|
| 103 |
+
)}
|
| 104 |
|
| 105 |
+
<div className="space-y-1.5">
|
| 106 |
+
<label className="text-[10px] text-gray-500 uppercase tracking-wider">Scenario Title</label>
|
|
|
|
|
|
|
| 107 |
<div className="relative">
|
| 108 |
+
<Target size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-600" />
|
|
|
|
|
|
|
| 109 |
<input
|
| 110 |
value={form.title}
|
| 111 |
+
onChange={e => setForm({ ...form, title: e.target.value })}
|
| 112 |
disabled={loading}
|
| 113 |
placeholder="e.g. US Debt Default Simulation"
|
| 114 |
+
className="w-full py-2.5 pl-9 pr-4 text-[13px] text-gray-200 rounded-xl border border-white/[0.06] focus:border-amber-500/30 focus:outline-none transition-colors placeholder:text-gray-700"
|
| 115 |
+
style={{ background: 'rgba(0,0,0,0.3)' }}
|
| 116 |
/>
|
| 117 |
</div>
|
| 118 |
</div>
|
| 119 |
|
| 120 |
+
<div className="space-y-1.5">
|
| 121 |
+
<label className="text-[10px] text-gray-500 uppercase tracking-wider">Initial Context</label>
|
| 122 |
<div className="relative">
|
| 123 |
+
<ListOrdered size={14} className="absolute left-3 top-3 text-gray-600" />
|
|
|
|
|
|
|
| 124 |
<textarea
|
| 125 |
value={form.seedText}
|
| 126 |
+
onChange={e => setForm({ ...form, seedText: e.target.value })}
|
| 127 |
disabled={loading}
|
| 128 |
+
placeholder="Background facts, market state, constraints..."
|
| 129 |
+
rows={3}
|
| 130 |
+
className="w-full py-2.5 pl-9 pr-4 text-[13px] text-gray-200 rounded-xl border border-white/[0.06] focus:border-amber-500/30 focus:outline-none transition-colors placeholder:text-gray-700 resize-none"
|
| 131 |
+
style={{ background: 'rgba(0,0,0,0.3)' }}
|
| 132 |
/>
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
|
| 136 |
+
<div className="space-y-1.5">
|
| 137 |
+
<label className="text-[10px] text-gray-500 uppercase tracking-wider">Prediction Goal</label>
|
| 138 |
<div className="relative">
|
| 139 |
+
<Share2 size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-600" />
|
|
|
|
|
|
|
| 140 |
<input
|
| 141 |
value={form.predictionGoal}
|
| 142 |
+
onChange={e => setForm({ ...form, predictionGoal: e.target.value })}
|
| 143 |
disabled={loading}
|
| 144 |
+
placeholder="e.g. Impact on 10-year treasury yields?"
|
| 145 |
+
className="w-full py-2.5 pl-9 pr-4 text-[13px] text-gray-200 rounded-xl border border-white/[0.06] focus:border-amber-500/30 focus:outline-none transition-colors placeholder:text-gray-700"
|
| 146 |
+
style={{ background: 'rgba(0,0,0,0.3)' }}
|
| 147 |
/>
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
|
| 151 |
+
<button
|
| 152 |
+
onClick={handleSubmit}
|
| 153 |
+
disabled={loading}
|
| 154 |
+
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-amber-500/10 border border-amber-500/20 hover:bg-amber-500/20 disabled:opacity-40 transition-all text-[12px] text-amber-300"
|
| 155 |
+
>
|
| 156 |
+
<Zap size={14} className={loading ? 'animate-pulse' : ''} />
|
| 157 |
+
{loading ? 'Running Simulation...' : 'Launch Simulation'}
|
| 158 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</div>
|
| 160 |
</div>
|
|
|
|
| 161 |
|
| 162 |
+
{/* Recent simulations */}
|
| 163 |
+
<div className="lg:col-span-5 space-y-3">
|
| 164 |
+
<div className="flex items-center justify-between mb-1">
|
| 165 |
+
<span className="text-[11px] text-gray-500 uppercase tracking-wider">Recent Executions</span>
|
| 166 |
+
<span className="text-[10px] text-gray-600">{simulations.length} total</span>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
{simulations.length === 0 ? (
|
| 170 |
+
<div className="card text-center py-12">
|
| 171 |
+
<Zap size={24} className="text-gray-700 mx-auto mb-3" />
|
| 172 |
+
<p className="text-[12px] text-gray-600">No simulations yet.</p>
|
| 173 |
+
</div>
|
| 174 |
+
) : (
|
| 175 |
+
simulations.map((sim, i) => (
|
| 176 |
+
<motion.div
|
| 177 |
+
key={sim.simulation_id}
|
| 178 |
+
initial={{ opacity: 0, x: 12 }}
|
| 179 |
+
animate={{ opacity: 1, x: 0 }}
|
| 180 |
+
transition={{ delay: i * 0.04 }}
|
| 181 |
+
onClick={() => router.push(`/simulation/${sim.simulation_id}`)}
|
| 182 |
+
className="card hover:border-amber-500/20 cursor-pointer group"
|
| 183 |
+
>
|
| 184 |
+
<div className="flex items-center justify-between mb-2">
|
| 185 |
+
<div className="flex items-center gap-2">
|
| 186 |
+
<div className={`w-1.5 h-1.5 rounded-full ${sim.status === 'completed' ? 'bg-emerald-400' : sim.status === 'failed' ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
|
| 187 |
+
<span className="text-[10px] text-gray-500 uppercase tracking-wider">{sim.status}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
</div>
|
| 189 |
+
<span className="text-[10px] text-gray-600">{sim.simulation_id.slice(0, 8)}</span>
|
| 190 |
+
</div>
|
| 191 |
+
<h4 className="text-[13px] text-gray-300 group-hover:text-amber-300 transition-colors line-clamp-1 mb-1">
|
| 192 |
+
{sim.user_input}
|
| 193 |
+
</h4>
|
| 194 |
+
<div className="flex items-center gap-3 text-[10px] text-gray-600">
|
| 195 |
+
<span>{sim.scenarios} scenarios</span>
|
| 196 |
+
<span>·</span>
|
| 197 |
+
<span>{sim.elapsed_seconds}s</span>
|
| 198 |
+
</div>
|
| 199 |
+
</motion.div>
|
| 200 |
+
))
|
| 201 |
+
)}
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
</div>
|
frontend/src/app/workspace/page.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from 'react';
|
| 4 |
+
import { motion } from 'framer-motion';
|
| 5 |
+
import { Brain, Sparkles, RefreshCw, Lightbulb } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function WorkspacePage() {
|
| 8 |
+
const [curiosity, setCuriosity] = useState<any>(null);
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [triggering, setTriggering] = useState(false);
|
| 11 |
+
|
| 12 |
+
const fetchData = useCallback(async () => {
|
| 13 |
+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 14 |
+
try {
|
| 15 |
+
const res = await fetch(`${baseUrl}/daemon/curiosity`);
|
| 16 |
+
if (res.ok) setCuriosity(await res.json());
|
| 17 |
+
} catch { /* silent */ }
|
| 18 |
+
finally { setLoading(false); }
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
useEffect(() => { fetchData(); }, [fetchData]);
|
| 22 |
+
|
| 23 |
+
const triggerCuriosity = async () => {
|
| 24 |
+
setTriggering(true);
|
| 25 |
+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
| 26 |
+
try {
|
| 27 |
+
const res = await fetch(`${baseUrl}/daemon/curiosity/now`, { method: 'POST' });
|
| 28 |
+
if (res.ok) {
|
| 29 |
+
const data = await res.json();
|
| 30 |
+
setCuriosity((prev: any) => prev
|
| 31 |
+
? { ...prev, discoveries: [...(prev.discoveries || []), ...(data.discoveries || [])], total_discoveries: (prev.total_discoveries || 0) + (data.discoveries || []).length }
|
| 32 |
+
: data
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
} catch { /* silent */ }
|
| 36 |
+
finally { setTriggering(false); }
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<div className="h-full flex flex-col overflow-hidden">
|
| 41 |
+
{/* Header */}
|
| 42 |
+
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-white/[0.04]">
|
| 43 |
+
<div className="flex items-center justify-between">
|
| 44 |
+
<div className="flex items-center gap-3">
|
| 45 |
+
<div className="w-9 h-9 rounded-xl bg-white/[0.04] border border-white/[0.06] flex items-center justify-center">
|
| 46 |
+
<Brain size={16} className="text-violet-400" />
|
| 47 |
+
</div>
|
| 48 |
+
<div>
|
| 49 |
+
<h1 className="text-lg font-light text-gray-100">Workspace</h1>
|
| 50 |
+
<p className="text-[11px] text-gray-600">Janus curiosity engine — autonomous discovery</p>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
<button
|
| 54 |
+
onClick={triggerCuriosity}
|
| 55 |
+
disabled={triggering}
|
| 56 |
+
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/15 text-[12px] text-violet-400 transition-all disabled:opacity-40"
|
| 57 |
+
>
|
| 58 |
+
<Sparkles size={12} className={triggering ? 'animate-pulse' : ''} />
|
| 59 |
+
{triggering ? 'Exploring...' : 'Explore'}
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{/* Content */}
|
| 65 |
+
<div className="flex-1 overflow-y-auto p-6">
|
| 66 |
+
{loading ? (
|
| 67 |
+
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
| 68 |
+
<RefreshCw size={20} className="text-violet-400 animate-spin" />
|
| 69 |
+
<p className="text-[12px] text-gray-500">Loading workspace...</p>
|
| 70 |
+
</div>
|
| 71 |
+
) : (
|
| 72 |
+
<div className="max-w-3xl mx-auto space-y-4">
|
| 73 |
+
{/* Stats */}
|
| 74 |
+
<div className="grid grid-cols-2 gap-4">
|
| 75 |
+
<div className="card p-5 text-center">
|
| 76 |
+
<div className="text-2xl font-light text-white">{curiosity?.total_discoveries || 0}</div>
|
| 77 |
+
<div className="text-[10px] text-gray-600 uppercase tracking-wider mt-1">Discoveries</div>
|
| 78 |
+
</div>
|
| 79 |
+
<div className="card p-5 text-center">
|
| 80 |
+
<div className="text-2xl font-light text-violet-400">{curiosity?.total_interests || 0}</div>
|
| 81 |
+
<div className="text-[10px] text-gray-600 uppercase tracking-wider mt-1">Topics of Interest</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{/* Discoveries list */}
|
| 86 |
+
<div className="card p-5">
|
| 87 |
+
<div className="flex items-center gap-2 text-[11px] text-violet-400 uppercase tracking-wider mb-5">
|
| 88 |
+
<Lightbulb size={13} /> Recent Discoveries
|
| 89 |
+
</div>
|
| 90 |
+
<div className="space-y-3">
|
| 91 |
+
{(curiosity?.discoveries || []).slice(-10).reverse().map((d: any, i: number) => (
|
| 92 |
+
<motion.div
|
| 93 |
+
key={i}
|
| 94 |
+
initial={{ opacity: 0, y: 6 }}
|
| 95 |
+
animate={{ opacity: 1, y: 0 }}
|
| 96 |
+
transition={{ delay: i * 0.04 }}
|
| 97 |
+
className="p-3 rounded-xl border border-white/[0.04] hover:border-white/[0.08] transition-colors"
|
| 98 |
+
style={{ background: 'rgba(255,255,255,0.015)' }}
|
| 99 |
+
>
|
| 100 |
+
<div className="flex items-center gap-2 mb-1.5">
|
| 101 |
+
<span className="text-[10px] text-violet-400 bg-violet-500/10 px-2 py-0.5 rounded-full">{d.topic}</span>
|
| 102 |
+
<span className="text-[10px] text-gray-700">{new Date(d.timestamp).toLocaleTimeString()}</span>
|
| 103 |
+
</div>
|
| 104 |
+
<p className="text-[13px] text-gray-300 leading-relaxed">{d.insight}</p>
|
| 105 |
+
</motion.div>
|
| 106 |
+
))}
|
| 107 |
+
{(!curiosity?.discoveries || curiosity.discoveries.length === 0) && (
|
| 108 |
+
<div className="text-center py-12">
|
| 109 |
+
<Brain size={28} className="text-gray-700 mx-auto mb-3" />
|
| 110 |
+
<p className="text-[13px] text-gray-500">No discoveries yet.</p>
|
| 111 |
+
<p className="text-[11px] text-gray-700 mt-1">Click "Explore" to trigger the curiosity engine.</p>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Interests */}
|
| 118 |
+
{curiosity?.interests && Object.keys(curiosity.interests).length > 0 && (
|
| 119 |
+
<div className="card p-5">
|
| 120 |
+
<div className="text-[11px] text-gray-500 uppercase tracking-wider mb-4">Topics of Interest</div>
|
| 121 |
+
<div className="flex flex-wrap gap-2">
|
| 122 |
+
{Object.entries(curiosity.interests).map(([topic, score]: [string, any]) => (
|
| 123 |
+
<span key={topic} className="px-3 py-1.5 rounded-full text-[11px] text-gray-400 border border-white/[0.06] bg-white/[0.02]">
|
| 124 |
+
{topic} <span className="text-gray-600 ml-1">{typeof score === 'number' ? Math.round(score * 100) + '%' : ''}</span>
|
| 125 |
+
</span>
|
| 126 |
+
))}
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
}
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -4,24 +4,51 @@ import { useState, useEffect, useCallback } from 'react';
|
|
| 4 |
import { usePathname, useRouter } from 'next/navigation';
|
| 5 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
import {
|
| 7 |
-
|
| 8 |
-
Zap,
|
| 9 |
-
ChevronLeft, ChevronRight, Menu, X
|
| 10 |
} from 'lucide-react';
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
{
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
];
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
export default function AppShell({ children }: { children: React.ReactNode }) {
|
| 26 |
const pathname = usePathname();
|
| 27 |
const router = useRouter();
|
|
@@ -43,150 +70,166 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
|
| 43 |
return () => clearInterval(iv);
|
| 44 |
}, [fetchStatus]);
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const isActive = (path: string) => {
|
| 47 |
if (path === '/') return pathname === '/';
|
| 48 |
return pathname?.startsWith(path);
|
| 49 |
};
|
| 50 |
|
| 51 |
-
const
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
className="
|
| 61 |
-
|
| 62 |
-
|
|
|
|
| 63 |
)}
|
| 64 |
-
</
|
| 65 |
|
| 66 |
-
{/*
|
| 67 |
-
<
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
JANUS
|
| 77 |
-
</motion.span>
|
| 78 |
-
)}
|
| 79 |
-
<button onClick={() => setCollapsed(!collapsed)} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500 hover:text-gray-300 transition-colors">
|
| 80 |
-
{collapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
|
| 81 |
-
</button>
|
| 82 |
-
</div>
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
const Icon = item.icon;
|
| 96 |
return (
|
| 97 |
<button
|
| 98 |
-
key={item.
|
| 99 |
-
onClick={() => router.push(item.
|
| 100 |
-
className={`w-full flex items-center gap-
|
| 101 |
active
|
| 102 |
-
? 'bg-
|
| 103 |
-
: 'text-gray-500 hover:text-gray-300 hover:bg-white/
|
| 104 |
-
} ${collapsed ? 'justify-center' : ''}`}
|
| 105 |
-
title={collapsed ? item.label : undefined}
|
| 106 |
>
|
| 107 |
-
<Icon size={16} className=
|
| 108 |
-
{!collapsed && (
|
| 109 |
-
<
|
| 110 |
-
{item.label}
|
| 111 |
-
</motion.span>
|
| 112 |
)}
|
| 113 |
</button>
|
| 114 |
);
|
| 115 |
})}
|
| 116 |
</div>
|
| 117 |
-
))}
|
| 118 |
-
</nav>
|
| 119 |
-
|
| 120 |
-
{/* Status */}
|
| 121 |
-
{!collapsed && (
|
| 122 |
-
<div className="px-4 py-3 border-t border-white/5">
|
| 123 |
-
<div className="flex items-center gap-2">
|
| 124 |
-
<div className={`w-1.5 h-1.5 rounded-full ${daemonStatus?.running ? 'bg-emerald-400 animate-pulse' : 'bg-gray-600'}`} />
|
| 125 |
-
<span className="text-[10px] font-mono text-gray-500 uppercase tracking-wider">
|
| 126 |
-
{daemonStatus?.running ? 'Living' : 'Offline'}
|
| 127 |
-
</span>
|
| 128 |
-
</div>
|
| 129 |
-
{daemonStatus?.circadian && (
|
| 130 |
-
<div className="text-[9px] font-mono text-gray-700 mt-1 capitalize">
|
| 131 |
-
{daemonStatus.circadian.current_phase} phase
|
| 132 |
-
</div>
|
| 133 |
-
)}
|
| 134 |
</div>
|
| 135 |
-
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
</motion.aside>
|
| 137 |
|
| 138 |
-
{/* Mobile
|
| 139 |
-
<div className="lg:hidden fixed top-0 left-0 right-0 h-
|
| 140 |
-
<button onClick={() => setMobileOpen(true)} className="p-
|
| 141 |
<Menu size={18} />
|
| 142 |
</button>
|
| 143 |
-
<
|
|
|
|
|
|
|
|
|
|
| 144 |
<div className="w-8" />
|
| 145 |
</div>
|
| 146 |
|
| 147 |
-
{/* Mobile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
<AnimatePresence>
|
| 149 |
{mobileOpen && (
|
| 150 |
<motion.div
|
| 151 |
-
initial={{ x: -
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
>
|
| 155 |
-
<div className="
|
| 156 |
-
<
|
| 157 |
-
<button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500">
|
| 158 |
<X size={16} />
|
| 159 |
</button>
|
| 160 |
</div>
|
| 161 |
-
<
|
| 162 |
-
{sections.map(section => (
|
| 163 |
-
<div key={section}>
|
| 164 |
-
<div className="px-3 py-2 text-[9px] font-mono text-gray-600 uppercase tracking-widest">{section}</div>
|
| 165 |
-
{navItems.filter(item => item.section === section).map(item => {
|
| 166 |
-
const active = isActive(item.id);
|
| 167 |
-
const Icon = item.icon;
|
| 168 |
-
return (
|
| 169 |
-
<button
|
| 170 |
-
key={item.id}
|
| 171 |
-
onClick={() => { router.push(item.id); setMobileOpen(false); }}
|
| 172 |
-
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm ${
|
| 173 |
-
active ? 'bg-indigo-500/10 text-indigo-300 border border-indigo-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-white/5 border border-transparent'
|
| 174 |
-
}`}
|
| 175 |
-
>
|
| 176 |
-
<Icon size={16} className="shrink-0" />
|
| 177 |
-
<span className="font-mono text-[11px] uppercase tracking-wider">{item.label}</span>
|
| 178 |
-
</button>
|
| 179 |
-
);
|
| 180 |
-
})}
|
| 181 |
-
</div>
|
| 182 |
-
))}
|
| 183 |
-
</nav>
|
| 184 |
</motion.div>
|
| 185 |
)}
|
| 186 |
</AnimatePresence>
|
| 187 |
|
| 188 |
-
{/* Main
|
| 189 |
-
<main className="flex-1 min-w-0 overflow-hidden pt-
|
| 190 |
{children}
|
| 191 |
</main>
|
| 192 |
</div>
|
|
|
|
| 4 |
import { usePathname, useRouter } from 'next/navigation';
|
| 5 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
import {
|
| 7 |
+
MessageSquare, Globe, BarChart3, Layers, Activity,
|
| 8 |
+
Zap, Shield, Terminal, Settings, Plus,
|
| 9 |
+
ChevronLeft, ChevronRight, Menu, X, Brain
|
| 10 |
} from 'lucide-react';
|
| 11 |
|
| 12 |
+
const navSections = [
|
| 13 |
+
{
|
| 14 |
+
label: 'Main',
|
| 15 |
+
items: [
|
| 16 |
+
{ path: '/', label: 'Chat', icon: MessageSquare },
|
| 17 |
+
{ path: '/intel', label: 'Intel', icon: Globe },
|
| 18 |
+
{ path: '/markets', label: 'Markets', icon: BarChart3 },
|
| 19 |
+
{ path: '/workspace', label: 'Workspace', icon: Brain },
|
| 20 |
+
{ path: '/pulse', label: 'Pulse', icon: Activity },
|
| 21 |
+
],
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
label: 'System',
|
| 25 |
+
items: [
|
| 26 |
+
{ path: '/cases', label: 'Cases', icon: Layers },
|
| 27 |
+
{ path: '/simulation', label: 'Simulation', icon: Zap },
|
| 28 |
+
{ path: '/sentinel', label: 'Sentinel', icon: Shield },
|
| 29 |
+
{ path: '/prompts', label: 'Prompts', icon: Terminal },
|
| 30 |
+
{ path: '/config', label: 'Config', icon: Settings },
|
| 31 |
+
],
|
| 32 |
+
},
|
| 33 |
];
|
| 34 |
|
| 35 |
+
function JanusOrbSmall({ pulse = false }: { pulse?: boolean }) {
|
| 36 |
+
return (
|
| 37 |
+
<div className="relative w-8 h-8 shrink-0">
|
| 38 |
+
<div
|
| 39 |
+
className="absolute inset-0 rounded-full"
|
| 40 |
+
style={{
|
| 41 |
+
background: 'radial-gradient(circle at 35% 35%, #818cf8, #4f46e5 60%, #1e1b4b 100%)',
|
| 42 |
+
boxShadow: '0 0 12px rgba(99,102,241,0.3)',
|
| 43 |
+
}}
|
| 44 |
+
/>
|
| 45 |
+
{pulse && (
|
| 46 |
+
<div className="absolute inset-0 rounded-full animate-ping opacity-20 bg-indigo-400" />
|
| 47 |
+
)}
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
export default function AppShell({ children }: { children: React.ReactNode }) {
|
| 53 |
const pathname = usePathname();
|
| 54 |
const router = useRouter();
|
|
|
|
| 70 |
return () => clearInterval(iv);
|
| 71 |
}, [fetchStatus]);
|
| 72 |
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
setMobileOpen(false);
|
| 75 |
+
}, [pathname]);
|
| 76 |
+
|
| 77 |
const isActive = (path: string) => {
|
| 78 |
if (path === '/') return pathname === '/';
|
| 79 |
return pathname?.startsWith(path);
|
| 80 |
};
|
| 81 |
|
| 82 |
+
const NavContent = ({ isMobile = false }: { isMobile?: boolean }) => (
|
| 83 |
+
<>
|
| 84 |
+
{/* Logo */}
|
| 85 |
+
<div className="flex items-center gap-3 px-4 h-14 border-b border-white/[0.04] shrink-0">
|
| 86 |
+
<JanusOrbSmall pulse={daemonStatus?.running} />
|
| 87 |
+
{(!collapsed || isMobile) && (
|
| 88 |
+
<motion.span
|
| 89 |
+
initial={{ opacity: 0 }}
|
| 90 |
+
animate={{ opacity: 1 }}
|
| 91 |
+
className="text-[13px] font-light tracking-[0.2em] text-gradient"
|
| 92 |
+
>
|
| 93 |
+
JANUS
|
| 94 |
+
</motion.span>
|
| 95 |
)}
|
| 96 |
+
</div>
|
| 97 |
|
| 98 |
+
{/* New Chat button */}
|
| 99 |
+
<div className="px-3 pt-3 pb-1 shrink-0">
|
| 100 |
+
<button
|
| 101 |
+
onClick={() => router.push('/')}
|
| 102 |
+
className={`w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-white/[0.06] hover:border-white/[0.12] hover:bg-white/[0.03] transition-all text-sm text-gray-400 hover:text-gray-200 ${collapsed && !isMobile ? 'justify-center' : ''}`}
|
| 103 |
+
>
|
| 104 |
+
<Plus size={16} className="shrink-0" />
|
| 105 |
+
{(!collapsed || isMobile) && <span className="text-[12px]">New conversation</span>}
|
| 106 |
+
</button>
|
| 107 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
{/* Nav sections */}
|
| 110 |
+
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
| 111 |
+
{navSections.map(section => (
|
| 112 |
+
<div key={section.label} className="mb-1">
|
| 113 |
+
{(!collapsed || isMobile) && (
|
| 114 |
+
<div className="px-2 pt-4 pb-1.5 text-[10px] font-medium text-gray-600 uppercase tracking-[0.15em]">
|
| 115 |
+
{section.label}
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
{collapsed && !isMobile && <div className="h-3" />}
|
| 119 |
+
<div className="space-y-0.5">
|
| 120 |
+
{section.items.map(item => {
|
| 121 |
+
const active = isActive(item.path);
|
| 122 |
const Icon = item.icon;
|
| 123 |
return (
|
| 124 |
<button
|
| 125 |
+
key={item.path}
|
| 126 |
+
onClick={() => router.push(item.path)}
|
| 127 |
+
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-xl transition-all duration-200 group ${
|
| 128 |
active
|
| 129 |
+
? 'bg-white/[0.06] text-gray-100'
|
| 130 |
+
: 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.03]'
|
| 131 |
+
} ${collapsed && !isMobile ? 'justify-center px-2' : ''}`}
|
| 132 |
+
title={collapsed && !isMobile ? item.label : undefined}
|
| 133 |
>
|
| 134 |
+
<Icon size={16} className={`shrink-0 ${active ? 'text-indigo-400' : 'group-hover:text-gray-400'}`} />
|
| 135 |
+
{(!collapsed || isMobile) && (
|
| 136 |
+
<span className="text-[12px] truncate">{item.label}</span>
|
|
|
|
|
|
|
| 137 |
)}
|
| 138 |
</button>
|
| 139 |
);
|
| 140 |
})}
|
| 141 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
</div>
|
| 143 |
+
))}
|
| 144 |
+
</nav>
|
| 145 |
+
|
| 146 |
+
{/* Status footer */}
|
| 147 |
+
{(!collapsed || isMobile) && (
|
| 148 |
+
<div className="px-4 py-3 border-t border-white/[0.04] shrink-0">
|
| 149 |
+
<div className="flex items-center gap-2">
|
| 150 |
+
<div className={`w-1.5 h-1.5 rounded-full ${daemonStatus?.running ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
| 151 |
+
<span className="text-[10px] text-gray-600">
|
| 152 |
+
{daemonStatus?.running ? 'System active' : 'Offline'}
|
| 153 |
+
</span>
|
| 154 |
+
</div>
|
| 155 |
+
{daemonStatus?.circadian && (
|
| 156 |
+
<div className="text-[10px] text-gray-700 mt-0.5 capitalize ml-3.5">
|
| 157 |
+
{daemonStatus.circadian.current_phase} phase
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
</div>
|
| 161 |
+
)}
|
| 162 |
+
</>
|
| 163 |
+
);
|
| 164 |
+
|
| 165 |
+
return (
|
| 166 |
+
<div className="h-screen flex overflow-hidden" style={{ background: 'var(--janus-bg)' }}>
|
| 167 |
+
{/* Desktop Sidebar */}
|
| 168 |
+
<motion.aside
|
| 169 |
+
initial={false}
|
| 170 |
+
animate={{ width: collapsed ? 68 : 260 }}
|
| 171 |
+
transition={{ duration: 0.2, ease: 'easeOut' }}
|
| 172 |
+
className="hidden lg:flex flex-col border-r border-white/[0.04] relative shrink-0"
|
| 173 |
+
style={{ background: 'rgba(10, 10, 15, 0.8)' }}
|
| 174 |
+
>
|
| 175 |
+
<NavContent />
|
| 176 |
+
{/* Collapse toggle */}
|
| 177 |
+
<button
|
| 178 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 179 |
+
className="absolute top-3 -right-3 w-6 h-6 rounded-full border border-white/[0.08] bg-[#111118] flex items-center justify-center text-gray-600 hover:text-gray-400 hover:border-white/[0.15] transition-all z-10"
|
| 180 |
+
>
|
| 181 |
+
{collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}
|
| 182 |
+
</button>
|
| 183 |
</motion.aside>
|
| 184 |
|
| 185 |
+
{/* Mobile Header */}
|
| 186 |
+
<div className="lg:hidden fixed top-0 left-0 right-0 h-12 border-b border-white/[0.04] flex items-center justify-between px-4 z-30" style={{ background: 'rgba(10, 10, 15, 0.95)', backdropFilter: 'blur(12px)' }}>
|
| 187 |
+
<button onClick={() => setMobileOpen(true)} className="p-1.5 rounded-lg hover:bg-white/[0.05] text-gray-400">
|
| 188 |
<Menu size={18} />
|
| 189 |
</button>
|
| 190 |
+
<div className="flex items-center gap-2">
|
| 191 |
+
<JanusOrbSmall />
|
| 192 |
+
<span className="text-[12px] tracking-[0.2em] text-gradient">JANUS</span>
|
| 193 |
+
</div>
|
| 194 |
<div className="w-8" />
|
| 195 |
</div>
|
| 196 |
|
| 197 |
+
{/* Mobile Overlay */}
|
| 198 |
+
<AnimatePresence>
|
| 199 |
+
{mobileOpen && (
|
| 200 |
+
<motion.div
|
| 201 |
+
initial={{ opacity: 0 }}
|
| 202 |
+
animate={{ opacity: 1 }}
|
| 203 |
+
exit={{ opacity: 0 }}
|
| 204 |
+
className="fixed inset-0 bg-black/60 z-40 lg:hidden"
|
| 205 |
+
onClick={() => setMobileOpen(false)}
|
| 206 |
+
/>
|
| 207 |
+
)}
|
| 208 |
+
</AnimatePresence>
|
| 209 |
+
|
| 210 |
+
{/* Mobile Drawer */}
|
| 211 |
<AnimatePresence>
|
| 212 |
{mobileOpen && (
|
| 213 |
<motion.div
|
| 214 |
+
initial={{ x: -300 }}
|
| 215 |
+
animate={{ x: 0 }}
|
| 216 |
+
exit={{ x: -300 }}
|
| 217 |
+
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
| 218 |
+
className="fixed top-0 left-0 bottom-0 w-72 z-50 lg:hidden flex flex-col border-r border-white/[0.04]"
|
| 219 |
+
style={{ background: 'var(--janus-bg)' }}
|
| 220 |
>
|
| 221 |
+
<div className="absolute top-3 right-3">
|
| 222 |
+
<button onClick={() => setMobileOpen(false)} className="p-1 rounded-lg hover:bg-white/[0.05] text-gray-500">
|
|
|
|
| 223 |
<X size={16} />
|
| 224 |
</button>
|
| 225 |
</div>
|
| 226 |
+
<NavContent isMobile />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
</motion.div>
|
| 228 |
)}
|
| 229 |
</AnimatePresence>
|
| 230 |
|
| 231 |
+
{/* Main Content Area */}
|
| 232 |
+
<main className="flex-1 min-w-0 overflow-hidden pt-12 lg:pt-0">
|
| 233 |
{children}
|
| 234 |
</main>
|
| 235 |
</div>
|
frontend/src/lib/api.ts
CHANGED
|
@@ -169,88 +169,6 @@ export class MiroOrgClient {
|
|
| 169 |
}
|
| 170 |
return response.json();
|
| 171 |
}
|
| 172 |
-
|
| 173 |
-
// Session endpoints
|
| 174 |
-
async createSession(): Promise<any> {
|
| 175 |
-
const r = await fetch(`${this.baseUrl}/sessions`, { method: 'POST' });
|
| 176 |
-
if (!r.ok) throw new Error('Failed to create session');
|
| 177 |
-
return r.json();
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
async getSession(sessionId: string): Promise<any> {
|
| 181 |
-
const r = await fetch(`${this.baseUrl}/sessions/${sessionId}`);
|
| 182 |
-
if (!r.ok) throw new Error('Failed to fetch session');
|
| 183 |
-
return r.json();
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
async listSessions(limit: number = 20): Promise<any[]> {
|
| 187 |
-
const r = await fetch(`${this.baseUrl}/sessions?limit=${limit}`);
|
| 188 |
-
if (!r.ok) return [];
|
| 189 |
-
return r.json();
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
async addMessage(sessionId: string, role: string, content: string): Promise<any> {
|
| 193 |
-
const r = await fetch(`${this.baseUrl}/sessions/${sessionId}/message`, {
|
| 194 |
-
method: 'POST',
|
| 195 |
-
headers: { 'Content-Type': 'application/json' },
|
| 196 |
-
body: JSON.stringify({ role, content }),
|
| 197 |
-
});
|
| 198 |
-
if (!r.ok) throw new Error('Failed to add message');
|
| 199 |
-
return r.json();
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
// Workflow endpoints
|
| 203 |
-
async createResearchWorkflow(query: string, depth: string = 'standard'): Promise<any> {
|
| 204 |
-
const r = await fetch(`${this.baseUrl}/workflows/research`, {
|
| 205 |
-
method: 'POST',
|
| 206 |
-
headers: { 'Content-Type': 'application/json' },
|
| 207 |
-
body: JSON.stringify({ query, depth }),
|
| 208 |
-
});
|
| 209 |
-
if (!r.ok) throw new Error('Failed to create research workflow');
|
| 210 |
-
return r.json();
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
async createSimulationWorkflow(scenario: string): Promise<any> {
|
| 214 |
-
const r = await fetch(`${this.baseUrl}/workflows/simulation`, {
|
| 215 |
-
method: 'POST',
|
| 216 |
-
headers: { 'Content-Type': 'application/json' },
|
| 217 |
-
body: JSON.stringify({ scenario }),
|
| 218 |
-
});
|
| 219 |
-
if (!r.ok) throw new Error('Failed to create simulation workflow');
|
| 220 |
-
return r.json();
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
async listWorkflows(limit: number = 20, type?: string): Promise<any[]> {
|
| 224 |
-
const url = type ? `${this.baseUrl}/workflows?limit=${limit}&type=${type}` : `${this.baseUrl}/workflows?limit=${limit}`;
|
| 225 |
-
const r = await fetch(url);
|
| 226 |
-
if (!r.ok) return [];
|
| 227 |
-
return r.json();
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
-
async getWorkflow(wfId: string): Promise<any> {
|
| 231 |
-
const r = await fetch(`${this.baseUrl}/workflows/${wfId}`);
|
| 232 |
-
if (!r.ok) throw new Error('Workflow not found');
|
| 233 |
-
return r.json();
|
| 234 |
-
}
|
| 235 |
-
|
| 236 |
-
async runWorkflow(wfId: string): Promise<any> {
|
| 237 |
-
const r = await fetch(`${this.baseUrl}/workflows/${wfId}/run`, { method: 'POST' });
|
| 238 |
-
if (!r.ok) throw new Error('Failed to run workflow');
|
| 239 |
-
return r.json();
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
// Curiosity endpoints
|
| 243 |
-
async getCuriosity(): Promise<any> {
|
| 244 |
-
const r = await fetch(`${this.baseUrl}/daemon/curiosity`);
|
| 245 |
-
if (!r.ok) return { total_discoveries: 0, total_interests: 0, discoveries: [] };
|
| 246 |
-
return r.json();
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
async triggerCuriosity(): Promise<any> {
|
| 250 |
-
const r = await fetch(`${this.baseUrl}/daemon/curiosity/now`, { method: 'POST' });
|
| 251 |
-
if (!r.ok) throw new Error('Failed to trigger curiosity');
|
| 252 |
-
return r.json();
|
| 253 |
-
}
|
| 254 |
}
|
| 255 |
|
| 256 |
// Export singleton instance
|
|
|
|
| 169 |
}
|
| 170 |
return response.json();
|
| 171 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
|
| 174 |
// Export singleton instance
|
frontend/src/lib/types.ts
CHANGED
|
@@ -23,6 +23,20 @@ export interface CaseRecord {
|
|
| 23 |
final_answer_preview?: string;
|
| 24 |
saved_at?: string;
|
| 25 |
simulation_id?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
export interface SimulationRecord {
|
|
|
|
| 23 |
final_answer_preview?: string;
|
| 24 |
saved_at?: string;
|
| 25 |
simulation_id?: string;
|
| 26 |
+
// Extended fields from /run response
|
| 27 |
+
final?: {
|
| 28 |
+
response?: string;
|
| 29 |
+
summary?: string;
|
| 30 |
+
confidence?: number;
|
| 31 |
+
data_sources?: string[];
|
| 32 |
+
};
|
| 33 |
+
domain?: string;
|
| 34 |
+
query_type?: string;
|
| 35 |
+
cached?: boolean;
|
| 36 |
+
elapsed_seconds?: number;
|
| 37 |
+
learned?: boolean;
|
| 38 |
+
learning_reason?: string;
|
| 39 |
+
adaptive_context?: Record<string, any>;
|
| 40 |
}
|
| 41 |
|
| 42 |
export interface SimulationRecord {
|